agents28 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents28/__init__.py +20 -0
- agents28/_delivery.py +206 -0
- agents28/_run.py +194 -0
- agents28/_sanitize.py +102 -0
- agents28/_version.py +1 -0
- agents28/client.py +516 -0
- agents28/py.typed +0 -0
- agents28-0.1.0.dist-info/METADATA +197 -0
- agents28-0.1.0.dist-info/RECORD +12 -0
- agents28-0.1.0.dist-info/WHEEL +5 -0
- agents28-0.1.0.dist-info/licenses/LICENSE +21 -0
- agents28-0.1.0.dist-info/top_level.txt +1 -0
agents28/__init__.py
ADDED
|
@@ -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__"]
|
agents28/_delivery.py
ADDED
|
@@ -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)
|
agents28/_run.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Runs: one unit of agent work, from start to finish."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from . import _sanitize as clean
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .client import Agents28
|
|
15
|
+
|
|
16
|
+
_current: contextvars.ContextVar[Run | None] = contextvars.ContextVar(
|
|
17
|
+
"agents28_current_run", default=None
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def current_run() -> Run | None:
|
|
22
|
+
"""The run active in this thread or task, or None outside a run."""
|
|
23
|
+
return _current.get()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _elapsed_ms(started: float) -> int:
|
|
27
|
+
return clean.duration_ms((time.perf_counter() - started) * 1000)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Run:
|
|
31
|
+
"""One unit of work. Use it as a context manager::
|
|
32
|
+
|
|
33
|
+
with client.run(task_title="Refund request review") as run:
|
|
34
|
+
run.message("user")
|
|
35
|
+
with run.tool("lookup_order"):
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
On exit the run is reported completed with its duration, or failed with
|
|
39
|
+
the exception's class name if one escaped (the exception is re-raised).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
client: Agents28,
|
|
45
|
+
*,
|
|
46
|
+
task_title: str | None = None,
|
|
47
|
+
task_type: str | None = None,
|
|
48
|
+
conversation_id: str | None = None,
|
|
49
|
+
run_id: str | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._client = client
|
|
52
|
+
self.run_id = clean.identifier(run_id) or uuid.uuid4().hex
|
|
53
|
+
self.conversation_id = clean.identifier(conversation_id) or self.run_id
|
|
54
|
+
self.task_title = task_title
|
|
55
|
+
self.task_type = task_type
|
|
56
|
+
self._started_at: float | None = None
|
|
57
|
+
self._finished = False
|
|
58
|
+
self._token: contextvars.Token[Run | None] | None = None
|
|
59
|
+
|
|
60
|
+
# -- lifecycle -----------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def start(self) -> Run:
|
|
63
|
+
if self._started_at is None:
|
|
64
|
+
self._started_at = time.perf_counter()
|
|
65
|
+
self._client.run_started(
|
|
66
|
+
self.conversation_id,
|
|
67
|
+
self.run_id,
|
|
68
|
+
task_title=self.task_title,
|
|
69
|
+
task_type=self.task_type,
|
|
70
|
+
)
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def complete(self) -> None:
|
|
74
|
+
"""Report the run completed. Called for you by the context manager."""
|
|
75
|
+
if self._finish():
|
|
76
|
+
self._client.run_completed(
|
|
77
|
+
self.conversation_id, self.run_id, self._duration()
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def fail(self, error_type: str = "error") -> None:
|
|
81
|
+
"""Report the run failed, e.g. ``run.fail("PolicyViolation")``."""
|
|
82
|
+
if self._finish():
|
|
83
|
+
self._client.run_failed(self.conversation_id, self.run_id, error_type)
|
|
84
|
+
|
|
85
|
+
def _finish(self) -> bool:
|
|
86
|
+
if self._finished:
|
|
87
|
+
return False
|
|
88
|
+
self.start()
|
|
89
|
+
self._finished = True
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
def _duration(self) -> int:
|
|
93
|
+
return _elapsed_ms(self._started_at) if self._started_at is not None else 0
|
|
94
|
+
|
|
95
|
+
def __enter__(self) -> Run:
|
|
96
|
+
self.start()
|
|
97
|
+
self._token = _current.set(self)
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def __exit__(
|
|
101
|
+
self,
|
|
102
|
+
exc_type: type[BaseException] | None,
|
|
103
|
+
exc: BaseException | None,
|
|
104
|
+
traceback: TracebackType | None,
|
|
105
|
+
) -> None:
|
|
106
|
+
if self._token is not None:
|
|
107
|
+
_current.reset(self._token)
|
|
108
|
+
self._token = None
|
|
109
|
+
if exc_type is None:
|
|
110
|
+
self.complete()
|
|
111
|
+
else:
|
|
112
|
+
self.fail(exc_type.__name__)
|
|
113
|
+
|
|
114
|
+
# -- events --------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def message(self, role: str, content: str | None = None) -> bool:
|
|
117
|
+
"""Record a message. Content is sent only with ``capture_content=True``."""
|
|
118
|
+
return self._client.message(self.conversation_id, self.run_id, role, content)
|
|
119
|
+
|
|
120
|
+
def tool_completed(
|
|
121
|
+
self,
|
|
122
|
+
name: str,
|
|
123
|
+
success: bool = True,
|
|
124
|
+
duration_ms: float = 0,
|
|
125
|
+
result_summary: str | None = None,
|
|
126
|
+
) -> bool:
|
|
127
|
+
return self._client.tool_completed(
|
|
128
|
+
self.conversation_id,
|
|
129
|
+
self.run_id,
|
|
130
|
+
name,
|
|
131
|
+
success,
|
|
132
|
+
duration_ms,
|
|
133
|
+
result_summary,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def action_completed(
|
|
137
|
+
self,
|
|
138
|
+
name: str,
|
|
139
|
+
action_type: str = "other",
|
|
140
|
+
success: bool = True,
|
|
141
|
+
duration_ms: float = 0,
|
|
142
|
+
result_summary: str | None = None,
|
|
143
|
+
) -> bool:
|
|
144
|
+
return self._client.action_completed(
|
|
145
|
+
self.conversation_id,
|
|
146
|
+
self.run_id,
|
|
147
|
+
action_type,
|
|
148
|
+
name,
|
|
149
|
+
success,
|
|
150
|
+
duration_ms,
|
|
151
|
+
result_summary,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def tool(self, name: str) -> Step:
|
|
155
|
+
"""Time a tool call; success is False if an exception escapes."""
|
|
156
|
+
return Step(self, "tool", name)
|
|
157
|
+
|
|
158
|
+
def action(self, name: str, action_type: str = "other") -> Step:
|
|
159
|
+
"""Time a business action, e.g. ``run.action("issue_refund", "business_operation")``."""
|
|
160
|
+
return Step(self, "action", name, action_type)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Step:
|
|
164
|
+
"""A timed tool call or action inside a run. Set ``result_summary`` if useful."""
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self, run: Run, kind: str, name: str, action_type: str = "other"
|
|
168
|
+
) -> None:
|
|
169
|
+
self._run = run
|
|
170
|
+
self._kind = kind
|
|
171
|
+
self.name = name
|
|
172
|
+
self.action_type = action_type
|
|
173
|
+
self.result_summary: str | None = None
|
|
174
|
+
self.success: bool | None = None
|
|
175
|
+
self._started_at = 0.0
|
|
176
|
+
|
|
177
|
+
def __enter__(self) -> Step:
|
|
178
|
+
self._started_at = time.perf_counter()
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
def __exit__(
|
|
182
|
+
self,
|
|
183
|
+
exc_type: type[BaseException] | None,
|
|
184
|
+
exc: BaseException | None,
|
|
185
|
+
traceback: TracebackType | None,
|
|
186
|
+
) -> None:
|
|
187
|
+
success = (exc_type is None) if self.success is None else bool(self.success)
|
|
188
|
+
duration = _elapsed_ms(self._started_at)
|
|
189
|
+
if self._kind == "tool":
|
|
190
|
+
self._run.tool_completed(self.name, success, duration, self.result_summary)
|
|
191
|
+
else:
|
|
192
|
+
self._run.action_completed(
|
|
193
|
+
self.name, self.action_type, success, duration, self.result_summary
|
|
194
|
+
)
|
agents28/_sanitize.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Make every value safe to send and valid for the Agents28 API.
|
|
2
|
+
|
|
3
|
+
The API validates each event strictly and rejects a whole batch when one event
|
|
4
|
+
is invalid, so the SDK fixes or drops bad values here instead. Nothing in this
|
|
5
|
+
module raises on bad input.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
# Limits enforced by the Agents28 API (POST /v1/events/batch).
|
|
13
|
+
ID_MAX_LENGTH = 100
|
|
14
|
+
NAME_MAX_LENGTH = 100
|
|
15
|
+
AGENT_VERSION_MAX_LENGTH = 64
|
|
16
|
+
TASK_TITLE_MAX_LENGTH = 120
|
|
17
|
+
TASK_TYPE_MAX_LENGTH = 64
|
|
18
|
+
CONTENT_MAX_LENGTH = 10_000
|
|
19
|
+
SUMMARY_MAX_LENGTH = 4_000
|
|
20
|
+
DURATION_MAX_MS = 3_600_000
|
|
21
|
+
ROLES = frozenset({"user", "assistant", "system", "tool"})
|
|
22
|
+
ACTION_TYPES = frozenset({"api_call", "business_operation", "handoff", "other"})
|
|
23
|
+
|
|
24
|
+
_ID_INVALID = re.compile(r"[^A-Za-z0-9_-]+")
|
|
25
|
+
_NAME_INVALID = re.compile(r"[^A-Za-z0-9_.-]+")
|
|
26
|
+
_TASK_TYPE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
|
27
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]+")
|
|
28
|
+
|
|
29
|
+
_REDACTIONS = (
|
|
30
|
+
# Our own API keys, OpenAI-style keys and AWS access key ids.
|
|
31
|
+
(re.compile(r"\back_[A-Za-z0-9_-]{8,256}"), "[REDACTED_API_KEY]"),
|
|
32
|
+
(re.compile(r"(?i)\bsk-[A-Za-z0-9_-]{8,256}"), "[REDACTED_API_KEY]"),
|
|
33
|
+
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[REDACTED_API_KEY]"),
|
|
34
|
+
(
|
|
35
|
+
re.compile(r"(?i)\bbearer\s{1,8}[A-Za-z0-9._~+/-]{8,4096}=*"),
|
|
36
|
+
"Bearer [REDACTED_TOKEN]",
|
|
37
|
+
),
|
|
38
|
+
# Bounded repeats (RFC 5321 limits) keep this linear on long text; an
|
|
39
|
+
# unbounded "[...]+@" is quadratic and stalled agents on large messages.
|
|
40
|
+
(
|
|
41
|
+
re.compile(
|
|
42
|
+
r"(?i)[A-Z0-9._%+-]{1,64}@[A-Z0-9-]{1,63}(?:\.[A-Z0-9-]{1,63}){0,8}\.[A-Z]{2,24}"
|
|
43
|
+
),
|
|
44
|
+
"[REDACTED_EMAIL]",
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def redact(text: str) -> str:
|
|
50
|
+
"""Replace email addresses, API keys and bearer tokens in ``text``."""
|
|
51
|
+
for pattern, replacement in _REDACTIONS:
|
|
52
|
+
text = pattern.sub(replacement, text)
|
|
53
|
+
return text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def identifier(value: object) -> str | None:
|
|
57
|
+
"""A conversation or run id the API accepts, or None if nothing is left."""
|
|
58
|
+
if value is None:
|
|
59
|
+
return None
|
|
60
|
+
cleaned = _ID_INVALID.sub("-", str(value)).strip("-")[:ID_MAX_LENGTH]
|
|
61
|
+
return cleaned or None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def name(
|
|
65
|
+
value: object, fallback: str = "unknown", max_length: int = NAME_MAX_LENGTH
|
|
66
|
+
) -> str:
|
|
67
|
+
"""A tool, action or error name in the API's allowed characters."""
|
|
68
|
+
cleaned = _NAME_INVALID.sub("_", str(value or "")).strip("_")[:max_length]
|
|
69
|
+
return cleaned or fallback
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def task_title(value: object) -> str | None:
|
|
73
|
+
"""One short, redacted line, or None. Never the conversation content."""
|
|
74
|
+
if not isinstance(value, str):
|
|
75
|
+
return None
|
|
76
|
+
cleaned = _CONTROL_CHARS.sub(" ", redact(value))
|
|
77
|
+
cleaned = " ".join(cleaned.split())[:TASK_TITLE_MAX_LENGTH].strip()
|
|
78
|
+
return cleaned or None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def task_type(value: object) -> str | None:
|
|
82
|
+
"""A stable slug such as ``refund_review``; anything else is dropped."""
|
|
83
|
+
if isinstance(value, str) and _TASK_TYPE.match(value):
|
|
84
|
+
return value
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def text(value: object, max_length: int) -> str | None:
|
|
89
|
+
"""Redacted free text, cut to the API limit."""
|
|
90
|
+
if value is None:
|
|
91
|
+
return None
|
|
92
|
+
# Cut first (with room for a secret straddling the limit), then redact.
|
|
93
|
+
return redact(str(value)[: max_length + 512])[:max_length]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def duration_ms(value: object) -> int:
|
|
97
|
+
"""Whole milliseconds within the API's range (0 to one hour)."""
|
|
98
|
+
try:
|
|
99
|
+
number = int(float(value)) # type: ignore[arg-type]
|
|
100
|
+
except (TypeError, ValueError, OverflowError):
|
|
101
|
+
return 0
|
|
102
|
+
return max(0, min(DURATION_MAX_MS, number))
|
agents28/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
agents28/client.py
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""The Agents28 client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import functools
|
|
7
|
+
import inspect
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import platform
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Any, TypeVar, overload
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from . import _sanitize as clean
|
|
20
|
+
from ._delivery import Delivery
|
|
21
|
+
from ._run import Run
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("agents28")
|
|
25
|
+
|
|
26
|
+
# Set when the permanent API domain is live; until then pass base_url or set
|
|
27
|
+
# AGENTS28_BASE_URL. Published releases must point at a domain we own.
|
|
28
|
+
DEFAULT_BASE_URL: str | None = None
|
|
29
|
+
DEFAULT_AGENT_VERSION = "unversioned"
|
|
30
|
+
SCHEMA_VERSION = "1.0"
|
|
31
|
+
_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
32
|
+
|
|
33
|
+
Event = dict[str, Any]
|
|
34
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _env_flag(name: str) -> bool | None:
|
|
38
|
+
raw = os.getenv(name, "").strip().lower()
|
|
39
|
+
if raw in ("1", "true", "yes", "on"):
|
|
40
|
+
return True
|
|
41
|
+
if raw in ("0", "false", "no", "off"):
|
|
42
|
+
return False
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _list_field(response: Any, field: str) -> list[dict[str, Any]]:
|
|
47
|
+
"""``response[field]`` if it is a list of objects, else an empty list."""
|
|
48
|
+
items = response.get(field) if isinstance(response, dict) else None
|
|
49
|
+
if not isinstance(items, list):
|
|
50
|
+
return []
|
|
51
|
+
return [item for item in items if isinstance(item, dict)]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Agents28:
|
|
55
|
+
"""Reports what an AI agent is working on to Agents28.
|
|
56
|
+
|
|
57
|
+
Telemetry never breaks the agent: no method raises, and events are sent
|
|
58
|
+
from a background thread. Configuration falls back to environment
|
|
59
|
+
variables: ``AGENTS28_API_KEY``, ``AGENTS28_BASE_URL``,
|
|
60
|
+
``AGENTS28_AGENT_VERSION`` and ``AGENTS28_ENABLED``.
|
|
61
|
+
|
|
62
|
+
Without an API key or base URL the client is disabled: every call is a
|
|
63
|
+
cheap no-op, so the same code runs in development without credentials.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
api_key: str | None = None,
|
|
69
|
+
*,
|
|
70
|
+
base_url: str | None = None,
|
|
71
|
+
agent_version: str | None = None,
|
|
72
|
+
capture_content: bool = False,
|
|
73
|
+
enabled: bool | None = None,
|
|
74
|
+
before_send: Callable[[Event], Event | None] | None = None,
|
|
75
|
+
timeout: float = 5.0,
|
|
76
|
+
max_queue_size: int = 5000,
|
|
77
|
+
batch_size: int = 100,
|
|
78
|
+
max_retries: int = 2,
|
|
79
|
+
flush_at_exit: bool = True,
|
|
80
|
+
transport: httpx.BaseTransport | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
self.api_key = (api_key or os.getenv("AGENTS28_API_KEY") or "").strip()
|
|
83
|
+
resolved_url = (
|
|
84
|
+
base_url or os.getenv("AGENTS28_BASE_URL") or DEFAULT_BASE_URL or ""
|
|
85
|
+
)
|
|
86
|
+
self.base_url = resolved_url.strip().rstrip("/")
|
|
87
|
+
self.agent_version = clean.name(
|
|
88
|
+
agent_version
|
|
89
|
+
or os.getenv("AGENTS28_AGENT_VERSION")
|
|
90
|
+
or DEFAULT_AGENT_VERSION,
|
|
91
|
+
fallback=DEFAULT_AGENT_VERSION,
|
|
92
|
+
max_length=clean.AGENT_VERSION_MAX_LENGTH,
|
|
93
|
+
)
|
|
94
|
+
self.capture_content = bool(capture_content)
|
|
95
|
+
self.before_send = before_send
|
|
96
|
+
self.timeout = timeout
|
|
97
|
+
self._transport = transport
|
|
98
|
+
self._http: httpx.Client | None = None
|
|
99
|
+
self._delivery: Delivery | None = None
|
|
100
|
+
|
|
101
|
+
if enabled is None:
|
|
102
|
+
enabled = _env_flag("AGENTS28_ENABLED")
|
|
103
|
+
problem = self._configuration_problem() if enabled is not False else "disabled"
|
|
104
|
+
self.enabled = problem is None
|
|
105
|
+
self.disabled_reason = problem
|
|
106
|
+
if problem not in (None, "disabled"):
|
|
107
|
+
logger.warning("Agents28 is disabled: %s.", problem)
|
|
108
|
+
|
|
109
|
+
self.headers = {
|
|
110
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
111
|
+
"User-Agent": (
|
|
112
|
+
f"agents28-python/{__version__} "
|
|
113
|
+
f"python/{platform.python_version()} {platform.system().lower()}"
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if self.enabled:
|
|
118
|
+
self._delivery = Delivery(
|
|
119
|
+
self.base_url + "/v1/events/batch",
|
|
120
|
+
self.headers,
|
|
121
|
+
timeout=timeout,
|
|
122
|
+
max_queue_size=max_queue_size,
|
|
123
|
+
batch_size=batch_size,
|
|
124
|
+
max_retries=max_retries,
|
|
125
|
+
transport=transport,
|
|
126
|
+
)
|
|
127
|
+
self._flush_at_exit = flush_at_exit
|
|
128
|
+
if flush_at_exit:
|
|
129
|
+
atexit.register(self._at_exit)
|
|
130
|
+
|
|
131
|
+
def _configuration_problem(self) -> str | None:
|
|
132
|
+
if not self.api_key:
|
|
133
|
+
return "no API key (pass api_key or set AGENTS28_API_KEY)"
|
|
134
|
+
if not self.base_url:
|
|
135
|
+
return "no base URL (pass base_url or set AGENTS28_BASE_URL)"
|
|
136
|
+
parts = urlsplit(self.base_url)
|
|
137
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
138
|
+
return "base URL must look like https://api.example.com"
|
|
139
|
+
if parts.scheme == "http" and parts.hostname not in _LOCAL_HOSTS:
|
|
140
|
+
return "base URL must use https (http is allowed only for localhost)"
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
# -- lifecycle -----------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def flush(self, timeout: float = 10) -> bool:
|
|
146
|
+
"""Wait until queued events are sent. Returns False on timeout."""
|
|
147
|
+
return self._delivery.flush(timeout) if self._delivery else True
|
|
148
|
+
|
|
149
|
+
def close(self, timeout: float = 10) -> bool:
|
|
150
|
+
"""Send what is queued and stop. Safe to call more than once."""
|
|
151
|
+
completed = self._delivery.close(timeout) if self._delivery else True
|
|
152
|
+
if self._delivery and self._flush_at_exit:
|
|
153
|
+
atexit.unregister(self._at_exit)
|
|
154
|
+
if self._http is not None:
|
|
155
|
+
self._http.close()
|
|
156
|
+
self._http = None
|
|
157
|
+
return completed
|
|
158
|
+
|
|
159
|
+
def _at_exit(self) -> None:
|
|
160
|
+
if self._delivery:
|
|
161
|
+
self._delivery.close(timeout=2.0)
|
|
162
|
+
|
|
163
|
+
def __enter__(self) -> Agents28:
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
167
|
+
self.close()
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def delivered_events(self) -> int:
|
|
171
|
+
return self._delivery.delivered if self._delivery else 0
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def failed_events(self) -> int:
|
|
175
|
+
return self._delivery.failed if self._delivery else 0
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def dropped_events(self) -> int:
|
|
179
|
+
return self._delivery.dropped if self._delivery else 0
|
|
180
|
+
|
|
181
|
+
def stats(self) -> dict[str, Any]:
|
|
182
|
+
"""Delivery counters and the last delivery problem, for health checks."""
|
|
183
|
+
delivery = self._delivery
|
|
184
|
+
return {
|
|
185
|
+
"enabled": self.enabled,
|
|
186
|
+
"disabled_reason": self.disabled_reason,
|
|
187
|
+
"delivered": delivery.delivered if delivery else 0,
|
|
188
|
+
"failed": delivery.failed if delivery else 0,
|
|
189
|
+
"dropped": delivery.dropped if delivery else 0,
|
|
190
|
+
"queued": delivery.queued if delivery else 0,
|
|
191
|
+
"last_error": delivery.last_error if delivery else None,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# -- runs ----------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def run(
|
|
197
|
+
self,
|
|
198
|
+
task_title: str | None = None,
|
|
199
|
+
*,
|
|
200
|
+
task_type: str | None = None,
|
|
201
|
+
conversation_id: str | None = None,
|
|
202
|
+
run_id: str | None = None,
|
|
203
|
+
) -> Run:
|
|
204
|
+
"""Start a run; use it as a context manager. See :class:`Run`."""
|
|
205
|
+
return Run(
|
|
206
|
+
self,
|
|
207
|
+
task_title=task_title,
|
|
208
|
+
task_type=task_type,
|
|
209
|
+
conversation_id=conversation_id,
|
|
210
|
+
run_id=run_id,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
@overload
|
|
214
|
+
def trace(self, function: F) -> F: ...
|
|
215
|
+
|
|
216
|
+
@overload
|
|
217
|
+
def trace(
|
|
218
|
+
self, *, task_title: str | None = None, task_type: str | None = None
|
|
219
|
+
) -> Callable[[F], F]: ...
|
|
220
|
+
|
|
221
|
+
def trace(
|
|
222
|
+
self,
|
|
223
|
+
function: F | None = None,
|
|
224
|
+
*,
|
|
225
|
+
task_title: str | None = None,
|
|
226
|
+
task_type: str | None = None,
|
|
227
|
+
) -> F | Callable[[F], F]:
|
|
228
|
+
"""Decorator: each call of the function is one run (sync or async).
|
|
229
|
+
|
|
230
|
+
Inside, ``agents28.current_run()`` returns the run.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def decorate(fn: F) -> F:
|
|
234
|
+
if inspect.iscoroutinefunction(fn):
|
|
235
|
+
|
|
236
|
+
@functools.wraps(fn)
|
|
237
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
238
|
+
with self.run(task_title, task_type=task_type):
|
|
239
|
+
return await fn(*args, **kwargs)
|
|
240
|
+
|
|
241
|
+
return async_wrapper # type: ignore[return-value]
|
|
242
|
+
|
|
243
|
+
@functools.wraps(fn)
|
|
244
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
245
|
+
with self.run(task_title, task_type=task_type):
|
|
246
|
+
return fn(*args, **kwargs)
|
|
247
|
+
|
|
248
|
+
return wrapper # type: ignore[return-value]
|
|
249
|
+
|
|
250
|
+
return decorate(function) if function is not None else decorate
|
|
251
|
+
|
|
252
|
+
# -- events (low level) --------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def emit(
|
|
255
|
+
self,
|
|
256
|
+
event_type: str,
|
|
257
|
+
conversation_id: str,
|
|
258
|
+
run_id: str,
|
|
259
|
+
payload: dict[str, Any],
|
|
260
|
+
) -> bool:
|
|
261
|
+
"""Queue one event. Returns False if it was not queued."""
|
|
262
|
+
if self._delivery is None:
|
|
263
|
+
return False
|
|
264
|
+
event: Event = {
|
|
265
|
+
"event_id": uuid.uuid4().hex,
|
|
266
|
+
"schema_version": SCHEMA_VERSION,
|
|
267
|
+
"conversation_id": clean.identifier(conversation_id) or "unknown",
|
|
268
|
+
"run_id": clean.identifier(run_id) or "unknown",
|
|
269
|
+
"agent_version": self.agent_version,
|
|
270
|
+
"occurred_at": datetime.now(UTC).isoformat(),
|
|
271
|
+
"event_type": event_type,
|
|
272
|
+
"payload": payload,
|
|
273
|
+
}
|
|
274
|
+
if self.before_send is not None:
|
|
275
|
+
try:
|
|
276
|
+
edited = self.before_send(event)
|
|
277
|
+
except Exception:
|
|
278
|
+
logger.warning(
|
|
279
|
+
"Agents28: before_send raised; the event was dropped.",
|
|
280
|
+
exc_info=True,
|
|
281
|
+
)
|
|
282
|
+
return False
|
|
283
|
+
if edited is None:
|
|
284
|
+
return False
|
|
285
|
+
event = edited
|
|
286
|
+
return self._delivery.submit(event)
|
|
287
|
+
|
|
288
|
+
def run_started(
|
|
289
|
+
self,
|
|
290
|
+
conversation_id: str,
|
|
291
|
+
run_id: str,
|
|
292
|
+
task_title: str | None = None,
|
|
293
|
+
task_type: str | None = None,
|
|
294
|
+
) -> bool:
|
|
295
|
+
"""Report a run start, optionally with what it is working on.
|
|
296
|
+
|
|
297
|
+
``task_title`` is a short human description ("Refund request review");
|
|
298
|
+
never put customer names or other personal data in it.
|
|
299
|
+
``task_type`` is a stable slug ("refund_review"). Invalid values are
|
|
300
|
+
dropped, never raised.
|
|
301
|
+
"""
|
|
302
|
+
payload: dict[str, Any] = {"source": "python-sdk"}
|
|
303
|
+
title = clean.task_title(task_title)
|
|
304
|
+
if title:
|
|
305
|
+
payload["task_title"] = title
|
|
306
|
+
kind = clean.task_type(task_type)
|
|
307
|
+
if kind:
|
|
308
|
+
payload["task_type"] = kind
|
|
309
|
+
return self.emit("run_started", conversation_id, run_id, payload)
|
|
310
|
+
|
|
311
|
+
def message(
|
|
312
|
+
self, conversation_id: str, run_id: str, role: str, content: str | None = None
|
|
313
|
+
) -> bool:
|
|
314
|
+
if role not in clean.ROLES:
|
|
315
|
+
logger.warning("Agents28: unknown message role %r; event dropped.", role)
|
|
316
|
+
return False
|
|
317
|
+
payload: dict[str, Any] = {"role": role}
|
|
318
|
+
if self.capture_content and content is not None:
|
|
319
|
+
payload["content"] = clean.text(content, clean.CONTENT_MAX_LENGTH)
|
|
320
|
+
return self.emit("message", conversation_id, run_id, payload)
|
|
321
|
+
|
|
322
|
+
def tool_completed(
|
|
323
|
+
self,
|
|
324
|
+
conversation_id: str,
|
|
325
|
+
run_id: str,
|
|
326
|
+
tool_name: str,
|
|
327
|
+
success: bool,
|
|
328
|
+
duration_ms: float,
|
|
329
|
+
result_summary: str | None = None,
|
|
330
|
+
) -> bool:
|
|
331
|
+
payload: dict[str, Any] = {
|
|
332
|
+
"tool_name": clean.name(tool_name),
|
|
333
|
+
"success": bool(success),
|
|
334
|
+
"duration_ms": clean.duration_ms(duration_ms),
|
|
335
|
+
}
|
|
336
|
+
if self.capture_content and result_summary is not None:
|
|
337
|
+
payload["result_summary"] = clean.text(
|
|
338
|
+
result_summary, clean.SUMMARY_MAX_LENGTH
|
|
339
|
+
)
|
|
340
|
+
return self.emit("tool_completed", conversation_id, run_id, payload)
|
|
341
|
+
|
|
342
|
+
def action_completed(
|
|
343
|
+
self,
|
|
344
|
+
conversation_id: str,
|
|
345
|
+
run_id: str,
|
|
346
|
+
action_type: str,
|
|
347
|
+
action_name: str,
|
|
348
|
+
success: bool,
|
|
349
|
+
duration_ms: float,
|
|
350
|
+
result_summary: str | None = None,
|
|
351
|
+
) -> bool:
|
|
352
|
+
payload: dict[str, Any] = {
|
|
353
|
+
"action_type": action_type
|
|
354
|
+
if action_type in clean.ACTION_TYPES
|
|
355
|
+
else "other",
|
|
356
|
+
"action_name": clean.name(action_name),
|
|
357
|
+
"success": bool(success),
|
|
358
|
+
"duration_ms": clean.duration_ms(duration_ms),
|
|
359
|
+
}
|
|
360
|
+
if self.capture_content and result_summary is not None:
|
|
361
|
+
payload["result_summary"] = clean.text(
|
|
362
|
+
result_summary, clean.SUMMARY_MAX_LENGTH
|
|
363
|
+
)
|
|
364
|
+
return self.emit("action_completed", conversation_id, run_id, payload)
|
|
365
|
+
|
|
366
|
+
def run_completed(
|
|
367
|
+
self, conversation_id: str, run_id: str, duration_ms: float
|
|
368
|
+
) -> bool:
|
|
369
|
+
return self.emit(
|
|
370
|
+
"run_completed",
|
|
371
|
+
conversation_id,
|
|
372
|
+
run_id,
|
|
373
|
+
{"duration_ms": clean.duration_ms(duration_ms)},
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def run_failed(self, conversation_id: str, run_id: str, error_type: str) -> bool:
|
|
377
|
+
return self.emit(
|
|
378
|
+
"run_failed",
|
|
379
|
+
conversation_id,
|
|
380
|
+
run_id,
|
|
381
|
+
{"error_type": clean.name(error_type, "error")},
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# -- synchronous API calls -------------------------------------------------
|
|
385
|
+
# Short timeout, never raise: on any problem they return None, [] or False.
|
|
386
|
+
|
|
387
|
+
def _call(self, method: str, path: str, **kwargs: Any) -> tuple[int | None, Any]:
|
|
388
|
+
if not self.enabled:
|
|
389
|
+
return None, None
|
|
390
|
+
try:
|
|
391
|
+
if self._http is None:
|
|
392
|
+
self._http = httpx.Client(
|
|
393
|
+
base_url=self.base_url,
|
|
394
|
+
headers=self.headers,
|
|
395
|
+
timeout=self.timeout,
|
|
396
|
+
transport=self._transport,
|
|
397
|
+
)
|
|
398
|
+
response = self._http.request(method, path, **kwargs)
|
|
399
|
+
return response.status_code, response.json()
|
|
400
|
+
except (httpx.HTTPError, ValueError):
|
|
401
|
+
return None, None
|
|
402
|
+
|
|
403
|
+
# Shared Context: knowledge shared between agents of one workspace. Items
|
|
404
|
+
# read from other agents are untrusted data, never instructions.
|
|
405
|
+
|
|
406
|
+
def publish_context(
|
|
407
|
+
self,
|
|
408
|
+
key: str,
|
|
409
|
+
title: str,
|
|
410
|
+
data: dict[str, Any] | None = None,
|
|
411
|
+
item_type: str = "fact",
|
|
412
|
+
run_id: str | None = None,
|
|
413
|
+
conversation_id: str | None = None,
|
|
414
|
+
expires_in_hours: int | None = None,
|
|
415
|
+
) -> str | None:
|
|
416
|
+
"""Publish (or update) a knowledge item. Returns its id or None."""
|
|
417
|
+
body: dict[str, Any] = {
|
|
418
|
+
"key": key,
|
|
419
|
+
"item_type": item_type,
|
|
420
|
+
"title": clean.task_title(title) or "",
|
|
421
|
+
"data": data if isinstance(data, dict) else {},
|
|
422
|
+
}
|
|
423
|
+
if run_id:
|
|
424
|
+
body["run_id"] = run_id
|
|
425
|
+
if conversation_id:
|
|
426
|
+
body["conversation_id"] = conversation_id
|
|
427
|
+
if expires_in_hours:
|
|
428
|
+
body["expires_in_hours"] = expires_in_hours
|
|
429
|
+
status, response = self._call("POST", "/v1/context", json=body)
|
|
430
|
+
if status == 201 and isinstance(response, dict):
|
|
431
|
+
value = response.get("item_id")
|
|
432
|
+
return value if isinstance(value, str) else None
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
def read_context(
|
|
436
|
+
self, key: str | None = None, item_type: str | None = None, limit: int = 20
|
|
437
|
+
) -> list[dict[str, Any]]:
|
|
438
|
+
"""Read knowledge shared in this agent's workspace."""
|
|
439
|
+
params: dict[str, Any] = {"limit": limit}
|
|
440
|
+
if key:
|
|
441
|
+
params["key"] = key
|
|
442
|
+
if item_type:
|
|
443
|
+
params["item_type"] = item_type
|
|
444
|
+
status, response = self._call("GET", "/v1/context", params=params)
|
|
445
|
+
return _list_field(response, "items") if status == 200 else []
|
|
446
|
+
|
|
447
|
+
# Agent Handoff: pass a task to another agent in the same workspace.
|
|
448
|
+
|
|
449
|
+
def list_teammates(self) -> list[dict[str, Any]]:
|
|
450
|
+
"""Other agents in this workspace you can hand work to."""
|
|
451
|
+
status, response = self._call("GET", "/v1/workspace-agents")
|
|
452
|
+
return _list_field(response, "agents") if status == 200 else []
|
|
453
|
+
|
|
454
|
+
def request_handoff(
|
|
455
|
+
self,
|
|
456
|
+
to_agent_id: str,
|
|
457
|
+
title: str,
|
|
458
|
+
context: dict[str, Any] | None = None,
|
|
459
|
+
context_item_ids: list[str] | None = None,
|
|
460
|
+
task_type: str | None = None,
|
|
461
|
+
request_key: str | None = None,
|
|
462
|
+
run_id: str | None = None,
|
|
463
|
+
conversation_id: str | None = None,
|
|
464
|
+
) -> str | None:
|
|
465
|
+
"""Ask another agent to take a task. Returns the handoff id or None.
|
|
466
|
+
|
|
467
|
+
Reusing the same ``request_key`` returns the existing handoff instead of
|
|
468
|
+
creating a duplicate, so it is safe to retry.
|
|
469
|
+
"""
|
|
470
|
+
payload: dict[str, Any] = {
|
|
471
|
+
"to_agent_id": to_agent_id,
|
|
472
|
+
"title": clean.task_title(title) or "Handoff",
|
|
473
|
+
"context": context if isinstance(context, dict) else {},
|
|
474
|
+
"context_item_ids": list(context_item_ids or []),
|
|
475
|
+
}
|
|
476
|
+
kind = clean.task_type(task_type)
|
|
477
|
+
if kind:
|
|
478
|
+
payload["task_type"] = kind
|
|
479
|
+
for field, value in (
|
|
480
|
+
("request_key", request_key),
|
|
481
|
+
("run_id", run_id),
|
|
482
|
+
("conversation_id", conversation_id),
|
|
483
|
+
):
|
|
484
|
+
if value:
|
|
485
|
+
payload[field] = value
|
|
486
|
+
status, response = self._call("POST", "/v1/handoffs", json=payload)
|
|
487
|
+
if status == 201 and isinstance(response, dict):
|
|
488
|
+
value = response.get("handoff_id")
|
|
489
|
+
return value if isinstance(value, str) else None
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
def my_handoffs(
|
|
493
|
+
self, direction: str = "incoming", status: str | None = None, limit: int = 20
|
|
494
|
+
) -> list[dict[str, Any]]:
|
|
495
|
+
"""Handoffs sent to this agent (incoming) or by it (outgoing)."""
|
|
496
|
+
params: dict[str, Any] = {"direction": direction, "limit": limit}
|
|
497
|
+
if status:
|
|
498
|
+
params["status"] = status
|
|
499
|
+
code, response = self._call("GET", "/v1/handoffs", params=params)
|
|
500
|
+
return _list_field(response, "handoffs") if code == 200 else []
|
|
501
|
+
|
|
502
|
+
def update_handoff(
|
|
503
|
+
self,
|
|
504
|
+
handoff_id: str,
|
|
505
|
+
status: str,
|
|
506
|
+
note: str | None = None,
|
|
507
|
+
result: dict[str, Any] | None = None,
|
|
508
|
+
) -> bool:
|
|
509
|
+
"""Accept, decline, complete, fail or cancel a handoff."""
|
|
510
|
+
payload: dict[str, Any] = {"status": status}
|
|
511
|
+
if note:
|
|
512
|
+
payload["note"] = clean.task_title(note)
|
|
513
|
+
if isinstance(result, dict):
|
|
514
|
+
payload["result"] = result
|
|
515
|
+
code, _ = self._call("POST", f"/v1/handoffs/{handoff_id}/status", json=payload)
|
|
516
|
+
return code == 200
|
agents28/py.typed
ADDED
|
File without changes
|
|
@@ -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,12 @@
|
|
|
1
|
+
agents28/__init__.py,sha256=ytk2N5f2PnxiRP5jdj9LxqElJGm1XnP_ek0v0E51rwk,542
|
|
2
|
+
agents28/_delivery.py,sha256=LQqlWmBwXstIkn9yZVwzN1o9j7o5ILnfZzji7-YxMyk,7191
|
|
3
|
+
agents28/_run.py,sha256=6d714pV4OdkilrQJsh_upgDXEOsMgndaQ4KU-sGYzJQ,6019
|
|
4
|
+
agents28/_sanitize.py,sha256=VHpjOz5G_nVAjs30wlpUkHJ3H_IPAeJU78V_RcXP4Oc,3546
|
|
5
|
+
agents28/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
6
|
+
agents28/client.py,sha256=ODBcAKZaG7QnWyXTfUjYK2ORcE4038WCBX6upPZS1N8,18651
|
|
7
|
+
agents28/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
agents28-0.1.0.dist-info/licenses/LICENSE,sha256=6hxIEIHqsZjyLS-JyJx4hZDfdqPods8xbfxLOZ3DEpo,1065
|
|
9
|
+
agents28-0.1.0.dist-info/METADATA,sha256=2sNLj6aB1IByfp16nkj22p5LkUzm7T5lYga4jLBKDRo,6703
|
|
10
|
+
agents28-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
agents28-0.1.0.dist-info/top_level.txt,sha256=y0aZ81KNQZY4YHtIiu7u-4qeSzJZxUVHSWR9qH1PIwY,9
|
|
12
|
+
agents28-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
agents28
|