Architecture5 of 10
Inline versus deferred execution
On this page
Most of the time an approval-gated tool blocks: it shows the card, waits, and runs or does not run
inside the same turn. On exactly one transport it cannot, and core/deferred_exec.py is the way
round.
The failure that makes deferral necessary
An ElevenLabs agent call is a live WebSocket with a clock on it. A turn served over
/v1/chat/completions that goes quiet gets timed out by ElevenLabs and re-fired. The re-fire
arrives as a new turn, turns.supersede cancels the waiter, and the approval the user then grants
arrives orphaned: nothing runs, and nothing says so.
So on that transport a gated tool does the opposite of blocking. It shows the card, ends the turn
with "I asked for your permission on screen", and hands the wait to a detached task that outlives
the turn entirely. When the answer comes, that task runs the action and announces the result through
work_state — the same work_done frame the background work runner emits, so the frontend's
existing announce path carries it.
The detached wait is generous — 180 seconds — precisely because nothing is timing it.
The criterion is the transport, not the channel
Both shell and execute_code compute it identically:
cannot_block = (el_agent_turn(ctx) is True
and _events.has_listener(getattr(ctx, "session_id", None)))
el_agent_turn(ctx) reads ToolContext.el_call_bound, which core/transport.py put there at
construction. It is True only inside /v1's producer.
The criterion used to be channel == "voice", and that was wrong in both directions. The local voice
WebSocket is voice and has no external clock — nothing re-fires a quiet turn there — so it was
deferring when it could simply have blocked and given the user a readable 60-second window. The fix
moved local voice to block in place, leaving ElevenLabs, typed web turns, the terminal and
Discord all behaving identically to one another.
The has_listener half matters too: with nobody to draw the card there is nothing to defer to, and
the inline path's refusal machinery is the honest answer.
A detached job must not inherit its parent's transport mark
The mark is a ContextVar, and asyncio.create_task copies the current context. So a background
job launched inside an ElevenLabs turn inherits True — and keeps reading it for the next half hour,
long after that turn returned its "I'll get on it" line.
The consequence was concrete: a job born in an ElevenLabs turn kept behaving as if an agent were
holding a clock over it, so it drew cards at a turn that was already dead. core/work_runner.py
drops the mark for the job's whole life:
with interaction.interactive_scope(run_id), transport.detached_from_el_call():
summary = await _run_with_compute_budget(agentic_loop(...), ...)
detached_from_el_call() answers one question honestly — is an agent holding this code's clock
open? — and for a detached job the answer is no.
What the transport genuinely still decides does not come through the ContextVar at all. The job's
compute ceiling is captured at creation, in work_runner.start, at the last moment anything knows
who asked for the work:
el_bound = transport.el_call_bound()
run_id = uuid.uuid4().hex[:8]
work_state.start(session_id, goal, el_call_bound=el_bound, run_id=run_id)
and passed down to _work_timeout(el_call_bound), which returns WORK_TIMEOUT_EL_BOUND_SECONDS
(1500 s) for a job born inside an ElevenLabs call and WORK_TIMEOUT_SECONDS (3600 s) otherwise. A
detached reader cannot be trusted to re-derive it: the runner is detached, the setting stays
live-settable all run, and the terminal has no transport at all.
transport.el_call_bound() is also read inside the marked turn to size the approval window and the
tool budget, which is why the drop has to be a scope rather than an argument.
One request, one run
Nothing durable records that a deferred tool ran, and the announce turn re-derives the same request —
so the model re-emits the same call, respelled. print(sum(range(1,101))) and
print(sum(range(1, 101))) are one intent.
deferred_exec.schedule keys on (the user's request text + the normalised action), whitespace
stripped, and refuses a second card:
- Already awaiting: nothing is scheduled; the model is told the card is up and to wait.
- Already settled (ran, declined, failed or cancelled): the model is handed the outcome sentence instead of a second card.
The key is anchored to the user request, so "run it again" is a new ask. With no user request to anchor to, the guard is off rather than session-global — a sticky session-wide block would refuse to ever run that command again for the rest of the call.
The action is settled before the runner starts, so a re-emission arriving mid-run cannot card and run it a second time.
The audit trail either way
deferred_exec records on the same trail ApprovalGate writes to, through gate.record. Skipping
it was measurable: the audit log once held 387 work-loop rows and 12 user ones — the human
approvals that actually authorised a host command were the ones missing.
An approved action that then failed to start raises NothingRan rather than returning a sentence.
That distinction is the whole point of the exception: a runner reports through its return value, and
prose is not a channel — a runner that answered "I couldn't get the environment ready" was read as a
summary of a successful run and filed as executed. The audit trail is the only place that answers
"did this reach my machine", so it is the one place a guess is not allowed.
What the loop draws afterwards
deferred_exec.ran_nothing(ctx, call_id) tells the loop that this tool call executed nothing —
either deferred, or refused as a re-emission. The loop then writes no executed audit row and marks
the terminal row pending rather than a green tick; _finish_step completes that same row in place,
by its original id, with the word its own witness knows (ok, refused, failed, interrupted).
ok is not the mark. A command that exited 124 produces a usable summary, so grading rows on ok
put a clean run's tick over every approved command and a real failure's cross over every refusal.
