novarch 0.1.2__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.
novarch/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Novarch SDK — public surface customers depend on.
2
+
3
+ ``from novarch import augur, NovarchKillError`` is the only import customers
4
+ need. Everything else (HTTP client, ContextVar plumbing, upstream wrapping)
5
+ is private.
6
+
7
+ See ``docs/designs/live-system-implementation.md`` Phase 3.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from novarch._version import __version__ as __version__ # re-export (not in __all__)
12
+ from novarch.augur import augur
13
+ from novarch.exceptions import NovarchKillError
14
+
15
+ # ``__version__`` is accessible as ``novarch.__version__`` but deliberately
16
+ # kept out of ``__all__`` — the ``*``-exported surface stays augur +
17
+ # NovarchKillError only (see test_public_novarch_surface_is_minimal).
18
+ __all__ = ["augur", "NovarchKillError"]
novarch/_client.py ADDED
@@ -0,0 +1,393 @@
1
+ """HTTP client to ``novarch_server``.
2
+
3
+ Sync ``httpx.Client`` because v0 SDK is sync. One client is created per
4
+ session at decoration time and torn down at function exit.
5
+
6
+ The client deals only in plain dicts — Pydantic validation lives at the
7
+ caller (decorator) layer where it can attach session/action context to
8
+ errors.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from novarch._version import VERSION as _SDK_VERSION
18
+
19
+
20
+ class NovarchAPIError(RuntimeError):
21
+ """Raised when the backend returns a non-2xx response.
22
+
23
+ The decorator catches this to translate timeouts and 5xxs into a
24
+ fail-closed ``NovarchKillError`` rather than letting an httpx
25
+ ``HTTPStatusError`` bubble up to customer code.
26
+ """
27
+
28
+ def __init__(self, status_code: int, detail: str) -> None:
29
+ super().__init__(f"novarch_server {status_code}: {detail}")
30
+ self.status_code = status_code
31
+ self.detail = detail
32
+
33
+
34
+ class NovarchClient:
35
+ def __init__(
36
+ self,
37
+ server_url: str,
38
+ *,
39
+ timeout_s: float = 30.0,
40
+ http_client: httpx.Client | None = None,
41
+ sdk_key: str | None = None,
42
+ ) -> None:
43
+ # Strip trailing slash so urljoin-style paths in the methods stay
44
+ # readable (`f"{base}/sessions"` instead of conditional joins).
45
+ self._base = server_url.rstrip("/")
46
+ # ``http_client`` is the SDK's seam for tests: pass a TestClient (or
47
+ # any httpx.Client subclass) bound to a TestClient-driven app and
48
+ # the SDK talks to your in-process server with no socket.
49
+ self._owns_http = http_client is None
50
+ # Phase 2 task 1: resolve the SDK key from explicit kwarg → env →
51
+ # nothing. When the SDK is calling a configured server the
52
+ # ``X-Novarch-Key`` header is required; partner agents deploy with
53
+ # ``NOVARCH_SDK_KEY`` set in their env. Tests that pass an
54
+ # explicit ``http_client`` bypass this default-header path —
55
+ # FastAPI's dependency_overrides handle auth in that case.
56
+ resolved_key = (
57
+ sdk_key if sdk_key is not None else os.environ.get("NOVARCH_SDK_KEY")
58
+ )
59
+ # Phase 6 task 4: declare the SDK version on every request so the
60
+ # server can reject a major.minor-incompatible SDK with one explicit
61
+ # 400 instead of a silent fail-closed kill on every action. Sent only
62
+ # on clients that own their httpx.Client; tests injecting an
63
+ # ``http_client`` (TestClient) deliberately skip the default headers.
64
+ default_headers: dict[str, str] = {"X-Novarch-SDK-Version": _SDK_VERSION}
65
+ if resolved_key:
66
+ default_headers["X-Novarch-Key"] = resolved_key
67
+ self._http = http_client or httpx.Client(
68
+ base_url=self._base,
69
+ timeout=timeout_s,
70
+ headers=default_headers,
71
+ )
72
+
73
+ def close(self) -> None:
74
+ if self._owns_http:
75
+ self._http.close()
76
+
77
+ def __enter__(self) -> NovarchClient:
78
+ return self
79
+
80
+ def __exit__(self, *exc: Any) -> None:
81
+ self.close()
82
+
83
+ def create_session(
84
+ self,
85
+ *,
86
+ tenant_id: str,
87
+ team_id: str,
88
+ agent_id: str,
89
+ session_id: str | None = None,
90
+ ) -> dict[str, Any]:
91
+ body: dict[str, Any] = {
92
+ "tenant_id": tenant_id,
93
+ "team_id": team_id,
94
+ "agent_id": agent_id,
95
+ }
96
+ if session_id is not None:
97
+ body["session_id"] = session_id
98
+ return self._post_json("/sessions", body)
99
+
100
+ def submit_action(
101
+ self,
102
+ session_id: str,
103
+ *,
104
+ action_type: str,
105
+ action_context: dict[str, Any],
106
+ agent_reasoning: str,
107
+ tools_called: list[dict[str, Any]],
108
+ action_id: str | None = None,
109
+ poll_timeout_s: float | None = None,
110
+ ) -> dict[str, Any]:
111
+ body: dict[str, Any] = {
112
+ "action_type": action_type,
113
+ "action_context": action_context,
114
+ "agent_reasoning": agent_reasoning,
115
+ "tools_called": tools_called,
116
+ }
117
+ # Zombie-hold resolution Task 6: forward the SDK's own poll deadline
118
+ # so the server can stamp decisions.hold_deadline precisely (Task 5)
119
+ # instead of leaving it NULL for the conservative reaper sweep.
120
+ # Omitted (not sent as None) for back-compat with servers that
121
+ # predate this field.
122
+ if poll_timeout_s is not None:
123
+ body["poll_timeout_s"] = poll_timeout_s
124
+ if action_id is not None:
125
+ body["action_id"] = action_id
126
+ return self._post_json(f"/sessions/{session_id}/actions", body)
127
+
128
+ def report_timeout(self, session_id: str, action_id: str) -> None:
129
+ """Best-effort: tell the server the agent gave up waiting on a HOLD
130
+ so it fail-closes server-side (precise) instead of waiting for the
131
+ reaper backstop (Task 4). Callers swallow ``NovarchAPIError`` —
132
+ the local fail-closed raise stands regardless of whether this
133
+ report lands."""
134
+ self._post_no_content(f"/sessions/{session_id}/actions/{action_id}/timeout")
135
+
136
+ def complete_session(self, session_id: str) -> None:
137
+ """Mark the session completed (Task 2.5 / D3).
138
+
139
+ Called by ``@augur.session``'s ``finally`` on every exit path
140
+ (normal return or exception, including ``NovarchKillError``) so the
141
+ backend leaves ``status='active'``. Best-effort: the decorator
142
+ wraps this in a try/except and never lets a completion failure
143
+ break customer code, so any ``NovarchAPIError`` here is the
144
+ caller's problem to catch, not ours to hide."""
145
+ self._post_no_content(f"/sessions/{session_id}/complete")
146
+
147
+ def get_decision(self, session_id: str, action_id: str) -> dict[str, Any]:
148
+ """Returns ``{"decision": {...}, "verdict": {...} | None}`` immediately."""
149
+ return self._get_json(
150
+ f"/sessions/{session_id}/actions/{action_id}/decision"
151
+ )
152
+
153
+ def wait_for_verdict(
154
+ self, session_id: str, action_id: str, *, wait_s: float
155
+ ) -> dict[str, Any]:
156
+ """Long-poll for the operator verdict (Phase 4 Task 4).
157
+
158
+ The server holds the request open up to ``wait_s`` seconds until a
159
+ verdict for this action is committed, then returns the same
160
+ ``{"decision": ..., "verdict": ...}`` shape as ``get_decision`` (verdict
161
+ ``None`` on timeout, so the caller loops). The caller keeps ``wait_s``
162
+ below the client's read timeout (default 30s) so the hold returns before
163
+ httpx gives up; a verdict that lands mid-hold is returned at once
164
+ regardless, so a low client timeout only re-polls more often.
165
+ """
166
+ return self._get_json(
167
+ f"/sessions/{session_id}/actions/{action_id}/decision/wait?wait={wait_s}"
168
+ )
169
+
170
+ def submit_operator_verdict(
171
+ self,
172
+ session_id: str,
173
+ action_id: str,
174
+ *,
175
+ verdict: str,
176
+ note: str | None = None,
177
+ operator_id: str | None = None,
178
+ ) -> dict[str, Any]:
179
+ """Phase 2 task 3: ``operator_id`` (when supplied) is sent as the
180
+ ``X-Operator-Id`` header. The server validates it against the
181
+ ``NOVARCH_OPERATOR_IDS`` allowlist and writes it into the audit
182
+ row's ``operator_id`` column."""
183
+ body: dict[str, Any] = {"verdict": verdict}
184
+ if note is not None:
185
+ body["note"] = note
186
+ headers: dict[str, str] = {}
187
+ if operator_id is not None:
188
+ headers["X-Operator-Id"] = operator_id
189
+ return self._post_json(
190
+ f"/sessions/{session_id}/actions/{action_id}/decision",
191
+ body,
192
+ headers=headers,
193
+ )
194
+
195
+ # ------------------------------------------------------------------
196
+ # Internals
197
+ # ------------------------------------------------------------------
198
+
199
+ def _post_json(
200
+ self,
201
+ path: str,
202
+ body: dict[str, Any],
203
+ headers: dict[str, str] | None = None,
204
+ ) -> dict[str, Any]:
205
+ try:
206
+ r = self._http.post(path, json=body, headers=headers or None)
207
+ except httpx.HTTPError as e:
208
+ raise NovarchAPIError(0, f"network error: {e}") from e
209
+ if r.status_code >= 400:
210
+ raise NovarchAPIError(r.status_code, _safe_body(r))
211
+ return _json_or_raise(r)
212
+
213
+ def _get_json(self, path: str) -> dict[str, Any]:
214
+ try:
215
+ r = self._http.get(path)
216
+ except httpx.HTTPError as e:
217
+ raise NovarchAPIError(0, f"network error: {e}") from e
218
+ if r.status_code >= 400:
219
+ raise NovarchAPIError(r.status_code, _safe_body(r))
220
+ return _json_or_raise(r)
221
+
222
+ def _post_no_content(self, path: str) -> None:
223
+ """POST with no request body. The response body (if any) is never
224
+ parsed — distinct from ``_post_json``, which always parses JSON.
225
+ Used by endpoints like ``/complete`` (204) and the Task 6
226
+ ``/timeout`` self-report (200 JSON) that are state-transition
227
+ writes whose response payload the caller doesn't need."""
228
+ try:
229
+ r = self._http.post(path)
230
+ except httpx.HTTPError as e:
231
+ raise NovarchAPIError(0, f"network error: {e}") from e
232
+ if r.status_code >= 400:
233
+ raise NovarchAPIError(r.status_code, _safe_body(r))
234
+
235
+
236
+ class NovarchAsyncClient:
237
+ """Async mirror of ``NovarchClient`` over ``httpx.AsyncClient``.
238
+
239
+ Intended for framework adapters that run on an event loop (LangGraph,
240
+ CrewAI, ADK, etc.) where the sync client would block the loop during
241
+ the judge round-trip. Constructor surface is identical to
242
+ ``NovarchClient`` so adapters can switch by swapping the class.
243
+ """
244
+
245
+ def __init__(
246
+ self,
247
+ server_url: str,
248
+ *,
249
+ timeout_s: float = 30.0,
250
+ http_client: httpx.AsyncClient | None = None,
251
+ sdk_key: str | None = None,
252
+ ) -> None:
253
+ self._base = server_url.rstrip("/")
254
+ self._owns_http = http_client is None
255
+ resolved_key = (
256
+ sdk_key if sdk_key is not None else os.environ.get("NOVARCH_SDK_KEY")
257
+ )
258
+ default_headers: dict[str, str] = {"X-Novarch-SDK-Version": _SDK_VERSION}
259
+ if resolved_key:
260
+ default_headers["X-Novarch-Key"] = resolved_key
261
+ self._http = http_client or httpx.AsyncClient(
262
+ base_url=self._base,
263
+ timeout=timeout_s,
264
+ headers=default_headers,
265
+ )
266
+
267
+ async def aclose(self) -> None:
268
+ if self._owns_http:
269
+ await self._http.aclose()
270
+
271
+ async def __aenter__(self) -> NovarchAsyncClient:
272
+ return self
273
+
274
+ async def __aexit__(self, *exc: Any) -> None:
275
+ await self.aclose()
276
+
277
+ async def acreate_session(
278
+ self,
279
+ *,
280
+ tenant_id: str,
281
+ team_id: str,
282
+ agent_id: str,
283
+ session_id: str | None = None,
284
+ ) -> dict[str, Any]:
285
+ body: dict[str, Any] = {
286
+ "tenant_id": tenant_id,
287
+ "team_id": team_id,
288
+ "agent_id": agent_id,
289
+ }
290
+ if session_id is not None:
291
+ body["session_id"] = session_id
292
+ return await self._apost_json("/sessions", body)
293
+
294
+ async def asubmit_action(
295
+ self,
296
+ session_id: str,
297
+ *,
298
+ action_type: str,
299
+ action_context: dict[str, Any],
300
+ agent_reasoning: str,
301
+ tools_called: list[dict[str, Any]],
302
+ action_id: str | None = None,
303
+ poll_timeout_s: float | None = None,
304
+ ) -> dict[str, Any]:
305
+ body: dict[str, Any] = {
306
+ "action_type": action_type,
307
+ "action_context": action_context,
308
+ "agent_reasoning": agent_reasoning,
309
+ "tools_called": tools_called,
310
+ }
311
+ if poll_timeout_s is not None:
312
+ body["poll_timeout_s"] = poll_timeout_s
313
+ if action_id is not None:
314
+ body["action_id"] = action_id
315
+ return await self._apost_json(f"/sessions/{session_id}/actions", body)
316
+
317
+ async def await_for_verdict(
318
+ self, session_id: str, action_id: str, *, wait_s: float
319
+ ) -> dict[str, Any]:
320
+ return await self._aget_json(
321
+ f"/sessions/{session_id}/actions/{action_id}/decision/wait?wait={wait_s}"
322
+ )
323
+
324
+ async def areport_timeout(self, session_id: str, action_id: str) -> None:
325
+ """Async mirror of ``NovarchClient.report_timeout`` (Task 6)."""
326
+ await self._apost_no_content(
327
+ f"/sessions/{session_id}/actions/{action_id}/timeout"
328
+ )
329
+
330
+ # ------------------------------------------------------------------
331
+ # Internals — reuse module-level _json_or_raise and _safe_body helpers
332
+ # ------------------------------------------------------------------
333
+
334
+ async def _apost_json(
335
+ self,
336
+ path: str,
337
+ body: dict[str, Any],
338
+ headers: dict[str, str] | None = None,
339
+ ) -> dict[str, Any]:
340
+ try:
341
+ r = await self._http.post(path, json=body, headers=headers or None)
342
+ except httpx.HTTPError as e:
343
+ raise NovarchAPIError(0, f"network error: {e}") from e
344
+ if r.status_code >= 400:
345
+ raise NovarchAPIError(r.status_code, _safe_body(r))
346
+ return _json_or_raise(r)
347
+
348
+ async def _aget_json(self, path: str) -> dict[str, Any]:
349
+ try:
350
+ r = await self._http.get(path)
351
+ except httpx.HTTPError as e:
352
+ raise NovarchAPIError(0, f"network error: {e}") from e
353
+ if r.status_code >= 400:
354
+ raise NovarchAPIError(r.status_code, _safe_body(r))
355
+ return _json_or_raise(r)
356
+
357
+ async def _apost_no_content(self, path: str) -> None:
358
+ """Async mirror of ``NovarchClient._post_no_content``: POST with no
359
+ request body, response body (204 or 200 JSON) never parsed."""
360
+ try:
361
+ r = await self._http.post(path)
362
+ except httpx.HTTPError as e:
363
+ raise NovarchAPIError(0, f"network error: {e}") from e
364
+ if r.status_code >= 400:
365
+ raise NovarchAPIError(r.status_code, _safe_body(r))
366
+
367
+
368
+ def _json_or_raise(r: httpx.Response) -> dict[str, Any]:
369
+ """Parse a 2xx body as JSON, or raise ``NovarchAPIError``.
370
+
371
+ A 200 with a non-JSON body (a proxy, captive portal, or wrong service
372
+ answering) would otherwise raise a raw ``json.JSONDecodeError`` from
373
+ ``r.json()`` — which is not an ``httpx.HTTPError``, so it would bypass the
374
+ decorator's fail-closed translation and crash customer code with an
375
+ undocumented exception. Funnel it through ``NovarchAPIError`` so the
376
+ write decorator turns it into a ``NovarchKillError`` like every other
377
+ transport failure.
378
+ """
379
+ try:
380
+ return r.json()
381
+ except ValueError as e: # json.JSONDecodeError is a ValueError subclass
382
+ content_type = r.headers.get("content-type", "unknown")
383
+ raise NovarchAPIError(
384
+ r.status_code,
385
+ f"expected JSON but got {content_type}: {_safe_body(r)[:200]}",
386
+ ) from e
387
+
388
+
389
+ def _safe_body(r: httpx.Response) -> str:
390
+ try:
391
+ return r.json().get("detail") or r.text
392
+ except Exception:
393
+ return r.text