Human-in-the-loop¶
An agent on this runtime never blocks a worker waiting for a person. It stages
an approval request as a kind = APPROVAL ToolIntent,
suspends, and frees the key; the answer re-enters the pipeline as an
ordinary element on the same key, minutes or days later, and resumes the
activation where it left off. When no answer ever arrives, the wait fails
closed — at two independent layers, so a lost message and a slow effector
are both survivable without a side effect escaping.
This page is the operator's map of that machinery. The authoring surface is
one method (ctx.request_approval) and one config object
(HitlPolicy); the
fraud triage example shows the whole loop running
offline, and the Slack approval example shows a
real approval surface closing it.
The suspension lifecycle¶
- Stage. The activation calls
ctx.request_approval(args, channel=..., ttl_ms=...)(or, for a side-effect tool,ctx.act(...)). Nothing executes: an intent is staged with a deterministicintent_id = intent_id_for(entity_key, seq, step_index), acreated_at_msfrom the activation clock, and anexpires_at_ms = now_ms + ttl_ms. The approval channel names where the effector routes the request — a queue, a pager, a Slack handler — it is not a registered tool, and the effector never executes an approval intent. - Suspend. The agent returns
Suspend(snapshot=..., timeout_ms=...). On commit the runtime persists aContinuation— the snapshot, the pending intent ids, and a real-time deadline (timeout_ms, or the policy'stimeout_mswhen unset) — emits the staged intents on.intents, and arms theHITL_TIMERat the deadline. - Resume. The approval (or tool result) is re-injected keyed by the same
entity_key. The DoFn admits it against the live continuation and re-invokes the agent withctx.resumeset andctx.snapshotcarrying the suspension's bytes. Results accumulate: an activation waiting on several intents re-suspends until every pending id is answered.
Because intent_id is a pure function of (entity_key, seq, step_index), a
retried bundle stages byte-identical intents and the effector's dedup makes
the request effectively once — the same invariant
the rest of the side-effect path rides.
Configuring it: HitlPolicy¶
from beam_agents import AgentConfig, Deny, Drop, Escalate, FallbackContext, HitlPolicy
def route(fallback: FallbackContext) -> Deny | Drop | Escalate:
return Deny() # module-level and pure — see below
config = AgentConfig(
provider_factory=make_client,
hitl_policy=HitlPolicy(
timeout_ms=900_000, # suspension deadline (default: 24 h)
intent_ttl_ms=600_000, # staged-intent lifetime (default: 1 h)
approval_channel="approval", # where the effector routes requests
max_escalations=0, # bound on Escalate re-arms
on_timeout=route, # default: deny
),
)
on_timeout must be pure, synchronous, and picklable — a module-level
function, never a lambda or closure. That is a correctness requirement, not
style: the route runs inside a timer callback whose bundle can be retried, so
a fallback that read a clock or called a model would make the retry diverge.
Every time value the route could need is already on the
FallbackContext
it is handed: the suspended activation's seq and snapshot, the
deadline_ms that elapsed, the timer's fired_at_ms, and the
pending_intent_ids nothing answered. Validation runs at construction —
non-positive timeouts, a negative escalation bound, or an empty channel raise
ValueError before any pipeline exists.
The three timeout routes¶
| route | what happens |
|---|---|
Deny(output=...) |
Deterministic bytes (default b"__hitl_timeout__") are emitted on the main output and the suspension ends. The agent's downstream sees an answer; it is just the configured "no". |
Drop(reason=...) |
Nothing on the main output; one record on .errors with reason hitl_timeout. The suspension ends. |
Escalate(tool_name, args_json, timeout_ms) |
Ask again, louder: a fresh approval intent is staged on the named escalation channel and the deadline extends by timeout_ms from the fire time. Bounded by max_escalations — an unbounded escalate loop would be a fail-open hole — after which the wait falls through to the deny path. |
Deny and Drop clear the continuation and its pending intents; a late
answer arriving afterwards finds no continuation and dead-letters (see below).
A route function that raises is itself failed closed: the raise becomes a
Drop to .errors rather than a wedged key retrying a broken policy forever.
Fail-closed, at both layers¶
Correctness invariant 6: a timed-out approval must not execute, no matter which side of the pipeline boundary learns about the timeout first.
Layer 1 — the pipeline. HITL_TIMER is a real-time timer armed at the
suspension's deadline. When it fires over a live continuation, the policy
routes it (Deny/Drop/Escalate, above). Stale firings — the suspension
was already resolved, or superseded by a later one with a farther deadline —
mutate nothing: a fail-closed mechanism that could be tricked into killing a
live continuation would not be fail-closed.
Layer 2 — the effector. Every staged intent carries expires_at_ms, and
the effector calls
refuse_expired before executing
anything: an expired intent is never executed; a ToolResult with status
EXPIRED is published instead, and re-injected on the key it lets a
still-live continuation take its own degraded path. The boundary is
deliberately strict — intent_expired reads a non-positive expires_at_ms
as already expired, never as unbounded, so an intent that somehow lost
its expiry cannot execute.
Late answers are dropped, visibly. A result or approval whose suspension
is gone — the timer already routed it, the continuation was superseded, or
the intent's own TTL passed — fails resume admission and lands on .errors
as one orphaned_result record. The record's detail names which of the four
admission checks failed (no_continuation, unknown_intent,
deadline_passed, intent_expired), so triage never has to re-derive it.
Both layers are release gates, not intentions:
tests/semantics/test_hitl_fail_closed.py
drives the timeout at both layers, and the
adapter conformance matrix pins the
suspend/approve/timeout lifecycle to identical behavior across every adapter
on DirectRunner and Flink.
Observing it¶
Suspensions and their outcomes are first-class records: suspensions and
orphaned_results counters in the runtime metrics, INTENT
and timeout events in the trace stream (each with the intent's
expires_at_ms and the suspension's deadline_ms as attributes), and a
live approval queue in the console. The
console demo generates approvals, denials, and
timeouts on purpose so those views have something honest to show.