Kotoba

Where to start

What Kotoba isWho she is and where she runs, in one page
InstallingOne package, two commands
First runA key, a model, her first words
The approval gateHow she asks before she acts
The two voice modesLocal voice, or the agent tunnel
The soul fileChange who she is
RoadmapWhat grows next, and what was cut on purpose

Or a section

↑↓ move openesc close124 pages
All pages

Architecture5 of 10

Inline versus deferred execution

About 5 minutes to read

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:

python
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:

python
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:

python
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.