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...
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 streaming with after=N sees the abort and everything after it
in order.
await psych_runtime.interrupt(store, run_id, reason="the user pressed stop", by="user-42")Three queues, because there are three intentions
Collapsing them into one flag is what produces the race that breaks streaming after an interrupt.
| Queue | Means | After an abort |
|---|---|---|
QueueKind.STEER (default) | Inject into the turn running right now. | Refused |
QueueKind.FOLLOW_UP | Deliver once the current turn settles, instead of letting the Run complete. "Keep going, and also..." | Refused |
QueueKind.NEXT_RUN | Hand a message to whatever Run comes next. | Legal, and this is the point |
await psych_runtime.send(store, run_id, message="actually, check the Berlin warehouse")
await psych_runtime.send(store, run_id, message="...", queue=psych_runtime.QueueKind.FOLLOW_UP)A plain string is wrapped as {"message": ...}, matching what dispatch's own
input accepts, so there is one shape to remember rather than two.
A steer reaches the turn after this one
Steers are drained at the start of the next turn. There is no mid-turn mutation: tool resolution happens at a turn boundary and stays fixed for the turn. So a steer sent while the model is mid-stream lands on the following turn, not the one you were watching. That is not latency to tune away; it is what keeps a turn's tool set stable while it executes.
Stop and immediately send another
This is the sequence, and every part of it is in the log in order:
await psych_runtime.interrupt(store, run_id, reason="user pressed stop")
await psych_runtime.send(
store, run_id, message="do this instead", queue=psych_runtime.QueueKind.NEXT_RUN
)
# once the aborted Run settles:
new_run = await psych_runtime.dispatch(
store, version, scope, input={"message": "do this instead"}, continues=run_id
)send(queue=NEXT_RUN) records that the message arrived and when, which is what
makes the arrival visible even if the continuation is dispatched much later. The
new Run's own admission is what carries the message forward.
send() or dispatch(continues=...)
send() is a low-level primitive for a Run already open. The common case,
a second chat message after the first Run settled with nothing left to steer, is
not this. Use dispatch(continues=run_id, input=...), which starts a fresh Run
carrying the earlier conversation forward instead of injecting into one that is
no longer there to receive it.
continues is refused with AccessDenied when that Run belongs to a different
Scope. A continuation is another way to reach another Run's content and gets the
same scrutiny as the MCP pool key.
In-flight tool calls
A tool registered interruptible=True (the default) may be cancelled mid-call.
One registered interruptible=False is not, and the abort waits for it. That is
the difference between a stopped agent and a half-issued refund.
A call recorded as started whose Worker then died is a dangling call. The
next Worker replays the log and settles it rather than guessing. Whether it may
be re-executed is safe_to_retry, which defaults to False because assuming a
side effect is repeatable is how double refunds happen.
Terminal states
Every Run reaches exactly one:
| State | Means |
|---|---|
COMPLETED | The loop finished on a turn with no tool calls. |
FAILED | An unrecoverable error. |
ABORTED | Interrupted, or past its deadline. |
ABANDONED | A suspension waited past its expiry. |
FORCE_SETTLED | The supervisor wrote a terminal record over work that would not unwind inside the grace period, and orphaned it. |
report.orphaned_attempt_id is set only for FORCE_SETTLED.
Gotchas
interrupt()on a settled Run raisesRunAlreadySettled. Checkpsych_runtime.status()first if that is a normal path for you.- A steer on a Run whose Worker already stopped goes nowhere useful. The message is recorded; nothing drains it. Dispatch a continuation instead.
- Aborting does not roll anything back. Psych has no compensation model. A
side effect already committed stays committed, which is why
interruptible=Falseexists.
Require approval for selected tool calls
An approval is a suspension, not a callback. The Run releases its lease, persists, and waits. Nothing is held in memory, so the process that resumes it need not be the one that...
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...