decidio 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- decidio/__init__.py +40 -0
- decidio/__main__.py +8 -0
- decidio/_client.py +71 -0
- decidio/adapters/__init__.py +35 -0
- decidio/adapters/_common.py +50 -0
- decidio/adapters/inngest.py +60 -0
- decidio/adapters/langgraph.py +62 -0
- decidio/adapters/openai.py +62 -0
- decidio/adapters/temporal.py +80 -0
- decidio/cli.py +285 -0
- decidio/errors.py +45 -0
- decidio/guard.py +302 -0
- decidio/jcs.py +235 -0
- decidio/py.typed +0 -0
- decidio/resume.py +313 -0
- decidio/signing.py +124 -0
- decidio/verify.py +340 -0
- decidio-0.1.0.dist-info/METADATA +84 -0
- decidio-0.1.0.dist-info/RECORD +22 -0
- decidio-0.1.0.dist-info/WHEEL +4 -0
- decidio-0.1.0.dist-info/entry_points.txt +2 -0
- decidio-0.1.0.dist-info/licenses/LICENSE +202 -0
decidio/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""@decidio/guard (Python) — the one-line approval gate for AI-agent actions.
|
|
2
|
+
|
|
3
|
+
from decidio import guard
|
|
4
|
+
@guard.approve(lambda o: {"action": "createOpportunity", "amount": o["amount"]})
|
|
5
|
+
async def create_opp(o): ... # no rename, no call-site change
|
|
6
|
+
|
|
7
|
+
# or the wrap form, when you cannot decorate the definition:
|
|
8
|
+
create_opp = guard.protect(create_opp_raw,
|
|
9
|
+
lambda o: {"action": "createOpportunity", "amount": o["Amount"], "scope": "Opportunity"})
|
|
10
|
+
|
|
11
|
+
Decidio gates (proceed | route | block) + records a portable, verifiable Authority
|
|
12
|
+
Receipt the customer owns; the AGENT executes its own action on resume. Decidio never
|
|
13
|
+
executes and holds no downstream credentials.
|
|
14
|
+
"""
|
|
15
|
+
from .guard import Guard, GuardConfig, guard, protect, approve, ApprovalContext
|
|
16
|
+
from .resume import (
|
|
17
|
+
ResumeController,
|
|
18
|
+
PendingAction,
|
|
19
|
+
PendingStore,
|
|
20
|
+
MemoryPendingStore,
|
|
21
|
+
FilePendingStore,
|
|
22
|
+
sign_body,
|
|
23
|
+
verify_signature,
|
|
24
|
+
)
|
|
25
|
+
from .errors import (
|
|
26
|
+
DecidioError,
|
|
27
|
+
DecidioBlocked,
|
|
28
|
+
DecidioRejected,
|
|
29
|
+
DecidioSuspended,
|
|
30
|
+
DecidioTimeout,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"guard", "protect",
|
|
35
|
+
"approve", "Guard", "GuardConfig", "ApprovalContext",
|
|
36
|
+
"ResumeController", "PendingAction", "PendingStore", "MemoryPendingStore", "FilePendingStore",
|
|
37
|
+
"sign_body", "verify_signature",
|
|
38
|
+
"DecidioError", "DecidioBlocked", "DecidioRejected", "DecidioSuspended", "DecidioTimeout",
|
|
39
|
+
]
|
|
40
|
+
__version__ = "0.1.0"
|
decidio/__main__.py
ADDED
decidio/_client.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Low-level gate transport — the HTTP calls to Decidio's agent floor.
|
|
2
|
+
|
|
3
|
+
Pluggable `Transport` so the SDK is testable offline (inject a fake) and so a host
|
|
4
|
+
can swap in its own HTTP stack. Default uses urllib (stdlib — the core stays
|
|
5
|
+
dependency-free). Shared by the gate (authorize) and the resume controller
|
|
6
|
+
(status/confirm) so there's one transport contract.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
from typing import Any, Optional, Protocol, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Transport(Protocol):
|
|
18
|
+
def request(self, method: str, url: str, headers: dict, body: Optional[str]) -> Tuple[int, str]:
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UrllibTransport:
|
|
23
|
+
def __init__(self, timeout: float = 30.0):
|
|
24
|
+
self.timeout = timeout
|
|
25
|
+
|
|
26
|
+
def request(self, method: str, url: str, headers: dict, body: Optional[str]) -> Tuple[int, str]:
|
|
27
|
+
data = body.encode("utf-8") if body is not None else None
|
|
28
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
29
|
+
try:
|
|
30
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
31
|
+
return resp.status, resp.read().decode("utf-8")
|
|
32
|
+
except urllib.error.HTTPError as e: # 4xx/5xx still carry a body
|
|
33
|
+
return e.code, e.read().decode("utf-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _headers(api_token: Optional[str]) -> dict:
|
|
37
|
+
h = {"content-type": "application/json"}
|
|
38
|
+
if api_token:
|
|
39
|
+
h["authorization"] = f"Bearer {api_token}"
|
|
40
|
+
return h
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def post(transport: Transport, api_url: str, path: str, body: dict, api_token: Optional[str]) -> Any:
|
|
44
|
+
status, text = transport.request("POST", api_url.rstrip("/") + path, _headers(api_token), json.dumps(body))
|
|
45
|
+
if not (200 <= status < 300):
|
|
46
|
+
raise RuntimeError(f"decidio {path} {status}: {text}")
|
|
47
|
+
return json.loads(text) if text else {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get(transport: Transport, api_url: str, path: str, api_token: Optional[str]) -> Any:
|
|
51
|
+
status, text = transport.request("GET", api_url.rstrip("/") + path, _headers(api_token), None)
|
|
52
|
+
if not (200 <= status < 300):
|
|
53
|
+
raise RuntimeError(f"decidio {path} {status}")
|
|
54
|
+
return json.loads(text) if text else {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def confirm(transport: Transport, api_url: str, api_token: Optional[str], decision_id: str, captured: Any, requester_identity: Optional[dict] = None) -> None:
|
|
58
|
+
"""Report the captured source response. A `requester_identity` bound proof lets the gate
|
|
59
|
+
grant the wrapper tier (application_confirmed); without it the server caps at agent_asserted."""
|
|
60
|
+
body: dict = {
|
|
61
|
+
"decisionId": decision_id,
|
|
62
|
+
"capturedResponse": captured if captured is not None else None,
|
|
63
|
+
"source": "wrapper",
|
|
64
|
+
}
|
|
65
|
+
if requester_identity is not None:
|
|
66
|
+
body["requesterIdentity"] = requester_identity
|
|
67
|
+
post(transport, api_url, "/agent/confirm", body, api_token)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def status_of(transport: Transport, api_url: str, api_token: Optional[str], decision_id: str) -> dict:
|
|
71
|
+
return get(transport, api_url, "/agent/status/" + quote(decision_id, safe=""), api_token)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Engine adapters — each maps the Decidio gate onto an engine's native durable wait.
|
|
2
|
+
Thin by rule (see _common): only the suspend/resume translation lives here.
|
|
3
|
+
|
|
4
|
+
`bind(name, ...)` backs `guard.protect(fn, describe, adapter=name)` for engines whose
|
|
5
|
+
wait primitive is contextvar-based (LangGraph `interrupt`, Temporal `wait_condition`) —
|
|
6
|
+
those are true drop-ins. Engines that pass an explicit handle (Inngest `step`, OpenAI
|
|
7
|
+
Agents `RunState`) are used via their own `gate(...)` functions (they need the handle
|
|
8
|
+
at call time); see decidio/adapters/{inngest,openai}.py.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def bind(name: str, guard: Any, fn: Callable[..., Any], describe: Callable[..., dict]) -> Callable[..., Any]:
|
|
17
|
+
if name == "langgraph":
|
|
18
|
+
from . import langgraph as lg
|
|
19
|
+
|
|
20
|
+
# functools.wraps for the same reason the core wrapper has it: OpenAI function-calling
|
|
21
|
+
# and LangChain @tool build the tool schema by INTROSPECTING the function, and an
|
|
22
|
+
# adapter-bound tool reporting `wrapped(*args, **kwargs)` hands the model an empty schema.
|
|
23
|
+
@functools.wraps(fn)
|
|
24
|
+
def wrapped(*a: Any, **k: Any) -> Any:
|
|
25
|
+
return lg.gate(guard, describe(*a, **k), lambda: fn(*a, **k))
|
|
26
|
+
return wrapped
|
|
27
|
+
|
|
28
|
+
if name in ("inngest", "temporal", "openai"):
|
|
29
|
+
# These pass an explicit handle at call time (Inngest `step`, Temporal workflow
|
|
30
|
+
# self, OpenAI RunState), so they can't be a no-arg `protect` drop-in.
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f'the "{name}" adapter needs the engine handle at call time — call '
|
|
33
|
+
f"decidio.adapters.{name}.gate(...) directly (see its docstring)."
|
|
34
|
+
)
|
|
35
|
+
raise ValueError(f'unknown adapter "{name}"')
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Shared adapter core — the thin-adapter rule in code. Every engine adapter reuses
|
|
2
|
+
these gate calls; only the *suspend/resume* translation differs per engine. Policy,
|
|
3
|
+
sealing, and the record live in Decidio (the core), never in an adapter."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from .. import _client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AuthResult:
|
|
14
|
+
decision: str # "proceed" | "route" | "block"
|
|
15
|
+
decision_id: str
|
|
16
|
+
receipt_id: Optional[str] = None
|
|
17
|
+
reason: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def authorize(guard: Any, ctx: dict, resume_url: Optional[str] = None) -> AuthResult:
|
|
21
|
+
cfg = guard.config
|
|
22
|
+
# Omit None optionals — the server's zod schema rejects null for optional fields.
|
|
23
|
+
body = {"requester": cfg.agent_id, "action": ctx["action"], "amount": ctx["amount"]}
|
|
24
|
+
if ctx.get("scope") is not None:
|
|
25
|
+
body["scope"] = ctx["scope"]
|
|
26
|
+
if cfg.source_system is not None:
|
|
27
|
+
body["sourceSystem"] = cfg.source_system
|
|
28
|
+
# Sign the bound proof so an adapter-path agent can prove its identity + auto-approve.
|
|
29
|
+
if cfg.agent_key and cfg.agent_did:
|
|
30
|
+
from ..signing import sign_bound_proof
|
|
31
|
+
body["requesterIdentity"] = sign_bound_proof(cfg.agent_key, agent_id=cfg.agent_id, did=cfg.agent_did, action=ctx["action"], amount=ctx["amount"], scope=ctx.get("scope"), workspace_id=getattr(cfg, "workspace_id", None))
|
|
32
|
+
url = resume_url or cfg.resume_url
|
|
33
|
+
if url:
|
|
34
|
+
body["resumeUrl"] = url
|
|
35
|
+
auth = _client.post(guard.transport, cfg.api_url, "/agent/authorize", body, cfg.api_token)
|
|
36
|
+
return AuthResult(auth["decision"], auth["decisionId"], auth.get("receiptId"), auth.get("reason"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def confirm(guard: Any, decision_id: str, result: Any) -> None:
|
|
40
|
+
cfg = guard.config
|
|
41
|
+
try:
|
|
42
|
+
# Sign the confirm (bound to {action:"confirm", scope:decisionId}) so the adapter path
|
|
43
|
+
# earns application_confirmed; without a key the server caps it at agent_asserted.
|
|
44
|
+
ri = None
|
|
45
|
+
if cfg.agent_key and cfg.agent_did:
|
|
46
|
+
from ..signing import sign_bound_proof
|
|
47
|
+
ri = sign_bound_proof(cfg.agent_key, agent_id=cfg.agent_id, did=cfg.agent_did, action="confirm", amount=0, scope=decision_id, workspace_id=getattr(cfg, "workspace_id", None))
|
|
48
|
+
_client.confirm(guard.transport, cfg.api_url, cfg.api_token, decision_id, result, ri)
|
|
49
|
+
except Exception as e: # noqa: BLE001 — best-effort; the action already happened
|
|
50
|
+
print(f"[decidio] confirm failed (action succeeded): {e}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Inngest adapter (Python) — durable suspend via the engine's native
|
|
2
|
+
`step.wait_for_event` (mirror of the verified TS Inngest adapter). Decidio keeps its
|
|
3
|
+
ONE signed-webhook contract; a bridge translates that webhook into the Inngest event
|
|
4
|
+
that wakes the suspended function (see `resume_event`).
|
|
5
|
+
|
|
6
|
+
Usage inside an Inngest function `async def fn(ctx, step)`:
|
|
7
|
+
out = await decidio.adapters.inngest.gate(step, guard, ctx, run=lambda: create_opp(o))
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
from ..errors import DecidioBlocked, DecidioRejected
|
|
14
|
+
from ._common import authorize, confirm
|
|
15
|
+
|
|
16
|
+
DECIDIO_RESUME_EVENT = "decidio/decision.resolved"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _authorize_dict(guard: Any, ctx: dict) -> dict:
|
|
20
|
+
a = authorize(guard, ctx)
|
|
21
|
+
return {"decision": a.decision, "decisionId": a.decision_id, "receiptId": a.receipt_id, "reason": a.reason}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def gate(step: Any, guard: Any, ctx: dict, run: Callable[[], Any], timeout: str = "30d") -> Any:
|
|
25
|
+
# authorize as a memoized step (a replay doesn't re-ask the gate)
|
|
26
|
+
auth = await step.run("decidio-authorize", lambda: _authorize_dict(guard, ctx))
|
|
27
|
+
if auth["decision"] == "block":
|
|
28
|
+
raise DecidioBlocked(auth.get("reason") or "blocked by policy", auth["decisionId"])
|
|
29
|
+
if auth["decision"] == "route":
|
|
30
|
+
# durably suspend until the resume event for THIS decision arrives (process may die)
|
|
31
|
+
ev = await step.wait_for_event(
|
|
32
|
+
"decidio-await-approval",
|
|
33
|
+
event=DECIDIO_RESUME_EVENT,
|
|
34
|
+
timeout=timeout,
|
|
35
|
+
if_exp=f'async.data.decisionId == "{auth["decisionId"]}"',
|
|
36
|
+
)
|
|
37
|
+
if ev is None:
|
|
38
|
+
raise TimeoutError(f"decidio approval timed out ({auth['decisionId']})")
|
|
39
|
+
data = getattr(ev, "data", None) or (ev.get("data") if isinstance(ev, dict) else {}) or {}
|
|
40
|
+
verdict = data.get("verdict")
|
|
41
|
+
if verdict == "rejected":
|
|
42
|
+
raise DecidioRejected("rejected by approver", auth["decisionId"])
|
|
43
|
+
if verdict not in ("approved", "auto_approved"):
|
|
44
|
+
raise DecidioBlocked("not approved", auth["decisionId"])
|
|
45
|
+
result = await step.run("agent-action", run)
|
|
46
|
+
await step.run("decidio-confirm", lambda: confirm(guard, auth["decisionId"], result))
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resume_event(webhook_body: dict) -> dict:
|
|
51
|
+
"""Translate Decidio's signed resume webhook into the Inngest event payload to send
|
|
52
|
+
(`await inngest_client.send(Event(name=..., data=...))`) — wakes the suspended gate."""
|
|
53
|
+
return {
|
|
54
|
+
"name": DECIDIO_RESUME_EVENT,
|
|
55
|
+
"data": {
|
|
56
|
+
"decisionId": str(webhook_body.get("decisionId")),
|
|
57
|
+
"verdict": webhook_body.get("verdict"),
|
|
58
|
+
"reason": webhook_body.get("reason"),
|
|
59
|
+
},
|
|
60
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""LangGraph adapter — durable suspend via the engine's native `interrupt()`.
|
|
2
|
+
|
|
3
|
+
`interrupt()` is contextvar-based (it finds the running graph itself), so the Decidio
|
|
4
|
+
gate fits LangGraph as a true drop-in: inside a node, `guard.protect(fn, describe,
|
|
5
|
+
adapter="langgraph")(...)` authorizes; on `route` it calls `interrupt(...)`, which
|
|
6
|
+
durably pauses the graph (the checkpointer persists state — the process can die). When
|
|
7
|
+
Decidio's approval webhook resumes the graph with `Command(resume={"verdict": ...})`,
|
|
8
|
+
`interrupt()` returns that payload and the node runs the agent's own action + confirms.
|
|
9
|
+
|
|
10
|
+
Decidio is the approval system; the host bridges its webhook to the graph resume:
|
|
11
|
+
cmd = decidio_resume_command(webhook_body) # build the Command
|
|
12
|
+
graph.invoke(cmd, config={"configurable": {"thread_id": thread_for(decisionId)}})
|
|
13
|
+
(The host maps decisionId → thread_id; that mapping is host state, like any LangGraph
|
|
14
|
+
human-in-the-loop resume.)
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import inspect
|
|
19
|
+
|
|
20
|
+
from ..resume import _complete
|
|
21
|
+
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
from langgraph.types import Command, interrupt # verified primitive
|
|
25
|
+
|
|
26
|
+
from .._client import post as _post # noqa: F401 (kept for parity; gate uses _common)
|
|
27
|
+
from ..errors import DecidioBlocked, DecidioRejected
|
|
28
|
+
from ._common import authorize, confirm
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def gate(guard: Any, ctx: dict, run: Callable[[], Any]) -> Any:
|
|
32
|
+
"""Run `run()` behind the Decidio gate inside a LangGraph node."""
|
|
33
|
+
auth = authorize(guard, ctx)
|
|
34
|
+
if auth.decision == "block":
|
|
35
|
+
raise DecidioBlocked(auth.reason or "blocked by policy", auth.decision_id)
|
|
36
|
+
if auth.decision == "route":
|
|
37
|
+
# Durable pause. On resume, interrupt() returns the Command(resume=...) payload.
|
|
38
|
+
resumed = interrupt({"decidio": {
|
|
39
|
+
"decisionId": auth.decision_id, "action": ctx["action"],
|
|
40
|
+
"amount": ctx["amount"], "scope": ctx.get("scope"),
|
|
41
|
+
}})
|
|
42
|
+
verdict = resumed.get("verdict") if isinstance(resumed, dict) else resumed
|
|
43
|
+
if verdict == "rejected":
|
|
44
|
+
raise DecidioRejected(((resumed or {}).get("reason") if isinstance(resumed, dict) else None) or "rejected by approver", auth.decision_id)
|
|
45
|
+
if verdict not in ("approved", "auto_approved"):
|
|
46
|
+
raise DecidioBlocked("not approved", auth.decision_id)
|
|
47
|
+
result = run()
|
|
48
|
+
# The core wrapper became async-aware, but adapters are dispatched BEFORE that branch, so
|
|
49
|
+
# this path kept the original bug: an `async def` action returned a coroutine, which was
|
|
50
|
+
# then handed to confirm() and failed json.dumps — printing "action succeeded" for an action
|
|
51
|
+
# that had not started, and stranding the receipt below application_confirmed. temporal.py
|
|
52
|
+
# already awaited; langgraph did not. Every equivalent path, not just the one that was reported.
|
|
53
|
+
if inspect.isawaitable(result):
|
|
54
|
+
result = _complete(result)
|
|
55
|
+
confirm(guard, auth.decision_id, result)
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def decidio_resume_command(webhook_body: dict) -> Command:
|
|
60
|
+
"""Translate Decidio's signed resume webhook into the LangGraph resume Command.
|
|
61
|
+
Pass it to graph.invoke/stream with the paused run's thread_id."""
|
|
62
|
+
return Command(resume={"verdict": webhook_body.get("verdict"), "reason": webhook_body.get("reason")})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""OpenAI Agents SDK adapter — gate tool-approval interruptions through Decidio, with
|
|
2
|
+
durable suspend via the SDK's native RunState serialization.
|
|
3
|
+
|
|
4
|
+
The OpenAI Agents loop surfaces tool-approval interruptions on the RunState
|
|
5
|
+
(`state.get_interruptions()` → ToolApprovalItem); you `state.approve(item)` /
|
|
6
|
+
`state.reject(item)` then resume with `Runner.run(agent, state)`. RunState serializes
|
|
7
|
+
(`state.to_string()` / `RunState.from_string(agent, s)`), which is the durability: park
|
|
8
|
+
the serialized state agent-side on `route`, resume on Decidio's approval.
|
|
9
|
+
|
|
10
|
+
state = result.to_state()
|
|
11
|
+
resolved, pending = decidio.adapters.openai.gate_interruptions(guard, state, describe)
|
|
12
|
+
if pending: # a human must decide — persist + suspend
|
|
13
|
+
store.put(decisionId, state.to_string()); return # process may exit
|
|
14
|
+
result = await Runner.run(agent, state) # all resolved → resume the run
|
|
15
|
+
# on Decidio approval webhook (per pending item):
|
|
16
|
+
# state = RunState.from_string(agent, parked); decidio.adapters.openai.apply_resume(state, item, verdict)
|
|
17
|
+
# result = await Runner.run(agent, state)
|
|
18
|
+
|
|
19
|
+
`describe(item)` maps a ToolApprovalItem → {"action","amount","scope"} (e.g.
|
|
20
|
+
lambda i: {"action": i.tool_name, "amount": json.loads(i.arguments).get("amount", 0)}).
|
|
21
|
+
This adapter is duck-typed on the state/item (no hard `agents` import) so it stays thin.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any, Callable, List, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
from ._common import authorize
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def gate_interruptions(
|
|
31
|
+
guard: Any,
|
|
32
|
+
state: Any,
|
|
33
|
+
describe: Callable[[Any], dict],
|
|
34
|
+
) -> Tuple[List[Tuple[Any, str, str]], List[Tuple[Any, str]]]:
|
|
35
|
+
"""Ask Decidio for each pending tool-approval on `state`.
|
|
36
|
+
proceed → state.approve(item); block → state.reject(item); route → left for a human
|
|
37
|
+
(durable). Returns (resolved, pending):
|
|
38
|
+
resolved = [(item, verdict, decisionId)] applied to the state now
|
|
39
|
+
pending = [(item, decisionId)] awaiting a human decision via Decidio
|
|
40
|
+
If `pending` is non-empty, persist state.to_string() and suspend; resume per
|
|
41
|
+
`apply_resume` when Decidio approves."""
|
|
42
|
+
resolved: List[Tuple[Any, str, str]] = []
|
|
43
|
+
pending: List[Tuple[Any, str]] = []
|
|
44
|
+
for item in state.get_interruptions():
|
|
45
|
+
auth = authorize(guard, describe(item))
|
|
46
|
+
if auth.decision == "proceed":
|
|
47
|
+
state.approve(item)
|
|
48
|
+
resolved.append((item, "approved", auth.decision_id))
|
|
49
|
+
elif auth.decision == "block":
|
|
50
|
+
state.reject(item, rejection_message=auth.reason or "blocked by policy")
|
|
51
|
+
resolved.append((item, "blocked", auth.decision_id))
|
|
52
|
+
else: # route → a human decides via Decidio; the host parks + resumes
|
|
53
|
+
pending.append((item, auth.decision_id))
|
|
54
|
+
return resolved, pending
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def apply_resume(state: Any, item: Any, verdict: str, *, rejection_message: Optional[str] = None) -> None:
|
|
58
|
+
"""Apply a Decidio verdict to a resumed RunState (after RunState.from_string)."""
|
|
59
|
+
if verdict in ("approved", "auto_approved"):
|
|
60
|
+
state.approve(item)
|
|
61
|
+
else:
|
|
62
|
+
state.reject(item, rejection_message=rejection_message or "rejected by approver")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Temporal adapter — durable suspend via the engine's native `workflow.wait_condition`
|
|
2
|
+
plus a Decidio resume signal. Temporal gives the strongest durability (the workflow
|
|
3
|
+
survives worker restarts for days); the agent's action runs on resume.
|
|
4
|
+
|
|
5
|
+
Compose `DecidioSignalState` into your workflow and expose a signal that records the
|
|
6
|
+
verdict; `gate()` authorizes (via an activity), waits on the signal, then runs + confirms:
|
|
7
|
+
|
|
8
|
+
class MyWorkflow(DecidioSignalState):
|
|
9
|
+
@workflow.signal
|
|
10
|
+
def decidio_resume(self, decision_id: str, verdict: str, reason: str | None = None):
|
|
11
|
+
self.decidio_set(decision_id, verdict, reason)
|
|
12
|
+
@workflow.run
|
|
13
|
+
async def run(self, o):
|
|
14
|
+
return await decidio.adapters.temporal.gate(self, guard, ctx, run=lambda: create_opp(o))
|
|
15
|
+
|
|
16
|
+
Decidio's webhook bridges to `client.get_workflow_handle(wf_id).signal(MyWorkflow.decidio_resume, decisionId, verdict)`.
|
|
17
|
+
Run authorize/confirm as ACTIVITIES (HTTP side effects belong in activities, not the
|
|
18
|
+
workflow) — `_authorize_activity`/`_confirm_activity` are provided for that.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import inspect
|
|
23
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
from ..errors import DecidioBlocked, DecidioRejected
|
|
26
|
+
from ._common import authorize, confirm
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DecidioSignalState:
|
|
30
|
+
"""Mixin: a per-decision verdict store a Temporal signal handler writes to."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._decidio_verdicts: Dict[str, Tuple[str, Optional[str]]] = {}
|
|
34
|
+
|
|
35
|
+
def decidio_set(self, decision_id: str, verdict: str, reason: Optional[str] = None) -> None:
|
|
36
|
+
self._decidio_verdicts[decision_id] = (verdict, reason)
|
|
37
|
+
|
|
38
|
+
def decidio_resolved(self, decision_id: str) -> bool:
|
|
39
|
+
return decision_id in self._decidio_verdicts
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Activity bodies (register these with your worker; they hold the HTTP side effects).
|
|
43
|
+
def _authorize_activity(guard: Any, ctx: dict) -> dict:
|
|
44
|
+
a = authorize(guard, ctx)
|
|
45
|
+
return {"decision": a.decision, "decisionId": a.decision_id, "receiptId": a.receipt_id, "reason": a.reason}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _confirm_activity(guard: Any, decision_id: str, result: Any) -> None:
|
|
49
|
+
confirm(guard, decision_id, result)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def gate(
|
|
53
|
+
wf: DecidioSignalState,
|
|
54
|
+
guard: Any,
|
|
55
|
+
ctx: dict,
|
|
56
|
+
run: Callable[[], Any],
|
|
57
|
+
*,
|
|
58
|
+
authorize_fn: Optional[Callable[[], dict]] = None,
|
|
59
|
+
confirm_fn: Optional[Callable[[str, Any], None]] = None,
|
|
60
|
+
) -> Any:
|
|
61
|
+
"""Gate inside a Temporal workflow. `authorize_fn`/`confirm_fn` let you route the
|
|
62
|
+
HTTP through activities (recommended); they default to direct calls for tests."""
|
|
63
|
+
from temporalio import workflow # lazy: only needed inside a workflow runtime
|
|
64
|
+
|
|
65
|
+
auth = (authorize_fn or (lambda: _authorize_activity(guard, ctx)))()
|
|
66
|
+
if auth["decision"] == "block":
|
|
67
|
+
raise DecidioBlocked(auth.get("reason") or "blocked by policy", auth["decisionId"])
|
|
68
|
+
if auth["decision"] == "route":
|
|
69
|
+
decision_id = auth["decisionId"]
|
|
70
|
+
await workflow.wait_condition(lambda: wf.decidio_resolved(decision_id))
|
|
71
|
+
verdict, reason = wf._decidio_verdicts[decision_id]
|
|
72
|
+
if verdict == "rejected":
|
|
73
|
+
raise DecidioRejected(reason or "rejected by approver", decision_id)
|
|
74
|
+
if verdict not in ("approved", "auto_approved"):
|
|
75
|
+
raise DecidioBlocked("not approved", decision_id)
|
|
76
|
+
result = run()
|
|
77
|
+
if inspect.isawaitable(result): # the agent's action may be async — await it (TS awaits too)
|
|
78
|
+
result = await result
|
|
79
|
+
(confirm_fn or (lambda did, r: _confirm_activity(guard, did, r)))(auth["decisionId"], result)
|
|
80
|
+
return result
|