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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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