Everything on this page describes a plain (sync)
agent — unconditionally
still true, zero change. async agent (see Agents)
reuses this exact mechanism but skips the caller’s blocking wait, which is
enough to make “no locking needed” below stop holding — see
What changes for async agent at the end.The constraint
Real pause/resume — suspend an agent’s execution mid-statement, with every local variable and every level of nested control flow intact, and continue it later from that exact point — usually means one of two things: rewrite the interpreter as a resumable state machine (generators/coroutines, or an explicit step-based trampoline with a serializable call stack), or find a way to get equivalent behavior from the host language for free. Sense’s tree-walking interpreter does deep, ordinary Python recursion for every level of Sense-level nesting (anif inside a while inside a
function call is three levels of nested Python calls). Rewriting that into
generators would mean touching every single _exec_*/_eval_* method —
correctness-critical, and a large surface for new bugs. The chosen
alternative: get the suspension “for free” from the OS.
The mechanism: one thread per running agent, cooperative hand-off
EachSenseAgent running via start(...) gets its own daemon
threading.Thread, plus two threading.Event objects used purely as a
one-directional signal (“go ahead”), never as a lock:
Why a sync agent needs no locking
For a plain (sync)agent, at every instant exactly one of the caller or
the agent is actually executing Sense interpreter code — the other is
parked in Event.wait(), which blocks without spinning and without
touching shared state. This is cooperative hand-off, not real
concurrency: nothing is ever running at the same time as anything else,
so there’s no race to guard against on Environment objects, the module
cache, or anything else the interpreter touches. Real OS threads are used
purely as a mechanism for “suspend this call stack and let me get back to
it later” — not as a way to run two things at once.
This guarantee is exactly what async agent gives up in exchange for
genuine concurrency — see What changes for async agent
below.
Error handling across the boundary
An exception raised inside the agent’s body — whether before anypause()
or after a resume() — is caught on the agent’s own thread, recorded on
the agent object, and the agent’s thread signals settled_event one last
time. Whichever thread was blocked in start()/resume() then re-raises
that same exception itself, so it surfaces as an ordinary SenseError
exactly at the start(...)/resume(...) call site — not as an uncaught
exception on a background thread, which Python would otherwise silently
print to stderr and ignore.
Daemon threads: no cleanup required
Agent threads are created withdaemon=True. A paused agent nobody ever
calls resume() on simply sits blocked on its resume_event forever —
and that’s fine, because daemon threads don’t prevent the Python process
from exiting. There’s no explicit agent-cleanup API, and none is needed for
correctness (only for reclaiming the thread’s memory, which the OS/Python
runtime handles at process exit either way).
What this deliberately doesn’t provide
- A plain (sync)
agent’sstart/resumeis a fully blocking round-trip — see below for whatasync agentchanges about this. - An agent cannot pause or resume another agent from inside its own body.
- Nothing here is serializable — a paused agent’s state is a live call stack on a live OS thread, gone if the process exits. True persistence across restarts would need a fundamentally different execution model (a step-based/bytecode interpreter with frames that can actually be written to disk), not an extension of this thread-based approach.
What changes for async agent
start()/resume() on an async agent (see
Agents#concurrency) skip the
agent._settled_event.wait() step in the diagram above and return
immediately. Everything else — the agent thread, pause(), the
resume_event/settled_event pair — is identical machinery; only the
caller’s willingness to block changes.
That one change is enough to break the “no locking needed” argument this
page opened with: with async, the caller and the agent’s body (or two
async agents) genuinely can both be executing Sense interpreter code at
the same instant — that’s the whole point. Nothing about Environment,
SenseMemory, or any other mutable runtime object was built with
thread-safety in mind, because until async existed, nothing needed it
(true mutual exclusion was structural, not just assumed). Two async
bodies (or an async body and its caller) writing to the same shared scope
or memory concurrently can therefore race — plain, undefended Python
threading semantics, same as writing multi-threaded Python without a lock.
No mutex/channel/atomic primitive exists in Sense to guard against this; it
is a real, currently unmitigated gap, not an oversold “it’s fine because
of the GIL” — the GIL only serializes individual Python bytecodes, not a
Sense-level statement, so a check-then-write sequence (like
Environment.define_or_assign walking the parent chain) is not atomic
across two threads.
Continue
Pause & resume
The user-facing feature this implements.
Scoping & delegation
The related fix this same work required.

