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.
@@ -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
+ ]
@@ -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
@@ -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