Hold on, let me check
Consultant runs the slow agent inside a live call. You supply a factory; what it builds is yours.
timeout_s defaults to DEFAULT_CONSULT_TIMEOUT_S, 45 seconds: how long a caller will wait on the line before “let me check” stops sounding like thinking and starts sounding like a dropped call.
Three behaviours are not obvious, and each exists because a caller is listening.
A blocking agent runs off the loop. A synchronous callable goes to a thread. Called inline it would hold the event loop for its whole duration, and the caller would hear the call itself stall.
One at a time, and the second asker is told so. A model that can delegate can delegate twice before the first answer lands. Queueing the second means it waits out both timeouts and answers far too late, so it is refused immediately with a sentence the model can say out loud. consultant.busy is that state, if your plugin would rather not offer the tool while one is running.
A timeout is admitted. The work cannot be cancelled: the thread runs to its end whatever this returns. The answer says it stopped rather than promising a follow-up nothing will send, and the timed-out agent is dropped so the next question builds a fresh one.
ask never raises. It always returns something speakable, because an exception in a tool result is an agent that goes quiet mid-sentence.I’ll send you the result
When the work will not finish while somebody waits, it becomes a promise, and a promise made on a call that is about to end has to outlive the process that made it.BackgroundTasks writes each task to disk before the work starts and removes it only once the result has actually been delivered.
remember returns the BackgroundTask it wrote, or None when it could not be written, which is a task that runs but will not survive a restart. That is worth knowing about and worth continuing with: a non-durable answer still beats no answer.
resume returns how many tasks it actually delivered, and its timeout_s is what each recovered run gets. BackgroundTasks(directory=..., ttl_s=..., resume_limit=...) takes all three. The directory defaults to a tasks folder under STANDIN_STATE_DIR, or under ~/.standin/state when that is unset, created owner-only. Deliberately never a temp directory: a temp directory passes every test and loses every parked promise on the next reboot, which is invisible until somebody is waiting for an answer that no longer exists.
The two-phase claim is what makes a restart safe. A task waiting to run is a
.json; a task being run is a .claimed. A process that dies mid-run leaves a claim behind, and the next startup takes it back once it is old enough to be sure nobody is still working on it. A delivery that fails puts the task back rather than dropping it.
A promise that outlives the call needs a stated lifetime, so here are the numbers.
Those four constants are not on the
standin barrel. Reach them as
from standin.consult import DEFAULT_CONSULT_TIMEOUT_S, DEFAULT_TASK_TIMEOUT_S, DEFAULT_TASK_TTL_S, DEFAULT_RESUME_LIMIT. The TypeScript twin exports all four from the package root, and the three that are durations are named for milliseconds there: DEFAULT_CONSULT_TIMEOUT_MS, DEFAULT_TASK_TIMEOUT_MS, DEFAULT_TASK_TTL_MS. DEFAULT_RESUME_LIMIT is a count and keeps its name.
The two tools
Both areToolSpec values, ready to register on CallTools. They are not built in, because an agent with no second agent behind it should not be told it has one.
background_available=True to Consultant and a timeout will point the caller at it. Leave it off and it will not, because promising a path that does not exist is worse than admitting the work stopped.
Next
Call tools
Where both tools are registered, beside the built-ins.
Meeting recap
The other job a
Consultant is normally handed.Reaching people
When the answer should ring somebody back rather than land in a chat.
Configuration
STANDIN_STATE_DIR and every other variable the SDK reads.