- hi
- i wanted to ask
- about the flat
- the one downtown
Four balloons. Four model calls.
One question, answered four times, and the last three replies were written without knowing what the person was still typing. lull waits for the conversation to fall quiet and hands your agent one turn.
Run it
takeoverTtlMs and sessionTtlMs run far shorter here than in the library, so both lapse while you are watching.
- Type a few short messages, the way somebody types on WhatsApp. lull buffers them and hands your agent one turn.
- Messages received
- 0
- Turns emitted
- 0
- Model calls avoided
- 0%
- Nothing has run yet. Send a message and the effects the reducer returns appear here.
The rule that closes a turn
Every message pushes the first deadline forward. The second one does not move, so somebody who never stops typing still gets an answer.
That line is the whole coalescing mechanism. The instrument above runs it: the vertical marker is the deadline, and it slides right each time you send.
The core never reads the clock
lull is a pure function with a small facade around it. The function takes a state, an event and a policy, and returns the next state plus the effects to run. It creates no timers and touches no network, so the demo above runs the same code your server does, compiled straight from the repository.
import { reduce, initialState } from '@luantaraschi/lull/core'
const [next, effects] = reduce(state, { type: 'message', id, text, at }, policy)
// effects: emitTurn | schedule | cancel | drop
Because time arrives as a number on the event, testing four messages in eight seconds is arithmetic. There are 46 tests, and the 25 over the reducer never sleep. Three are property based: no message is ever lost, no turn is emitted while a human holds the conversation, and the deduplication window never grows past its bound.
What it handles
Fragmented messages
Balloons are buffered and released as one turn after the silence, capped so the wait cannot run away. How long to wait is the one number worth measuring: at the 5s default a simulation has the bot answering before the person finished in 25% of bursts, and at 6s that falls to 14%. Better still, feed it the typing event your channel already emits and the turn stays open while they compose. Start typing in the composer above and watch the deadline move while no message arrives.
Redelivered webhooks
Gateways retry. The same message id twice produces one event, tracked in a window of the last 200 ids per conversation rather than a history that grows forever. Press Redeliver last webhook above and the copy arrives, is recognised and is dropped, without disturbing the turn the original is still waiting in.
Human takeover
Call takeover and the bot goes quiet for a TTL. Messages arriving meanwhile are dropped rather than queued, so when the TTL lapses the bot does not wake up and answer twenty messages a colleague already handled. Press the button above and watch what you send arrive dropped rather than buffered.
Session expiry
After a period of inactivity the next turn arrives flagged as a new session, which is the cue to reset the model context. The fader above starts sessionTtlMs at 20 seconds rather than the half hour the library uses, so a pause in your own conversation is enough to watch the next turn come back flagged.
What it costs
A seeded benchmark of a thousand conversations, typed the way people actually type, with bursts of one to six balloons and a pause for an answer.
- Messages
- 20,888
- Turns
- 6,000
- Model calls avoided
- 71.3%
Run npm run bench to reproduce the number. The generator is seeded, so it
prints the same figure on every machine.
Where it runs
Node 20 or newer, with no runtime dependencies. State lives behind a four method interface, with an in-memory implementation and a Redis one that takes locks with SET NX PX and releases them with a compare-and-delete script.
Where no process stays alive, skip the facade: drive the core from a stateless handler, keep the state in your database, and let an external scheduler deliver the tick.
npm i @luantaraschi/lull
Known limitation, stated where you can find it: with the facade, timers live in the process that received the message. If that process dies with a turn buffered, the turn waits for the next message instead of firing on time.