caura-bus-core 0.3.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.
- caura_bus_core/__init__.py +46 -0
- caura_bus_core/agent.py +58 -0
- caura_bus_core/bus.py +452 -0
- caura_bus_core/collaboration.py +103 -0
- caura_bus_core/config.py +22 -0
- caura_bus_core/consult.py +437 -0
- caura_bus_core/envelope.py +71 -0
- caura_bus_core/protocol.py +98 -0
- caura_bus_core/py.typed +0 -0
- caura_bus_core/retry.py +39 -0
- caura_bus_core-0.3.0.dist-info/METADATA +16 -0
- caura_bus_core-0.3.0.dist-info/RECORD +16 -0
- caura_bus_core-0.3.0.dist-info/WHEEL +5 -0
- caura_bus_core-0.3.0.dist-info/licenses/LICENSE +202 -0
- caura_bus_core-0.3.0.dist-info/licenses/NOTICE +7 -0
- caura_bus_core-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Caura Bus: all messaging crosses the authenticated Caura platform API."""
|
|
2
|
+
|
|
3
|
+
from .agent import AgentConfig, AgentInfo
|
|
4
|
+
from .bus import IDEMPOTENCY_KEY_REUSED, RESYNC_EVENT, Bus, DirectoryIncomplete, PlatformError
|
|
5
|
+
from .config import CONFIG_ENV_VAR, load_config, require_api_key
|
|
6
|
+
from .consult import (
|
|
7
|
+
Answer,
|
|
8
|
+
Collection,
|
|
9
|
+
ConsultationBudget,
|
|
10
|
+
ConsultationCycleError,
|
|
11
|
+
ConsultationLimitError,
|
|
12
|
+
PresentedResponses,
|
|
13
|
+
ResponseCollector,
|
|
14
|
+
collect_responses,
|
|
15
|
+
)
|
|
16
|
+
from .envelope import Envelope, Kind, new_msg_id, new_thread_id, now_ms
|
|
17
|
+
from .protocol import Claim, Receipt, SendMessage
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CONFIG_ENV_VAR",
|
|
21
|
+
"IDEMPOTENCY_KEY_REUSED",
|
|
22
|
+
"RESYNC_EVENT",
|
|
23
|
+
"AgentConfig",
|
|
24
|
+
"AgentInfo",
|
|
25
|
+
"Answer",
|
|
26
|
+
"Bus",
|
|
27
|
+
"DirectoryIncomplete",
|
|
28
|
+
"Claim",
|
|
29
|
+
"Collection",
|
|
30
|
+
"ConsultationBudget",
|
|
31
|
+
"ConsultationCycleError",
|
|
32
|
+
"ConsultationLimitError",
|
|
33
|
+
"Envelope",
|
|
34
|
+
"Kind",
|
|
35
|
+
"PlatformError",
|
|
36
|
+
"PresentedResponses",
|
|
37
|
+
"Receipt",
|
|
38
|
+
"ResponseCollector",
|
|
39
|
+
"SendMessage",
|
|
40
|
+
"collect_responses",
|
|
41
|
+
"load_config",
|
|
42
|
+
"new_msg_id",
|
|
43
|
+
"new_thread_id",
|
|
44
|
+
"now_ms",
|
|
45
|
+
"require_api_key",
|
|
46
|
+
]
|
caura_bus_core/agent.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Agent identity and per-agent local config schemas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AgentInfo(BaseModel):
|
|
11
|
+
"""Expected identity; the platform credential is authoritative."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(extra="ignore")
|
|
14
|
+
|
|
15
|
+
agent_id: str
|
|
16
|
+
tenant_id: str
|
|
17
|
+
fleet_id: str | None = None
|
|
18
|
+
description: str = ""
|
|
19
|
+
# Skills advertised with presence (e.g. by caura-bus-mcp); they grant no permissions.
|
|
20
|
+
capabilities: list[str] = Field(default_factory=list, max_length=50)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConsultationLimits(BaseModel):
|
|
24
|
+
"""Client-side bounds on peer consultation within one task (see SPEC "Consultation bounds")."""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(extra="forbid")
|
|
27
|
+
|
|
28
|
+
max_requests: int = Field(default=4, ge=1, le=50)
|
|
29
|
+
deadline_seconds: float = Field(default=300, gt=0, le=3600)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AgentConfig(BaseModel):
|
|
33
|
+
"""API location and expected identity. Keys are supplied through the environment."""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
|
+
|
|
37
|
+
agent: AgentInfo
|
|
38
|
+
peers: list[str] = Field(default_factory=list)
|
|
39
|
+
api_url: str
|
|
40
|
+
allow_insecure_http: bool = False
|
|
41
|
+
consultation: ConsultationLimits = Field(default_factory=ConsultationLimits)
|
|
42
|
+
|
|
43
|
+
@field_validator("api_url")
|
|
44
|
+
@classmethod
|
|
45
|
+
def valid_url(cls, value: str) -> str:
|
|
46
|
+
url = urlsplit(value)
|
|
47
|
+
if (
|
|
48
|
+
url.scheme not in {"https", "http"}
|
|
49
|
+
or not url.hostname
|
|
50
|
+
or url.username
|
|
51
|
+
or url.password
|
|
52
|
+
or url.query
|
|
53
|
+
or url.fragment
|
|
54
|
+
):
|
|
55
|
+
raise ValueError("api_url must be a Caura HTTP(S) origin without credentials, query or fragment")
|
|
56
|
+
if url.path not in {"", "/"}:
|
|
57
|
+
raise ValueError("api_url must be the gateway origin, without /api/v1")
|
|
58
|
+
return value.rstrip("/")
|
caura_bus_core/bus.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Async client. No Redis access, anonymous mode, or fallback transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import secrets
|
|
8
|
+
from time import monotonic
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .agent import AgentConfig
|
|
13
|
+
from .collaboration import AgentDescription, Checkpoint, Presence
|
|
14
|
+
from .config import require_api_key
|
|
15
|
+
from .protocol import Claim, Receipt, SendMessage
|
|
16
|
+
from .retry import Backoff, retry_after_seconds, transient_status
|
|
17
|
+
|
|
18
|
+
# Sent instead of a gap when retention removed history after the stream cursor.
|
|
19
|
+
RESYNC_EVENT = "stream.resync_required"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HumanRequired(RuntimeError):
|
|
23
|
+
def __init__(self, intervention):
|
|
24
|
+
self.intervention = intervention
|
|
25
|
+
super().__init__("Caura requires human input before this work can continue")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Reusing an Idempotency-Key with a different message (HTTP 409). Recover by
|
|
29
|
+
# sending the new message under a new key; resend under the same key only to
|
|
30
|
+
# retry the identical message.
|
|
31
|
+
IDEMPOTENCY_KEY_REUSED = "COLLABORATION_IDEMPOTENCY_KEY_REUSED"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PlatformError(RuntimeError):
|
|
35
|
+
def __init__(self, status: int, detail: object):
|
|
36
|
+
self.status = status
|
|
37
|
+
self.detail = detail
|
|
38
|
+
# Stable machine-readable code when Caura supplies one (e.g. IDEMPOTENCY_KEY_REUSED).
|
|
39
|
+
code = detail.get("code") if isinstance(detail, dict) else None
|
|
40
|
+
self.code: str | None = code if isinstance(code, str) else None
|
|
41
|
+
message = detail.get("message") if isinstance(detail, dict) else None
|
|
42
|
+
text = f"{self.code}: {message}" if self.code and isinstance(message, str) else detail
|
|
43
|
+
super().__init__(f"Caura returned {status}: {text}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Directory pagination bounds. The server pages ``agents``/``discover`` with an
|
|
47
|
+
# opaque keyset cursor and accepts 1-500 agents per page.
|
|
48
|
+
DIRECTORY_PAGE_MAX = 500
|
|
49
|
+
DIRECTORY_PAGE_DEFAULT = 100
|
|
50
|
+
DIRECTORY_MAX_PAGES = 200
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DirectoryIncomplete(RuntimeError):
|
|
54
|
+
"""The directory has more pages than a full listing is allowed to fetch."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _page_size(limit: int) -> int:
|
|
58
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= DIRECTORY_PAGE_MAX:
|
|
59
|
+
raise ValueError(f"limit must be between 1 and {DIRECTORY_PAGE_MAX}")
|
|
60
|
+
return limit
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _directory_page(result) -> dict:
|
|
64
|
+
# A server without directory pagination ignores cursor/limit and returns
|
|
65
|
+
# its capped bare list; it offers no continuation, so treat it as final.
|
|
66
|
+
if isinstance(result, list):
|
|
67
|
+
return {"agents": result, "next_cursor": None}
|
|
68
|
+
return {"agents": result["agents"], "next_cursor": result.get("next_cursor")}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Bus:
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
config: AgentConfig,
|
|
75
|
+
*,
|
|
76
|
+
api_key: str | None = None,
|
|
77
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
78
|
+
):
|
|
79
|
+
if config.api_url.startswith("http:") and not config.allow_insecure_http:
|
|
80
|
+
raise ValueError("HTTPS is required; allow_insecure_http is only for isolated local demos")
|
|
81
|
+
self.config = config
|
|
82
|
+
self._notice_session: str | None = None
|
|
83
|
+
self._notice_receipt: str | None = None
|
|
84
|
+
self._http = httpx.AsyncClient(
|
|
85
|
+
base_url=config.api_url + "/api/v1/bus/",
|
|
86
|
+
headers={"X-API-Key": api_key or require_api_key()},
|
|
87
|
+
timeout=httpx.Timeout(20, connect=5),
|
|
88
|
+
follow_redirects=False,
|
|
89
|
+
transport=transport,
|
|
90
|
+
trust_env=False,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def __aenter__(self):
|
|
94
|
+
try:
|
|
95
|
+
await self.connect()
|
|
96
|
+
except BaseException:
|
|
97
|
+
await self.close()
|
|
98
|
+
raise
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
async def __aexit__(self, *_):
|
|
102
|
+
await self.close()
|
|
103
|
+
|
|
104
|
+
async def connect(self):
|
|
105
|
+
identity = await self.request("GET", "identity")
|
|
106
|
+
expected = self.config.agent
|
|
107
|
+
if (identity["agent_id"], identity["tenant_id"]) != (expected.agent_id, expected.tenant_id):
|
|
108
|
+
raise PlatformError(403, "credential does not match configured agent and tenant")
|
|
109
|
+
return identity
|
|
110
|
+
|
|
111
|
+
async def close(self):
|
|
112
|
+
await self._http.aclose()
|
|
113
|
+
|
|
114
|
+
async def request(self, method: str, path: str, *, retry_safe: bool = False, **kwargs):
|
|
115
|
+
# Snapshot proof of the previous response; retries retain exactly the
|
|
116
|
+
# same acknowledgement even if another concurrent call receives notices.
|
|
117
|
+
headers = dict(kwargs.pop("headers", {}))
|
|
118
|
+
if self._notice_session and self._notice_receipt:
|
|
119
|
+
headers["X-Caura-Session-ID"] = self._notice_session
|
|
120
|
+
headers["X-Caura-Notice-Receipt"] = self._notice_receipt
|
|
121
|
+
kwargs["headers"] = headers
|
|
122
|
+
# Only operations with durable idempotency retry ambiguous responses.
|
|
123
|
+
for attempt in range(3):
|
|
124
|
+
hinted = None
|
|
125
|
+
try:
|
|
126
|
+
response = await self._http.request(method, path, **kwargs)
|
|
127
|
+
except httpx.TransportError:
|
|
128
|
+
if not retry_safe or attempt == 2:
|
|
129
|
+
raise
|
|
130
|
+
else:
|
|
131
|
+
if response.is_success:
|
|
132
|
+
return response.json()
|
|
133
|
+
if not transient_status(response.status_code) or not retry_safe or attempt == 2:
|
|
134
|
+
try:
|
|
135
|
+
detail = response.json().get("detail", "request rejected")
|
|
136
|
+
except ValueError:
|
|
137
|
+
detail = "gateway rejected request"
|
|
138
|
+
raise PlatformError(response.status_code, detail)
|
|
139
|
+
# A shed or overloaded service says when to come back; honour it
|
|
140
|
+
# (capped) instead of returning on the fixed backoff alone.
|
|
141
|
+
hinted = retry_after_seconds(response)
|
|
142
|
+
delay = 0.25 * (2**attempt)
|
|
143
|
+
if hinted is not None:
|
|
144
|
+
delay = max(delay, hinted)
|
|
145
|
+
await asyncio.sleep(delay + secrets.randbelow(100) / 1000)
|
|
146
|
+
raise AssertionError("unreachable")
|
|
147
|
+
|
|
148
|
+
async def send(self, message: SendMessage, *, idempotency_key: str) -> Receipt:
|
|
149
|
+
if (
|
|
150
|
+
message.kind != "response"
|
|
151
|
+
and "*" not in self.config.peers
|
|
152
|
+
and not set(message.to) <= set(self.config.peers)
|
|
153
|
+
):
|
|
154
|
+
raise ValueError("recipient is outside the local peer allow-list")
|
|
155
|
+
if not idempotency_key or len(idempotency_key) > 128:
|
|
156
|
+
raise ValueError("idempotency_key must contain 1–128 characters")
|
|
157
|
+
result = await self.request(
|
|
158
|
+
"POST",
|
|
159
|
+
"messages",
|
|
160
|
+
json=message.model_dump(),
|
|
161
|
+
headers={"Idempotency-Key": idempotency_key},
|
|
162
|
+
retry_safe=True,
|
|
163
|
+
)
|
|
164
|
+
return Receipt.model_validate(result)
|
|
165
|
+
|
|
166
|
+
async def claim(self, lease_seconds: int = 30) -> Claim | None:
|
|
167
|
+
data = await self.request("POST", "inbox/claim", json={"lease_seconds": lease_seconds})
|
|
168
|
+
return Claim.model_validate(data["delivery"]) if data["delivery"] else None
|
|
169
|
+
|
|
170
|
+
async def wait(self, session_id: str, timeout: float = 50) -> Claim | None:
|
|
171
|
+
result = await self.wait_result(session_id, timeout)
|
|
172
|
+
return Claim.model_validate(result["delivery"]) if result["delivery"] else None
|
|
173
|
+
|
|
174
|
+
async def wait_result(self, session_id: str, timeout: float = 50) -> dict:
|
|
175
|
+
# Short HTTP polls fit the Caura gateway's 25-second upstream timeout and
|
|
176
|
+
# revalidate credentials; the model still sees one bounded MCP wait.
|
|
177
|
+
deadline = monotonic() + timeout
|
|
178
|
+
failures = 0
|
|
179
|
+
responded = False
|
|
180
|
+
while True:
|
|
181
|
+
remaining = max(0, deadline - monotonic())
|
|
182
|
+
poll = min(20, remaining)
|
|
183
|
+
try:
|
|
184
|
+
async with asyncio.timeout(remaining + 1 if timeout else 5):
|
|
185
|
+
data = await self.request(
|
|
186
|
+
"POST",
|
|
187
|
+
"inbox/wait",
|
|
188
|
+
json={"session_id": session_id, "timeout": poll},
|
|
189
|
+
timeout=httpx.Timeout(min(poll + 5, remaining + 1) if timeout else 5, connect=5),
|
|
190
|
+
)
|
|
191
|
+
responded = True
|
|
192
|
+
failures = 0
|
|
193
|
+
if data.get("notice_receipt"):
|
|
194
|
+
self._notice_session = session_id
|
|
195
|
+
self._notice_receipt = data["notice_receipt"]
|
|
196
|
+
if data["delivery"] or data.get("notices"):
|
|
197
|
+
return data
|
|
198
|
+
except PlatformError as exc:
|
|
199
|
+
if exc.status < 500:
|
|
200
|
+
raise
|
|
201
|
+
failures += 1
|
|
202
|
+
except (httpx.TransportError, TimeoutError):
|
|
203
|
+
failures += 1
|
|
204
|
+
remaining = deadline - monotonic()
|
|
205
|
+
if remaining <= 0 or not timeout:
|
|
206
|
+
if responded:
|
|
207
|
+
return {"delivery": None, "notices": []}
|
|
208
|
+
raise PlatformError(503, "Caura remained unavailable during wait; retry wait")
|
|
209
|
+
if failures:
|
|
210
|
+
await asyncio.sleep(min(remaining, 0.25 * 2 ** min(failures - 1, 4)))
|
|
211
|
+
|
|
212
|
+
async def inbox_state(self):
|
|
213
|
+
return await self.request("GET", "inbox/state")
|
|
214
|
+
|
|
215
|
+
async def delivery_action(self, delivery_id: str, action: str, token=None, **params):
|
|
216
|
+
return await self.request(
|
|
217
|
+
"POST",
|
|
218
|
+
f"deliveries/{delivery_id}/{action}",
|
|
219
|
+
json={"lease_token": token, **params},
|
|
220
|
+
retry_safe=action in {"ack", "progress"},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
async def reply(
|
|
224
|
+
self,
|
|
225
|
+
delivery_id: str,
|
|
226
|
+
*,
|
|
227
|
+
token=None,
|
|
228
|
+
idempotency_key: str,
|
|
229
|
+
body: str,
|
|
230
|
+
reply_to=None,
|
|
231
|
+
ack=True,
|
|
232
|
+
):
|
|
233
|
+
return await self.request(
|
|
234
|
+
"POST",
|
|
235
|
+
f"deliveries/{delivery_id}/reply",
|
|
236
|
+
json={"lease_token": token, "body": body, "reply_to": reply_to, "ack": ack},
|
|
237
|
+
headers={"Idempotency-Key": idempotency_key},
|
|
238
|
+
retry_safe=True,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
async def settle(self, claim: Claim, action: str):
|
|
242
|
+
if action not in {"ack", "nack", "renew", "paused"}:
|
|
243
|
+
raise ValueError("unknown delivery action")
|
|
244
|
+
return await self.request(
|
|
245
|
+
"POST",
|
|
246
|
+
f"deliveries/{claim.delivery_id}/{action}",
|
|
247
|
+
json={"lease_token": claim.lease_token},
|
|
248
|
+
retry_safe=action == "ack",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
async def agents(self, fleet_id: str | None = None):
|
|
252
|
+
"""First directory page only, as a bare list (legacy). Prefer ``agents_page``/``agents_all``."""
|
|
253
|
+
return await self.request("GET", "agents", params={"fleet_id": fleet_id} if fleet_id else {})
|
|
254
|
+
|
|
255
|
+
async def agents_page(
|
|
256
|
+
self, fleet_id: str | None = None, *, cursor: str | None = None, limit: int = DIRECTORY_PAGE_DEFAULT
|
|
257
|
+
) -> dict:
|
|
258
|
+
"""One bounded directory page: ``{"agents", "next_cursor"}``; more exist while ``next_cursor`` is set."""
|
|
259
|
+
params = {"fleet_id": fleet_id, "cursor": cursor, "limit": _page_size(limit)}
|
|
260
|
+
return _directory_page(
|
|
261
|
+
await self.request("GET", "agents", params={k: v for k, v in params.items() if v is not None})
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
async def agents_all(self, fleet_id: str | None = None, *, max_pages: int = DIRECTORY_MAX_PAGES) -> list:
|
|
265
|
+
"""Every registered agent, following ``next_cursor`` to the last page."""
|
|
266
|
+
return await self._all_pages(
|
|
267
|
+
lambda cursor: self.agents_page(fleet_id, cursor=cursor, limit=DIRECTORY_PAGE_MAX), max_pages
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
async def _all_pages(self, fetch, max_pages: int) -> list:
|
|
271
|
+
agents: list = []
|
|
272
|
+
cursor = None
|
|
273
|
+
for _ in range(max_pages):
|
|
274
|
+
page = await fetch(cursor)
|
|
275
|
+
agents.extend(page["agents"])
|
|
276
|
+
cursor = page["next_cursor"]
|
|
277
|
+
if cursor is None:
|
|
278
|
+
return agents
|
|
279
|
+
raise DirectoryIncomplete(f"agent directory exceeds {max_pages} pages")
|
|
280
|
+
|
|
281
|
+
async def recent(
|
|
282
|
+
self,
|
|
283
|
+
*,
|
|
284
|
+
thread_id: str | None = None,
|
|
285
|
+
peer_agent_id: str | None = None,
|
|
286
|
+
limit: int = 20,
|
|
287
|
+
before: str | None = None,
|
|
288
|
+
reply_to: str | None = None,
|
|
289
|
+
):
|
|
290
|
+
params = {
|
|
291
|
+
"limit": limit,
|
|
292
|
+
"thread_id": thread_id,
|
|
293
|
+
"peer_agent_id": peer_agent_id,
|
|
294
|
+
"before": before,
|
|
295
|
+
"reply_to": reply_to,
|
|
296
|
+
}
|
|
297
|
+
return await self.request(
|
|
298
|
+
"GET", "messages", params={k: v for k, v in params.items() if v is not None}
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
async def threads(self):
|
|
302
|
+
return await self.request("GET", "threads")
|
|
303
|
+
|
|
304
|
+
async def requests(self, *, state=None, limit=20):
|
|
305
|
+
return await self.request(
|
|
306
|
+
"GET",
|
|
307
|
+
"requests",
|
|
308
|
+
params={k: v for k, v in {"state": state, "limit": limit}.items() if v is not None},
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
async def status(self, message_id: str):
|
|
312
|
+
return await self.request("GET", f"messages/{message_id}")
|
|
313
|
+
|
|
314
|
+
async def memory_context(self, *, message_id: str | None = None, delivery_id: str | None = None):
|
|
315
|
+
from .protocol import MemoryContextRequest
|
|
316
|
+
|
|
317
|
+
params = MemoryContextRequest(message_id=message_id, delivery_id=delivery_id)
|
|
318
|
+
return await self.request("GET", "memory-context", params=params.model_dump(exclude_none=True))
|
|
319
|
+
|
|
320
|
+
async def advertise(self, profile: Presence):
|
|
321
|
+
# Unset optional fields (host, runtime) stay off the wire: servers that
|
|
322
|
+
# predate them reject unknown keys.
|
|
323
|
+
return await self.request(
|
|
324
|
+
"PUT", "presence", json=profile.model_dump(exclude_none=True), retry_safe=True
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
async def description(self):
|
|
328
|
+
"""This agent's registered description (``description`` may be null)."""
|
|
329
|
+
return await self.request("GET", "agents/me/description", retry_safe=True)
|
|
330
|
+
|
|
331
|
+
async def describe(self, description: str | None):
|
|
332
|
+
"""Set (or, with null/blank, clear) this agent's registered description."""
|
|
333
|
+
body = AgentDescription(description=description)
|
|
334
|
+
return await self.request("PUT", "agents/me/description", json=body.model_dump(), retry_safe=True)
|
|
335
|
+
|
|
336
|
+
async def discover(self, *, capability: str | None = None, available_only=True, fleet_id=None):
|
|
337
|
+
"""First discovery page only, as a bare list (legacy). Prefer ``discover_page``."""
|
|
338
|
+
params = {"capability": capability, "available_only": available_only, "fleet_id": fleet_id}
|
|
339
|
+
return await self.request(
|
|
340
|
+
"GET", "discover", params={k: v for k, v in params.items() if v is not None}
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
async def discover_page(
|
|
344
|
+
self,
|
|
345
|
+
*,
|
|
346
|
+
capability: str | None = None,
|
|
347
|
+
available_only=True,
|
|
348
|
+
fleet_id=None,
|
|
349
|
+
cursor: str | None = None,
|
|
350
|
+
limit: int = DIRECTORY_PAGE_DEFAULT,
|
|
351
|
+
) -> dict:
|
|
352
|
+
"""One bounded discovery page: ``{"agents", "next_cursor"}``.
|
|
353
|
+
|
|
354
|
+
Pass ``next_cursor`` back with the same filters for the next page; a
|
|
355
|
+
cursor is bound to its filters and tenant.
|
|
356
|
+
"""
|
|
357
|
+
params = {
|
|
358
|
+
"capability": capability,
|
|
359
|
+
"available_only": available_only,
|
|
360
|
+
"fleet_id": fleet_id,
|
|
361
|
+
"cursor": cursor,
|
|
362
|
+
"limit": _page_size(limit),
|
|
363
|
+
}
|
|
364
|
+
return _directory_page(
|
|
365
|
+
await self.request("GET", "discover", params={k: v for k, v in params.items() if v is not None})
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
async def discover_all(
|
|
369
|
+
self,
|
|
370
|
+
*,
|
|
371
|
+
capability: str | None = None,
|
|
372
|
+
available_only=True,
|
|
373
|
+
fleet_id=None,
|
|
374
|
+
max_pages: int = DIRECTORY_MAX_PAGES,
|
|
375
|
+
) -> list:
|
|
376
|
+
"""Every matching agent, following ``next_cursor`` to the last page."""
|
|
377
|
+
return await self._all_pages(
|
|
378
|
+
lambda cursor: self.discover_page(
|
|
379
|
+
capability=capability,
|
|
380
|
+
available_only=available_only,
|
|
381
|
+
fleet_id=fleet_id,
|
|
382
|
+
cursor=cursor,
|
|
383
|
+
limit=DIRECTORY_PAGE_MAX,
|
|
384
|
+
),
|
|
385
|
+
max_pages,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
async def checkpoint(self, checkpoint: Checkpoint):
|
|
389
|
+
result = await self.request("POST", "checkpoints", json=checkpoint.model_dump(), retry_safe=True)
|
|
390
|
+
if result["decision"] == "human_required":
|
|
391
|
+
raise HumanRequired(result["intervention"])
|
|
392
|
+
return result
|
|
393
|
+
|
|
394
|
+
async def escalate(self, delivery_id: str, reason: str):
|
|
395
|
+
return await self.request(
|
|
396
|
+
"POST", "interventions", json={"delivery_id": delivery_id, "reason": reason}
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
async def resync(self):
|
|
400
|
+
"""Reload durable inbox state over REST after the stream lost history.
|
|
401
|
+
|
|
402
|
+
A read, so transient failures are retried like other safe calls.
|
|
403
|
+
"""
|
|
404
|
+
return await self.request("GET", "inbox/state", retry_safe=True)
|
|
405
|
+
|
|
406
|
+
async def events(self, after=0):
|
|
407
|
+
"""Resume a live stream by durable cursor; reconnect revalidates credentials.
|
|
408
|
+
|
|
409
|
+
When retention removed events after the cursor, Caura sends
|
|
410
|
+
``stream.resync_required`` instead of skipping them. That event is
|
|
411
|
+
yielded with ``state`` holding a fresh REST snapshot (``resync()``),
|
|
412
|
+
and the stream continues from the event's ``resume_after``. Consumers
|
|
413
|
+
must treat it as "anything may have changed", never as unknown noise.
|
|
414
|
+
"""
|
|
415
|
+
cursor = after
|
|
416
|
+
backoff = Backoff(maximum=15)
|
|
417
|
+
while True:
|
|
418
|
+
try:
|
|
419
|
+
async with self._http.stream(
|
|
420
|
+
"GET",
|
|
421
|
+
"events",
|
|
422
|
+
params={"after": cursor},
|
|
423
|
+
headers={"Accept": "text/event-stream"},
|
|
424
|
+
) as response:
|
|
425
|
+
if response.status_code != 200:
|
|
426
|
+
if not transient_status(response.status_code):
|
|
427
|
+
raise PlatformError(response.status_code, "live event access failed")
|
|
428
|
+
else:
|
|
429
|
+
async for line in response.aiter_lines():
|
|
430
|
+
if line.startswith("data: "):
|
|
431
|
+
event = json.loads(line[6:])
|
|
432
|
+
if event.get("event_type") == RESYNC_EVENT:
|
|
433
|
+
# Advance only after the reload succeeds;
|
|
434
|
+
# a failed reload reconnects and resyncs again.
|
|
435
|
+
try:
|
|
436
|
+
state = await self.resync()
|
|
437
|
+
except PlatformError as exc:
|
|
438
|
+
if not transient_status(exc.status):
|
|
439
|
+
raise
|
|
440
|
+
break
|
|
441
|
+
event = {**event, "state": state}
|
|
442
|
+
payload = event.get("payload") or {}
|
|
443
|
+
cursor = int(payload.get("resume_after", event["seq"]))
|
|
444
|
+
else:
|
|
445
|
+
cursor = event["seq"]
|
|
446
|
+
backoff.reset()
|
|
447
|
+
yield event
|
|
448
|
+
except httpx.TransportError:
|
|
449
|
+
pass
|
|
450
|
+
# This also bounds reconnects when an upstream closes an empty
|
|
451
|
+
# stream successfully, without supplying an event or heartbeat.
|
|
452
|
+
await backoff.sleep()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Agent discovery, runtime checkpoints and human intervention contracts."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, field_validator
|
|
6
|
+
|
|
7
|
+
from .protocol import StrictModel
|
|
8
|
+
|
|
9
|
+
AGENT_DESCRIPTION_MAX_LENGTH = 1000
|
|
10
|
+
PRESENCE_HOST_MAX_LENGTH = 255
|
|
11
|
+
PRESENCE_RUNTIME_MAX_LENGTH = 64
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AgentDescription(StrictModel):
|
|
15
|
+
"""Registered, durable expertise text shown in the agent directory.
|
|
16
|
+
|
|
17
|
+
Unlike ``Presence.description`` (runtime, expires with the session), this
|
|
18
|
+
survives while the agent is offline. ``null`` or blank clears it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
description: str | None = Field(max_length=AGENT_DESCRIPTION_MAX_LENGTH)
|
|
22
|
+
|
|
23
|
+
@field_validator("description")
|
|
24
|
+
@classmethod
|
|
25
|
+
def normalized(cls, value: str | None) -> str | None:
|
|
26
|
+
if value is None:
|
|
27
|
+
return None
|
|
28
|
+
if any(ord(c) < 32 and c not in "\n\t" for c in value):
|
|
29
|
+
raise ValueError("description must not contain control characters")
|
|
30
|
+
return value.strip() or None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Presence(StrictModel):
|
|
34
|
+
session_id: str = Field(min_length=1, max_length=128)
|
|
35
|
+
display_name: str = Field(min_length=1, max_length=120)
|
|
36
|
+
description: str = Field(default="", max_length=2000)
|
|
37
|
+
capabilities: list[str] = Field(default_factory=list, max_length=50)
|
|
38
|
+
# ``waiting_for_user``: the session is held at a prompt only its person can
|
|
39
|
+
# answer (a permission request), so it neither works nor takes new work.
|
|
40
|
+
status: Literal["ready", "busy", "waiting_for_user", "offline"] = "ready"
|
|
41
|
+
supports_interrupt: bool = False
|
|
42
|
+
# Where the session runs, for people reading the directory: the machine's
|
|
43
|
+
# hostname and the agent runtime (``claude-code``, ``codex``, ...). Optional
|
|
44
|
+
# and omitted from the wire when unset, so older servers keep accepting it.
|
|
45
|
+
host: str | None = Field(default=None, max_length=PRESENCE_HOST_MAX_LENGTH)
|
|
46
|
+
runtime: str | None = Field(default=None, max_length=PRESENCE_RUNTIME_MAX_LENGTH)
|
|
47
|
+
|
|
48
|
+
@field_validator("capabilities")
|
|
49
|
+
@classmethod
|
|
50
|
+
def bounded_capabilities(cls, values):
|
|
51
|
+
if any(not v.strip() or len(v) > 80 for v in values):
|
|
52
|
+
raise ValueError("capabilities must contain 1–80 characters")
|
|
53
|
+
return sorted(set(values))
|
|
54
|
+
|
|
55
|
+
@field_validator("host", "runtime")
|
|
56
|
+
@classmethod
|
|
57
|
+
def single_line(cls, value: str | None) -> str | None:
|
|
58
|
+
if value is None:
|
|
59
|
+
return None
|
|
60
|
+
if any(ord(c) < 32 or ord(c) == 127 for c in value):
|
|
61
|
+
raise ValueError("host and runtime must be a single line of text")
|
|
62
|
+
return value.strip() or None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Checkpoint(StrictModel):
|
|
66
|
+
delivery_id: str = Field(min_length=1, max_length=80)
|
|
67
|
+
lease_token: str = Field(min_length=1, max_length=128)
|
|
68
|
+
checkpoint_key: str = Field(min_length=1, max_length=128)
|
|
69
|
+
summary: str = Field(min_length=1, max_length=2000)
|
|
70
|
+
proposed_action: str = Field(min_length=1, max_length=4000)
|
|
71
|
+
action_type: Literal["read", "write", "external", "destructive"] = "read"
|
|
72
|
+
confidence: float = Field(default=1, ge=0, le=1)
|
|
73
|
+
missing_information: list[str] = Field(default_factory=list, max_length=20)
|
|
74
|
+
conflicting_results: bool = False
|
|
75
|
+
request_human: bool = False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class HumanDecision(StrictModel):
|
|
79
|
+
version: int = Field(ge=1)
|
|
80
|
+
action: Literal["approve", "reject", "redirect", "nudge", "reassign", "cancel"]
|
|
81
|
+
target_agent_id: str | None = Field(default=None, min_length=1, max_length=256)
|
|
82
|
+
instructions: str = Field(default="", max_length=4000)
|
|
83
|
+
allow_unconfirmed: bool = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Interrupt(StrictModel):
|
|
87
|
+
delivery_id: str = Field(min_length=1, max_length=80)
|
|
88
|
+
reason: str = Field(min_length=1, max_length=2000)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CollaborationPolicy(StrictModel):
|
|
92
|
+
request_reply_timeout_seconds: int = Field(default=900, ge=60, le=604800)
|
|
93
|
+
unanswered_after_seconds: int = Field(default=86400, ge=60, le=604800)
|
|
94
|
+
overdue_action: Literal["notify_sender", "nudge", "escalate"] = "notify_sender"
|
|
95
|
+
messaging_scope: Literal["same_fleet", "tenant"] = "same_fleet"
|
|
96
|
+
processing_timeout_seconds: int = Field(default=600, ge=30, le=86400)
|
|
97
|
+
max_extensions: int = Field(default=6, ge=0, le=100)
|
|
98
|
+
minimum_confidence: float = Field(default=0.75, ge=0, le=1)
|
|
99
|
+
require_human_for: list[Literal["read", "write", "external", "destructive"]] = Field(
|
|
100
|
+
default_factory=lambda: ["external", "destructive"]
|
|
101
|
+
)
|
|
102
|
+
escalate_missing_information: bool = True
|
|
103
|
+
escalate_conflicts: bool = True
|
caura_bus_core/config.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Strict configuration; obsolete direct-transport settings fail at startup."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .agent import AgentConfig
|
|
8
|
+
|
|
9
|
+
CONFIG_ENV_VAR = "CAURA_BUS_AGENT_CONFIG"
|
|
10
|
+
DEFAULT_CONFIG_PATH = Path("caura-bus.toml")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_config(path: Path | None = None) -> AgentConfig:
|
|
14
|
+
path = path or Path(os.environ.get(CONFIG_ENV_VAR, str(DEFAULT_CONFIG_PATH)))
|
|
15
|
+
return AgentConfig.model_validate(tomllib.loads(path.read_text()))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def require_api_key() -> str:
|
|
19
|
+
key = os.environ.get("CAURA_API_KEY", "").strip()
|
|
20
|
+
if not key:
|
|
21
|
+
raise RuntimeError("CAURA_API_KEY is required: use a Caura agent-scoped credential")
|
|
22
|
+
return key
|