Skip to main content

resonate-human-in-the-loop-pattern-python

Implement human-in-the-loop workflows in Python — durable functions that suspend on ctx.promise() until an external actor (webhook, UI, CLI, operator) resolves or rejects. Use when a workflow must wait on an action that doesn't originate from another worker, such as approval gates, manual review, or out-of-band data.

Quellinformationen

Repository
resonatehq/resonate-skills
Letzte Quellaktivität
22. Juni 2026 um 19:25
Erkannte Sprache von SKILL.md
Englisch
Sterne
6
Forks
0

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
resonate-human-in-the-loop-pattern-python
description
Implement human-in-the-loop workflows in Python — durable functions that suspend on ctx.promise() until an external actor (webhook, UI, CLI, operator) resolves or rejects. Use when a workflow must wait on an action that doesn't originate from another worker, such as approval gates, manual review, or out-of-band data.
license
Apache-2.0
# Resonate Human-in-the-Loop Pattern — Python ## Overview A human-in-the-loop workflow suspends on a durable promise that is resolved (or rejected) by something outside the Resonate worker set — a person clicking an Approve button, a webhook from a third-party system, an operator running a CLI command. The worker doesn't poll or sleep; it opens a `ctx.promise()` and awaits it, and Resonate wakes it when the promise settles. ## When to use - Approval gates in business workflows (expense approval, content moderation, deploy gate) - Waiting on third-party callbacks (Stripe webhooks, DocuSign signature events) - Operator-driven unblock steps (break-glass in incident runbooks) - Any workflow step where the data or decision comes from outside the Resonate worker ## Basic shape ```python from __future__ import annotations import asyncio, os, time from typing import TYPE_CHECKING from resonate.resonate import Resonate from resonate.types import Value if TYPE_CHECKING: from resonate.context import Context r = Resonate(url=os.environ.get("RESONATE_URL", "http://localhost:8001")) async def notify_reviewer(ctx: Context, order_id: str, amount: int, approval_id: str) -> str: # Side effects live in leaves — this prints/notifies exactly once print(f" order {order_id} (${amount}) needs review; promise id: {approval_id!r}") return approval_id async def ship_order(ctx: Context, order_id: str) -> str: return f"shipped-{order_id}" async def cancel_order(ctx: Context, order_id: str) -> str: return f"canceled-{order_id}" async def fulfill_order(ctx: Context, order_id: str, amount: int) -> str: # Open the approval promise; its id is deterministic (derived from the workflow id) approval = ctx.promise() approval_id = await approval.id() # Notify the reviewer via a leaf (side effects belong in leaves) await ctx.run(notify_reviewer, order_id=order_id, amount=amount, approval_id=approval_id) # Suspend here — the worker holds no state while waiting # This can be seconds, hours, or days; no process needs to stay alive decision_raw = await approval if decision_raw.get("approve"): return await ctx.run(ship_order, order_id=order_id) return await ctx.run(cancel_order, order_id=order_id) ``` From outside the worker (a webhook, a UI route, a CLI): ```python from resonate.types import Value # Resolve the promise — wakes the suspended workflow await r.promises.resolve(approval_id, Value(data={"approve": True, "note": "looks good"})) # Or reject it await r.promises.reject(approval_id, Value(data={"reason": "policy violation"})) ``` `r.promises.resolve/reject` are `async` — always `await` them. ## The canonical SDK example The SDK's `examples/human-in-the-loop` shows the full pattern with a `ReviewerInbox` dependency (an `asyncio.Future`) so the simulated reviewer learns the promise id the instant the leaf creates it — without polling: ```python import asyncio from resonate.resonate import Resonate from resonate.types import Value class ReviewerInbox: def __init__(self) -> None: self.approval_id: asyncio.Future[str] = asyncio.get_running_loop().create_future() def publish(self, approval_id: str) -> None: if not self.approval_id.done(): self.approval_id.set_result(approval_id) async def notify_reviewer(ctx, order_id: str, amount: int, approval_id: str) -> str: print(f" order {order_id} (${amount}) pending; id: {approval_id!r}") ctx.get_dependency(ReviewerInbox).publish(approval_id) return approval_id async def fulfill_order(ctx, order_id: str, amount: int) -> str: approval = ctx.promise() approval_id = await approval.id() await ctx.run(notify_reviewer, order_id=order_id, amount=amount, approval_id=approval_id) decision_raw = await approval if decision_raw.get("approve"): return await ctx.run(ship_order, order_id=order_id, note=decision_raw.get("note", "")) return await ctx.run(cancel_order, order_id=order_id, note=decision_raw.get("note", "")) async def main() -> None: r = Resonate(url="http://localhost:8001") inbox = ReviewerInbox() r.with_dependency(inbox) r.register(fulfill_order) try: wid = f"fulfill-{time.time_ns()}" handle = r.run(wid, fulfill_order, "order-42", 199) # Simulated reviewer: wait for the inbox, then resolve async def simulate_reviewer() -> None: approval_id = await inbox.approval_id await r.promises.resolve(approval_id, Value(data={"approve": True, "note": "ok"})) reviewer = asyncio.create_task(simulate_reviewer()) out = await handle.result() await reviewer print(f"OK: {out}") finally: await r.stop() asyncio.run(main()) ``` ## Promise IDs are deterministic The orchestrator and the external resolver need to agree on the promise ID. When you call `ctx.promise()` without an explicit id, the SDK assigns a deterministic id derived from the workflow's own promise id (e.g., `{workflow_id}.1` for the first promise). Your leaf captures and publishes this id so the external resolver knows where to settle. If you need a portable, human-readable id, pass a keyword argument (not yet in the v0.7.0 public API for `ctx.promise`; derive it from workflow inputs and embed it in the leaf instead). The safe pattern is always: open the promise, await its id, pass the id to a leaf for notification. ## Passing context to the resolver Pass data to the resolver via a leaf that publishes the promise id alongside any routing metadata: ```python async def notify_for_travel( ctx: Context, traveler_id: str, destination: str, approval_id: str ) -> str: # In a real system: Slack message, email, dashboard row print(f" travel approval needed: {traveler_id} → {destination} [{approval_id}]") return approval_id ``` The reviewer sees the id and calls `r.promises.resolve(approval_id, Value(data=decision))`. ## Webhook-driven resolution A typical FastAPI webhook route that resolves a promise: ```python from fastapi import FastAPI, Request from resonate.resonate import Resonate from resonate.types import Value app = FastAPI() r = Resonate(url="http://localhost:8001") @app.post("/webhooks/docusign") async def docusign_callback(req: Request) -> dict: body = await req.json() envelope_id = body["envelopeId"] status = body["status"] if status == "completed": await r.promises.resolve( f"docusign:{envelope_id}", Value(data={"signed": True, "signed_at": body["completedAt"]}), ) elif status in {"declined", "voided"}: await r.promises.reject( f"docusign:{envelope_id}", Value(data={"reason": status}), ) return {"ok": True} ``` Note: the worker that is awaiting `ctx.promise()` is woken as soon as the webhook resolves. ## Multiple human approvers (parallel) Fan out to independent approvers; wait for all to respond: ```python async def multi_approver(ctx: Context, request_id: str, count: int) -> dict: # Open N approval promises in sequence (each gets a deterministic id) approvals = [ctx.promise() for _ in range(count)] ids = [await a.id() for a in approvals] # Notify each approver via a leaf for i, approval_id in enumerate(ids): await ctx.run(notify_approver, request_id, i, approval_id) # Await all decisions decisions = [await a for a in approvals] approved_count = sum(1 for d in decisions if d.get("approve")) if approved_count == count: return {"status": "approved"} return {"status": "rejected", "approved": approved_count, "of": count} ``` ## Distinct Python idioms - **`Value(data=...)`** — `r.promises.resolve/reject` wrap the payload in a `Value` object. Import from `resonate.types`. - **`r.promises.*` are all `async`** — always `await` them. - **FastAPI `async def` routes** work naturally with `await r.promises.resolve(...)`. - **`ctx.get_dependency(ReviewerInbox)`** — dependencies are fetched by type, not string key. - **`try/except` on `await approval`** — handles timeout/rejection naturally with Python's exception model. ## Avoid: polling Bad: ```python async def bad_approval(ctx: Context, order_id: str) -> dict: # Polling defeats the point while True: status = await ctx.run(check_approval_status, order_id) if status: break await ctx.sleep(timedelta(seconds=30)) return {"status": "approved"} ``` `ctx.promise()` gives you wake-on-event; polling wastes checkpoints and loses the ability to pass decision data into the workflow. ## Related skills - `resonate-basic-ephemeral-world-usage-python` — `r.promises.resolve/reject/get` from the ephemeral world (webhook handler side) - `resonate-basic-durable-world-usage-python` — `ctx.promise`, `ctx.get_dependency` - `resonate-saga-pattern-python` — sagas with human-gated steps - `durable-execution` — foundational replay semantics; promise-wait survives crashes
Auf GitHub ansehen