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.
- underwrit_client-0.4.0.dev2/LICENSE +21 -0
- underwrit_client-0.4.0.dev2/NOTICE +5 -0
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/PKG-INFO +19 -2
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/README.md +14 -0
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/pyproject.toml +2 -2
- underwrit_client-0.4.0.dev2/underwrit_client/__init__.py +456 -0
- underwrit_client-0.4.0.dev2/underwrit_client/durable.py +104 -0
- underwrit_client-0.4.0.dev2/underwrit_client/integrations/autogen.py +56 -0
- underwrit_client-0.4.0.dev2/underwrit_client/integrations/crewai.py +49 -0
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/langgraph.py +20 -1
- underwrit_client-0.4.0.dev2/underwrit_client/integrations/temporal.py +83 -0
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client.egg-info/PKG-INFO +19 -2
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client.egg-info/SOURCES.txt +7 -1
- underwrit_client-0.3.0/underwrit_client/__init__.py +0 -265
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/setup.cfg +0 -0
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/__init__.py +0 -0
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/claude_code.py +0 -0
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/google_adk.py +0 -0
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/http_interceptor.py +0 -0
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/openai_agents.py +0 -0
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/otel.py +0 -0
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/task.py +0 -0
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client.egg-info/dependency_links.txt +0 -0
- {underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client.egg-info/entry_points.txt +0 -0
- {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.
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: underwrit-client
|
|
3
|
-
Version: 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://
|
|
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.
|
|
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://
|
|
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
|
{underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/langgraph.py
RENAMED
|
@@ -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
|
+
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://
|
|
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.
|
{underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client.egg-info/SOURCES.txt
RENAMED
|
@@ -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
|
|
File without changes
|
{underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/__init__.py
RENAMED
|
File without changes
|
{underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/claude_code.py
RENAMED
|
File without changes
|
{underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/google_adk.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client/integrations/otel.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{underwrit_client-0.3.0 → underwrit_client-0.4.0.dev2}/underwrit_client.egg-info/top_level.txt
RENAMED
|
File without changes
|