underwrit-client 0.3.0__tar.gz → 0.4.0.dev2__tar.gz

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.
Files changed (25) hide show
  1. underwrit_client-0.4.0.dev2/LICENSE +21 -0
  2. underwrit_client-0.4.0.dev2/NOTICE +5 -0
  3. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/PKG-INFO +19 -2
  4. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/README.md +14 -0
  5. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/pyproject.toml +2 -2
  6. underwrit_client-0.4.0.dev2/underwrit_client/__init__.py +456 -0
  7. underwrit_client-0.4.0.dev2/underwrit_client/durable.py +104 -0
  8. underwrit_client-0.4.0.dev2/underwrit_client/integrations/autogen.py +56 -0
  9. underwrit_client-0.4.0.dev2/underwrit_client/integrations/crewai.py +49 -0
  10. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/langgraph.py +20 -1
  11. underwrit_client-0.4.0.dev2/underwrit_client/integrations/temporal.py +83 -0
  12. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client.egg-info/PKG-INFO +19 -2
  13. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client.egg-info/SOURCES.txt +7 -1
  14. underwrit_client-0.3.0/underwrit_client/__init__.py +0 -265
  15. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/setup.cfg +0 -0
  16. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/__init__.py +0 -0
  17. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/claude_code.py +0 -0
  18. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/google_adk.py +0 -0
  19. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/http_interceptor.py +0 -0
  20. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/openai_agents.py +0 -0
  21. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/otel.py +0 -0
  22. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/task.py +0 -0
  23. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client.egg-info/dependency_links.txt +0 -0
  24. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client.egg-info/entry_points.txt +0 -0
  25. {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client.egg-info/top_level.txt +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Underwrit
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,5 @@
1
+ underwrit-client
2
+ Copyright 2026 Underwrit
3
+
4
+ MIT License (see LICENSE). The Underwrit data plane and control plane this
5
+ client talks to are licensed separately, under the Underwrit Software Licence.
@@ -1,14 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: underwrit-client
3
- Version: 0.3.0
3
+ Version: 0.4.0.dev2
4
4
  Summary: Client for an Underwrit data plane: decide, claim, record, and produce evidence for what an agent did.
5
5
  License: MIT
6
- Project-URL: Homepage, https://github.com/underwrit-io/dataplane
6
+ Project-URL: Homepage, https://underwrit.io
7
7
  Classifier: Programming Language :: Python :: 3
8
8
  Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Topic :: Security
10
10
  Requires-Python: >=3.9
11
11
  Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ License-File: NOTICE
14
+ Dynamic: license-file
12
15
 
13
16
  # underwrit-client
14
17
 
@@ -53,3 +56,17 @@ The full routes are in the Underwrit README; this client adds nothing the servic
53
56
  checkpoint) once a checkpoint covers the entry; `Decision["receipt"]` carries the chain position now.
54
57
  - 429 and 503 are retried with `Retry-After` (three times by default, capped at thirty seconds).
55
58
  - `Held` exposes `id`, `verdict`, `budgeted`, `duplicate`, `quorum` and `risk`.
59
+ - `decide(..., usage={"usd": 0.42}, delegation=["planner", "ops"], components={"model": "m-1"})`
60
+ reports cost, the agents behind the call and what the agent is built from; the chain keeps names
61
+ and digests only.
62
+ - `preview(tool, args, session=… | environment=…)` asks the gate without recording anything;
63
+ `gates()` lists its stages and codes.
64
+ - `verify(decision_id, "passed" | "failed" | "inconclusive")` lands a verification after the
65
+ outcome; `verifications_due()` and `approval_load()` need a reader's token.
66
+ - `compensate(decision_id, tool, args, reason=…)` proposes an undo for a decision whose outcome is
67
+ uncertain or whose verification failed. It always goes to a person; claim it once approved. It
68
+ does not clear the original's scope hold.
69
+
70
+ ## Licence
71
+
72
+ MIT (see LICENSE). The Underwrit planes this client talks to are licensed separately.
@@ -41,3 +41,17 @@ The full routes are in the Underwrit README; this client adds nothing the servic
41
41
  checkpoint) once a checkpoint covers the entry; `Decision["receipt"]` carries the chain position now.
42
42
  - 429 and 503 are retried with `Retry-After` (three times by default, capped at thirty seconds).
43
43
  - `Held` exposes `id`, `verdict`, `budgeted`, `duplicate`, `quorum` and `risk`.
44
+ - `decide(..., usage={"usd": 0.42}, delegation=["planner", "ops"], components={"model": "m-1"})`
45
+ reports cost, the agents behind the call and what the agent is built from; the chain keeps names
46
+ and digests only.
47
+ - `preview(tool, args, session=… | environment=…)` asks the gate without recording anything;
48
+ `gates()` lists its stages and codes.
49
+ - `verify(decision_id, "passed" | "failed" | "inconclusive")` lands a verification after the
50
+ outcome; `verifications_due()` and `approval_load()` need a reader's token.
51
+ - `compensate(decision_id, tool, args, reason=…)` proposes an undo for a decision whose outcome is
52
+ uncertain or whose verification failed. It always goes to a person; claim it once approved. It
53
+ does not clear the original's scope hold.
54
+
55
+ ## Licence
56
+
57
+ MIT (see LICENSE). The Underwrit planes this client talks to are licensed separately.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "underwrit-client"
7
- version = "0.3.0"
7
+ version = "0.4.0.dev2"
8
8
  description = "Client for an Underwrit data plane: decide, claim, record, and produce evidence for what an agent did."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -17,7 +17,7 @@ classifiers = [
17
17
  ]
18
18
 
19
19
  [project.urls]
20
- Homepage = "https://github.com/underwrit-io/dataplane"
20
+ Homepage = "https://underwrit.io"
21
21
 
22
22
  [project.scripts]
23
23
  underwrit-hook = "underwrit_client.integrations.claude_code:main"
@@ -0,0 +1,456 @@
1
+ """underwrit-client: integrate an agent runtime with an Underwrit data plane in under twenty lines.
2
+
3
+ from underwrit_client import Underwrit, Held
4
+
5
+ underwrit = Underwrit("http://127.0.0.1:8787", token=os.environ["UNDERWRIT_AGENT_TOKEN"])
6
+ with underwrit.session(agent="ops-assistant", environment="production",
7
+ intent="Restart api during incident 4417") as s:
8
+ logs = s.call("k8s/get_k8s_logs", get_logs, pod="api-1") # decided, run, recorded
9
+ try:
10
+ s.call("k8s/rollout_restart", restart, deployment="api") # held? raises Held
11
+ except Held as h:
12
+ print("waiting on a person:", h.decision["id"])
13
+
14
+ Standard library only. Every method maps to one data-plane route, and the shapes returned are the
15
+ service's own — nothing is reinterpreted here.
16
+
17
+ **`Held` is the contract.** When the data plane says `act: "hold"`, the wrapped call is not made
18
+ and `Held` is raised with the decision attached, so a runtime can park the task, show the decision
19
+ id, and come back with `claim()` once a person has answered. In shadow mode `act` is `"proceed"`
20
+ and nothing is raised; the verdict is still recorded.
21
+
22
+ **A claim before an approved execution.** `claim()` presents the same arguments again and is told
23
+ whether the approval still holds — same arguments, same relevant state, still within its validity
24
+ window, not already used. Use it immediately before executing anything that was held.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import functools
30
+ import json
31
+ import time
32
+ import urllib.error
33
+ import urllib.request
34
+ from typing import Any, Callable
35
+
36
+ __version__ = "0.4.0"
37
+ __all__ = ["Underwrit", "Session", "Held", "UnderwritError", "guarded", "tool_definition_digest"]
38
+
39
+
40
+ def tool_definition_digest(definition: dict) -> str:
41
+ """SHA-256 over the canonical JSON of a tool's `name`, `description` and `inputSchema`.
42
+
43
+ The same construction as `underwrit.scope.tool_definition_digest`, kept here so a runtime
44
+ needs only this package: those three keys and no others, keys sorted, `,`/`:` separators, no
45
+ whitespace, non-ASCII left as UTF-8. Pass the result as `tool_definition_digest` to `decide()`
46
+ and again to `claim()`; the data plane refuses a claim whose digest differs from the one the
47
+ decision was made against.
48
+ """
49
+ import hashlib
50
+
51
+ if not isinstance(definition, dict):
52
+ raise TypeError("a tool definition is a mapping with name, description and inputSchema")
53
+ canon = {"name": str(definition.get("name") or ""),
54
+ "description": str(definition.get("description") or ""),
55
+ "inputSchema": definition.get("inputSchema") if isinstance(definition.get("inputSchema"), dict) else {}}
56
+ return hashlib.sha256(json.dumps(canon, sort_keys=True, separators=(",", ":"),
57
+ ensure_ascii=False).encode("utf-8")).hexdigest()
58
+
59
+
60
+ class UnderwritError(Exception):
61
+ """The data plane refused or failed a request. `status` is the HTTP status."""
62
+
63
+ def __init__(self, status: int, message: str):
64
+ super().__init__(f"{status}: {message}")
65
+ self.status = status
66
+ self.message = message
67
+
68
+
69
+ class Held(Exception):
70
+ """The action must not proceed until a person answers, or was refused.
71
+
72
+ `decision` is the data plane's response — its `id` is what to resolve, and `reasons` say why.
73
+ """
74
+
75
+ def __init__(self, decision: dict, reason: str = ""):
76
+ rs = decision.get("reasons") or ([reason] if reason else [])
77
+ super().__init__(f"{decision.get('verdict', 'held')}: {'; '.join(rs) or 'held for a person'}")
78
+ self.decision = decision
79
+ self.reason = reason or (rs[0] if rs else "")
80
+ # What the data plane said beside the verdict, so a runtime can act on it without parsing.
81
+ self.id: str = str(decision.get("id") or "")
82
+ self.verdict: str = str(decision.get("verdict") or "")
83
+ self.budgeted: bool = bool(decision.get("budgeted"))
84
+ self.duplicate: bool = bool(decision.get("duplicate"))
85
+ self.quorum: dict | None = decision.get("quorum") if isinstance(decision.get("quorum"), dict) else None
86
+ self.risk: int = int(decision.get("risk") or 0)
87
+ # What to tell the model: don't retry, tell the user it waits for approval. Safe to show it.
88
+ self.guidance: dict = decision.get("agentGuidance") if isinstance(decision.get("agentGuidance"), dict) else {}
89
+
90
+
91
+ class Underwrit:
92
+ def __init__(self, url: str, token: str, *, timeout: float = 10.0, retries: int = 3,
93
+ max_retry_wait: float = 30.0, sleep: Callable[[float], None] = time.sleep):
94
+ self.url = url.rstrip("/")
95
+ self.token = token
96
+ self.timeout = timeout
97
+ self.retries = retries
98
+ self.max_retry_wait = max_retry_wait
99
+ self._sleep = sleep
100
+
101
+ # -- transport -------------------------------------------------------------------------
102
+
103
+ def _call(self, method: str, path: str, body: dict | None = None) -> dict:
104
+ """One request. 429 and 503 are retried, honouring Retry-After, up to `retries` times and
105
+ never longer than `max_retry_wait` per wait; every other error is the caller's."""
106
+ attempt = 0
107
+ while True:
108
+ req = urllib.request.Request(
109
+ f"{self.url}{path}",
110
+ data=json.dumps(body).encode("utf-8") if body is not None else None,
111
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.token}"},
112
+ method=method,
113
+ )
114
+ try:
115
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
116
+ return json.loads(r.read().decode("utf-8") or "{}")
117
+ except urllib.error.HTTPError as e:
118
+ try:
119
+ payload = json.loads(e.read().decode("utf-8") or "{}")
120
+ except ValueError:
121
+ payload = {}
122
+ if e.code in (429, 503) and attempt < self.retries:
123
+ attempt += 1
124
+ try:
125
+ wait = float(e.headers.get("Retry-After") or payload.get("retryAfterSeconds") or 0)
126
+ except (TypeError, ValueError):
127
+ wait = 0.0
128
+ self._sleep(min(self.max_retry_wait, wait or min(2.0 ** attempt, self.max_retry_wait)))
129
+ continue
130
+ raise UnderwritError(e.code, payload.get("error", "") or e.reason) from None
131
+ except urllib.error.URLError as e:
132
+ raise UnderwritError(0, f"cannot reach {self.url}: {e.reason}") from None
133
+
134
+ # -- the routes ------------------------------------------------------------------------
135
+
136
+ def open_session(self, *, session: str = "", agent: str = "", environment: str = "",
137
+ intent: str = "", actor: str = "", task_policy: dict | None = None) -> "Session":
138
+ s = self._call("POST", "/v1/sessions", {"session": session, "agent": agent,
139
+ "environment": environment, "intent": intent,
140
+ "actor": actor,
141
+ **({"taskPolicy": task_policy} if task_policy else {})})
142
+ return Session(self, s["id"], s)
143
+
144
+ session = open_session
145
+
146
+ def decide(self, session: str, tool: str, arguments: dict | None = None, *,
147
+ preconditions: Any = None, idempotency_key: str = "", signals: dict | None = None,
148
+ scope: str | None = None, tool_definition_digest: str | None = None,
149
+ usage: dict | None = None, delegation: list | None = None,
150
+ components: dict | None = None) -> dict:
151
+ """`signals` carries an external classifier's verdict ({"taint": True, "risk": 80,
152
+ "source": "my-classifier"}); it can raise taint and risk, never lower them.
153
+
154
+ `scope` names the target this action reaches when the data plane's own derivation (the
155
+ tool's declared authority fields, else identifier-looking argument keys) would be wrong or
156
+ too wide; the plane records a keyed digest of it, never the string. An unknown outcome
157
+ holds the scope for every later proposal on it. `tool_definition_digest` pins the tool's
158
+ definition into the approval; present the same one at `claim()`.
159
+
160
+ `usage` is what this call costs, by unit ({"usd": 0.42, "tokens": 1800}); the policy's spend
161
+ limits are sums over a session's reported usage. `delegation` is the chain of agents that
162
+ proposed the call, outermost first, held to the policy's maximum depth. `components` is a
163
+ manifest of what the agent is built from (model, prompt digest, tool versions); the chain
164
+ keeps its keys and digest. All three are recorded as names and digests, never values.
165
+ """
166
+ body: dict = {"session": session, "tool": tool, "arguments": arguments or {}}
167
+ if usage:
168
+ body["usage"] = usage
169
+ if delegation:
170
+ body["delegation"] = delegation
171
+ if components:
172
+ body["components"] = components
173
+ if preconditions is not None:
174
+ body["preconditions"] = preconditions
175
+ if idempotency_key:
176
+ body["idempotencyKey"] = idempotency_key
177
+ if signals:
178
+ body["signals"] = signals
179
+ if scope:
180
+ body["scope"] = scope
181
+ if tool_definition_digest:
182
+ body["toolDefinitionDigest"] = tool_definition_digest
183
+ return self._call("POST", "/v1/decide", body)
184
+
185
+ def preview(self, tool: str, arguments: dict | None = None, *, session: str = "",
186
+ environment: str = "", intent: str = "", taint: list | None = None,
187
+ task_policy: dict | None = None, policy: dict | None = None, **v1) -> dict:
188
+ """"Ask the gate": what this proposal would get right now, recording nothing.
189
+
190
+ Against an existing `session`, or a hypothetical one (`environment`, `intent`, `taint`,
191
+ `task_policy`); `policy` judges it against a candidate policy instead of the live one. The
192
+ answer has `preview: True, recorded: False` — it is not evidence, and the decision that
193
+ counts is still `decide()`'s. `usage`, `delegation` and `components` are accepted as in
194
+ `decide()`.
195
+ """
196
+ body: dict = {"tool": tool, "arguments": arguments or {}}
197
+ for k, v in (("session", session), ("environment", environment), ("intent", intent), ("taint", taint),
198
+ ("taskPolicy", task_policy), ("policy", policy)):
199
+ if v:
200
+ body[k] = v
201
+ body.update({k: v for k, v in v1.items() if k in ("usage", "delegation", "components") and v})
202
+ return self._call("POST", "/v1/preview", body)
203
+
204
+ def gates(self) -> dict:
205
+ """The stages the gate asks, in order, and every code a decision can carry."""
206
+ return self._call("GET", "/v1/gates")
207
+
208
+ def verify(self, decision_id: str, status: str, *, checks: list | None = None, summary: str = "") -> dict:
209
+ """A verification that lands after the outcome (`passed`, `failed` or `inconclusive`) —
210
+ for a check that takes minutes to settle, inside the policy's post-check window."""
211
+ if status not in ("passed", "failed", "inconclusive"):
212
+ raise ValueError("status must be passed, failed or inconclusive")
213
+ body: dict = {"decision": decision_id, "status": status}
214
+ if checks:
215
+ body["checks"] = checks
216
+ if summary:
217
+ body["summary"] = summary
218
+ return self._call("POST", "/v1/verify", body)
219
+
220
+ def verifications_due(self, *, include_verified: bool = False) -> dict:
221
+ """Open post-checks, oldest first, with how many are overdue. Needs the `read` grant."""
222
+ return self._call("GET", "/v1/verifications/due" + ("?all=1" if include_verified else ""))
223
+
224
+ def approval_load(self, days: int = 7) -> dict:
225
+ """Holds per tool over the last `days`, and how fast people answered. Needs `read`."""
226
+ return self._call("GET", f"/v1/approvals/load?days={int(days)}")
227
+
228
+ def compensate(self, decision_id: str, tool: str, arguments: dict | None = None, *, reason: str,
229
+ idempotency_key: str = "", tool_definition_digest: str | None = None) -> dict:
230
+ """Propose an action that undoes or repairs `decision_id`, whose effect is unknown.
231
+
232
+ Allowed when its outcome is uncertain, its verification failed, or a scope hold it placed
233
+ still stands. The proposal always goes to a person (`act: "hold"`), so the usual path is:
234
+ `compensate()`, wait for the answer, then `claim()` with the same arguments and run it. It
235
+ does not clear the original's hold — probe, reconcile or release that once the undo ran.
236
+ """
237
+ if not reason or not reason.strip():
238
+ raise ValueError("compensate() needs a reason; a person reads it before approving")
239
+ body: dict = {"tool": tool, "arguments": arguments or {}, "reason": reason}
240
+ if idempotency_key:
241
+ body["idempotencyKey"] = idempotency_key
242
+ if tool_definition_digest:
243
+ body["toolDefinitionDigest"] = tool_definition_digest
244
+ return self._call("POST", f"/v1/decisions/{decision_id}/compensate", body)
245
+
246
+ def receipt(self, seq: int) -> dict:
247
+ """The per-entry receipt: leaf index, tree size, root, inclusion proof and the signed
248
+ checkpoint. `pending: True` until a checkpoint covers the entry."""
249
+ return self._call("GET", f"/v1/receipts/{int(seq)}")
250
+
251
+ def pending(self, limit: int = 100, offset: int = 0) -> dict:
252
+ return self._call("GET", f"/v1/pending?limit={int(limit)}&offset={int(offset)}")
253
+
254
+ def claim(self, decision_id: str, arguments: dict, *,
255
+ preconditions: Any = None, tool_definition_digest: str | None = None) -> dict:
256
+ """Revalidate an approval against the arguments about to be executed.
257
+
258
+ `arguments` is required: the approval is bound to it, and the data plane refuses a claim
259
+ that presents nothing to check. Pass the same mapping `decide` was given — `{}` if the
260
+ action genuinely takes no arguments — and the same `tool_definition_digest` if the
261
+ decision was given one: a definition that changed in between is refused.
262
+ """
263
+ if not isinstance(arguments, dict):
264
+ raise TypeError("claim() needs the arguments the approval was bound to; pass {} for "
265
+ "an action that takes none")
266
+ body: dict = {"arguments": arguments}
267
+ if preconditions is not None:
268
+ body["preconditions"] = preconditions
269
+ if tool_definition_digest:
270
+ body["toolDefinitionDigest"] = tool_definition_digest
271
+ return self._call("POST", f"/v1/decisions/{decision_id}/claim", body)
272
+
273
+ def outcome(self, decision_id: str, status: str, detail: str = "", *,
274
+ verification: dict | None = None, reconcile: bool = False) -> dict:
275
+ body: dict = {"decision": decision_id, "status": status, "detail": detail}
276
+ if verification:
277
+ body["verification"] = verification
278
+ if reconcile:
279
+ body["reconcile"] = True
280
+ return self._call("POST", "/v1/outcome", body)
281
+
282
+ def decision(self, decision_id: str) -> dict:
283
+ return self._call("GET", f"/v1/decisions/{decision_id}")
284
+
285
+ def wait(self, decision_id: str, timeout: float = 300.0) -> dict:
286
+ """Block until a held decision is answered (or expires), or `timeout` passes; returns it.
287
+
288
+ Long-polls the data plane (`?wait=`, at most a minute per request), so a person's answer is
289
+ seen at once without asking every few seconds. Check `lifecycle` on what comes back:
290
+ `approved` means `claim()` and run, anything else but `held` means don't.
291
+ """
292
+ end = time.monotonic() + max(0.0, timeout)
293
+ while True:
294
+ left = end - time.monotonic()
295
+ step = max(0, min(60, int(left)))
296
+ saved = self.timeout
297
+ self.timeout = max(saved, step + 10)
298
+ try:
299
+ d = self._call("GET", f"/v1/decisions/{decision_id}" + (f"?wait={step}" if step else ""))
300
+ finally:
301
+ self.timeout = saved
302
+ if d.get("lifecycle") != "held" or left <= 1:
303
+ return d
304
+
305
+ # -- durable suspension: park a held step, resume it when it settles ------------------
306
+
307
+ def park(self, decision_id: str, *, kind: str, key: str, resume_url: str = "", state: dict | None = None) -> dict:
308
+ """Hand a held decision to the plane and stop waiting; see `underwrit_client.durable`."""
309
+ return self._call("POST", f"/v1/decisions/{decision_id}/park",
310
+ {"kind": kind, "key": key, "resumeUrl": resume_url, "state": state or {}})
311
+
312
+ def parked(self, *, settled: bool | None = None, kind: str = "") -> dict:
313
+ q = [f"settled={'1' if settled else '0'}"] if settled is not None else []
314
+ q += [f"kind={kind}"] if kind else []
315
+ return self._call("GET", "/v1/parked" + ("?" + "&".join(q) if q else ""))
316
+
317
+ def ack_parked(self, decision_id: str) -> dict:
318
+ return self._call("POST", f"/v1/parked/{decision_id}/ack", {})
319
+
320
+ # -- scope holds: an unknown outcome holds the target ----------------------------------
321
+
322
+ def scope_holds(self, *, include_cleared: bool = False) -> dict:
323
+ """Active scope holds, or every hold ever placed. Needs the `read` grant."""
324
+ return self._call("GET", "/v1/scope-holds" + ("?all=1" if include_cleared else ""))
325
+
326
+ def scope_hold(self, hold_id: str) -> dict:
327
+ return self._call("GET", f"/v1/scope-holds/{hold_id}")
328
+
329
+ def probe_scope_hold(self, hold_id: str, status: str, detail: str) -> dict:
330
+ """Clear a hold with a read-back from an independent source. The plane records the
331
+ status, a digest of `detail` and its length — never the text."""
332
+ return self._call("POST", f"/v1/scope-holds/{hold_id}/probe", {"status": status, "detail": detail})
333
+
334
+ def release_scope_hold(self, hold_id: str, reason: str) -> dict:
335
+ """Release a hold as a person. Approver credential; two of them where enforcement is on."""
336
+ return self._call("POST", f"/v1/scope-holds/{hold_id}/release", {"reason": reason})
337
+
338
+ def close(self, session: str, status: str = "succeeded") -> dict:
339
+ return self._call("POST", f"/v1/sessions/{session}/close", {"status": status})
340
+
341
+ def evidence(self, session: str) -> dict:
342
+ return self._call("GET", f"/v1/evidence/{session}")
343
+
344
+ def health(self) -> dict:
345
+ return self._call("GET", "/v1/health")
346
+
347
+
348
+ class Session:
349
+ """One agent session. A context manager: closes on exit, `failed` if an exception escaped."""
350
+
351
+ def __init__(self, underwrit: Underwrit, session_id: str, meta: dict | None = None):
352
+ self.underwrit = underwrit
353
+ self.id = session_id
354
+ self.meta = meta or {}
355
+
356
+ def __enter__(self) -> "Session":
357
+ return self
358
+
359
+ def __exit__(self, exc_type, exc, tb) -> bool:
360
+ try:
361
+ self.underwrit.close(self.id, "failed" if exc_type else "succeeded")
362
+ except UnderwritError:
363
+ pass
364
+ return False
365
+
366
+ def decide(self, tool: str, arguments: dict | None = None, **kw) -> dict:
367
+ return self.underwrit.decide(self.id, tool, arguments, **kw)
368
+
369
+ def outcome(self, decision_id: str, status: str, detail: str = "", **kw) -> dict:
370
+ return self.underwrit.outcome(decision_id, status, detail, **kw)
371
+
372
+ def claim(self, decision_id: str, arguments: dict, **kw) -> dict:
373
+ return self.underwrit.claim(decision_id, arguments, **kw)
374
+
375
+ def preview(self, tool: str, arguments: dict | None = None, **kw) -> dict:
376
+ return self.underwrit.preview(tool, arguments, session=self.id, **kw)
377
+
378
+ def verify(self, decision_id: str, status: str, **kw) -> dict:
379
+ return self.underwrit.verify(decision_id, status, **kw)
380
+
381
+ def compensate(self, decision_id: str, tool: str, arguments: dict | None = None, **kw) -> dict:
382
+ return self.underwrit.compensate(decision_id, tool, arguments, **kw)
383
+
384
+ def call(self, tool: str, fn: Callable[..., Any], /, *args, preconditions: Any = None,
385
+ idempotency_key: str = "", verify: Callable[[Any], dict] | None = None,
386
+ signals: dict | None = None, scope: str | None = None,
387
+ tool_definition_digest: str | None = None, **kwargs) -> Any:
388
+ """Decide, then run `fn(*args, **kwargs)`, then report what happened. Raises `Held`.
389
+
390
+ The keyword arguments are the action's arguments as the data plane sees them; positional
391
+ arguments are passed through to `fn` but are not part of the decision. `scope` and
392
+ `tool_definition_digest` go to `decide()` and are not arguments of the action.
393
+ """
394
+ d = self.decide(tool, kwargs, preconditions=preconditions, idempotency_key=idempotency_key, signals=signals,
395
+ scope=scope, tool_definition_digest=tool_definition_digest)
396
+ if d.get("act") == "hold":
397
+ raise Held(d)
398
+ return self._run(d["id"], fn, args, kwargs, verify)
399
+
400
+ def resume(self, decision_id: str, fn: Callable[..., Any], /, *args,
401
+ preconditions: Any = None, verify: Callable[[Any], dict] | None = None,
402
+ tool_definition_digest: str | None = None, **kwargs) -> Any:
403
+ """After a person answered: claim the approval with the same arguments, then run.
404
+
405
+ `preconditions` and `tool_definition_digest` must be the same ones `call()` was given: the
406
+ data plane refuses a claim that omits preconditions a decision was bound to, because the
407
+ approver relied on that state and a claim that does not present it has not rechecked it —
408
+ and refuses one whose tool definition differs from the one the decision was made against.
409
+ """
410
+ c = self.claim(decision_id, kwargs, preconditions=preconditions,
411
+ tool_definition_digest=tool_definition_digest)
412
+ if not c.get("ok") and c.get("act") == "hold":
413
+ raise Held(c.get("decision") or {"id": decision_id}, c.get("reason", ""))
414
+ return self._run(decision_id, fn, args, kwargs, verify)
415
+
416
+ def _run(self, decision_id, fn, args, kwargs, verify):
417
+ try:
418
+ result = fn(*args, **kwargs)
419
+ except TimeoutError as exc:
420
+ # A timeout may mean the action completed and the response was lost. Recorded as
421
+ # uncertain, which the data plane refuses to let a retry overwrite silently.
422
+ self.outcome(decision_id, "uncertain", str(exc)[:2000])
423
+ raise
424
+ except Exception as exc:
425
+ self.outcome(decision_id, "failed", str(exc)[:2000])
426
+ raise
427
+ detail = result if isinstance(result, str) else json.dumps(result, default=str)[:2000]
428
+ ver = None
429
+ if verify is not None:
430
+ try:
431
+ ver = verify(result)
432
+ except Exception as exc: # noqa: BLE001 — a broken check is a failed verification
433
+ ver = {"status": "failed", "summary": f"verification raised {type(exc).__name__}"}
434
+ self.outcome(decision_id, "succeeded", detail, verification=ver)
435
+ return result
436
+
437
+
438
+ def guarded(session: Session, tool: str | None = None, **opts):
439
+ """Decorate a tool function so every call goes through the session first.
440
+
441
+ @guarded(session, "bank/send_money")
442
+ def send_money(amount, recipient): ...
443
+
444
+ The function must be called with keyword arguments; they are the action's arguments.
445
+ """
446
+
447
+ def wrap(fn):
448
+ name = tool or fn.__name__
449
+
450
+ @functools.wraps(fn)
451
+ def inner(**kwargs):
452
+ return session.call(name, fn, **opts, **kwargs)
453
+
454
+ return inner
455
+
456
+ return wrap
@@ -0,0 +1,104 @@
1
+ """Durable suspension: a held step stops the workflow instead of holding a process open.
2
+
3
+ from underwrit_client.durable import Resumer
4
+
5
+ resumer = Resumer(uw, secret=os.environ["UNDERWRIT_RESUME_WEBHOOK_SECRET"])
6
+ resumer.on("temporal", temporal.resume_with(temporal_client))
7
+ resumer.on("langgraph", langgraph.resume_with(graph))
8
+
9
+ # In the web handler that UNDERWRIT_RESUME_ALLOW lets the plane call:
10
+ resumer.handle(request.body, request.headers)
11
+ # On worker start-up, for anything settled while it was down or whose webhook was lost:
12
+ resumer.catch_up()
13
+
14
+ A handler gets the resume (`decision`, `kind`, `key`, `state`, `outcome`: approved | denied |
15
+ expired) and resumes the run the engine knows by `key`. The step itself then calls
16
+ `Session.resume()`, which claims the approval against the same arguments before running; a denied
17
+ or expired step is told so and does not run. Each resume is acknowledged after its handler returns,
18
+ so one delivered twice is resumed once, and one whose handler failed is offered again.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import hashlib
25
+ import hmac
26
+ import inspect
27
+ import json
28
+ import time
29
+ from typing import Any, Callable
30
+
31
+ from . import Underwrit
32
+
33
+
34
+ class BadResume(Exception):
35
+ """The body is not a resume this plane signed (or it is too old to trust)."""
36
+
37
+
38
+ def verify(body: bytes, headers: dict, secret: str, *, tolerance: float = 300.0, now: float | None = None) -> dict:
39
+ """The resume, if its signature verifies; raises BadResume otherwise. Header names are matched
40
+ case-insensitively."""
41
+ h = {str(k).lower(): str(v) for k, v in (headers or {}).items()}
42
+ if secret:
43
+ ts, sig = h.get("x-underwrit-timestamp", ""), h.get("x-underwrit-signature", "")
44
+ if not ts or not sig.startswith("sha256="):
45
+ raise BadResume("unsigned resume")
46
+ want = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
47
+ if not hmac.compare_digest(want, sig[7:]):
48
+ raise BadResume("signature does not verify")
49
+ try:
50
+ if abs((now or time.time()) - float(ts)) > tolerance:
51
+ raise BadResume("resume is too old")
52
+ except ValueError:
53
+ raise BadResume("bad timestamp") from None
54
+ doc = json.loads(body.decode("utf-8"))
55
+ if doc.get("event") != "decision.resume":
56
+ raise BadResume("not a resume")
57
+ return doc
58
+
59
+
60
+ class Resumer:
61
+ def __init__(self, client: Underwrit, *, secret: str = ""):
62
+ self.client = client
63
+ self.secret = secret
64
+ self.handlers: dict[str, Callable[[dict], Any]] = {}
65
+
66
+ def on(self, kind: str, handler: Callable[[dict], Any]) -> "Resumer":
67
+ self.handlers[kind] = handler
68
+ return self
69
+
70
+ def dispatch(self, resume: dict) -> Any:
71
+ handler = self.handlers.get(resume.get("kind", "")) or self.handlers.get("*")
72
+ if handler is None:
73
+ raise LookupError(f"no handler for {resume.get('kind')!r} resumes")
74
+ result = handler(resume)
75
+ if inspect.isawaitable(result):
76
+ result = asyncio.run(_awaited(result))
77
+ out = self.client.ack_parked(resume["decision"])
78
+ return {"result": result, "alreadyAcked": out.get("alreadyAcked", False)}
79
+
80
+ async def adispatch(self, resume: dict) -> Any:
81
+ """`dispatch` from inside an event loop (an async web handler, a Temporal client)."""
82
+ handler = self.handlers.get(resume.get("kind", "")) or self.handlers.get("*")
83
+ if handler is None:
84
+ raise LookupError(f"no handler for {resume.get('kind')!r} resumes")
85
+ result = handler(resume)
86
+ if inspect.isawaitable(result):
87
+ result = await result
88
+ out = await asyncio.to_thread(self.client.ack_parked, resume["decision"])
89
+ return {"result": result, "alreadyAcked": out.get("alreadyAcked", False)}
90
+
91
+ def handle(self, body: bytes, headers: dict) -> Any:
92
+ return self.dispatch(verify(body, headers, self.secret))
93
+
94
+ def catch_up(self, kind: str = "") -> list[str]:
95
+ done = []
96
+ for p in self.client.parked(settled=True, kind=kind).get("parked", []):
97
+ if p["kind"] in self.handlers or "*" in self.handlers:
98
+ self.dispatch({**p, "event": "decision.resume"})
99
+ done.append(p["decision"])
100
+ return done
101
+
102
+
103
+ async def _awaited(aw):
104
+ return await aw
@@ -0,0 +1,56 @@
1
+ """AutoGen AgentChat: a held tool call ends the turn; the resume reloads the team and continues.
2
+
3
+ from underwrit_client.integrations import autogen as uw_autogen
4
+
5
+ send_money = uw_autogen.guarded_tool(session, "bank/send_money", send_money_impl,
6
+ run_id=run_id, resume_url=RESUME_URL)
7
+ # ... the team runs; a held call returns a message saying it waits for approval, and
8
+ # the caller saves `await team.save_state()` under run_id and stops.
9
+
10
+ async def load(run_id): # the team as it was, from your store
11
+ team = build_team(); await team.load_state(STORE[run_id]); return team
12
+
13
+ resumer.on("autogen", uw_autogen.resume_with(load))
14
+
15
+ On an approval the reloaded team is run with a message naming the decision; the tool, called again
16
+ with the same arguments, claims the approval and runs. On a denial it is told not to.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .. import Session
22
+
23
+
24
+ def guarded_tool(session: Session, tool: str, fn, *, run_id: str, resume_url: str = "", preconditions=None):
25
+ """Wrap a tool for an AutoGen agent. A hold is parked and reported to the model, not raised."""
26
+ approved: dict[str, str] = {}
27
+
28
+ def _run(**kwargs):
29
+ d = session.decide(tool, kwargs, preconditions=preconditions)
30
+ if d.get("act") == "hold":
31
+ prior = d.get("duplicate") and d.get("lifecycle") == "approved"
32
+ if prior or approved.get(d["id"]) == "approved":
33
+ return session.resume(d["id"], fn, preconditions=preconditions, **kwargs)
34
+ session.underwrit.park(d["id"], kind="autogen", key=run_id, resume_url=resume_url)
35
+ g = d.get("agentGuidance") or {}
36
+ return (g.get("tellUser") or f"{tool} is waiting for a person to approve it (decision {d['id']}). "
37
+ "Do not retry; the run continues once it is answered.")
38
+ return session._run(d["id"], fn, (), kwargs, None)
39
+ _run.__name__ = getattr(fn, "__name__", tool.replace("/", "_"))
40
+ _run.__doc__ = getattr(fn, "__doc__", None)
41
+ _run.underwrit_approved = approved
42
+ return _run
43
+
44
+
45
+ def resume_with(load, *, message: str = ""):
46
+ """A Resumer handler: `load(run_id)` returns the team; it is run with the outcome."""
47
+ async def handler(resume: dict):
48
+ team = load(resume["key"])
49
+ if hasattr(team, "__await__"):
50
+ team = await team
51
+ outcome = resume.get("outcome", "")
52
+ task = message or (f"Decision {resume['decision']} on {resume.get('tool', 'the held step')} was {outcome}. "
53
+ + ("Call the tool again with the same arguments to run it." if outcome == "approved"
54
+ else "Do not run it; tell the user."))
55
+ return await team.run(task=task)
56
+ return handler
@@ -0,0 +1,49 @@
1
+ """CrewAI Flows: a held step ends the flow run; the resume kicks off the persisted flow again.
2
+
3
+ from underwrit_client.integrations import crewai as uw_crewai
4
+
5
+ @persist
6
+ class Payout(Flow[PayoutState]):
7
+ @start()
8
+ def propose(self):
9
+ d = uw_crewai.propose(session, "bank/send_money", self.state.args, flow_id=self.state.id,
10
+ resume_url=RESUME_URL)
11
+ self.state.decision = d["id"]
12
+ return d
13
+
14
+ @router(propose)
15
+ def route(self, d):
16
+ return "pay" if d["act"] == "proceed" else "parked" # "parked": the run ends here
17
+
18
+ @listen("pay")
19
+ def pay(self):
20
+ return session.resume(self.state.decision, send_money, **self.state.args)
21
+
22
+ resumer.on("crewai", uw_crewai.resume_with(Payout))
23
+
24
+ `@persist` keeps the state under its id. The resume calls `kickoff` with that id and the outcome,
25
+ so the restored flow can route to `pay` (approved) or stop (denied, expired); `pay` claims the
26
+ approval against the same arguments before running.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from .. import Session
32
+
33
+
34
+ def propose(session: Session, tool: str, arguments: dict, *, flow_id: str, resume_url: str = "",
35
+ preconditions=None) -> dict:
36
+ d = session.decide(tool, arguments, preconditions=preconditions)
37
+ if d.get("act") == "hold":
38
+ session.underwrit.park(d["id"], kind="crewai", key=flow_id, resume_url=resume_url)
39
+ return d
40
+
41
+
42
+ def resume_with(flow_cls, *, start_method: str | None = None):
43
+ """A Resumer handler: kick the persisted flow off again with the decision's outcome."""
44
+ def handler(resume: dict):
45
+ flow = flow_cls()
46
+ inputs = {"id": resume["key"], "underwrit_decision": resume["decision"],
47
+ "underwrit_outcome": resume.get("outcome", "")}
48
+ return flow.kickoff(inputs=inputs) if start_method is None else getattr(flow, start_method)(**inputs)
49
+ return handler
@@ -15,10 +15,15 @@ from __future__ import annotations
15
15
  from .. import Held, Session
16
16
 
17
17
 
18
- def guarded_tool(session: Session, tool: str, fn, *, preconditions=None, verify=None):
18
+ def guarded_tool(session: Session, tool: str, fn, *, preconditions=None, verify=None,
19
+ thread_id: str = "", resume_url: str = ""):
20
+ """With `thread_id`, a hold is also parked: the plane resumes the thread when it settles
21
+ (`resume_with(graph)` below), so nothing has to poll and no worker waits."""
19
22
  def _run(**kwargs):
20
23
  d = session.decide(tool, kwargs, preconditions=preconditions)
21
24
  if d.get("act") == "hold":
25
+ if thread_id:
26
+ session.underwrit.park(d["id"], kind="langgraph", key=thread_id, resume_url=resume_url)
22
27
  try:
23
28
  from langgraph.types import interrupt # type: ignore
24
29
  except ImportError:
@@ -31,3 +36,17 @@ def guarded_tool(session: Session, tool: str, fn, *, preconditions=None, verify=
31
36
  _run.__name__ = getattr(fn, "__name__", tool)
32
37
  _run.__doc__ = getattr(fn, "__doc__", None)
33
38
  return _run
39
+
40
+
41
+ def resume_with(graph):
42
+ """A durable Resumer handler: resume the checkpointed thread with the decision's outcome.
43
+
44
+ The interrupted node re-runs with `interrupt()` returning the resume value; a denied or expired
45
+ decision is refused by the claim inside `session.resume`, which raises `Held`.
46
+ """
47
+ def handler(resume: dict):
48
+ from langgraph.types import Command # type: ignore
49
+
50
+ value = {"underwrit": {"decision": resume["decision"], "outcome": resume.get("outcome", "")}}
51
+ return graph.invoke(Command(resume=value), config={"configurable": {"thread_id": resume["key"]}})
52
+ return handler
@@ -0,0 +1,83 @@
1
+ """Temporal: a held step suspends the workflow on a signal; the resume sends it.
2
+
3
+ from underwrit_client.integrations import temporal as uw_temporal
4
+
5
+ # An activity proposes the step and, if it is held, parks it under the workflow's id.
6
+ @activity.defn
7
+ async def propose(req: dict) -> dict:
8
+ return uw_temporal.propose(session, req["tool"], req["arguments"],
9
+ workflow_id=activity.info().workflow_id, resume_url=RESUME_URL)
10
+
11
+ @workflow.defn
12
+ class Payout(uw_temporal.Waiting):
13
+ @workflow.run
14
+ async def run(self, req):
15
+ d = await workflow.execute_activity(propose, req, start_to_close_timeout=timedelta(seconds=30))
16
+ if d["act"] == "hold":
17
+ outcome = await self.underwrit_wait(d["id"]) # no worker is busy while a person decides
18
+ if outcome != "approved":
19
+ return {"status": outcome}
20
+ return await workflow.execute_activity(execute, {**req, "decision": d["id"]}, ...)
21
+
22
+ resumer.on("temporal", uw_temporal.resume_with(temporal_client))
23
+
24
+ The workflow waits on the `underwrit_resume` signal with `workflow.wait_condition`, which is
25
+ durable: the worker can restart, and the history replays to the same wait. The `execute` activity
26
+ calls `Session.resume()`, which claims the approval with the same arguments before running.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from .. import Session
32
+
33
+ SIGNAL = "underwrit_resume"
34
+
35
+
36
+ def propose(session: Session, tool: str, arguments: dict, *, workflow_id: str, resume_url: str = "",
37
+ preconditions=None) -> dict:
38
+ """Decide; on a hold, park it under `workflow_id` and return at once."""
39
+ d = session.decide(tool, arguments, preconditions=preconditions)
40
+ if d.get("act") == "hold":
41
+ session.underwrit.park(d["id"], kind="temporal", key=workflow_id, resume_url=resume_url)
42
+ return d
43
+
44
+
45
+ class Waiting:
46
+ """Mix into a workflow class: a signal handler and `underwrit_wait(decision_id)`."""
47
+
48
+ def __init__(self) -> None:
49
+ self._underwrit_outcomes: dict[str, str] = {}
50
+
51
+ def underwrit_resume(self, resume: dict) -> None:
52
+ if not hasattr(self, "_underwrit_outcomes"):
53
+ self._underwrit_outcomes = {}
54
+ self._underwrit_outcomes[str(resume.get("decision"))] = str(resume.get("outcome") or "denied")
55
+
56
+ async def underwrit_wait(self, decision_id: str, timeout=None) -> str:
57
+ from temporalio import workflow # type: ignore
58
+
59
+ if not hasattr(self, "_underwrit_outcomes"):
60
+ self._underwrit_outcomes = {}
61
+ await workflow.wait_condition(lambda: decision_id in self._underwrit_outcomes, timeout=timeout)
62
+ return self._underwrit_outcomes[decision_id]
63
+
64
+
65
+ def _register_signal() -> None:
66
+ """Make `underwrit_resume` a Temporal signal when temporalio is installed."""
67
+ try:
68
+ from temporalio import workflow # type: ignore
69
+ except ImportError:
70
+ return
71
+ Waiting.underwrit_resume = workflow.signal(name=SIGNAL)(Waiting.underwrit_resume)
72
+
73
+
74
+ _register_signal()
75
+
76
+
77
+ def resume_with(client):
78
+ """A Resumer handler: signal the parked workflow (`client` is a `temporalio.client.Client`)."""
79
+ async def handler(resume: dict):
80
+ handle = client.get_workflow_handle(resume["key"])
81
+ await handle.signal(SIGNAL, {"decision": resume["decision"], "outcome": resume.get("outcome", "")})
82
+ return resume["key"]
83
+ return handler
@@ -1,14 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: underwrit-client
3
- Version: 0.3.0
3
+ Version: 0.4.0.dev2
4
4
  Summary: Client for an Underwrit data plane: decide, claim, record, and produce evidence for what an agent did.
5
5
  License: MIT
6
- Project-URL: Homepage, https://github.com/underwrit-io/dataplane
6
+ Project-URL: Homepage, https://underwrit.io
7
7
  Classifier: Programming Language :: Python :: 3
8
8
  Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Topic :: Security
10
10
  Requires-Python: >=3.9
11
11
  Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ License-File: NOTICE
14
+ Dynamic: license-file
12
15
 
13
16
  # underwrit-client
14
17
 
@@ -53,3 +56,17 @@ The full routes are in the Underwrit README; this client adds nothing the servic
53
56
  checkpoint) once a checkpoint covers the entry; `Decision["receipt"]` carries the chain position now.
54
57
  - 429 and 503 are retried with `Retry-After` (three times by default, capped at thirty seconds).
55
58
  - `Held` exposes `id`, `verdict`, `budgeted`, `duplicate`, `quorum` and `risk`.
59
+ - `decide(..., usage={"usd": 0.42}, delegation=["planner", "ops"], components={"model": "m-1"})`
60
+ reports cost, the agents behind the call and what the agent is built from; the chain keeps names
61
+ and digests only.
62
+ - `preview(tool, args, session=… | environment=…)` asks the gate without recording anything;
63
+ `gates()` lists its stages and codes.
64
+ - `verify(decision_id, "passed" | "failed" | "inconclusive")` lands a verification after the
65
+ outcome; `verifications_due()` and `approval_load()` need a reader's token.
66
+ - `compensate(decision_id, tool, args, reason=…)` proposes an undo for a decision whose outcome is
67
+ uncertain or whose verification failed. It always goes to a person; claim it once approved. It
68
+ does not clear the original's scope hold.
69
+
70
+ ## Licence
71
+
72
+ MIT (see LICENSE). The Underwrit planes this client talks to are licensed separately.
@@ -1,6 +1,9 @@
1
+ LICENSE
2
+ NOTICE
1
3
  README.md
2
4
  pyproject.toml
3
5
  underwrit_client/__init__.py
6
+ underwrit_client/durable.py
4
7
  underwrit_client/task.py
5
8
  underwrit_client.egg-info/PKG-INFO
6
9
  underwrit_client.egg-info/SOURCES.txt
@@ -8,9 +11,12 @@ underwrit_client.egg-info/dependency_links.txt
8
11
  underwrit_client.egg-info/entry_points.txt
9
12
  underwrit_client.egg-info/top_level.txt
10
13
  underwrit_client/integrations/__init__.py
14
+ underwrit_client/integrations/autogen.py
11
15
  underwrit_client/integrations/claude_code.py
16
+ underwrit_client/integrations/crewai.py
12
17
  underwrit_client/integrations/google_adk.py
13
18
  underwrit_client/integrations/http_interceptor.py
14
19
  underwrit_client/integrations/langgraph.py
15
20
  underwrit_client/integrations/openai_agents.py
16
- underwrit_client/integrations/otel.py
21
+ underwrit_client/integrations/otel.py
22
+ underwrit_client/integrations/temporal.py
@@ -1,265 +0,0 @@
1
- """underwrit-client: integrate an agent runtime with an Underwrit data plane in under twenty lines.
2
-
3
- from underwrit_client import Underwrit, Held
4
-
5
- underwrit = Underwrit("http://127.0.0.1:8787", token=os.environ["UNDERWRIT_AGENT_TOKEN"])
6
- with underwrit.session(agent="ops-assistant", environment="production",
7
- intent="Restart api during incident 4417") as s:
8
- logs = s.call("k8s/get_k8s_logs", get_logs, pod="api-1") # decided, run, recorded
9
- try:
10
- s.call("k8s/rollout_restart", restart, deployment="api") # held? raises Held
11
- except Held as h:
12
- print("waiting on a person:", h.decision["id"])
13
-
14
- Standard library only. Every method maps to one data-plane route, and the shapes returned are the
15
- service's own — nothing is reinterpreted here.
16
-
17
- **`Held` is the contract.** When the data plane says `act: "hold"`, the wrapped call is not made
18
- and `Held` is raised with the decision attached, so a runtime can park the task, show the decision
19
- id, and come back with `claim()` once a person has answered. In shadow mode `act` is `"proceed"`
20
- and nothing is raised; the verdict is still recorded.
21
-
22
- **A claim before an approved execution.** `claim()` presents the same arguments again and is told
23
- whether the approval still holds — same arguments, same relevant state, still within its validity
24
- window, not already used. Use it immediately before executing anything that was held.
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- import functools
30
- import json
31
- import time
32
- import urllib.error
33
- import urllib.request
34
- from typing import Any, Callable
35
-
36
- __version__ = "0.3.0"
37
- __all__ = ["Underwrit", "Session", "Held", "UnderwritError", "guarded"]
38
-
39
-
40
- class UnderwritError(Exception):
41
- """The data plane refused or failed a request. `status` is the HTTP status."""
42
-
43
- def __init__(self, status: int, message: str):
44
- super().__init__(f"{status}: {message}")
45
- self.status = status
46
- self.message = message
47
-
48
-
49
- class Held(Exception):
50
- """The action must not proceed until a person answers, or was refused.
51
-
52
- `decision` is the data plane's response — its `id` is what to resolve, and `reasons` say why.
53
- """
54
-
55
- def __init__(self, decision: dict, reason: str = ""):
56
- rs = decision.get("reasons") or ([reason] if reason else [])
57
- super().__init__(f"{decision.get('verdict', 'held')}: {'; '.join(rs) or 'held for a person'}")
58
- self.decision = decision
59
- self.reason = reason or (rs[0] if rs else "")
60
- # What the data plane said beside the verdict, so a runtime can act on it without parsing.
61
- self.id: str = str(decision.get("id") or "")
62
- self.verdict: str = str(decision.get("verdict") or "")
63
- self.budgeted: bool = bool(decision.get("budgeted"))
64
- self.duplicate: bool = bool(decision.get("duplicate"))
65
- self.quorum: dict | None = decision.get("quorum") if isinstance(decision.get("quorum"), dict) else None
66
- self.risk: int = int(decision.get("risk") or 0)
67
-
68
-
69
- class Underwrit:
70
- def __init__(self, url: str, token: str, *, timeout: float = 10.0, retries: int = 3,
71
- max_retry_wait: float = 30.0, sleep: Callable[[float], None] = time.sleep):
72
- self.url = url.rstrip("/")
73
- self.token = token
74
- self.timeout = timeout
75
- self.retries = retries
76
- self.max_retry_wait = max_retry_wait
77
- self._sleep = sleep
78
-
79
- # -- transport -------------------------------------------------------------------------
80
-
81
- def _call(self, method: str, path: str, body: dict | None = None) -> dict:
82
- """One request. 429 and 503 are retried, honouring Retry-After, up to `retries` times and
83
- never longer than `max_retry_wait` per wait; every other error is the caller's."""
84
- attempt = 0
85
- while True:
86
- req = urllib.request.Request(
87
- f"{self.url}{path}",
88
- data=json.dumps(body).encode("utf-8") if body is not None else None,
89
- headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.token}"},
90
- method=method,
91
- )
92
- try:
93
- with urllib.request.urlopen(req, timeout=self.timeout) as r:
94
- return json.loads(r.read().decode("utf-8") or "{}")
95
- except urllib.error.HTTPError as e:
96
- try:
97
- payload = json.loads(e.read().decode("utf-8") or "{}")
98
- except ValueError:
99
- payload = {}
100
- if e.code in (429, 503) and attempt < self.retries:
101
- attempt += 1
102
- try:
103
- wait = float(e.headers.get("Retry-After") or payload.get("retryAfterSeconds") or 0)
104
- except (TypeError, ValueError):
105
- wait = 0.0
106
- self._sleep(min(self.max_retry_wait, wait or min(2.0 ** attempt, self.max_retry_wait)))
107
- continue
108
- raise UnderwritError(e.code, payload.get("error", "") or e.reason) from None
109
- except urllib.error.URLError as e:
110
- raise UnderwritError(0, f"cannot reach {self.url}: {e.reason}") from None
111
-
112
- # -- the routes ------------------------------------------------------------------------
113
-
114
- def open_session(self, *, session: str = "", agent: str = "", environment: str = "",
115
- intent: str = "", actor: str = "", task_policy: dict | None = None) -> "Session":
116
- s = self._call("POST", "/v1/sessions", {"session": session, "agent": agent,
117
- "environment": environment, "intent": intent,
118
- "actor": actor,
119
- **({"taskPolicy": task_policy} if task_policy else {})})
120
- return Session(self, s["id"], s)
121
-
122
- session = open_session
123
-
124
- def decide(self, session: str, tool: str, arguments: dict | None = None, *,
125
- preconditions: Any = None, idempotency_key: str = "", signals: dict | None = None) -> dict:
126
- """`signals` carries an external classifier's verdict ({"taint": True, "risk": 80,
127
- "source": "my-classifier"}); it can raise taint and risk, never lower them."""
128
- body: dict = {"session": session, "tool": tool, "arguments": arguments or {}}
129
- if preconditions is not None:
130
- body["preconditions"] = preconditions
131
- if idempotency_key:
132
- body["idempotencyKey"] = idempotency_key
133
- if signals:
134
- body["signals"] = signals
135
- return self._call("POST", "/v1/decide", body)
136
-
137
- def receipt(self, seq: int) -> dict:
138
- """The per-entry receipt: leaf index, tree size, root, inclusion proof and the signed
139
- checkpoint. `pending: True` until a checkpoint covers the entry."""
140
- return self._call("GET", f"/v1/receipts/{int(seq)}")
141
-
142
- def pending(self, limit: int = 100, offset: int = 0) -> dict:
143
- return self._call("GET", f"/v1/pending?limit={int(limit)}&offset={int(offset)}")
144
-
145
- def claim(self, decision_id: str, arguments: dict | None = None, *,
146
- preconditions: Any = None) -> dict:
147
- body: dict = {}
148
- if arguments is not None:
149
- body["arguments"] = arguments
150
- if preconditions is not None:
151
- body["preconditions"] = preconditions
152
- return self._call("POST", f"/v1/decisions/{decision_id}/claim", body)
153
-
154
- def outcome(self, decision_id: str, status: str, detail: str = "", *,
155
- verification: dict | None = None, reconcile: bool = False) -> dict:
156
- body: dict = {"decision": decision_id, "status": status, "detail": detail}
157
- if verification:
158
- body["verification"] = verification
159
- if reconcile:
160
- body["reconcile"] = True
161
- return self._call("POST", "/v1/outcome", body)
162
-
163
- def decision(self, decision_id: str) -> dict:
164
- return self._call("GET", f"/v1/decisions/{decision_id}")
165
-
166
- def close(self, session: str, status: str = "succeeded") -> dict:
167
- return self._call("POST", f"/v1/sessions/{session}/close", {"status": status})
168
-
169
- def evidence(self, session: str) -> dict:
170
- return self._call("GET", f"/v1/evidence/{session}")
171
-
172
- def health(self) -> dict:
173
- return self._call("GET", "/v1/health")
174
-
175
-
176
- class Session:
177
- """One agent session. A context manager: closes on exit, `failed` if an exception escaped."""
178
-
179
- def __init__(self, underwrit: Underwrit, session_id: str, meta: dict | None = None):
180
- self.underwrit = underwrit
181
- self.id = session_id
182
- self.meta = meta or {}
183
-
184
- def __enter__(self) -> "Session":
185
- return self
186
-
187
- def __exit__(self, exc_type, exc, tb) -> bool:
188
- try:
189
- self.underwrit.close(self.id, "failed" if exc_type else "succeeded")
190
- except UnderwritError:
191
- pass
192
- return False
193
-
194
- def decide(self, tool: str, arguments: dict | None = None, **kw) -> dict:
195
- return self.underwrit.decide(self.id, tool, arguments, **kw)
196
-
197
- def outcome(self, decision_id: str, status: str, detail: str = "", **kw) -> dict:
198
- return self.underwrit.outcome(decision_id, status, detail, **kw)
199
-
200
- def claim(self, decision_id: str, arguments: dict | None = None, **kw) -> dict:
201
- return self.underwrit.claim(decision_id, arguments, **kw)
202
-
203
- def call(self, tool: str, fn: Callable[..., Any], /, *args, preconditions: Any = None,
204
- idempotency_key: str = "", verify: Callable[[Any], dict] | None = None,
205
- signals: dict | None = None, **kwargs) -> Any:
206
- """Decide, then run `fn(*args, **kwargs)`, then report what happened. Raises `Held`.
207
-
208
- The keyword arguments are the action's arguments as the data plane sees them; positional
209
- arguments are passed through to `fn` but are not part of the decision.
210
- """
211
- d = self.decide(tool, kwargs, preconditions=preconditions, idempotency_key=idempotency_key, signals=signals)
212
- if d.get("act") == "hold":
213
- raise Held(d)
214
- return self._run(d["id"], fn, args, kwargs, verify)
215
-
216
- def resume(self, decision_id: str, fn: Callable[..., Any], /, *args,
217
- preconditions: Any = None, verify: Callable[[Any], dict] | None = None,
218
- **kwargs) -> Any:
219
- """After a person answered: claim the approval with the same arguments, then run."""
220
- c = self.claim(decision_id, kwargs, preconditions=preconditions)
221
- if not c.get("ok") and c.get("act") == "hold":
222
- raise Held(c.get("decision") or {"id": decision_id}, c.get("reason", ""))
223
- return self._run(decision_id, fn, args, kwargs, verify)
224
-
225
- def _run(self, decision_id, fn, args, kwargs, verify):
226
- try:
227
- result = fn(*args, **kwargs)
228
- except TimeoutError as exc:
229
- # A timeout may mean the action completed and the response was lost. Recorded as
230
- # uncertain, which the data plane refuses to let a retry overwrite silently.
231
- self.outcome(decision_id, "uncertain", str(exc)[:2000])
232
- raise
233
- except Exception as exc:
234
- self.outcome(decision_id, "failed", str(exc)[:2000])
235
- raise
236
- detail = result if isinstance(result, str) else json.dumps(result, default=str)[:2000]
237
- ver = None
238
- if verify is not None:
239
- try:
240
- ver = verify(result)
241
- except Exception as exc: # noqa: BLE001 — a broken check is a failed verification
242
- ver = {"status": "failed", "summary": f"verification raised {type(exc).__name__}"}
243
- self.outcome(decision_id, "succeeded", detail, verification=ver)
244
- return result
245
-
246
-
247
- def guarded(session: Session, tool: str | None = None, **opts):
248
- """Decorate a tool function so every call goes through the session first.
249
-
250
- @guarded(session, "bank/send_money")
251
- def send_money(amount, recipient): ...
252
-
253
- The function must be called with keyword arguments; they are the action's arguments.
254
- """
255
-
256
- def wrap(fn):
257
- name = tool or fn.__name__
258
-
259
- @functools.wraps(fn)
260
- def inner(**kwargs):
261
- return session.call(name, fn, **opts, **kwargs)
262
-
263
- return inner
264
-
265
- return wrap