06 · Things go wrong
The firehose
The agent reads a 5,000-line log and answers correctly. Nothing crashes. Then look at the bill, and at what happens when the next file has no line breaks in it.
This lesson builds on lesson 05. New here? Start at lesson 01, or carry on: every lab is self-contained.
A model that has got itself stuck: it asks to read missing.py, and when told the file is not there, asks for it again. The workspace holds only config.py. You call run_agent(..., max_turns=2). Predict the shape of the record afterwards, in lab.shape's notation.
U A[c1] R(c1) A[c2] R(c2) — the limit is reached and the run simply ends
U A[c1] R(c1) A[c2] R(c2) A(error)
U A[c1] R(c1) A[c2] R(c2) A[c3] — the third reply arrives, and only then is the limit noticed
Two calls, then a turn that costs nothing: the limit is checked at the top, so the third reply is never bought. Until now the bill has been a detail at the end of a line. Today it is the lesson.
import harness, lab
# A model that asks for the same missing file over and over. The file is not there.
ws = lab.Workspace({"config.py": "PORT = 9090\n"})
model = lab.ScriptedModel([lab.stuck(lab.call("read", {"path": "missing.py"}))])
messages = []
harness.run_agent(model, harness.SYSTEM, messages, [harness.make_read_tool(ws)],
"Read missing.py.", max_turns=2)
print(lab.shape(messages))
print()
print(lab.show(messages, stop_reason=True))
print()
print("model calls:", len(model.calls))
U A[c1] R(c1) A[c2] R(c2) A(error)
user -> "Read missing.py."
assistant -> toolCall c1 read({"path": "missing.py"}) [stop_reason=toolUse]
toolResult c1 -> "File not found: missing.py. Files here: config.py" [is_error]
assistant -> toolCall c2 read({"path": "missing.py"}) [stop_reason=toolUse]
toolResult c2 -> "File not found: missing.py. Files here: config.py" [is_error]
assistant -> (nothing) [stop_reason=error] [error: Agent stopped after max_turns=2]
model calls: 2
A different run, with messages = [] passed in by you. The model asks for read, the result comes back, it asks again, that result comes back — and during the third model call a bug in your own code raises. run_agent never returns. You catch the exception. How many messages are in your list?
Five. messages is your list, not the loop's: run_agent appends to it as it goes, so whatever died, you still hold everything that happened up to the moment it died. That is the property this lesson leans on, because today the interesting thing about the record is not its shape. It is its size.
A run where nothing goes wrong
An ordinary job. Check big.log, a 5,000-line server log, then look at the config three times, then say the work is done. Five model calls. Your read tool does what it has done since lesson 4: it opens the file and returns it, all 139,986 bytes.
No tool fails. The model does not get stuck. The answer is right. The only thing worth looking at is the invoice.
The file arrives as a
Run the cell. Every reply carries a usage figure, and the cell prints the input count of each of the five calls beside what the model asked for.
Your lesson 5 harness, unchanged, on that five-turn job. One line per model call: what it cost, and what the model asked for.
import harness, lab
# big.log is 5,000 lines of an ordinary server log: 140,000 bytes.
ws = lab.Workspace({"big.log": lab.big_log(), "config.py": "PORT = 9090\n"})
# Turn 1: read big.log. Then four more turns of ordinary work.
model = lab.ScriptedModel(
[lab.reply(lab.call("read", {"path": "big.log"}))]
+ [lab.reply(lab.call("read", {"path": "config.py"})) for _ in range(3)]
+ [lab.say("Done.")])
messages = []
harness.run_agent(model, harness.SYSTEM, messages,
[harness.make_read_tool(ws), harness.make_write_tool(ws)],
"Check big.log, then the config.")
print(lab.shape(messages))
print()
for number, reply in enumerate([m for m in messages if m["role"] == "assistant"], start=1):
asked = lab.show([reply])[len("assistant -> "):]
print(f"call {number}: {reply['usage']['input']:>7,} input tokens {asked}")
print()
print(f"the whole run: {model.bill:,} input tokens")
U A[c1] R(c1) A[c2] R(c2) A[c3] R(c3) A[c4] R(c4) A
call 1: 167 input tokens toolCall c1 read({"path": "big.log"})
call 2: 36,468 input tokens toolCall c2 read({"path": "config.py"})
call 3: 36,527 input tokens toolCall c3 read({"path": "config.py"})
call 4: 36,585 input tokens toolCall c4 read({"path": "config.py"})
call 5: 36,644 input tokens "Done."
the whole run: 146,391 input tokens
Call 1 costs 167 tokens. Call 2 costs 36,468, and so does every call after it, give or take the odd short message. The file was read once and sent four times, and the run cost 146,391 tokens to answer a question about a config file.
Nothing here is a bug. It is lesson 1's rule arriving with a price tag: the model keeps nothing between calls, so everything it should still know has to be in the list, and everything in the list is sent again. [general] Providers that cache a prefix charge less for the repeat, not nothing, and the repeat still takes up its full share of what one request may hold. It is the first of the three ideas that carry the rest of the course:
You cannot stop resending. You can decide what goes in.
Where this comes from: Tau caps what one tool result may bring back at 2,000 lines or 50 KB (src/tau_coding/tools.py:41-42), for exactly this reason: one read of a lockfile or a minified bundle sits in the transcript for every turn that follows.
The cap everybody writes first
So put a limit on it. The tool has a string and the string is too long, and Python cuts strings in one obvious way. Fifty thousand of something, and stop.
return text[:50_000]
The cell below is that line, with four questions asked of whatever comes out of it: how many bytes, how many whole lines, is it inside the budget, and was it cut in the middle of a line. It ships with a 200-line log, which is well under the limit and comes back untouched.
Replace MY_INPUT with a file this cut handles badly, and run it. You are looking for a report you would not want to hand a model. There is more than one way to get one.
import lab
BUDGET = 50_000 # bytes: the most one tool result may bring back
def cut(text):
"""Truncation, the obvious way."""
return text[:BUDGET]
def report(name, text):
kept = cut(text)
size = len(kept.encode())
lines = kept.splitlines(keepends=True)
whole_lines = [line for line in lines if line.endswith("\n")]
mid_line = kept != text and not kept.endswith("\n")
print(f"{name}:")
print(f" {len(text.encode()):>7,} bytes in, {size:>7,} bytes out")
print(f" whole lines kept: {len(whole_lines):>7,}")
print(f" over the budget? {'YES' if size > BUDGET else 'no':>7}")
print(f" cut inside a line? {'YES' if mid_line else 'no':>7}")
report("200 lines of server log", lab.big_log(lines=200))
# Your turn. Put an input here that `cut` handles badly, and run the cell.
MY_INPUT = "hello\n"
report("yours", MY_INPUT)
200 lines of server log:
5,600 bytes in, 5,600 bytes out
whole lines kept: 200
over the budget? no
cut inside a line? no
yours:
6 bytes in, 6 bytes out
whole lines kept: 1
over the budget? no
cut inside a line? no
What did you put in, and what did the model get out? One or two sentences: name the damage, not the input.
Three things in that report are not the same thing: a character, a byte and a line. The budget is counted in one of them and the slice counts another. And what does a rule about lines do with a file that has exactly one?
There are three ways to break it, and they are three different bugs.
- The cut lands inside a line. The model is handed
line 01786: INFO reqand has no way to know that is not what the file says. - Bytes are not characters. The budget is bytes, because bytes are what is sent and billed;
text[:50_000]counts characters. One Japanese character is three bytes and an emoji is four, so a log in Japanese sails through the slice and arrives at nearly twice the budget. - One line can be the whole file. Minified JavaScript has no line breaks. A 200 KB bundle gives you 50,000 characters of half a statement: no whole line, nothing a model can reason about, and about 12,500 tokens of it on every later turn.
If you found more than one of these, you found more than most people do on the first try. The cell below runs all three.
Where these come from: Tau's truncation answers all three in code. It keeps whole lines, it measures each one with len(line.encode()) because the limit is bytes, and when the first line alone is over the budget it returns nothing rather than half of it (src/tau_coding/tools.py:831-835).
Common answers, and what each one misses
- "Nothing broke; my input was smaller than the limit." Then you found the one case this line handles. A cut that never cuts is not the thing under test.
- "It cut a line in half, but the model can tell." It cannot. It receives a string. Nothing in that string says where the file stopped and the cut began, and a model that cannot tell will quote the half-line back to you as fact.
- "Half a minified bundle is better than none of it." Half of one line of minified code is not half the information; it is a fragment of a statement with no beginning and no end, at full price, for the rest of the session. None of it, plus a sentence saying why, is worth more.
The same one-line cut against all three: an ASCII log, a log of the same kind in Japanese with an emoji on every line, and 200 KB of minified JavaScript. The last line of each block is what the model reads at the very end of the result.
import lab
BUDGET = 50_000
def cut(text):
return text[:BUDGET]
def report(name, text):
kept = cut(text)
size = len(kept.encode())
lines = kept.splitlines(keepends=True)
whole_lines = [line for line in lines if line.endswith("\n")]
mid_line = kept != text and not kept.endswith("\n")
print(f"{name}:")
print(f" {len(text.encode()):>7,} bytes in, {size:>7,} bytes out")
print(f" whole lines kept: {len(whole_lines):>7,}")
print(f" over the budget? {'YES' if size > BUDGET else 'no':>7}")
print(f" cut inside a line? {'YES' if mid_line else 'no':>7}")
print(f" the model reads, at the very end: ...{kept[-26:]!r}")
print()
report("big.log: 5,000 lines of ASCII", lab.big_log())
report("ops.log: 4,000 lines of the same kind of log, in Japanese, with an emoji",
"".join(f"{n:04d} 日本語のログ 🚀 ok\n" for n in range(1, 4001)))
report("min.js: one line of minified JavaScript", lab.minified(200_000))
big.log: 5,000 lines of ASCII:
139,986 bytes in, 50,000 bytes out
whole lines kept: 1,785
over the budget? no
cut inside a line? YES
the model reads, at the very end: ...'st ok\nline 01786: INFO req'
ops.log: 4,000 lines of the same kind of log, in Japanese, with an emoji:
128,000 bytes in, 94,115 bytes out
whole lines kept: 2,941
over the budget? YES
cut inside a line? YES
the model reads, at the very end: ...' 🚀 ok\n2941 日本語のログ 🚀 ok\n294'
min.js: one line of minified JavaScript:
200,000 bytes in, 50,000 bytes out
whole lines kept: 0
over the budget? no
cut inside a line? YES
the model reads, at the very end: ...' a+b};function f(a,b){retu'
Cut it properly, then
Keep whole lines. Count bytes, not characters. Stop before the cap rather than after it. That fixes every row in the report above, and it is about eight lines of Python, which you will write shortly. The cap has a name: it is the tool's
It also introduces the failure this lesson is really about. A tool that cuts carefully still returns a string, and a string that stops early looks exactly like a string that had nothing more to say.
Below, a careful cut: 2,000 whole lines of big.log, nothing broken, nothing mid-line. The model is lab.pager, a stage prop that trusts the text it is shown completely. Its rule is printed here, word for word, because the answer follows from it:
- It reads the file.
- If the last line of the result is a notice containing
offset=N, it reads again fromN. - It answers with the line number where it saw the needle, or "does not exist" as soon as a result holds neither the needle nor such a notice.
The question put to it is whether big.log contains a def target. It does, on line 2,400 of 5,000.
The tool returns lines 1 to 2,000 and says nothing about the other 3,000. The needle is on line 2,400. What is the person who asked the question told?
line 02000: INFO request ok. Nothing in it is marked "and then it stopped". The cut was visible on your side of the tool and invisible on the other.def target does not exist in big.log
A confident, wrong, unfalsifiable answer, from a model that did nothing wrong and a tool that did not crash. This is worse than a crash: a crash gets fixed, and this gets believed. The tool held one fact the model needed — that the file goes on — and did not pass it on.
Where this failure comes from: Tau's read is written so that it cannot happen. Every result it shortens ends in a bracketed notice carrying the file's total line count and the exact next offset (src/tau_coding/tools.py:336-346).
import harness, lab
# Lesson 5's read tool with the obvious cut bolted on: the first
# 2,000 lines, and not a word about the other 3,000.
def make_silent_read_tool(ws, max_lines=2000):
tool = harness.make_read_tool(ws)
whole_file = tool["execute"]
def execute(arguments):
whole = whole_file(arguments).splitlines(keepends=True)
return "".join(whole[:max_lines])
return {**tool, "execute": execute}
# big.log is 5,000 lines, and `def target` is on line 2,400.
ws = lab.Workspace({"big.log": lab.big_log()})
model = lab.ScriptedModel([lab.pager("def target")])
messages = []
harness.run_agent(model, harness.SYSTEM, messages, [make_silent_read_tool(ws)],
"Is there a `def target` in big.log?")
print(lab.shape(messages))
print()
print(lab.show(messages))
print()
result = messages[2]["content"]
print("the last line the model was shown: ", result.splitlines()[-1])
print("line 2,400, which it was not shown: ", lab.big_log().splitlines()[2399])
U A[c1] R(c1) A
user -> "Is there a `def target` in big.log?"
assistant -> toolCall c1 read({"path": "big.log"})
toolResult c1 -> "line 00001: INFO request ok\nline 00002: INFO request ok\nline 00003: INFO request... (56000 characters)"
assistant -> "`def target` does not exist in big.log."
the last line the model was shown: line 02000: INFO request ok
line 2,400, which it was not shown: def target():
One line of text, stuck on the end of that result, would have prevented it. Tap everything that line has to carry.
- how many lines were left out
- how many bytes were dropped
- which lines of the file are in this result
- the name of the tool that did the cutting
- how many lines the file has altogether
- a warning that the answer may be incomplete
- the exact argument that fetches the next part
Three facts, no adjectives: where you are, how big the thing is, and the literal next move. How much was left out is arithmetic that falls out of the other two, and the model cannot do anything with the number in any case. Nor with a count of bytes, because read is asked for a line offset, so tell it a line offset. The tool's name is already in the call it just made; and a warning with no next move is only anxiety, since the offset is the warning — a result you can continue is a result that was cut.
Yours will read like this, and the wording is Tau's:
[Showing lines 1-20 of 50. Use offset=21 to continue.]
A result that ends there is a
Which end do you keep?
Two tools, two kinds of result. read brings back a file. bash brings back what a command printed — say 3,000 lines of pytest, which is the sort of thing an agent runs constantly. Both go over the budget. Whole lines and honest notices are settled; what is not settled is which end of the text survives.
Keep the first lines that fit, or the last lines that fit? Answer for both tools at once.
big.log and the model gets line 4,995 of 5,000, with no way back: read's continuation argument is an offset, and an offset only moves forward.Through the head, pytest gives you the count of tests collected, a handful of PASSED lines, and no word about what failed; through the tail you get the assertion, the file, the line number and the tally. Do it the other way round and big.log starts at line 4,995, with no argument that asks to go back. So: head for a file, tail for a command. Same budget, opposite ends, and the tool decides which because the tool is the only thing that knows what kind of text it is holding.
Where this comes from: Tau keeps the tail for bash, and when the output does not fit it writes the whole of it to a temporary file and tells the model the path, so the part that was cut is still reachable (src/tau_coding/tools.py:651-673).
import harness, lab
# A budget of 6 lines, so that both ends fit on this screen.
BUDGET = {"max_lines": 6, "max_bytes": 2000}
ENDS = [("head", harness.truncate_head), ("tail", harness.truncate_tail)]
shell = lab.Shell(lab.Workspace({}))
output, code = shell.run("pytest") # 3,000 lines, and it fails
for name, keep in ENDS:
kept, shown = keep(output, **BUDGET)
print(f"pytest, keeping the {name}: {shown} of {len(output.splitlines()):,} lines")
for line in kept.splitlines():
print(" |", line)
print(" does the model learn what failed?",
"yes" if "E assert 5 == 6" in kept else "no")
print()
log = lab.big_log() # 5,000 lines; `def target` is on line 2,400
for name, keep in ENDS:
kept, shown = keep(log, **BUDGET)
first, last = kept.splitlines()[0], kept.splitlines()[-1]
print(f"big.log, keeping the {name}: {shown} lines, {first!r} to {last!r}")
pytest, keeping the head: 6 of 3,000 lines | collected 2992 items | | tests/test_mod_0000.py::test_ok PASSED | tests/test_mod_0001.py::test_ok PASSED | tests/test_mod_0002.py::test_ok PASSED | tests/test_mod_0003.py::test_ok PASSED does the model learn what failed? no pytest, keeping the tail: 6 of 3,000 lines | ___________ test_total ___________ | def test_total(): | > assert total([1, 2, 3]) == 6 | E assert 5 == 6 | tests/test_cart.py:12: AssertionError | ===== 1 failed, 2991 passed ===== does the model learn what failed? yes big.log, keeping the head: 6 lines, 'line 00001: INFO request ok' to 'line 00006: INFO request ok' big.log, keeping the tail: 6 lines, 'line 04995: INFO request ok' to 'line 05000: INFO request ok'
Figure 6.1 The same budget from both ends, drawn to one scale. read keeps the head of big.log: whole lines 1 to 1,785, which is where 50 KB ran out before 2,000 lines did, and then the notice that sends the model on to the page holding line 2,400. bash keeps the tail of pytest, because the failing assertion is at the bottom.
One call, two audiences
Last question before you build it. Your terminal frontend wants to draw a red-and-green diff after every write, and show how long each command took. The tool knows both things at the moment it returns. The obvious place to put them is the string it returns.
Should the diff and the timing line go into the tool's result?
Ten writes and one closing sentence: 4,940 tokens the plain way, 15,115 with the diffs. Three times the bill, and the screen looks identical either way, because the screen was never reading the transcript. A tool call has two audiences who want opposite things, so a result wants two fields, and the model is only ever sent one of them.
Where this comes from: Tau's tools return a result with content for the model and details for everyone else (src/tau_agent/tools.py:21-27). bash puts the exit code, the duration and the truncation figures in details (src/tau_coding/tools.py:690-698), the terminal reads them from there (src/tau_coding/session.py:3110-3112), and the provider adapter, which builds the actual request, copies only content and is_error (src/tau_ai/anthropic.py:662-672).
import harness, lab
# What the screen wants beside every write: a coloured diff, and how long it took.
DIFF = "".join(f"+{n:03d} print({n!r})\n" for n in range(1, 41))
def make_chatty_write_tool(ws):
tool = harness.make_write_tool(ws)
plain = tool["execute"]
def execute(arguments):
path = arguments["path"]
return (f"{plain(arguments)}\n--- a/{path}\n+++ b/{path}\n{DIFF}"
"[wrote 812 bytes in 0.004s, exit 0]")
return {**tool, "execute": execute}
def ten_writes(make_write):
ws = lab.Workspace({})
model = lab.ScriptedModel(
[lab.reply(lab.call("write", {"path": f"part{n}.py", "content": "ok\n"}))
for n in range(1, 11)] + [lab.say("Done.")])
harness.run_agent(model, harness.SYSTEM, [], [make_write(ws)], "Write the ten parts.")
return model.bill
plain = ten_writes(harness.make_write_tool)
chatty = ten_writes(make_chatty_write_tool)
print(f"result says only 'Successfully wrote to part1.py.' {plain:>8,} input tokens")
print(f"result also carries the diff and the timing line {chatty:>8,} input tokens")
print()
print(f"The screen looks the same either way. The model's bill is {chatty / plain:.1f} times higher,")
print(f"{chatty - plain:,} tokens for eleven calls, and it grows with every turn that follows.")
result says only 'Successfully wrote to part1.py.' 4,940 input tokens result also carries the diff and the timing line 15,115 input tokens The screen looks the same either way. The model's bill is 3.1 times higher, 10,175 tokens for eleven calls, and it grows with every turn that follows.
Build: a result that fits, and says what it cut
Most of the plumbing has arrived in your starter, marked # given in lesson 6 and visible in the diff: the two budget constants, int_arg for the optional whole-number arguments a model may or may not send, the offset and limit handling inside read, a finished truncate_tail already wired into bash, and max_lines / max_bytes parameters on both factories. Both tool descriptions now tell the model the limits before it calls, which is the cheapest part of this whole lesson and the part people forget.
Two gaps are yours.
About 18 lines across two gaps in harness.py.
truncate_head(text, max_lines, max_bytes)returns(kept, lines_shown): the start oftextthat fits, whole lines only, newlines kept, measured in UTF-8 bytes.truncate_tailsits directly below your gap and does the same job from the other end; read it first. If even the first line is over the byte budget, the answer is("", 0): half a line is worse than none.- The end of
read'sexecute, where you havelines(the whole file),wanted(the lines the model asked for) andkept, the firstshownof those, which is what fitted. Three things to say, and never nothing:offsetis past the last line: raiseValueErrorwith a message that names how many lines the file has. An empty file read from the start is not that case; it is an ordinary empty result.- Not even the first wanted line fits: return one line of advice in brackets that says how big that line is, with no part of the line in it.
- The file goes on after the last line shown: end the result with
[Showing lines A-B of N. Use offset=B+1 to continue.], where A, B and N are line numbers in the file, counted from 1.
The tests use a budget of 20 lines and 200 bytes, passed through make_read_tool, so everything is small enough to read. They check that nothing is cut mid-line, that the byte budget survives emoji and CJK, that a 200 KB one-line file comes back as advice rather than as 50,000 bytes of noise, and that following your own notices from the first page to the last and gluing the pages together reproduces the file byte for byte. Then the pager from earlier on this page goes looking for line 2,400 for real, and the bill is checked against a ceiling. Two last tests cover the half you were given, so that a stray edit to truncate_tail or to bash cannot slip past.
- Read
truncate_tail, just below your gap. When the next line would take it past the byte budget, does it keep part of that line or none of it — and at what moment does it decide? Then, inread: you are holdinglines,wanted,offsetandshown. Which two of them give you the file line number of the last line you are showing, and which one tells you whether the file goes on after it? truncate_head: walktext.splitlines(keepends=True), keeping a running byte total fromlen(line.encode()). Test the budgets before you keep a line, not after, and stop the moment either one is broken. Join what you kept and return it with how many there were. The giant first line needs no code of its own: that line never fits, so nothing is kept, and("", 0)falls out of the same loop.In
read, three cases in this order. The offset check comes first, because it is a question about the file rather than about the budget; mind the file with no lines in it at all, which is still an ordinary read. Then the case where the model asked for lines and none of them fitted: the offending line iswanted[0], andoffsetagainstlen(lines)tells you whether there is anything after it worth pointing at. Then the notice, which is written only when the last line you are showing is not the file's last line. Append it straight ontokept, which ends in a newline whenever more follows.- In outline:
truncate_head(text, max_lines, max_bytes): kept = [] ; size = 0 for each line of text, newlines kept: add this line's BYTE length to size if we already hold max_lines lines, or size has passed max_bytes: stop keep the line return the kept lines joined, and how many read's execute, where kept and shown are known: if offset is past the file's last line (careful: an empty file has no lines, and reading it is not an error): raise ValueError naming offset, the path, and how many lines the file has if lines were wanted and none fitted: return one bracketed line: which line it is, how many bytes it is, the byte budget, and (only when a later line exists) the offset that skips past it last = the file's line number of the last line shown, which counts from offset, not from 1 if last is short of the file's last line: add the notice: offset, last, the total, and the offset that continues return kept
Your tool now hands the model a page and the way to ask for the next one. The test that matters most is the quiet one: every page of a file of accents, Japanese, emoji and blank lines was read by following your own notices, and the pages glued back together are the file, byte for byte. Nothing was lost at a page break and nothing was shown twice.
What changed since lesson 05
The line-by-line diff needs JavaScript. The whole file this exercise starts from is printed at the end of it.
- Text inside the budget is returned unchanged, with its line count.
- Over the line budget, truncate_head keeps exactly the first max_lines lines.
- Over the byte budget, truncate_head keeps as many whole lines as fit and not a byte of the next.
- Emoji and CJK text stays inside the byte budget: bytes are what is sent and paid for.
- When even the first line is over the byte budget, truncate_head returns ("", 0).
- A read that was cut ends with "[Showing lines 1-20 of 50. Use offset=21 to continue.]".
- A file that fits, and the last page of one that does not, come back with nothing added.
- Following each notice's offset to the end, and joining the pages, rebuilds the file byte for byte.
- A cut read that started at offset=21 says "Showing lines 21-40 of 50. Use offset=41".
- Reading a 200 KB one-line file returns a short note with the line's size, and no part of the line.
- offset=51 on a 50-line file is an error result that says the file has 50 lines.
- A model that follows the notices finds `def target` on line 2,400 of big.log for under 45,000 tokens.
- Read big.log once, work four more turns: the whole run costs under 80,000 input tokens.
- bash keeps the end of a long output: the failing assertion survives a budget of 20 lines.
- bash output over the budget ends with a notice of how much was cut, on its own line.
Three runs, no tests. The five-turn job from the top of this page, billed again. Then the pager, let loose on big.log for real. Then one read of the 200 KB bundle. Once the lab has passed, this runs on your code.
import harness, lab
ws = lab.Workspace({"big.log": lab.big_log(), "min.js": lab.minified(200_000),
"config.py": "PORT = 9090\n"})
# 1. The opening job again: read big.log on turn 1, then four more turns of work.
model = lab.ScriptedModel(
[lab.reply(lab.call("read", {"path": "big.log"}))]
+ [lab.reply(lab.call("read", {"path": "config.py"})) for _ in range(3)]
+ [lab.say("Done.")])
harness.run_agent(model, harness.SYSTEM, [],
[harness.make_read_tool(ws), harness.make_write_tool(ws)],
"Check big.log, then the config.")
print(f"the same five turns as at the top of this page: {model.bill:,} input tokens")
print(" before today: 146,391")
print()
# 2. A model that reads big.log, believes what it is shown, and follows the notices.
model, messages = lab.ScriptedModel([lab.pager("def target")]), []
harness.run_agent(model, harness.SYSTEM, messages, [harness.make_read_tool(ws)],
"Is there a `def target` in big.log?")
for message in messages:
if message["role"] == "toolResult":
print("read ->", message["content"].splitlines()[-1])
print(lab.show(messages[-1:]))
print(f"{len(model.calls)} model calls, {model.bill:,} input tokens")
print()
# 3. One read of the 200 KB one-line bundle.
call = lab.call("read", {"path": "min.js"}, id="m1")
print(lab.show([harness.run_tool([harness.make_read_tool(ws)], call)], clip=None))
the same five turns as at the top of this page: 53,489 input tokens
before today: 146,391
read -> [Showing lines 1-1785 of 5000. Use offset=1786 to continue.]
read -> [Showing lines 1786-3571 of 5000. Use offset=3572 to continue.]
assistant -> "`def target` is on line 2400 of big.log."
3 model calls, 39,455 input tokens
toolResult m1 -> "[Line 1 of min.js is 200000 bytes, over the 50000-byte budget of one read, so none of it is shown.]"
The opening job cost 146,391 tokens. The same job, same tools, same answer, now costs 53,489. The pager found line 2,400 in three calls, because the first result ended in a sentence telling it how to get to the second. And min.js came back as ninety-nine bytes of explanation instead of fifty thousand bytes of somebody's build output.
Look at what the pager did not do. Nobody wrote a loop that pages. The tool said where it stopped and how to continue, and the model did the rest. A notice is not an apology; it is an instruction, and it is the cheapest code on this page.
- 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.
In your own words, a sentence or two. What is the string a tool returns, really — and what follows from that about how you write one?
Who reads it? How many times is it read, and who pays on each of them? And what does that reader do when the string stops early and says nothing about it?
It is not output. It is a paragraph of a prompt you are writing on the model's behalf: it joins the transcript, it is sent again on every turn until the session ends, and its reader can see nothing you did not put in it. So cap it, cap it in whole lines and in bytes, keep the end that carries the news, and when anything is left out, say so in a sentence that names the next move.
Common answers, and what each one misses
- "Truncate it so the request does not get refused for being too long." That is the emergency, not the cost. A result well inside the window is still paid for on every later call, which is what the opening bill was about.
- "Add a note saying the output was truncated." Better than silence, and not enough. "Output truncated" tells the model it is missing something and gives it nothing to do about it. The line numbers and the next offset are the part that works.
- "Return everything and let the model decide what to ignore." The model cannot ignore a cost it has already paid, and it cannot ask for less than it was sent. The only place that decision can be made is inside the tool, before the text becomes a message.
Everything a tool returns is paid for again on every turn, so a tool result is written, budgeted and signposted like a prompt.
Tau's truncate_head is your loop: a byte total, a test before each line is kept, and a branch that returns nothing at all when the first line is over the budget.
kept, size = [], 0
for line in text.splitlines(keepends=True):
size += len(line.encode())
if len(kept) == max_lines or size > max_bytes:
break
kept.append(line)
return "".join(kept), len(kept)
first_line_bytes = len(lines[0].encode()) if lines else 0
if first_line_bytes > max_bytes:
return _truncation_result(
"", True, "bytes", total_lines, total_bytes, 0, 0, first_line=True
)
# ...
for index, line in enumerate(lines[:max_lines]):
line_bytes = len(line.encode()) + (1 if index > 0 else 0)
if output_bytes + line_bytes > max_bytes:
truncated_by = "bytes"
break
output_lines.append(line)
output_bytes += line_bytes
The same decisions, one by one. Tau is async; read async for as for until lesson 15. The two functions above are ordinary ones.
- The same budget, and the same two limits, whichever runs out first: 2,000 lines or 50 KB (
src/tau_coding/tools.py:41-42). len(line.encode()), because the limit is bytes and a character is not one.- The giant first line returns no content and a flag, and the
readtool turns that flag into a sentence with the line's size in it, exactly as yours does (src/tau_coding/tools.py:325-331). Tau's sentence also hands the model a shell command that would get the line in pieces; yours hands it the offset that skips the line. - The notice, with the same three facts and very nearly your wording (
src/tau_coding/tools.py:336-346). Tau separates it from the output with a blank line and adds(50KB limit)when it was the byte budget that ran out rather than the line budget. - An offset past the end is an error naming the line count (
src/tau_coding/tools.py:308-311). - The tool description tells the model the limits and what to do about them before it ever calls (
src/tau_coding/tools.py:364-372). Your two descriptions say the same in fewer words. - Optional whole-number arguments are validated by hand, one helper, the same shape as your
int_arg(src/tau_coding/tools.py:1044-1050). Like yours, it acceptsTrueas an integer, which is Python being Python. - Tail for
bash, head forread, and a status line appended after the output rather than glued to it (src/tau_coding/tools.py:764-766).
What Tau adds. Its truncation returns an eleven-field record rather than a pair: which limit was hit, the totals before and after, whether the last line is partial, whether the first line was too big (src/tau_coding/tools.py:68-92). None of it is sent to the model. It goes in details, for the screen, which is the argument you just had about the diff. When bash truncates, the whole output is written to a temporary file and the notice names the path, so nothing is actually lost — the model can go and read the part it was not shown (src/tau_coding/tools.py:654-662). CRLF files are normalised before counting (src/tau_coding/tools.py:305). A read of an image is detected by its content and never by its extension, and an oversized one is refused from a 64 KB prefix before the whole file is loaded (src/tau_coding/tools.py:227-242). And the real bash is a subprocess with stdin closed so a command cannot hang waiting for input, stderr merged into stdout so errors are not invisible, a session of its own and a process-group kill so that children do not outlive it (src/tau_coding/tools.py:626-635, src/tau_coding/tools.py:1174-1187). None of that can run in a browser tab, so your bash is a simulator and says so. It comes back in lesson 16.
Where Tau and you differ. Tau splits on "\n" alone and rejoins with "\n"; you keep each line's own ending through splitlines(keepends=True), which is what makes "read every page and glue them together" give the file back byte for byte (src/tau_coding/tools.py:1012-1018). It costs you: splitlines also breaks on a form feed, a vertical tab and half a dozen other rare control and Unicode separators, none of which an editor counts as a line break, so a file containing one of those would be numbered differently from how a human would number it. Nothing in the course depends on it. In the other direction, Tau's truncate_tail does cut inside a line, on purpose: when a single line is over the budget it keeps the last max_bytes of it and raises a flag (src/tau_coding/tools.py:883-890). Half a line of a stack trace is still worth reading; half a line of a file is not, which is why truncate_head refuses and truncate_tail does not.
Where yours is weaker. Your budget is per result and nothing watches the total. Twenty budgeted reads still add up to a transcript nothing can send, and neither you nor Tau does anything about that here; lesson 12 does. [general] Both of you count bytes because bytes are countable, while providers bill tokens, and the ratio between them depends on the text: a byte budget on prose and a byte budget on minified JSON buy very different numbers of tokens. Your notice tells the model to continue and nothing checks whether it does; a model that ignores every notice reads one page and answers, as it did before you wrote the line. And your read holds the whole file in memory before it truncates, which is fine for a workspace made of dicts and is not how you would read a 2 GB log.
What both of you get for free. Neither harness has any code that pages through a file. The behaviour in your victory run — read, continue, read, answer — is not implemented anywhere. It is what a model does when a tool tells it the truth about what it did.
src/tau_coding/tools.py:831-848 · pinned to commit 9fe6a71 · view on GitHub
A new tool, search: it takes a pattern and returns every matching line in the project, as path:line: text. Today's run matches 10,000 lines. Head or tail — and write the notice, word for word, as you would put it in the result.
Two questions decide it. Is there an order in those results that means anything? And whatever your notice tells the model to do next, is there an argument on search that does it?
Head, and only because the results are in some order the caller can rely on — by path, say. Then a notice of the same three parts, naming whatever the tool can actually be asked for next:
[Showing 20 of 10000 matches, up to src/parser.py. Use offset=21 to continue, or narrow the pattern.]
Which is where search stops being a copy of read. A file has line numbers whether or not anyone wants them; a search has 10,000 matches because the pattern was too broad, and paging through them one screen at a time is the wrong fix for that. So the notice carries two next moves, and the count is the argument for the second one: a model that is told it matched 10,000 lines is being told to ask a better question. If your search has no offset, then it has one honest notice and it is "narrow the pattern", because a notice that suggests something the tool cannot do is worse than no notice at all.
Common answers, and what each one misses
- "Tail, because the last matches are the most relevant." Nothing has made them so. With
bashthe end was where the verdict was; here the end is wherever the alphabet ran out. - "
[Output truncated.]" The model now knows it is missing something and has no idea how much, or what to do. Every number you leave out is a number it will guess. - "Return all 10,000 and let the model pick." Ten thousand lines of
path:line: textis half a megabyte, well over 100,000 tokens, and it is in the transcript for the rest of the session: by the fourth turn it has been paid for four times. That is the bill at the top of this page, in one tool call.
- You hit
- a 5,000-line file, paid for on every turn, and then a model that said a function did not exist because it was on line 2,400
- You built
truncate_head, and the notice inreadthat names the lines shown, the file's length and the next offset- The principle
- tool output is a prompt you are writing on the model's behalf: budget it in whole lines and bytes, and never cut without saying so
- Your harness now
- error_message
- str_arg
- int_arg
- tool_specs
- run_tool
- truncate_head
- truncate_tail
- make_read_tool
- make_write_tool
- make_bash_tool
- run_agent
- context_for_model
- Still open
- Every one of those runs printed nothing until it was over. A job that takes forty seconds looks, from the outside, exactly like a job that has crashed. Lesson 7.
One optional thing hangs off this lesson: a side quest about the
edit tool, where a one-line fix to a 3,000-line file costs 3,000 lines of
output. It is an hour, and nothing later needs it — lesson 7 carries on from the harness
you have now, whether or not you take it.