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 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
@@ -0,0 +1,8 @@
1
+ import sys
2
+ try:
3
+ sys.stdout.reconfigure(encoding="utf-8")
4
+ except Exception:
5
+ pass
6
+ from .cli import main
7
+
8
+ raise SystemExit(main())
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