5 min readResonate HQJust published

Human-in-the-loop summarization agent in Python on Resonate

How a Resonate workflow combines an LLM step with a durable-promise approval gate and a re-summarize loop when the human rejects.

Resonate brand card on a dark background with a plum spectrum wave at the bottom and the post headline in white Sansation.

A workflow needs to scrape a URL, hand the text to a local LLM, present the summary to a human, and not move on until the human approves — and if the human rejects, re-summarize and ask again. Resonate models this as a single generator function that yields a durable promise to suspend on the human's decision, and a separate gateway process resolves that promise from an HTTP confirmation route. The example wires a Flask gateway, a Resonate worker that drives Selenium + Beautiful Soup + Ollama, and a Resonate Server in between.

The shape of the solution

@resonate.register
def downloadAndSummarize(ctx, params):
    url = params["url"]
    usable_id = params["usable_id"]
    email = params["email"]
    print(f"beginning work on {url}")
    # Download the content from the URL and save it to a file
    filename = yield ctx.lfc(download, usable_id, url).options(durable=False,non_retryable_exceptions=(NetworkResolutionError,))
    while True:
        # Summarize the content of the file
        summary = yield ctx.lfc(summarize, filename)
 
        # Create Durable Promise to block on confirmation
        promise = yield ctx.promise()
 
        # Send email with summary and confirmation/rejection links
        yield ctx.lfc(send_email, summary, email, promise.id)
 
        # Wait for summary to be confirmed or rejected / wait for the promise to be resolved
        confirmed = yield promise
        if confirmed:
            break
 
        print("summary was rejected, re-summarizing")
    print("summary confirmed, workflow complete.")
    return summary
# from example-website-summarization-agent-py/src/app.py:13

The matching gateway route invokes the workflow over async RPC and the confirm route resolves the durable promise:

handle = resonate.options(target="poll://any@worker").begin_rpc(
    f"downloadAndSummarize-{params['usable_id']}",
    "downloadAndSummarize",
    params,
)
# ...
resonate.promises.resolve(
    id=promise_id,
    data=json.dumps(confirm),
)
# from example-website-summarization-agent-py/src/gateway.py:27 and :53

The durable primitives in play

  • Resonate.remote(group="worker") / Resonate.remote(group="gateway") — instantiates a Resonate client wired to a remote Resonate Server as both promise store and message source; group decides which poll:// target a process listens on. src/app.py:8, src/gateway.py:8.
  • @resonate.register — registers downloadAndSummarize as a top-level durable function the worker will pick up off the worker poll queue. src/app.py:13.
  • ctx.lfc(download, ...).options(durable=False, non_retryable_exceptions=(NetworkResolutionError,)) — local function call. durable=False means the SDK does not checkpoint this call's result (the scraped page is large and reproducible from the URL); non_retryable_exceptions marks DNS failures as terminal so the default exponential retry policy gives up immediately rather than burning retries on a permanently broken URL. src/app.py:20.
  • ctx.lfc(summarize, filename) — local function call to the Ollama summarizer; checkpointed by default so the summary text survives a worker crash before the email step runs. src/app.py:23.
  • ctx.promise() — creates a durable promise with no explicit id; the SDK derives one from the workflow context counter ({workflow_id}.{counter}) on the worker and registers it with the Resonate Server. Yielding it (yield promise) suspends the workflow until something outside resolves it. src/app.py:26, then src/app.py:32.
  • ctx.lfc(send_email, summary, email, promise.id) — local function call that emits the confirm/reject URLs containing the promise ID. The example prints them; in production this is where you'd send an actual email. src/app.py:29.
  • resonate.options(target="poll://any@worker").begin_rpc(workflow_id, "downloadAndSummarize", params) — gateway-side async RPC. Routes the invocation to any process in the worker group; returns a Handle immediately. Same workflow_id reconnects to an existing PENDING execution instead of starting a new one. src/gateway.py:27.
  • resonate.promises.resolve(id=promise_id, data=json.dumps(confirm)) — low-level promise-store call that resolves the durable promise from outside the workflow process. The workflow's yield promise unblocks with the JSON-decoded value. src/gateway.py:53.

What the SDK handles vs. what you write

You write the generator function, the Selenium/BS4 scrape, the Ollama chat call, and the two Flask routes. The SDK handles everything between yields.

Specifically, the SDK does:

  • Persists the workflow's state across yields, so a worker crash between summarize and send_email resumes by re-invoking download (not durable, so the function runs again — though its os.path.exists guard short-circuits before re-driving Selenium) and replaying summarize from its checkpoint (durable, so the saved result returns without re-calling Ollama).
  • Maintains the durable promise as a row in the Resonate Server. The workflow process can crash, the gateway process can crash, and the promise survives — when both come back, the workflow either picks up where it left off or, if it had already received the resolution, completes.
  • Routes invocations via the target="poll://any@worker" selector to whichever worker is currently polling the worker group.
  • Applies the default retry policy (exponential) to lfc calls, except where non_retryable_exceptions opts out.
  • Deduplicates: begin_rpc(f"downloadAndSummarize-{usable_id}", ...) with the same usable_id will not start a second execution; it returns a handle to the existing one.

You do not write: a job queue, a database row to track which step the workflow is on, a polling loop on the gateway side to check whether the human has clicked yet, or retry logic for the LLM call.

Failure modes covered

  • Worker crashes between download and summarize. On restart, the workflow replays. download is re-invoked (because .options(durable=False) skipped the checkpoint), but the if os.path.exists(filename): return filename early-return at src/app.py:47-49 short-circuits before Selenium runs, so the URL is not re-scraped; summarize then runs as if it were the first time. src/app.py:20, src/app.py:23.
  • Worker crashes between summarize and send_email. The summary is checkpointed (default durable=True), so summarize is not re-invoked against Ollama; send_email runs with the recovered summary. src/app.py:23, src/app.py:29.
  • Worker crashes while blocked on the human. The durable promise lives in the Resonate Server. The workflow process can be killed entirely; when a worker in the worker group polls again the workflow resumes at the yield promise point and either keeps waiting or proceeds with the already-resolved value. src/app.py:32.
  • Permanent DNS failure on the URL. The if "net::ERR_NAME_NOT_RESOLVED" in str(e): branch at src/app.py:62 raises NetworkResolutionError on src/app.py:63, and that exception type is listed in non_retryable_exceptions on the lfc options, so Resonate stops retrying and the workflow fails fast instead of consuming the full exponential-backoff budget. src/app.py:20, src/app.py:41-42.
  • Human rejects the summary. The confirm route resolves the promise with json.dumps(False); the workflow's confirmed = yield promise returns False, the if confirmed: break is skipped, and the while True loop runs summarize again on the same filename. src/gateway.py:53, src/app.py:32.
  • Same URL submitted twice while the first run is still pending. begin_rpc is keyed by f"downloadAndSummarize-{usable_id}", so a duplicate POST to /summarize reconnects to the in-flight execution rather than spawning a second scrape + summarize. src/gateway.py:27.

When to reach for this pattern

  • If a workflow contains an LLM step whose output a human needs to approve before the workflow continues, and the human may take seconds, hours, or days to respond.
  • If "rejected" should mean "redo this step with the same inputs and ask again," not "abort the workflow."
  • If the approval signal arrives over HTTP from a process that is not the workflow worker (a Flask gateway, a webhook receiver, a button click in a separate UI).
  • If you want the LLM call's result to be checkpointed so a crash after the LLM call doesn't re-bill you, but you don't want to checkpoint a multi-megabyte scrape that's cheap to redo.
  • If the trigger arrives over HTTP and the caller should not hold the connection open while the LLM runs — the gateway returns immediately after begin_rpc.

Sources