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.

ソース情報

リポジトリ
resonatehq/resonate-skills
ソースの最終更新活動
2026年6月22日 19:25
検出された SKILL.md の言語
英語
スター
6
フォーク
0

インストール方法

デフォルトでは、最初にソースを確認する Prompt が選択されています。直接コマンドに切り替えるか、ローカルコピーをダウンロードすることもできます。

ソースファイルを確認

インストールを決める前に、SKILL.md と SkillsMP に表示されている付属ファイルをお読みください。

SKILL.md を表示中

SKILL.md
ソースの指示 · 読み取り専用プレビュー
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
GitHubで見る