unitares-sdk 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.
@@ -0,0 +1,78 @@
1
+ """UNITARES Agent SDK — typed client and lifecycle base class for governance agents."""
2
+
3
+ from unitares_sdk.errors import (
4
+ GovernanceConnectionError,
5
+ GovernanceError,
6
+ GovernanceTimeoutError,
7
+ IdentityDriftError,
8
+ VerdictError,
9
+ )
10
+ from unitares_sdk.models import (
11
+ ArchiveResult,
12
+ AuditResult,
13
+ CheckinResult,
14
+ CleanupResult,
15
+ IdentityResult,
16
+ InferenceHost,
17
+ InferenceHostResult,
18
+ InferenceHostsResult,
19
+ InferenceProvenance,
20
+ MetricsResult,
21
+ ModelResult,
22
+ NoteResult,
23
+ OnboardResult,
24
+ RecoveryResult,
25
+ SearchResult,
26
+ )
27
+
28
+ __all__ = [
29
+ # Clients (imported lazily by consumers)
30
+ "GovernanceClient",
31
+ "SyncGovernanceClient",
32
+ # Agent base class
33
+ "GovernanceAgent",
34
+ "CycleResult",
35
+ # Models
36
+ "ArchiveResult",
37
+ "AuditResult",
38
+ "CheckinResult",
39
+ "CleanupResult",
40
+ "IdentityResult",
41
+ "InferenceHost",
42
+ "InferenceHostResult",
43
+ "InferenceHostsResult",
44
+ "InferenceProvenance",
45
+ "MetricsResult",
46
+ "ModelResult",
47
+ "NoteResult",
48
+ "OnboardResult",
49
+ "RecoveryResult",
50
+ "SearchResult",
51
+ # Errors
52
+ "GovernanceError",
53
+ "GovernanceConnectionError",
54
+ "GovernanceTimeoutError",
55
+ "IdentityDriftError",
56
+ "VerdictError",
57
+ ]
58
+
59
+
60
+ def __getattr__(name: str):
61
+ """Lazy imports for heavier modules to keep initial import fast."""
62
+ if name == "GovernanceClient":
63
+ from unitares_sdk.client import GovernanceClient
64
+
65
+ return GovernanceClient
66
+ if name == "SyncGovernanceClient":
67
+ from unitares_sdk.sync_client import SyncGovernanceClient
68
+
69
+ return SyncGovernanceClient
70
+ if name == "GovernanceAgent":
71
+ from unitares_sdk.agent import GovernanceAgent
72
+
73
+ return GovernanceAgent
74
+ if name == "CycleResult":
75
+ from unitares_sdk.agent import CycleResult
76
+
77
+ return CycleResult
78
+ raise AttributeError(f"module 'unitares_sdk' has no attribute {name!r}")
@@ -0,0 +1,223 @@
1
+ """Lease-plane substrate-state emission for resident agents (RFC §7.13).
2
+
3
+ Used by `unitares_sdk.client.UnitaresClient.checkin()` to emit each resident's
4
+ post-checkin EISV onto `lease_plane.surface_leases.substrate_state`. Per RFC
5
+ §7.13.4 dual-run authority, the emission is observational-only until each
6
+ resident's individual canary completes — `audit.events` (gated by PR 8's
7
+ `AUDIT_EISV_SYNC_ENABLED_RESIDENTS` env var) remains authoritative until the
8
+ operator removes that resident's name from the env var.
9
+
10
+ Net-new write path; does NOT replace `process_agent_update`. Failures are
11
+ swallowed to keep the checkin contract — RFC §7.13.4 contract: lease-plane
12
+ failures MUST NOT fail the resident's checkin loop.
13
+
14
+ Design:
15
+ - `KNOWN_RESIDENT_NAMES` is the deployment's resident roster, read from the
16
+ `UNITARES_RESIDENTS` env var (comma-separated labels). It mirrors
17
+ `src/grounding/class_indicator.py::KNOWN_RESIDENT_LABELS`, which reads the
18
+ same env var. The env var NAME is the cross-package contract — the SDK is
19
+ a standalone package that must not import from `src/`. Unset/empty means
20
+ this install has no named residents (the user-agnostic default).
21
+ - Emission is GATED on `name in KNOWN_RESIDENT_NAMES` — non-resident agents
22
+ using the SDK don't emit (the migration-034 `substrate_state_only_on_resident_kind`
23
+ CHECK would reject them at the DB anyway; gating client-side is friendlier).
24
+ - Lease handle (`_LeaseCache`) is per-client-instance (one per resident loop).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import os
31
+ from datetime import UTC, datetime
32
+ from typing import Any
33
+ from uuid import UUID
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Deployment resident roster, read from UNITARES_RESIDENTS (comma-separated
38
+ # labels). Mirrors src/grounding/class_indicator.py::KNOWN_RESIDENT_LABELS,
39
+ # which reads the same env var — the env var name is the cross-package
40
+ # contract. Empty by default (user-agnostic install with no named residents).
41
+ RESIDENT_ROSTER_ENV = "UNITARES_RESIDENTS"
42
+
43
+
44
+ def parse_resident_roster(raw: str | None) -> frozenset[str]:
45
+ """Parse a comma-separated resident roster string into a label set."""
46
+ if not raw:
47
+ return frozenset()
48
+ return frozenset(part.strip() for part in raw.split(",") if part.strip())
49
+
50
+
51
+ KNOWN_RESIDENT_NAMES: frozenset[str] = parse_resident_roster(
52
+ os.environ.get(RESIDENT_ROSTER_ENV)
53
+ )
54
+
55
+
56
+ class _LeaseCache:
57
+ """Per-client cached lease handle for substrate emission.
58
+
59
+ First emission triggers acquire (idempotent on (surface_id, holder_uuid)
60
+ per Repo.acquire — restart re-uses the existing lease row). Subsequent
61
+ emissions trigger renew. Renew failure clears cache so next emission
62
+ re-acquires.
63
+ """
64
+
65
+ def __init__(self) -> None:
66
+ self.lease_id: UUID | None = None
67
+
68
+ def reset(self) -> None:
69
+ self.lease_id = None
70
+
71
+
72
+ def _resolve_resident_name(raw_name: str) -> str | None:
73
+ """Match `raw_name` (case-sensitive) against KNOWN_RESIDENT_NAMES.
74
+
75
+ Residents are constructed with the SDK's `onboard(name=...)` and the
76
+ name string lands in `core.identities`. The casing convention is
77
+ capitalized labels (Vigil, Sentinel, etc.) per CLAUDE.md identity
78
+ rules. Returns None for non-resident or unrecognized names.
79
+ """
80
+ if not raw_name:
81
+ return None
82
+ if raw_name in KNOWN_RESIDENT_NAMES:
83
+ return raw_name
84
+ return None
85
+
86
+
87
+ def _build_substrate_state(metrics: dict[str, Any]) -> dict[str, Any]:
88
+ """Project a checkin response's metrics into the §7.13.1.2 shape.
89
+
90
+ Pulls E/I/S/V from the metrics object (set by process_agent_update on
91
+ the server side). Sensor status defaults to 'healthy' — we only reach
92
+ this code path when checkin succeeded, so by the SDK's reckoning the
93
+ resident's substrate observation is intact. Future iterations may
94
+ inspect metrics for NaN / out-of-range / staleness and emit 'degraded'
95
+ or 'failed' with a `reason` field per §7.13.1.2.
96
+ """
97
+ return {
98
+ "E": float(metrics.get("E", 0.0)),
99
+ "I": float(metrics.get("I", 0.0)),
100
+ "S": float(metrics.get("S", 0.0)),
101
+ "V": float(metrics.get("V", 0.0)),
102
+ "sensor": {"status": "healthy"},
103
+ }
104
+
105
+
106
+ def _make_client():
107
+ """Build a lease-plane client lazily.
108
+
109
+ Imported lazily to keep module load light. The lease-plane client is
110
+ part of this SDK (``unitares_sdk.lease_plane``); emission is skipped
111
+ silently when no bearer token is configured.
112
+ """
113
+ from unitares_sdk.lease_plane.client import (
114
+ LeasePlaneClient,
115
+ LeasePlaneClientConfig,
116
+ LeasePlaneDisabledClient,
117
+ )
118
+
119
+ token = os.environ.get("LEASE_PLANE_BEARER_TOKEN", "").strip()
120
+ base_url = os.environ.get(
121
+ "LEASE_PLANE_BASE_URL", "http://127.0.0.1:8788"
122
+ ).strip()
123
+
124
+ if not token:
125
+ return LeasePlaneDisabledClient()
126
+
127
+ return LeasePlaneClient(
128
+ LeasePlaneClientConfig(
129
+ base_url=base_url,
130
+ bearer_token=token,
131
+ timeout_s=2.0,
132
+ )
133
+ )
134
+
135
+
136
+ def emit_substrate_observation(
137
+ *,
138
+ resident_name: str,
139
+ holder_uuid: str,
140
+ metrics: dict[str, Any],
141
+ cache: _LeaseCache,
142
+ client=None,
143
+ ) -> bool:
144
+ """Emit one substrate observation for a resident agent.
145
+
146
+ Returns True iff the lease-plane call succeeded. Caller MUST ignore the
147
+ return value for checkin contract — this is observational-only per
148
+ RFC §7.13.4. Skips silently if `resident_name` isn't a known resident
149
+ (non-resident agents shouldn't write `resident:/` surfaces).
150
+ """
151
+ matched = _resolve_resident_name(resident_name)
152
+ if matched is None:
153
+ return False
154
+ if not holder_uuid:
155
+ return False
156
+ if not metrics:
157
+ return False
158
+
159
+ if client is None:
160
+ client = _make_client()
161
+ if client is None:
162
+ return False
163
+
164
+ surface_id = f"resident:/{matched.lower()}"
165
+ substrate_state = _build_substrate_state(metrics)
166
+ observed_at = datetime.now(UTC)
167
+
168
+ if cache.lease_id is None:
169
+ from unitares_sdk.lease_plane.models import AcquireOk, AcquireRequest
170
+
171
+ try:
172
+ request = AcquireRequest(
173
+ surface_id=surface_id,
174
+ holder_agent_uuid=UUID(holder_uuid),
175
+ holder_class="substrate_earned",
176
+ holder_kind="remote_heartbeat",
177
+ ttl_s=1000, # §7.5 v0.9 measurement: p99 × 1.5 rounded
178
+ intent=f"{matched} resident heartbeat (RFC §7.13)",
179
+ substrate_state=substrate_state,
180
+ substrate_state_observed_at=observed_at,
181
+ )
182
+ result = client.acquire(request)
183
+ except Exception as exc: # noqa: BLE001 — observational-only by contract
184
+ logger.debug("[substrate] %s acquire raised: %r", matched, exc)
185
+ return False
186
+
187
+ if isinstance(result, AcquireOk):
188
+ cache.lease_id = result.lease.lease_id
189
+ logger.info(
190
+ "[substrate] %s acquired lease_id=%s (idempotent=%s)",
191
+ matched,
192
+ cache.lease_id,
193
+ result.idempotent,
194
+ )
195
+ return True
196
+
197
+ logger.debug("[substrate] %s acquire non-OK: %r", matched, type(result).__name__)
198
+ return False
199
+
200
+ from unitares_sdk.lease_plane.models import RenewRequest, SimpleOk
201
+
202
+ try:
203
+ renew_request = RenewRequest(
204
+ lease_id=cache.lease_id,
205
+ substrate_state=substrate_state,
206
+ substrate_state_observed_at=observed_at,
207
+ )
208
+ result = client.renew(renew_request)
209
+ except Exception as exc: # noqa: BLE001
210
+ logger.debug("[substrate] %s renew raised: %r", matched, exc)
211
+ cache.reset()
212
+ return False
213
+
214
+ if isinstance(result, SimpleOk):
215
+ return True
216
+
217
+ logger.debug(
218
+ "[substrate] %s renew non-OK (%r); resetting for re-acquire",
219
+ matched,
220
+ type(result).__name__,
221
+ )
222
+ cache.reset()
223
+ return False