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, waiting on children, a workflow's timers and its breakpoints are one mechanism, because they all run through the same lease and log machinery.
Six reasons
SuspendReason | Waiting on | Default expiry |
|---|---|---|
APPROVAL | A human decision on one tool call, or on a workflow tool step | 24h |
QUESTION | A person's answer to ask_question, or to a workflow human step | 24h |
EXTERNAL | A webhook, a callback: anything outside Psych, including a workflow wait step's event | 7 days |
CHILDREN | Background subagents this Run spawned | 1h |
TIMER | A workflow sleep step or a retry backoff. Nobody answers it: the Run is parked with a wake time and claimed again when it passes | wake + 7 days |
BREAKPOINT | A person saying "go on" to a workflow paused before a step | 24h |
TIMER and BREAKPOINT are produced only by workflows (psych-workflows).
A timer parks the Run as RUNNABLE with runnable_at set rather than as
SUSPENDED, because nothing will ever call resume() for it; the claiming
Worker writes the resumed record itself. Every other reason parks the Run
SUSPENDED until a resume arrives.
Which step is waiting
When the wait belongs to a workflow step, the suspended record names it
(step_id, step_name, event, wake_at) and status() projects it:
status.suspended_step_id, plus one of pending_approval (call_id=None,
step_id set, the tool and its resolved arguments), pending_question
(call_id=None, step_id set), or pending_wait (reason, event,
payload_schema, wake_at, expires_at) for an event, a timer or a
breakpoint. psych_runtime.workflow_view(store, run_id).waiting says the same
thing inside the step tree.
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.
scope= is whose decision it is. Like every read entry point, resume()
refuses a Run belonging to another tenant with AccessDenied. It is optional
so existing callers keep working; a consumer serving end users should pass it.
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 scheduler. An agent waiting
on a clock is one your own scheduler resumes with resume(), the same way it
admits one with dispatch(); suspend it as EXTERNAL, since from the
runtime's side a clock and a webhook are the same thing. A workflow sleep
step or retry backoff is the one clock the runtime does keep, as TIMER: the
store parks the Run until the wake time and any Worker's claim wakes it, so
no timer lives in a process.
A workflow wait step is the same webhook pattern with the event named:
status.pending_wait.event says which event the Run wants, and the payload
you deliver becomes the step's output, checked against the step's
payload_schema first.
Breakpoints
A workflow dispatched with breakpoints= or step_mode=True suspends as
BREAKPOINT before the named step. resume() with no payload continues;
resume(payload={"step_mode": False}) or True turns stepping off or on for
the rest of the Run. A resume delivered to a TIMER wakes it early.
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. |
AccessDenied | scope= names a different tenant than the Run's. |
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.