Adaptive batching¶
By default RunAgent activates the agent once per event. For bursty keys — a
sensor emitting twenty readings in a second, a card fired at twenty times in a
minute — that buys twenty LLM round-trips where one activation over the whole
burst reaches the same decision at a twentieth of the cost.
BatchPolicy.ADAPTIVE turns that burst into one activation:
from beam_agents import AgentConfig, RunAgent
from beam_agents.core.batching import BatchPolicy
out = keyed_envelopes | RunAgent(
my_agent,
config=AgentConfig(
provider_factory=make_client,
batch_policy=BatchPolicy.ADAPTIVE,
max_batch_size=20, # flush as soon as the buffer reaches this
max_wait_ms=500, # ...or this much processing time after the first event
),
)
BatchPolicy.NONE is the default and changes nothing: one activation per
event, ctx.event as bytes, no buffer, no flush timer.
The knobs¶
| Knob | Meaning | Default under ADAPTIVE |
|---|---|---|
batch_policy |
NONE (per-event) or ADAPTIVE (buffered) |
NONE |
max_batch_size |
Buffer length that triggers an immediate flush | 10 |
max_wait_ms |
Processing-time bound, measured from the buffer's first event | 500 |
max_buffered_events |
Hard cap on buffered events; beyond it, events dead-letter | 4 × max_batch_size |
All four are validated at AgentConfig construction. Setting a batch knob
while batch_policy is NONE raises ValueError — a knob that silently does
nothing is a misconfiguration, not a default.
What the agent sees¶
Under ADAPTIVE a flush activation presents the batch as a list:
async def react(ctx: ActivationContext) -> Complete:
assert ctx.is_batch # true on every ADAPTIVE flush
readings = ctx.events # tuple[bytes, ...], arrival order
return Complete(output=summarize(readings))
ctx.eventis alist[bytes]on every flush, including a flush of one. The shape follows the configured policy, not the runtime batch size, so anADAPTIVEagent is written against exactly one shape.ctx.eventsis the uniform accessor: the batch underADAPTIVE, a one-element tuple underNONE, and empty on a resume under either policy.ctx.single_eventis the narrowing accessor for agents (and the framework adapters) written against exactly one event. It raises underADAPTIVE.
The activation clock ctx.now_ms is max(event_time_ms) over the batch — the
freshest ground truth the batch contains, and a pure function of the buffer, so
a retried bundle reproduces it. Intent expiries and suspension deadlines derive
from it exactly as on the per-event path.
One flush is one activation¶
A committed flush consumes exactly one SEQ, mints intent IDs from
(entity_key, seq, step_index), and keys its replay-cache entries by the same
(entity_key, seq) scope — all identical to a per-event activation. Five
buffered events that flush together produce one activations count, one
commit, and one trace.
The flush's trace carries beam_agents.batch.size and
beam_agents.batch.trigger (size or timer) on its ACTIVATION_START
event, so a trace consumer can tell a batch decision from a per-event one.
Suspension is whole-batch¶
If a flush activation returns Suspend, the entire batch suspends: one
Continuation at the batch's seq, one HITL_TIMER, one resume. There is no
per-element suspension and no partial resume.
- The resume runs once, at the batch's
seq, from the persistedstep_indexandsnapshot.ctx.eventsis empty on a resume — theContinuationdoes not persist the batched events, so whatever the resume needs must be in the agent's ownsnapshot. - A HITL timeout fails the whole batch closed through the usual fallback route,
once, and a late result is one
orphaned_result.
While a continuation is live, flushing defers. Both triggers are suppressed
(a flush that suspended would overwrite the live continuation and orphan its
pending intents), so the buffer keeps absorbing events past max_batch_size.
When the suspension resolves — the resume commits, or the HITL policy's
Deny/Drop route ends the wait — FLUSH_TIMER is re-armed to fire promptly
and the deferred batch flushes in its own callback, with its own SEQ. An
Escalate keeps the suspension live and keeps deferring.
When things go wrong¶
| Situation | Behavior |
|---|---|
| The flush activation raises or times out | Nothing commits; one .errors record per buffered envelope (activation_error/activation_timeout, with batch_size=<n>,trigger=<size\|timer> in detail), and the buffer plus its timer are cleared. A poison batch cannot wedge the key in a retry loop; triage stays element-granular. |
The buffer reaches max_buffered_events |
The next event is dead-lettered as batch_buffer_overflow instead of appended. Buffer growth during deferral is bounded and visible. |
TTL_TIMER fires over a non-empty buffer |
Every buffered envelope is dead-lettered as ttl_wiped_batch, then all six state specs and the flush timer are wiped. Reaching this means a stalled pipeline or a watermark jump — max_wait_ms is orders of magnitude inside ttl_ms. |
Caveats¶
- Lateness.
max_wait_msis a processing-time bound, not a windowing feature — which is what makes batching keep working during a backlog replay, when event time lags wall time by hours. The cost is that the watermark can advance past a buffered element while it waits, so outputs emitted from theFLUSH_TIMERcallback may be droppably late for a windowed consumer downstream. Outputs carry the batch clock (max(event_time_ms)), andmax_wait_msis small by design. Beam's Python SDK does not expose a timer output-timestamp (watermark hold); if it gains one,FLUSH_TIMERshould hold the watermark at the earliest buffered event time. - Resumes never buffer. A
tool_resultorapprovalanswers one specific suspension; delaying it would spend that suspension's own deadline doing nothing. - Per key only. Batching is per entity key, like everything else in the runtime. Cross-key or global micro-batching would break per-key serialization; cross-key parallelism belongs to the runner.
- Framework adapters are single-event. The LangGraph/ADK/Pydantic AI
adapters consume one event per activation and raise on
ctx.single_eventunderADAPTIVE. Batching those authoring surfaces is a separate change.