15 · Meeting the real world
The stop button that does not stop
Everything so far was instant, because the model and the shell are fakes. A real build takes ten minutes, and while it runs, nothing else in your process is running at all.
This lesson builds on lesson 14. New here? Start at lesson 01, or carry on: every lab is self-contained.
Your lesson 14 gate is up. The check is an allow-list: it looks the command's first word up in a dict of commands it knows. The model asks for bash with rm -r build, and "rm" is not a key in that dict, so the check raises KeyError. Does the command run?
KeyError comes out of the run and your process ends on a traceback
The build directory is still there, and the model got one sentence it can act on, although the sentence is poor: your gate reported the KeyError's own text, which is the missing key and nothing else. Fail closed and tell the model: two lessons in one line of output.
import harness, lab
ws = lab.Workspace({"build/out.txt": "artifacts\n", "src/app.py": "print(1)\n"})
shell = lab.Shell(ws)
ALLOWED = {"pytest": True, "echo": True, "cat": True}
def only_known_commands(name, arguments):
"""Refuses any bash command whose first word is not on the list above."""
if name != "bash":
return None
first = arguments["command"].split()[0]
return None if ALLOWED[first] else f"{first} is not on the allow-list"
tools = [harness.guard(harness.make_bash_tool(shell), only_known_commands)]
system = harness.build_system_prompt(tools, [], [], "/work/shop", "2026-03-14")
model = lab.ScriptedModel([
lab.reply(lab.call("bash", {"command": "rm -r build"})),
lab.say("Nothing was deleted."),
])
h = harness.Harness(model, system, tools)
for event in h.prompt("Clear the build directory."):
pass
print(lab.show(list(h.messages)))
try:
ws.read_text("build/out.txt")
print("build/out.txt: still there")
except FileNotFoundError:
print("build/out.txt: gone")
user -> "Clear the build directory."
assistant -> toolCall c1 bash({"command": "rm -r build"})
toolResult c1 -> "Tool call blocked: the check failed: 'rm'" [is_error]
assistant -> "Nothing was deleted."
build/out.txt: still there
Lesson 4, eleven lessons back. You decided how wide the except should be and settled on Exception. Below, a tool raises lab.PowerCut, the stand-in lesson 4 gave you for something that is not a tool failing. What does the record hold when the dust settles?
is_error saying PowerCut, and the loop asks the model what to do next
Out of the run came the PowerCut, and the record stopped where it stood: U A[c1], a call nobody answered. Hold on to that shape. By the end of this page you will have made it on purpose, and lesson 10 will make it harmless.
import harness, lab
def execute(arguments):
"""A tool that does not fail. Something else is ending the program it runs in."""
raise lab.PowerCut("the machine is going down")
tool = {"name": "build", "description": "Build the project.",
"parameters": {"type": "object", "properties": {}}, "execute": execute}
system = harness.build_system_prompt([tool], [], [], "/work/shop", "2026-03-14")
model = lab.ScriptedModel([lab.reply(lab.call("build")), lab.say("unused")])
h = harness.Harness(model, system, [tool])
try:
for event in h.prompt("Build it."):
pass
except lab.PowerCut as exc:
print("out of h.prompt() came:", type(exc).__name__, "-", exc)
print()
print("the record:", lab.shape(list(h.messages)))
print(lab.show(list(h.messages)))
print("model calls:", len(model.calls))
print("is_running:", h.is_running)
out of h.prompt() came: PowerCut - the machine is going down
the record: U A[c1]
user -> "Build it."
assistant -> toolCall c1 build({})
model calls: 1
is_running: False
Warm-up: async in three cells
This lesson needs async, await and one more thing about tasks. If you have written them before, skip this; it is about five minutes. Read each cell and decide what it prints before you press Run.
A function with await in it. What does calling it do?
# An async function is a function with `await` in it. This one is four lines.
import asyncio
async def job():
print(" inside: starting")
await asyncio.sleep(0) # the floor is offered here
print(" inside: finishing")
print("before the call")
running = job()
print("after the call, job() handed back a", type(running).__name__)
print("still nothing above this line came from inside the function.")
print()
await running
print("and now it has run, top to bottom")
before the call after the call, job() handed back a coroutine still nothing above this line came from inside the function. inside: starting inside: finishing and now it has run, top to bottom
Two jobs, one thread of control. One pair has an await in its loop, the other has none. In what order do they print?
# Two jobs, one thread of control. Who runs when?
import asyncio
async def polite(name, n):
for i in range(1, n + 1):
print(f" {name}{i}")
await asyncio.sleep(0) # the only place this loop offers the floor
async def busy(name, n):
for i in range(1, n + 1):
print(f" {name}{i}") # no await anywhere in this loop
print("two jobs with an await in the loop:")
await asyncio.gather(polite("a", 3), polite("b", 3))
print()
print("two jobs with no await in the loop:")
await asyncio.gather(busy("a", 3), busy("b", 3))
two jobs with an await in the loop: a1 b1 a2 b2 a3 b3 two jobs with no await in the loop: a1 a2 a3 b1 b2 b3
A job waiting for something that never comes, and somebody who wants it to stop. Watch where the interruption lands, and what cancel() does the instant it is called.
# A job that is waiting for something that never comes, and somebody who wants it to stop.
import asyncio
async def waiting():
print(" job: waiting for the build")
try:
await asyncio.get_running_loop().create_future() # never resolves
except asyncio.CancelledError:
print(" job: it arrived here, inside the await")
raise
finally:
print(" job: tidying up on the way out")
task = asyncio.ensure_future(waiting())
await asyncio.sleep(0) # let the job reach its await
print("task.done() before:", task.done())
task.cancel() # only asks
print("task.done() right after cancel():", task.done())
try:
await task # a cancelled task still has to be waited for
except asyncio.CancelledError:
print("whoever waited for it is told too")
print("task.cancelled():", task.cancelled())
job: waiting for the build task.done() before: False task.done() right after cancel(): False job: it arrived here, inside the await job: tidying up on the way out whoever waited for it is told too task.cancelled(): True
Three facts to carry out of that: calling an async function runs none of it; a job holds the floor until it reaches an await, and then whoever else is ready gets a turn; and task.cancel() does not stop anything on the spot, it arranges for CancelledError to be raised inside the await the job is sitting in, next time the job gets a turn.
Minute three
Every run on this course has been instant. The model answers in the same breath it is asked, and lab.Shell runs pytest between two characters of output. That was a kindness, and it is over.
A real model can take thirty seconds to answer. A real build takes ten minutes. So: your agent is three minutes into a ten-minute build, the user has changed their mind, and there is a Stop button on the screen. Pressing it calls a function of yours. Before you read on, ask the smaller question.
Here is the whole call stack while that build runs, from your frontend down. Tap every place at which the Stop button's handler could run.
for event in h.prompt("Run the tests."): # your frontend
render(event)
# One lap of that for-loop runs all of this, in one go,
# to fetch one event:
Harness._run -> run_agent # a generator of events
run_tool(tools, call)
guard's execute -> check(name, arguments)
bash's execute -> shell.run("pytest")
# ten minutes in here
- in your frontend, in the instant between one event arriving and your asking for the next
- inside
bash'sexecute, while the command runs - inside
run_agent, while it waits for the model's reply - inside
run_tool, while it turns a failure into a result - inside
guard'sexecute, whilecheckdecides
One place, and it lasts an instant. Everything under that for line is one long function call, and this program has one thread of control running it: while any of it is running, your frontend is not. You could start a second thread; nothing you have written does, and lesson 16 will show you what that costs. The cell below prints the stack as it really happens.
Your lesson 14 harness on a ten-minute build. Every line marked frontend: is a moment your code had the floor; the indented lines come from inside bash. The Stop button's handler is press_stop(), and the last line says how often it ran.
import harness, lab
ws = lab.Workspace({"src/app.py": "print(1)\n"})
bash = harness.make_bash_tool(lab.Shell(ws))
really_run = bash["execute"]
stop = {"pressed": False} # the Stop button's handler sets this flag. Nothing else does.
def press_stop(): # wired to the button in your terminal, three minutes in
stop["pressed"] = True
def execute(arguments):
print(" | bash: the build starts. Ten minutes.")
print(" | bash: minute 3. The user presses Stop.")
print(" | bash: minute 10. The build ends. Nobody was asked anything.")
return really_run(arguments)
tools = [{**bash, "execute": execute}]
system = harness.build_system_prompt(tools, [], [], "/work/shop", "2026-03-14")
model = lab.ScriptedModel([lab.reply(lab.call("bash", {"command": "pytest"})),
lab.say("Two tests failed.")])
h = harness.Harness(model, system, tools)
looks = 0
for event in h.prompt("Run the tests."):
looks += 1
print(f"frontend: {event['type']}")
if stop["pressed"]: # your frontend looks at the flag here, and only here
print("frontend: Stop seen. Walking away.")
break
print()
print("times your frontend held the floor:", looks)
print("press_stop() ever ran:", stop["pressed"])
frontend: agent_start
frontend: turn_start
frontend: message_end
frontend: message_end
frontend: tool_execution_start
| bash: the build starts. Ten minutes.
| bash: minute 3. The user presses Stop.
| bash: minute 10. The build ends. Nobody was asked anything.
frontend: message_end
frontend: tool_execution_end
frontend: turn_end
frontend: turn_start
frontend: message_end
frontend: turn_end
frontend: agent_end
times your frontend held the floor: 12
press_stop() ever ran: False
Twelve moments with the floor, and the build sits inside one of them. Your frontend asked for the next event, and got it back ten minutes later. In between, the only code running in that process was shell.run. The button is drawn, the handler is written, and nothing can call it. This is not a bug in your harness. It is what a plain
Where this failure comes from: Tau's agent loop is async on its very first line (src/tau_agent/loop.py:52), and every Tau block on this course so far has carried the same footnote: read async for as for until lesson 15. This is lesson 15.
The same file, a different colour
Python has a second kind of function, the kind from the warm-up. Inside one, await means: I am going to be waiting for a while; whoever else is ready may have the floor until I am done. That is the only thing you need from
You do not have to redesign anything for it. There is one rule, and it decides for you, function by function:
A function is async if and only if it waits, directly or through
something it calls, on the model or on a tool.
Your starter is the lesson 14 file with that await. It is shown beside lesson 14's file in the lab's diff, and it is worth two minutes of your time, because of what is in it.
Your starter, asked about itself: one row per function, and whether it is async or plain. bash's execute and the one guard wraps it in are in there too. One row is worth a second look: Harness.prompt is plain, because it makes the run and hands it back without taking a single step in it. So when does a run begin?
import harness, inspect, lab
def colour(f):
return "async" if inspect.iscoroutinefunction(f) or inspect.isasyncgenfunction(f) else "plain"
ws = lab.Workspace({})
bash = harness.make_bash_tool(lab.Shell(ws))
guarded = harness.guard(bash, harness.deny_destructive)
rows = [
("1 messages", "user_message", harness.user_message),
("1 messages", "error_message", harness.error_message),
("1 messages", "render", harness.render),
("2 tools", "str_arg", harness.str_arg),
("2 tools", "tool_specs", harness.tool_specs),
("2 tools", "truncate_tail", harness.truncate_tail),
("2 tools", "run_tool", harness.run_tool),
("2 tools", "make_bash_tool", harness.make_bash_tool),
("2 tools", "bash's execute", bash["execute"]),
("2 tools", "guard", harness.guard),
("2 tools", "the guarded execute", guarded["execute"]),
("2 tools", "deny_destructive", harness.deny_destructive),
("3 loop", "run_agent", harness.run_agent),
("3 loop", "context_for_model", harness.context_for_model),
("3 loop", "repair_tool_history", harness.repair_tool_history),
("4 harness", "Harness.prompt", harness.Harness.prompt),
("4 harness", "Harness._run", harness.Harness._run),
("4 harness", "Harness.subscribe", harness.Harness.subscribe),
("5 frontends", "FinalTextRenderer.render", harness.FinalTextRenderer.render),
("5 frontends", "JsonRenderer.finish", harness.JsonRenderer.finish),
("6 environment", "SessionLog.append_message", harness.SessionLog.append_message),
("6 environment", "persist_to", harness.persist_to),
("6 environment", "estimate_tokens", harness.estimate_tokens),
("6 environment", "summarize", harness.summarize),
("6 environment", "find_cut", harness.find_cut),
("6 environment", "compact", harness.compact),
("6 environment", "build_system_prompt", harness.build_system_prompt),
]
print(f"{'region':<16}{'in your file':<28}colour")
for region, name, f in rows:
print(f"{region:<16}{name:<28}{colour(f)}")
print()
print("async:", sum(colour(f) == "async" for _, _, f in rows),
" plain:", sum(colour(f) == "plain" for _, _, f in rows))
region in your file colour 1 messages user_message plain 1 messages error_message plain 1 messages render plain 2 tools str_arg plain 2 tools tool_specs plain 2 tools truncate_tail plain 2 tools run_tool async 2 tools make_bash_tool plain 2 tools bash's execute async 2 tools guard plain 2 tools the guarded execute async 2 tools deny_destructive plain 3 loop run_agent async 3 loop context_for_model plain 3 loop repair_tool_history plain 4 harness Harness.prompt plain 4 harness Harness._run async 4 harness Harness.subscribe plain 5 frontends FinalTextRenderer.render plain 5 frontends JsonRenderer.finish plain 6 environment SessionLog.append_message plain 6 environment persist_to plain 6 environment estimate_tokens plain 6 environment summarize async 6 environment find_cut plain 6 environment compact async 6 environment build_system_prompt plain async: 7 plain: 20
Seven of twenty-seven. Now the other side of that listing. Tap everything the recolouring did not change.
- the order in which messages are appended to the record
- the
event types, and the order they arrive in SessionLog, and what one line of the log holds- what
run_toolpromises: one call in, exactly one result out - how many arguments
run_tooltakes - the assertions in the earlier lessons' hidden tests
One parameter arrived, in three kinds of place: signal, on run_tool, on run_agent and on every execute (and error_message gained a stop_reason). That is the whole list of design changes. A hundred and forty-five hidden tests from lessons 2 to 14 run against this file unchanged: the driver awaits what needs awaiting and the assertions never find out. async is a colour, not an architecture, and what it buys you is one thing: a place where your code can stop and let somebody else run.
Who is somebody else?
Nobody, yet. A Stop button needs something to set, and the run needs something to look at. The something is small: an object with a flag, cancel() to set it and is_cancelled() to read it. The Harness makes one for each run and hands it to the loop, which passes it to the model call and to every tool. Its name in the code is signal, and your starter already has the parameters, unused, in every signature.
That is the machinery, and it is not the lesson. The interesting questions are about what the run owes the record on the way out. The next two predictions run against the finished reference file, so they work before your lab does.
One reply asks for three files at once: c1, c2, c3. The first read is running when the user presses Stop. What happens to the second and the third?
for loop that you wrote. It can look at the flag before each lap as easily as after.a.py keeps its real contents, because that read had already finished; c2 and c3 come back Operation aborted, marked is_error. Then a last assistant message says the run was stopped. Count the model calls: one. A second request would be paid for, and its answer thrown away.
Where this failure comes from: Tau's loop makes the same choice in the same words. Before each call it asks whether the signal is cancelled, and if it is, the call's result is the text Operation aborted with the error flag set (src/tau_agent/loop.py:304-306).
import harness, lab
SYSTEM = "You are a careful assistant."
box, looked = {}, []
async def look(arguments, signal=None):
looked.append(arguments["path"])
if len(looked) == 1:
print("(the user presses Stop while the first read is running)")
box["h"].cancel()
return f"contents of {arguments['path']}"
tool = {"name": "look", "description": "Read one file.",
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}},
"execute": look}
calls = [lab.call("look", {"path": path}) for path in ("a.py", "b.py", "c.py")]
model = lab.ScriptedModel([lab.reply(*calls), lab.say("This reply costs money after Stop.")])
h = box["h"] = harness.Harness(model, SYSTEM, [tool])
await lab.drive(h.prompt("Look at all three files."))
print("files really read:", looked)
print()
print(lab.show(list(h.messages), stop_reason=True))
print("shape: ", lab.shape(list(h.messages)))
print("model calls: ", len(model.calls))
print("record valid: ", lab.validate(list(h.messages), record=True) == [])
(the user presses Stop while the first read is running)
files really read: ['a.py']
user -> "Look at all three files."
assistant -> toolCall c1 look({"path": "a.py"}) + toolCall c2 look({"path": "b.py"}) + toolCall c3 look({"path": "c.py"}) [stop_reason=toolUse]
toolResult c1 -> "contents of a.py"
toolResult c2 -> "Operation aborted" [is_error]
toolResult c3 -> "Operation aborted" [is_error]
assistant -> (nothing) [stop_reason=aborted] [error: Operation aborted]
shape: U A[c1,c2,c3] R(c1) R(c2) R(c3) A(aborted)
model calls: 1
record valid: True
Now the tool that is running. Your bash is inside await shell.arun("sleep inf"), which waits for the command and looks at nothing else. Stop is pressed. When does it stop?
cancel() set the flag, and the flag is what Stop means
await. The run is async now, so the interruption lands at the first place the tool gives the floor away
await does not read flags.Two tools, one Stop. The one that polls is finished one turn of the event loop later, with Command cancelled as the last line of its result. The one that only awaits is still going after two hundred. This is
Where this failure comes from: Tau's bash does not await the command either. It waits on the command and on a watcher that reads the token every 50 milliseconds, whichever finishes first, and kills the process if the command was not the one (src/tau_coding/tools.py:769-793, polling at src/tau_coding/tools.py:812-814). The last line it appends is Command cancelled (src/tau_coding/tools.py:681-682), which is where your text comes from.
import harness, lab
SYSTEM = "You are a careful assistant."
def deaf_tool():
"""A build tool that awaits the command and looks at nothing else."""
shell = lab.Shell(lab.Workspace({}))
async def execute(arguments, signal=None):
return (await shell.arun("sleep inf"))[0]
return {"name": "build", "description": "Build the project. Takes ten minutes.",
"parameters": {"type": "object", "properties": {}}, "execute": execute}
async def press_stop_on(label, tool, call):
model = lab.ScriptedModel([lab.reply(call), lab.say("unused")])
h = harness.Harness(model, SYSTEM, [tool])
async with lab.background(h.prompt("Build it.")) as task:
await lab.ticks(20)
h.cancel() # the flag is set here
turns = 0
while turns < 200 and not task.done():
await lab.tick()
turns += 1
print(f"{label}:")
print(f" Stop, then {turns} turn(s) of the event loop:",
"the run is over" if task.done() else "the run is STILL GOING")
print(" record:", lab.shape(list(h.messages)))
if task.done():
print(" last line of the tool's result:",
repr(h.messages[2]["content"].splitlines()[-1]))
bash = harness.make_bash_tool(lab.Shell(lab.Workspace({})))
await press_stop_on("a tool that looks at the flag (your bash)", bash,
lab.call("bash", {"command": "sleep inf"}))
print()
await press_stop_on("a tool that never looks", deaf_tool(), lab.call("build"))
a tool that looks at the flag (your bash): Stop, then 1 turn(s) of the event loop: the run is over record: U A[c1] R(c1) A(aborted) last line of the tool's result: 'Command cancelled' a tool that never looks: Stop, then 200 turn(s) of the event loop: the run is STILL GOING record: U A[c1]
So the flag has to be read, by hand, wherever a run spends time. Tap every place where your run spends time that will have to do the reading.
- at the top of each turn, before the model is called
- in
run_tool, before it runs a tool - inside
bash, while the command runs - in
context_for_model, while it builds the request - in
repair_tool_history, while it pairs calls with results - in
persist_to, while it writes a line to the log
Three places, and they are the three that wait. The other three are arithmetic over lists in memory: they are finished before anybody could press anything, and a check in them would be a line that is never true. (persist_to writes to a workspace that is a dict; on a real disk that line stops being free, and you would think again.) The rule is not "check everywhere", it is "check wherever you wait". Each of the three owes the record something different, and that is the lab.
Build: ask the run to stop
The rungs took the wrong designs away one at a time: a flag that stops things by itself, an interruption that arrives at the next await, a batch that skips its remaining calls without answering them, a paid request after Stop. What is left is about thirty lines, spread over the three places where your run waits, plus the flag itself.
Five things to write, about thirty lines, in ten marked gaps. The signatures and the signal=None parameters are already there; you change no signature you already have.
CancelToken, three methods. A new one is not cancelled,cancel()sets it, and it stays set. Beside it,ABORTED = "Operation aborted": a stopped run says that in two places, as the result of every call it did not run and as the last message'serror_message.error_message(text, stop_reason="error"). Lesson 5's body, with the new parameter used. One argument still means anin-band error ;stop_reason="aborted"is the same message with a different label.Harness, about five lines in three places. A freshtoken for every run, made whereis_runningturnsTrue;cancel()cancels the current run's token and must do nothing, and not raise, when there is no run;_runhands it torun_agentassignal=.run_agent, three small changes. At the top of a turn, after themax_turnscheck and before the model call: a cancelled signal means the reply iserror_message(ABORTED, stop_reason="aborted")and the model is not called at all. A reply labelledaborted ends the run the way an errored one does. Andrun_toolneeds the signal too.context_for_modelgets the matching one-word change: an emptyabortedmessage is as unwelcome in a request as an emptyerrorone.run_toolandbash. A cancelled signal means the tool is not run and the call still gets its one result,ABORTED, withis_errorset; a tool that is run is handed the signal. Inbash,await shell.arun(command)becomes a poll loop overprocess = shell.start(command), which returns at once:process.done(),process.kill(),process.output()once it is done. Until it is done, a cancelled signal ends the call with"Command cancelled", and otherwiseawait asyncio.sleep(0)gives the floor away for one turn of the event loop, which is when Stop's code gets to run.
Two things the tests are strict about. The hidden tests count turns of the event loop, never seconds, so await asyncio.sleep(0) is the only wait you need. And however bash's execute is left, the command must not still be running when it is over.
One thing is done for you, and the diff marks it # given in lesson 15: both renderers now treat a stopped run as a run that did not end well. A frontend that prints an empty answer and exits zero because the user pressed Stop is wrong, but it is not today's idea.
- One token for the harness's life, or a fresh one per run? Imagine the user stopping run 1, then typing again. And when exactly does a run begin: when
prompt()is called, or when the consumer asks for the first event? - Take the three waits one at a time and ask what the record owes each. The top of the turn owes an assistant message that says the run was stopped, and owes the user no paid request.
run_toolowes every call a result, whether or not the tool ran.bashowes a result that says why there is no output, and owes the machine a killed process. For the harness: a run begins whereis_runningturnsTrue, because a frontend hands the run to a task and the user can press Stop before the task takes its first step; that is where the token has to exist.cancel()with nothing running is not a mistake, it is a user who was a moment late, and it must do nothing. - In outline.
CancelToken: one attribute, set in__init__, set again bycancel(), returned byis_cancelled().Harness.prompt: after the run guard, a new token onself;cancel: one line on that token;_run: one more keyword argument.run_agent: the turn-topifgains a branch between themax_turnsbranch and the model call;failedcompares against two labels instead of one; therun_toolcall gains an argument.run_tool: a branch before the "not found" branch, guarded bysignal is not None and signal.is_cancelled(), setting the same two names the other branches set; theexecutecall gains an argument.bash: start, thenwhile not process.done():— inside, return the cancelled text if the signal says so, else sleep zero; then, outside the loop, the output. Wrap the loop so that the process is killed on every way out.
Thirty-odd lines, and the button on the screen is wired to something. Look at what you did not write: a thread, a timeout, a second loop, or one character inside repair_tool_history. Your run now stops because three pieces of slow code agreed to look.
What changed since lesson 14
The line-by-line diff needs JavaScript. The whole file this exercise starts from is printed at the end of it.
- One reply asks for three tools and the first, while it runs, calls h.cancel(). The first call keeps its real result, the second and third are not run and each gets the result "Operation aborted" marked is_error, the model is not called again, and the run ends with an assistant message whose stop_reason is "aborted".
- The events of that stopped run are the canonical sequence: every call still has its start, its recorded result and its end; then a last turn holding only the aborted message; then agent_end. Every message on the record was announced, in order.
- CancelToken() starts clear, cancel() sets it and it stays set. run_tool with a cancelled token does not run the tool and answers "Operation aborted"; run_agent with a cancelled token records the prompt and the aborted message and calls nobody. With a clear token both behave as before.
- signal is optional: run_agent called with no signal, as every caller written before this lesson calls it, runs its tools and ends normally.
- error_message(text) is still an "error" message; error_message(text, stop_reason="aborted") is the same message with stop_reason "aborted".
- h.cancel() called after prompt() has returned, before anyone has taken the run's first event, still stops the run: no model call, and the record is the prompt and the aborted message.
- cancel() on a harness that never ran, or between runs, does nothing. After run 1 was stopped, run 2 on the same harness runs its tools and ends normally.
- After a stopped run the next prompt is accepted. The request it sends holds everything that happened except the empty aborted message, which stays on the record.
- A follow-up queued during a run that is then stopped is not delivered into the stopped run: it waits, and opens a turn of the next run.
- Stop pressed while the request is on its way: the model is handed the run's signal, so it gives up at once with an "aborted" reply, and nothing in it is acted on.
- A tool's execute(arguments, signal) receives the run's token, bare or behind lesson 14's guard: it reads False, and True once h.cancel() has been called.
- bash is running `sleep inf`. Within five turns of the event loop after h.cancel() the command has been killed, its result ends with the line "Command cancelled", and the run has ended with the aborted message. The same through lesson 14's guard.
- A command that takes three turns of the event loop and then prints is waited for: its output comes back as an ordinary result.
- Both renderers' finish() return False for a run whose last message is "aborted", and FinalTextRenderer prints the reason, not an empty answer.
Lesson 5 left you a list of the ways a run can end, and every one of them left a transcript you could send again. That list just got one longer, and the new one obeys the same rule.
Figure 15.1 Every exit is balanced, and cancel is now one of them.
- Exit 1, the ordinary one: the reply asks for no tools, so the loop returns it. The last thing on the record is a plain
assistantmessage. - Exit 2,
max_turns, checked at the top of a turn. The last message is anassistantmarkederror, carryingAgent stopped after max_turns=1. - Exit 3, the reply itself failed. The loop acts on none of it and leaves an
assistantmarkederrorcarrying the provider's text. - Not an exit: an unknown tool gives an error result, and the loop goes round again. A result is news, not an ending.
- The exit you must never build: a
max_turnscheck between the reply and its tool calls, crossed out here, would leave a call with no result. - Exit 4, new today: cancel, checked at the top of a turn beside
max_turns. The last message is anassistantmarkedabortedcarryingOperation aborted, and no model call was made for it.
The tool that will not take a hint
Your own bash polls, because you wrote it. The next tool will not be yours. It will be a library call, or a colleague's, or a good tool written before anybody had a Stop button, and it will sit inside one await for ten minutes without ever looking at the signal you handed it. You saw one of those two rungs ago: two hundred turns of the event loop and the run had not moved.
There is a blunter instrument, and the trade calls it a task.cancel() arranges for CancelledError to be raised inside whatever the job is awaiting. Point it at the task consuming your run and the exception surfaces inside the tool, in the await it is stuck in, whether or not that tool ever agreed to anything.
Which puts it in a place you have thought about before.
That exception climbs out of the tool and reaches run_tool, which is one try around one call. Should your boundary catch it? In a sentence or two: what kind of agent do you get if it does, and what does the same question mean for lesson 14's guard, which wraps the same call one layer out?
A tool result is a sentence written for the model. What would this one say, and what would the model do about it?
No. A cancelled tool did not fail: the program it runs in is being stopped, and that is news for whoever pressed the button, not for the model. Catch it and you get an agent that answers Stop with "the tool failed", takes that as a reason to think again, and carries on spending money. The guard has the same duty one layer out: it may catch what check throws, because a crashed check is a refusal, but it must not wrap the real tool in anything wider than Exception, or Stop arrives at the model as Tool call blocked and the run continues.
Here is what you said in lesson 4, when the question was still abstract:
You were asked whether that except should catch everything, and the answer was no: some things that come out of a tool are not that tool failing. lab.PowerCut was the stand-in. This is the real one.
Python takes the same side, as it happens: since version 3.8 CancelledError has not been an Exception, so except Exception lets it through without being asked. Your lab writes the clause out anyway, above the wide one, because the next person to edit that boundary will not know any of this.
Common answers, and what each one misses
- "Catch it and return a result saying the run was cancelled, so the transcript stays tidy." Tidy, and the run does not end: the loop takes that result, goes round, and asks the model for another turn. Stop has to be something the loop cannot answer.
- "Catch it, then re-raise after cleaning up." That is a
finally, and it is the right instinct in the wrong clause.finallyruns on every way out and changes nothing about what is caught; yourbashalready uses one to kill its process. - "It does not matter, since
CancelledErroris not anExceptionanyway." True today, and the clause is a sentence to the next reader, which is the only reason it exists. Rules that hold by accident are the ones that get widened.
The same hung tool and the same task.cancel(), through three boundaries: run_tool catching Exception, run_tool catching BaseException, and a guard that catches BaseException around the real tool. Read what each hands back to the loop.
# run_tool, cut down to the one `try` that matters, and a tool that is stuck in an await.
import asyncio
async def ten_minute_build(arguments, signal=None):
await asyncio.get_running_loop().create_future() # waits for ever
return "build finished"
async def run_tool(catching):
try:
return "toolResult: " + await ten_minute_build({})
except catching as exc:
return f"toolResult: the tool failed ({type(exc).__name__}) [is_error]"
async def guarded_run_tool(catching):
"""Lesson 14's guard around the same tool, widened the same way."""
async def guarded():
try:
return await ten_minute_build({})
except catching as exc:
raise PermissionError(f"Tool call blocked: {type(exc).__name__}")
try:
return "toolResult: " + await guarded()
except Exception as exc:
return f"toolResult: {exc} [is_error]"
async def press_stop(label, coroutine):
task = asyncio.ensure_future(coroutine)
await asyncio.sleep(0) # let the tool reach its await
task.cancel()
try:
print(f"{label:<34} {await task}")
except asyncio.CancelledError:
print(f"{label:<34} nothing. The cancellation went straight through.")
await press_stop("run_tool, except Exception", run_tool(Exception))
await press_stop("run_tool, except BaseException", run_tool(BaseException))
await press_stop("the guard, except BaseException", guarded_run_tool(BaseException))
run_tool, except Exception nothing. The cancellation went straight through. run_tool, except BaseException toolResult: the tool failed (CancelledError) [is_error] the guard, except BaseException toolResult: Tool call blocked: CancelledError [is_error]
So the polite path and the blunt one are the same mechanism seen twice: ask, wait a little, and if nothing happens, insist. That is the whole shape of stop_run, and it is the last thing you write on this course before the capstone.
Figure 15.2 One Stop, and everything that has to look at it. A hollow mark is a look that found the flag clear; a filled one found it set.
- Time runs down the page, in three lanes: the model,
run_agent, andbash. Three looks at the flag find it clear. Thenc1starts, and while it runs the user presses Stop, which sets the flag. Nothing happens. - The tool notices first.
bashis polling, so on its next lap it sees the flag, kills the command, and the callc1gets the resultCommand cancelled. - The loop notices next.
c2andc3are not run, and each getsOperation aborted, because every call still gets exactly one result. At the top of the following turn the flag is set, so no model call is made: anassistantmessage markedabortedis appended, andagent_endcloses the run. - The backstop, for a tool that never looks.
stop_runsets the flag, waitsgraceturns of the event loop, and then callstask.cancel(), which raisesCancelledErrorinside the tool'sawait.run_toollets it through, soc1ends with no result at all, andstop_runreturns"hard".
Two gaps, about fifteen lines.
run_tool, two lines: the explicitexcept asyncio.CancelledError: raise, above the wide clause, so that nobody ever widens the wide clause.stop_run(harness, task, grace=5), returning"clean"or"hard". Ask first:harness.cancel(), then give the flag up tograceturns of the event loop to be noticed, stopping early if the task finishes. If it finished, that is"clean". If it did not,task.cancel(), then wait for the task, and that is"hard". Either way the task is over whenstop_runreturns, because a cancelled task that nobody waits for is a task nobody knows is over.
One subtlety, and the tests will find it. Waiting for the cancelled task raises that task's CancelledError in stop_run, and swallowing it is the whole point. But stop_run is itself running in somebody's task, and that somebody may be stopping it, in which case the CancelledError is not the task's, it is stop_run's own, and a function that eats its own cancellation cannot be stopped. asyncio.current_task().cancelling() is how the two are told apart: it counts the cancellations aimed at the task you are in, so anything above zero means this one is yours to pass on.
Your starter carries the reference bash from the last lab, whose poll loop kills the process however the loop is left. If you paste in your own and it kills the command only on the path where it saw the flag, one test will fail and say so: a hard cancel arrives inside the poll loop's sleep, by a way out that path never sees.
- Two questions. In
run_tool: the new clause returns nothing and builds no result, so what is the only statement in it? Instop_run: if the run stops politely on turn 3 of a grace of 5, what should the remaining two turns be spent on? stop_runis five steps in a straight line, no nesting beyond onetry. Cancel the harness. Loop up togracetimes, leaving the loop early iftask.done(), and spending each turn onawait asyncio.sleep(0)so that the run gets its chance. Asktask.done()once more: if it is done, you are finished and it was clean. Otherwise cancel the task and wait for it, catching theCancelledErrorthat the wait raises. Inside that catch, one question decides whether to swallow it or re-raise it, andasyncio.current_task().cancelling()answers it. Then say it was hard.- In outline.
run_tool: above the wide clause, one namingasyncio.CancelledError, whose body is a single word.stop_run, in order: cancel the harness; aforoverrange(grace)that leaves early when the task is done and otherwise spends the turn on a zero sleep; then ask once whether the task is done and, if it is, say so and stop there. Past that point: cancel the task; atrywhose body is oneawaitof the task; anexceptforasyncio.CancelledErrorholding oneif, on the countasyncio.current_task().cancelling()returns, whose body is a bareraise; and the other answer, last, outside thetry. There is more than one correct shape for that end —asyncio.waiton the task raises nothing at all and needs noexcept. The reference keeps the exception in view because this lab is about that exception; when you have passed, write it the other way and see.
Fifteen lines, and your harness can now be stopped whether the tool it is waiting on co-operates or not. The hard path leaves a call with no result, which is the shape you predicted at the top of this page. Nothing was built to clean it up.
What changed since the previous lab
The line-by-line diff needs JavaScript. The whole file this exercise starts from is printed at the end of it.
- bash is running `sleep inf`. stop_run(h, task, grace=5) returns "clean": the flag was enough, the task finished by itself and was not cancelled, and the record ends with the cancelled command's result and the aborted message.
- A tool awaits `sleep inf` and never looks at its signal. stop_run returns "hard": the task was cancelled, CancelledError went up through run_tool without becoming a result, the run is over and is_running is False.
- bash is running `sleep inf` and is given no grace at all: stop_run(h, task, grace=0) returns "hard", and the command has been killed all the same.
- The same deaf tool behind lesson 14's guard: stop_run returns "hard", the task is cancelled, and the guard has not turned the cancellation into a refusal.
- After a hard stop the record equals log.replay(), and two further prompts are answered: the killed call is shown to the model as interrupted, and nothing new was built for that.
- A tool that sees the flag and then needs ten turns of the loop to tidy up: stop_run with grace=50 returns "clean" and the tool's result is recorded; with grace=2 it returns "hard". A run that is already over is "clean" at once.
- While stop_run is waiting for a cancelled task that is slow to die, stop_run's own task is cancelled: the CancelledError is not swallowed, so whoever cancelled it sees it end as cancelled.
- A job that includes a slow bash command is closed (aclose) after every k events. At every k the run is over, the log equals the record, two further prompts are answered, and no tool that ran is shown to the model as interrupted.
Two ten-minute builds on one harness, each stopped in its third minute: first a bash command that watches its signal, then a tool that never looks. A
import asyncio, harness, lab
SYSTEM = "You are a careful assistant."
ws = lab.Workspace({})
shell = lab.Shell(ws)
async def consume(run): # a frontend, with the rendering left out
async for event in run:
pass
def deaf_build():
"""A build tool that waits for the command and never looks at its signal."""
async def execute(arguments, signal=None):
return (await shell.arun("sleep 600"))[0]
return {"name": "build", "description": "Build the project. Takes ten minutes.",
"parameters": {"type": "object", "properties": {}}, "execute": execute}
def job(request):
newest = [m for m in request.messages if m["role"] == "user"][-1]["content"]
if newest.startswith("Run the tests"):
return lab.reply(lab.call("bash", {"command": "sleep 600"}))
if newest.startswith("Build"):
return lab.reply(lab.call("build"))
return lab.say(f"Answer {sum(m['role'] == 'user' for m in request.messages)}.")
model = lab.ScriptedModel([lab.forever(job)])
tools = [harness.make_bash_tool(shell), deaf_build()]
h = harness.Harness(model, SYSTEM, tools)
log = harness.SessionLog(ws, "session.jsonl")
h.subscribe(harness.persist_to(log))
async def stop_it(prompt):
task = asyncio.ensure_future(consume(h.prompt(prompt)))
for _ in range(20): # the build gets going
await asyncio.sleep(0)
return await harness.stop_run(h, task, grace=5)
print("Run 1. The ten-minute command is `bash`, which looks at its signal.")
print(" stop_run said:", await stop_it("Run the tests."))
print(" record:", lab.shape(list(h.messages)))
print(" the command's result ends:", repr(h.messages[2]["content"].splitlines()[-1]))
print()
print("Run 2. The ten-minute command is a tool that never looks.")
before = len(h.messages)
print(" stop_run said:", await stop_it("Build it."))
print(" new on the record:", lab.shape(list(h.messages)[before:]), " is_running:", h.is_running)
print()
print("Two more prompts on the same harness:")
for text in ("What happened?", "Thank you."):
await lab.drive(h.prompt(text))
print(f" {text:<16} -> {harness.text_of(h.messages[-1])}")
print()
sent = model.calls[-1].messages
for message in sent:
if message.get("tool_name") == "build":
print(" what the model was shown for the killed call:")
print(" ", message["content"])
print()
print("log lines:", len(log.entries()), " messages on the record:", len(h.messages),
" they agree:", log.replay() == list(h.messages))
Run 1. The ten-minute command is `bash`, which looks at its signal.
stop_run said: clean
record: U A[c1] R(c1) A(aborted)
the command's result ends: 'Command cancelled'
Run 2. The ten-minute command is a tool that never looks.
stop_run said: hard
new on the record: U A[c2] is_running: False
Two more prompts on the same harness:
What happened? -> Answer 3.
Thank you. -> Answer 4.
what the model was shown for the killed call:
Tool call interrupted: no result was recorded. It may not have run, or may have run partly; check before repeating it.
log lines: 10 messages on the record: 10 they agree: True
clean, then hard, and then the session simply carries on. The killed call has no result on the record and never will: the record says what happened, and what happened is that nobody knows whether the build ran. The next request shows the model Tool call interrupted in its place, because context_for_model repairs the view before every request, and it has done that since lesson 10. You wrote nothing today to make that work.
- Hold a conversation: it knows what was said, and who said it.
- Run a tool the model asks for and show it the result.
- Keep going until the model stops asking.
- Tell the model when a tool fails, and carry on.
- Stop a runaway, and survive a provider failure.
- Keep every tool result within a budget.
- Report what it is doing, as events, to any frontend.
- Own its transcript: one writer at a time.
- Take your input mid-run, at a safe point.
- Send a valid transcript even after an interruption.
- Survive a power cut: an append-only log, resumed by replay.
- Outlast the context window: summarise the old, keep the recent word for word, delete nothing.
- Brief the model from what is really there: the enabled tools, the project's files, an index of skills.
- Refuse a tool call at the door, in your code, and hand the model the reason.
- Be stopped mid-build, politely or not, and be usable a second later.
A colleague is adding a Stop button to their own agent and says: "Easy. A boolean on the harness, and cancel() sets it." In a sentence or two, what would you tell them?
Their harness is three minutes into a ten-minute build. What has to happen next, and who has to have written it?
The boolean is right and it is about a fifth of the work. Setting it stops nothing: every slow piece of the run has to look at it, by hand, and anything that never looks needs an interruption raised inside the await it is stuck in. And the button is only reachable at all because the run gives the floor away while it waits.
Common answers, and what each one misses
- "Just raise an exception from
cancel()."cancel()runs on the button's thread of control, not the run's. The exception would end the button handler and leave the build going. - "Check the flag at the top of the loop." Necessary, and it is the check that saves the money. It is also the check that a ten-minute tool never reaches.
- "Kill the process." An honest answer, and it throws away the transcript, the log's last lines, and any chance of the user typing again. Everything on this page exists to make Stop cheaper than that.
await is how one thread waits politely. A cancel flag stops nothing until slow code looks at it.
Tau cancels the same way, in the same two layers: a token every slow piece checks, and a CancelledError that the tool boundary is told, in writing, to let through.
if signal is not None and signal.is_cancelled():
content, is_error = ABORTED, True
elif tool is None:
content, is_error = f"Tool {call['name']} not found", True
else:
try:
content, is_error = await tool["execute"](call["arguments"], signal), False
except asyncio.CancelledError:
raise
except Exception as exc:
content, is_error = str(exc), True
if blocked:
result = _error_result(block_reason or "Tool execution was blocked")
is_error = True
elif signal is not None and signal.is_cancelled():
result = _error_result("Operation aborted")
is_error = True
# ...
try:
result = await tool.execute(call.id, call.arguments, signal, on_update)
return result, False, updates
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - tools are an isolation boundary
return _error_result(str(exc)), True, updates
The same parts, piece by piece. This is the lesson where the standing footnote retires: Tau has been async all along, and from here you read it as it is written.
- The token is a protocol with one method, so any object with
is_cancelled()will do (src/tau_agent/provider.py:13-16). The implementation Tau ships is yourCancelToken, line for line (src/tau_agent/harness.py:51-59). - One fresh token per run (
src/tau_agent/harness.py:165-166), andcancel()sets whichever is current (src/tau_agent/harness.py:117-119). - A reply labelled
errororabortedends the run, in exactly those two words (src/tau_agent/loop.py:148), and an empty one of either kind is left out of the next request while staying on the record (src/tau_agent/loop.py:185-201). That function iscontext_for_modelunder another name. - The fake provider honours the token by ending its stream (
src/tau_ai/fake.py:35-39), and so does the wait between retries, so a backoff of thirty seconds does not swallow a Stop (src/tau_ai/retry.py:46-62). - Both layers at once, in Tau's own test:
harness.cancel()on one line andtask.cancel()on the next (tests/test_agent_harness.py:260-265), which isstop_runwith the waiting left out. The terminal does the same when you press Escape: cancel the session, then cancel the worker (src/tau_coding/tui/app.py:6172-6177).
Where the two differ, and why. Three places, and the first is ours to own.
- Nothing in Tau produces
abortedtoday. The value is in the vocabulary (src/tau_agent/messages.py:154) and seven files read it, but no provider or loop insrc/ever sets it, so in Tau a Stop surfaces as an error terminal or as no terminal at all. Ours sets it, because a run the user stopped and a run the provider failed are different news for whoever reads the log tomorrow. - Tau's loop does not check the token between turns. Its turn top has the
max_turnscheck and nothing beside it (src/tau_agent/loop.py:112-113); the check before each tool call is the only one, and stopping the model call itself is left to the provider. Ours checks once per turn, which is the check that keeps a request from being paid for after Stop. - Tau clears the current token only if it is still the same one (
src/tau_agent/harness.py:203-204), against a later run having replaced it while an olderfinallywas pending. Your run guard makes that impossible, so the line would be dead code and the lab does not ask for it.
A gap in Tau, found by writing this lesson. Tau's prompt_message is a plain def that marks the run as running and hands back the generator (src/tau_agent/harness.py:147-150), and the token is made on the first line of that generator, which does not execute until the consumer asks for the first event. Meanwhile cancel() does nothing when there is no token. The question below is about Tau's code, so nothing waits on it.
A frontend does what frontends do: task = ensure_future(consume(h.prompt(text))), and the user hits Stop before that task has taken a single step. With Tau's ordering, what happens?
cancel() finds no token, and the run then starts with a fresh one that nobody has cancelled
prompt(), on the line where is_running turns True, for exactly this reason.cancel() have written to?h.prompt(text) started it
Your harness records the prompt and one aborted message, and calls the model zero times. Tau's ordering calls the model, gets an answer, and hands the user the answer to the question they cancelled. Both classes are in the cell, and the only difference between them is which line makes the token.
import harness, lab
SYSTEM = "You are a careful assistant."
class TauOrder(harness.Harness):
"""Your harness with Tau's ordering: prompt() only marks the run as running, and the token
is made on the first line of the run generator, which does not execute until the consumer
asks for the first event. cancel() looks for a token and does nothing if there is none."""
def prompt(self, text):
run = super().prompt(text)
self._signal = None # no token yet (tau_agent/harness.py:117-119)
async def token_on_the_first_step():
self._signal = harness.CancelToken() # (tau_agent/harness.py:165-166)
async for event in run:
yield event
return token_on_the_first_step()
def cancel(self):
if self._signal is not None:
self._signal.cancel()
async def stop_before_the_first_step(label, made):
model = lab.ScriptedModel([lab.say("Deleted 4,000 files. You are welcome.")])
h = made(model, SYSTEM, [])
run = h.prompt("Tidy the repo.") # a frontend hands this to a task...
h.cancel() # ...and the user hits Stop before it runs
await lab.drive(run)
print(f"{label}:")
print(" record:", lab.shape(list(h.messages)), " model calls:", len(model.calls))
print(" last message:", repr(harness.text_of(h.messages[-1])
or h.messages[-1].get("error_message")))
await stop_before_the_first_step("yours: the token is made in prompt()", harness.Harness)
print()
await stop_before_the_first_step("Tau's order: the token is made at the first step", TauOrder)
yours: the token is made in prompt(): record: U A(aborted) model calls: 0 last message: 'Operation aborted' Tau's order: the token is made at the first step: record: U A model calls: 1 last message: 'Deleted 4,000 files. You are welcome.'
What Tau adds. Tools get a token of their own, a second one-method protocol with the same shape as the provider's (src/tau_agent/tools.py:15-18), so a tool can take a Stop button without knowing anything about providers. bash does not poll in its own body: it waits on the command and on a watcher of that token at the same time, and acts on whichever finishes first (src/tau_coding/tools.py:783-793), so its own body burns nothing while it waits. It then kills the whole process group, not one process (src/tau_coding/tools.py:1174-1180). A cancelled bash also refuses before it starts, with the same words it would have ended with (src/tau_coding/tools.py:622-623). And on a cancelled exit Tau's finally does durably what lesson 10 taught you to do only in the view: it appends a synthetic result for every dangling call, with the text Tool call interrupted by user (src/tau_agent/tool_history.py:16), and pushes those messages to subscribers inside suppress(Exception) so that a broken listener cannot swallow the cancellation on its way out (src/tau_agent/harness.py:192-205). That is the bug lesson 11's comparison described, fixed, and it is why Tau's record holds a result where yours holds a hole. Neither is wrong: Tau writes down that nobody knows, and you compute the same sentence fresh on every request.
Where Tau is weaker. The watcher polls every 50 milliseconds, so a cancelled command can keep the machine for another fiftieth of a second, and every waiting tool pays for a task of its own whether or not anyone ever cancels. More seriously, the gap above is real: between prompt() and the consumer's first step, Tau has no token, and a Stop in that window is lost without a trace. And aborted is declared and unwritten, so a tool that is cancelled and a provider that failed look the same in a saved session.
Where yours is weaker. grace counts turns of the event loop, which is a fine unit in a lab where all time is fake and a poor one on a real machine, where you would want seconds. Your bash polls with await asyncio.sleep(0), which is a busy loop: it burns the processor for as long as the command runs. [general] On a real event loop you would sleep for a small interval instead, as Tau does, or wait on the token itself. Nothing in your harness times out on its own, so a hung provider waits for ever unless somebody presses Stop. And your token has one bit; it cannot say who cancelled, or why, or offer a reason to put in the message.
src/tau_agent/loop.py:301-313,355-361 · pinned to commit 9fe6a71 · view on GitHub
A new case. This time nothing is wrong with the tools: the model call hangs for sixty seconds. You have two mechanisms. Which one stops it against the lab's model, and which one against a provider that calls urllib on a worker thread?
urllib has never heard of it.async program the thread is just another thing being awaited
Against a model that reads the signal, stop_run says clean and the record holds one aborted message. Against one that never looks, it says hard and the record holds the prompt alone: the reply never arrived, so there is nothing to write down. [general] A real provider client usually does take a cancellation token or an abort signal, and closes the connection; one that does not leaves a request billing in the background while your user is already typing the next thing.
import asyncio, harness, lab
SYSTEM = "You are a careful assistant."
class BlockingProvider:
"""A provider that goes away for sixty seconds and looks at nothing while it is gone.
That is urllib on a worker thread, written as one class."""
def __init__(self):
self.calls = []
async def acomplete(self, system, messages, tools=(), signal=None):
self.calls.append(messages)
await asyncio.get_running_loop().create_future() # comes back in sixty seconds
async def consume(run):
async for event in run:
pass
async def press_stop_during_the_model_call(label, model):
h = harness.Harness(model, SYSTEM, [])
task = asyncio.ensure_future(consume(h.prompt("Summarise the repo.")))
await asyncio.sleep(0) # the request is on its way
how = await harness.stop_run(h, task, grace=5)
print(f"{label}:")
print(f" stop_run said {how!r}; the record is {lab.shape(list(h.messages))!r}")
await press_stop_during_the_model_call("the lab's model, which looks at the signal",
lab.ScriptedModel([lab.say("unused")]))
print()
await press_stop_during_the_model_call("a provider that never looks", BlockingProvider())
the lab's model, which looks at the signal: stop_run said 'clean'; the record is 'U A(aborted)' a provider that never looks: stop_run said 'hard'; the record is 'U'
- You hit
- a Stop button with nothing behind it: three minutes into a ten-minute build, no code of yours was running
- You built
CancelToken,ABORTED,Harness.cancel(), three checks where the run waits, a pollingbash, andstop_run()- The principle
awaitis how one thread waits politely; a cancel flag stops nothing until slow code looks at it- Your harness now
- run_tool
- run_agent
- context_for_model
- repair_tool_history
- Harness
- SessionLog
- persist_to
- summarize
- find_cut
- compact
- maybe_compact
- discover_context
- build_system_prompt
- guard
- deny_destructive
- confirm_with
- CancelToken
- ABORTED
- Harness.cancel
- stop_run
- Still open
- Fifteen lessons of harness, and it has never met a model. A real provider does not take your dicts and does not return them: it sends back a stream of lines in which a tool call's arguments arrive as fragments of a JSON string. Lesson 16.