Topic 14.3
Interrupting and Steering
In one line
The user must be able to stop an agent, refuse one step, change the instruction half way, and undo what it did. Stop is a request that the server may only partly honour, so the screen reports what really happened from a server ledger. A correction after a stop needs the half-finished tool calls cleaned from the history.
Think of it like this
Driving with a satnav. You can pull over (stop), refuse one turn ("not that road"), say "actually, take me to the petrol station first" (steer), and the satnav works out the new route from where you really are. It does not pretend the first ten minutes never happened. An agent screen needs the same four controls, and the same honesty about where you really are.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- AbortSignal
- A browser object that tells running code to stop. It is the same signal that cancels a
fetch. - abortSignal option
- The option of
agent.stream()and the field of a tool'sexecuteoptions that carries the signal to your code. - Ledger
- A server-side record of each step and how it ended: done, cancelled or never started.
- Steering
- Changing what an agent does while it works. In practice: stop, then send a corrected instruction.
- Incomplete tool call
- A tool call in the history that has no result, usually because the run was stopped while it ran.
- Approval gate
- A tool that waits for the user's yes or no before it runs.
- output-denied
- The tool part state after the user refused an approval. The model is told the step was denied.
- Compensating action
- A new action that reverses the effect of an earlier one, such as removing store credit.
- Idempotent
- Safe to repeat: doing it a second time changes nothing more.
Step by step
01Stop, and what happens on both sides
The user presses Stop. The browser aborts its request. The server's signal fires. A tool that listens throws and the loop ends. A tool that does not listen finishes its work. Then the loop sees the abort and stops.
02Two stops, two different truths
The first live example runs the same three-step plan twice. In run one, the user stops while the refund is running. The refund tool listens to its abort signal, so it is cancelled and the email never starts. In run two, the user stops while the email is being sent. The email step does not listen, so it finishes, and the ledger says so.
The text on screen comes from the ledger, so it is true in both runs. "Stopped" did not mean "nothing more happened" in the second one, and the user needs to read that.
03A refused step inside a plan
The second live example puts the refund behind toolApproval. When the model asks for it, the tool part waits in approval-requested. The scripted user denies it. The part becomes output-denied, and the browser sends the next request by itself (sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses).
The model carries on to the email step. The email tool checks the server's own record, sees that no refund happened, and returns a "skipped" result. The rule is in the tool, not in the prompt.
04Correcting a stopped run
The third live example stops a run during the refund, then sends "Actually, use store credit". Two chats do this, one with the naive history and one with ignoreIncompleteToolCalls: true. The naive one fails with AI_MissingToolResultsError. The safe one finishes the job.
05Undo, and the steps that cannot be undone
The fourth live example is the undo panel for the finished run. Each action in the ledger says up front whether it can be reversed. Pressing undo twice on the same action is safe. Pressing it on the email returns a 409 Conflict with a reason, and the screen shows the reason as plain words.
Try it yourself
- 1
Make the email stop-able
In the first example, change the
notifystep to{ cancellable: true }and give its work function the signal:execute: step("notify", (signal) => sleep(40, signal), { cancellable: true }).Run it. The second run now ends with the email under Cancelled instead of finished anyway. One word in the ledger, a very different message to the user.
- 2
Approve instead
In the second example, set
APPROVE = true.The refund runs, the
refundedflag becomes true, and the email tool sends the receipt. The same code produces both endings, which is why the rule sits in the tool. - 3
Press undo twice on the credit
In the fourth example, add one more
"a1"to thePRESSESlist, so it reads["a1", "a2", "a1", "a1"].The third press on the same action gets the same 200 answer with "Already undone". Nothing changes a second time.
Code & diagrams
The Stop is pressed from the stream itself, when a given step starts. Run one stops the cancellable refund. Run two stops while the email, which ignores the signal, is being sent.
Preview
Console
Nothing logged yet.Expected console output
user presses Stop while 'Issue the refund' is running
Stopped. Done: Find the order. Cancelled: Issue the refund. Could not be stopped, finished anyway: nothing. Never started: Email the receipt.
user presses Stop while 'Email the receipt' is running
Stopped. Done: Find the order, Issue the refund. Cancelled: nothing. Could not be stopped, finished anyway: Email the receipt. Never started: nothing.The scripted user denies the refund. The agent goes on to the email step, and the email tool refuses because the server's record says no refund happened. Change APPROVE to true and run it again.
Preview
Console
Nothing logged yet.Expected console output
the screen asks: Refund Rs 349 for ORD-1042 to the original payment method
the user answers: deny
lookup: done {"order":"ORD-1042","total":349}
refund: refused by you
notify: done {"skipped":"No refund was issued, so no receipt was sent."}The same stop, the same correction, two histories. The only difference is one option on convertToModelMessages. The bare AI_MissingToolResultsError line is the SDK's own log of the failure.
Preview
Console
Nothing logged yet.Expected console output
naive history: the user presses Stop during the card refund (tool state: input-available)
naive history: the user types a correction
AI_MissingToolResultsError: Tool result is missing for tool call call_2.
naive history: the correction failed: AI_MissingToolResultsError: Tool result is missing for tool call call_2.
safe history: the user presses Stop during the card refund (tool state: input-available)
safe history: the user types a correction
safe history: answer: Done: Rs 349 store credit added.Three scripted presses: undo the credit, try to undo the email, undo the credit again. The ledger knows which actions are reversible, and the screen says so before you press.
Preview
Console
Nothing logged yet.Expected console output
undo a1 -> 200: Undone: Added Rs 349 store credit.
undo a2 -> 409: An email cannot be unsent.
undo a1 -> 200: Already undone. Nothing changed.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget to drop the unfinished tool call
In the third example, change { ignoreIncompleteToolCalls: safe } to { ignoreIncompleteToolCalls: false }, so both chats use the naive history.
Myth vs fact
Myth
Stop cancels everything the agent was doing.
Fact
Stop aborts the request. Work that already left your server (an email, a payment call) may finish. Only tools that listen to the abort signal stop in the middle, and a ledger tells the user which was which.
Myth
If the user denies a step, the agent stops.
Fact
The model is told the step was denied and decides what to do next. It may skip it, try another way, or continue as if nothing happened. Enforce rules that depend on that step in your tool code.
Myth
Undo is always possible if I store the old value.
Fact
Some effects leave the system: an email is read, a parcel is shipped. Mark those steps before they run and say "This cannot be undone" at the approval, because afterwards it is too late.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Pass
abortSignal: req.signaltoagent.stream(), and listen to theabortSignalyour tools receive. Remember that a tool which awaits a library call needs that library to accept a signal too: many HTTP and database clients do (fetch(url, { signal })). - ▸
Keep a stop token per run in your database when the run lives in a background job. The user's Stop writes
cancelled_at; the job checks it between steps. Browser-sidestop()only closes a connection, and a background job does not see that. - ▸
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponsesmakes the browser send a new request after the user answers, with the history so far. So an approval gate splits one run into two HTTP requests. Make the second request idempotent (same chat id, no duplicate steps) and carry the plan across it. - ▸
A Stop button needs four states:
idle(hidden),running(enabled),stopping(disabled, "Stopping…") andstopped(hidden, with the summary). Keep them in one status variable, like the states in Topic 0.4.
Remember this
- 1
The problem this solves: A chat answer is harmless to cancel. An agent that is half way through a refund, an email and a database update is not. If the user presses Stop, what exactly stopped? If they change their mind, does the agent know what it already did? If a step went wrong, can it be undone? Without clear answers, users either never trust the agent with real work or lose track of what happened.
- 2
Stop is a request, not a guarantee. In the browser,
chat.stop()aborts the HTTP request, andonFinishreportsisAbort: true. On the server the abort reaches your code as a signal:req.signalin the route, and theabortSignaloption ofagent.stream(). The SDK passes the same signal to each tool'sexecuteasoptions.abortSignal. Only a tool that listens to it can stop in the middle. A tool that does not listen finishes anyway (an email that has been handed to the mail server cannot be recalled). - 3
Report from a ledger, not from hope. After a stop, the screen should say three things: what was done, what was cancelled while running, and what never started. The browser cannot know the second one, because the abort races the server. The server writes a ledger (a record of each step and how it ended) and the screen asks for it after the stop. Words built from the ledger are true even when the stop arrived too late.
- 4
A stopped run leaves loose ends in the history. The tool that was running when you stopped has a tool call in the message but no result. If the user then sends a correction, the SDK refuses the history: the model API needs a result for every tool call, and you get
AI_MissingToolResultsError: Tool result is missing for tool call …. The fix isconvertToModelMessages(messages, { ignoreIncompleteToolCalls: true }), which drops the unfinished call, and the stop will not break the next message. - 5
Steering is stop plus a new message. There is no way to push new words into a run that is already going. To change the instruction, stop the run, then send the correction as the next user message. The model sees the earlier steps that finished (their results are in the history) and the new instruction. Say on screen what carried over ("Mitra kept the order lookup and cancelled the refund"), so the user does not repeat work.
- 6
Pause is a gate between steps. An HTTP stream cannot be frozen and resumed. What you can build is a gate: a tool that needs approval pauses the loop until the user answers (
toolApproval, Phase 8), and the loop ends for now; the answer starts a new request that continues. A user who refuses one step inside a plan makes the tool part end inoutput-denied. The model receives a result that says the step was denied, and decides what to do next. It might skip the email, or try something else. So any rule of the form "no refund, no receipt" belongs in your tool code, not in a hope that the model understands. - 7
Undo is a compensating action. You cannot rewind time; you can run the opposite action. Store credit added can be removed. A receipt email cannot be unsent, so the honest screen says "This cannot be undone" before the step runs, and again after. An undo must be safe to press twice (idempotent: the second press changes nothing and says so), and a failed undo must say why.
- 8
Show that stopping is in progress. Between the click and the confirmation, the button should read "Stopping…" and be disabled, so a second click does nothing. If the run had already finished, Stop is a no-op, so hide the button when the status is
ready.
Explain it without notes
What exactly does chat.stop() stop, and what can it not stop?
Why does a correction after a stopped run fail with AI_MissingToolResultsError, and how do you fix it?
A user denies one approval in the middle of a plan. What happens next, and where should dependent rules live?
How would you design undo for an agent that issues refunds and sends emails?
Practice
Write stopLabel(status) for a Stop button with the four states idle, running, stopping and stopped. It returns { text, disabled, hidden }.
Change the first example's server so the ledger also records, for each step, whether the user stopped before it started. Say what the entry for a step that never started should look like and why.
Trade-offs
- ↔
Aborting fast gives the user control but can leave half-done work. Letting the current step finish before stopping is safer for consistency but slower. A cancellable tool plus a ledger gives you both, at the cost of writing each tool carefully.
- ↔
Approval gates give the user real control but split a run into several requests and interrupt flow. Use them on steps that change things, not on reads.
Done when you can
I can pass the abort signal from the request down to the tools.
I can build the text after a stop from a server ledger.
I can send a correction after a stopped run without a missing-tool-result error.
I can explain what the model does after a denied approval and put dependent rules in code.
I can design undo with reversible and irreversible steps and an idempotent endpoint.