Pause a run and resume it later
A Run that needs something outside itself suspends: it releases its lease, persists, and waits. Nothing is held in memory. The process that resumes it need not be the one that...
A Run that needs something outside itself suspends: it releases its lease, persists, and waits. Nothing is held in memory. The process that resumes it need not be the one that asked, and may be on another machine.
The alternative, holding the lease and polling, is exactly what this removes. A fan-out of four children would pin four Workers doing nothing while their own children queue behind them.
Approvals, questions, webhook waits and waiting on children are one mechanism, because they all run through the same lease and log machinery.
Four reasons
SuspendReason | Waiting on | Default expiry |
|---|---|---|
APPROVAL | A human decision on one tool call | 24h |
QUESTION | A person's answer to ask_question | 24h |
EXTERNAL | A webhook, a callback, a clock: anything outside Psych | 7 days |
CHILDREN | Background subagents this Run spawned | 1h |
CHILDREN is a distinct reason rather than a reuse of EXTERNAL, and the
difference earns the enum member. Every other suspension waits on something
Psych cannot see, so nothing in the library can say when it ends or whether it
ever will. This one waits on Runs Psych admitted, can name, and can read. That
buys two things: a parent about to suspend reconciles its children first and
does not suspend at all if they have already finished, and a reader can be told
what it is waiting for by name.
Asking a person a question
Off by default. Turn it on per Spec:
spec = psych_runtime.AgentSpec(
name="support",
model=psych_runtime.ModelRef(model="gpt-4o"),
suspension=psych_runtime.SuspensionPolicy(may_ask_questions=True),
)The model then has ask_question. It is the one built-in whose body never runs:
it is a request to stop. The agent loop intercepts it at the same gate an
approval passes through, writes a suspension and returns. What comes back is not
a return value but a person's answer, delivered through psych_runtime.resume(payload=)
on whatever Worker picks the Run up next.
A question is not a bare string. The model passes up to four questions, each
with the question text, an optional short header for a compact label, and up to
four {label, description} options. An open text box makes the person guess
what an acceptable answer looks like and makes the model parse prose that may
not contain one. Options move both problems to where they are cheap.
Options never trap anyone. They are a suggestion, not an enumeration. A person may always answer in their own words, and the answer comes back as free text either way. Nothing validates the reply against the option list. Render the options as the easy path and keep a way to type something else.
Four questions and four options is the cap, in the field definitions rather than in advice, because advice in a tool description is a suggestion a model may take or leave. A prompt asking eight questions at once is a form, and people abandon forms.
Reading and resuming
status = await psych_runtime.status(store, run_id, scope=scope)
if status.suspend_reason == "question":
q = status.pending_question
await psych_runtime.resume(store, run_id, payload={"answer": "the Berlin one"}, by="user-42")
if status.suspend_reason == "approval":
await psych_runtime.resume(store, run_id, approved=True, by="manager-7")by= is who decided, as you identify people. It is recorded on the resumed
record and interpreted by nothing. It exists because an approval whose log
cannot say who approved it is not an audit trail.
Waiting on something external
Suspend for EXTERNAL, park your correlation id, and resume from your webhook
handler:
async def on_provider_callback(event: dict) -> None:
await psych_runtime.resume(store, RunId(event["run_id"]), payload=event["result"])Psych stores no cron expressions and runs no timers. A Run waiting on a
clock is one your own scheduler resumes with resume(), the same way it admits
one with dispatch(). Suspend it as EXTERNAL: from the runtime's side a clock
and a webhook are the same thing, something outside Psych that will call back.
There is deliberately no SCHEDULED reason. An enum member nothing in the
library produces is a branch a consumer writes and can never execute, and their
coverage tool flags it forever with no way to tell from outside whether they
misread the runtime or found a bug.
Expiry
Past the policy's window, resume() raises SuspensionExpired and the Run is
settled ABANDONED first, so a stale approval never executes. Configure it
per Spec:
psych_runtime.SuspensionPolicy(
approval_expires_seconds=86_400.0,
question_expires_seconds=86_400.0,
external_expires_seconds=604_800.0,
children_expires_seconds=3_600.0,
)Errors
| Raised | Means |
|---|---|
RunNotFound | No such Run. |
RunNotSuspended | The Run is not waiting for anything. |
SuspensionExpired | The decision arrived too late; the Run is already ABANDONED. |
Gotchas
may_ask_questionsis inside the Version hash. An agent that may ask is a different agent from one that may not.- A suspended Run holds no Worker. Do not poll for it with a Worker; read
psych_runtime.status()from your own code. payloadandapprovedare different answers.approved=is the approval decision;payload=is data for a question or an external wait.ask_questionis not how to ask permission for a tool call. That is approvals, and the tool description says so to the model.
Stop or steer a running agent
An abort is a Record, not a flag. It has a sequence number, so "what arrived after the stop" is a question the log answers rather than a race the runtime has to win. A client...
Keep a long conversation inside the window
Off by default. AgentSpec.compaction is None, and a conversation grows until the provider refuses it.