Psych Runtime
Guides

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

SuspendReasonWaiting onDefault expiry
APPROVALA human decision on one tool call24h
QUESTIONA person's answer to ask_question24h
EXTERNALA webhook, a callback, a clock: anything outside Psych7 days
CHILDRENBackground subagents this Run spawned1h

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

RaisedMeans
RunNotFoundNo such Run.
RunNotSuspendedThe Run is not waiting for anything.
SuspensionExpiredThe decision arrived too late; the Run is already ABANDONED.

Gotchas

  • may_ask_questions is 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.
  • payload and approved are different answers. approved= is the approval decision; payload= is data for a question or an external wait.
  • ask_question is not how to ask permission for a tool call. That is approvals, and the tool description says so to the model.

On this page