v0.9.4 - the session that got long
0.9.3 asked whether a stranger could install tau and get a working
conversation. This release is about the hour after that: the things that work
on a five-message chat and stop working on a five-hundred-message one. An
800-message transcript took over four minutes to redraw, streaming throttled
to a few tokens a second, Esc discarded the turn, and the tree editor became
unreadable at exactly the size that makes it necessary.
Three things nobody planned turned up while building that, and each is a case
of tau being quietly wrong rather than slow: the system prompt tau composes
had never reached a model through the TUI, the token counter re-counted every
earlier turn on every turn, and a reasoning model's thinking was streamed into
a widget that was never on screen.
What the model is told
- THE PROMPT TAU BUILDS IS NOW THE ONE THE MODEL GETS. TauBackend composes
the real prompt - base text, the project's AGENTS.md / CLAUDE.md, and the
tool list - and then the TUI and tau -p each composed a SECOND string from
config["system_prompt"] and stored it as the session's first message,
which is what goes on the wire. The TUI also substituted "You are a
helpful assistant." when no prompt was configured, so the stored message
was never absent and the built one was never used: no project context and
no tool list had ever reached a model throughtau. This is the other
half of 0.9.3's context-files work, which built the prompt and then let
the frontends write over it. - Every frontend folds the configured prompt onto the MODEL ENTRY now, where
the backend reads it as custom_prompt and composes the context files and
the tool list around it. rpc_mode already did this and is the precedent. - {{fields}} in a system prompt: {{base_prompt}}, {{project_context}},
{{tools}}, {{tool_names}}, {{cwd}}, {{model}}. Naming a section MOVES it
rather than copying it, so a custom prompt can wrap tau's voice instead of
replacing it and choose where the tool list goes. Substitution runs on the
template only, never on the assembled prompt, so a {{tools}} inside a
project's AGENTS.md reaches the model as written. An unknown field raises;
a misspelled {{tols}} rendered literally would look exactly like a prompt
that worked.
Losing work
- ESC NO LONGER THROWS THE TURN AWAY. Aborting while a tool call's arguments
were mid-stream discarded the whole turn - the user's own prompt, the
assistant message, and every completed tool result - and reported the loss
as a JSONDecodeError traceback. The traceback was not a side effect of the
loss; it was the cause, because the session persists a turn as one batch
after the loop returns and the raise killed the local list. - The finalizer reads stop_reason. On an abort, a tool call that cannot be
finished is DROPPED - not repaired and not defaulted to {} - because a
half-streamed {"path": "/etc/pas turned into something is a call the model
never issued. usage.extra["dropped_partial_tool_calls"] records how many.
The same unfinishable buffer on a COMPLETE stream still errors. - A failed run carries what it finished, and the session persists it before
re-raising. Every outstanding tool_call_id is answered "Operation
aborted", above both executors, because the parallel one had no abort
check of its own and would have run the whole batch. - Verified against a real llama.cpp, which emitted 12 argument fragments for
one two-key tool call - the fragmentation the bug needed, and the thing a
single-chunk cloud response hides.
Long conversations
- Loading a saved chat was quadratic in its length: an 800-message session
took 251s to redraw, and it redrew on every resume, /compact, tree
navigation and elide. It is 0.24s now. The chat view mounts the last 4
user turns or 50 messages, whichever comes first, and writes a
"... N earlier - click to show them" row above them. This bounds what is
DRAWN and nothing else: the whole conversation is loaded, in the log, and
is what the model is sent. - The same mounted tree throttled the next turn's streaming to a couple of
tokens per second, which is what "text accumulates but doesn't display"
looked like from outside. Streaming after a reload is 22.4 tok/s
regardless of transcript length. - A running turn says what it is doing. Between Enter and the first answer
text nothing on screen changed, so a turn spent reasoning or waiting on a
thirty-second tool looked exactly like a turn that had died. The exchange
line now counts: "Working... 812 out ~143 chunks 0:47". The two numbers
are never added - "812 out" is the real output_tokens the server reported,
summed over finished completions; "~143 chunks" is the completion still in
flight, where no usage figure exists yet. - TOKEN COUNTS STOPPED COUNTING THE SAME PROMPT AGAIN EVERY TURN.
total_tokens is one completion's prompt plus its completion, and the
prompt is the whole conversation; two surfaces summed it across the
conversation anyway. A real 17-message session read 192.9k tokens for a
conversation that was 22.6k long. There are two numbers now, never their
sum: ctx (the prompt last sent, carried as a replace) and out (which sums). - Underneath it, the cached span was billed twice. OpenAI and Google both
report cached tokens INSIDE the prompt count, and tau copied them to
cache_read_tokens without taking them back out of input_tokens, so
compute_cost_usd charged them at the input rate and again at the
cache-read rate. Both providers subtract now. - REASONING TEXT REACHES THE SCREEN. A reasoning model's thinking
accumulated into a widget that had never been mounted: the region was
assigned before the mount that raised, so every later call took the
"already exists" branch and returned a widget nobody could see. Measured
at 0 of 28 tokens visible. Tool-call boxes had the same bug, and a tool
result written before its box mounted was silently dropped by
Markdown.update. The test suite could not see any of it, because it paused
after every event on the assumption that a real backend never bursts.
The tree browser
- Indentation counts forks, not messages. One parentId level used to be one
widget level, so a 25-message conversation drove the label width to zero. - A turn folds. Your message owns the turn it started - the reply, every
tool call and every result are its children, starting folded - and the
next thing you asked is its SIBLING, so a hundred turns is a hundred rows
rather than a hundred levels of indent. - navigate rows are gone. They record that the cursor moved and carry no
message. Two exceptions keep their row: the current node, and one with
more than one child, which is a real branch point. - Enter on one of your own messages forks from its parent and brings the
message back in the input box to edit, because continuing from below a
user message would put two user turns in a row. - Only a row with something under it has an expand arrow, and those rows get
the two cells back for their preview. - Rows say what they are, in colour: the user: / assistant: / toolResult:
tag is painted in the hue that role wears in the transcript, and
bookkeeping entries take a quiet grey. - A branch summary is drawn as a pair with the branch it looks back on, and
hovering a row traces where it leaves your path. - The detail pane folds away with ctrl+D or a double-click on its border.
- Every row was one or two cells too wide, because the labels were sized
against a width that did not subtract the vertical scrollbar. - ELIDING IS ONE KEY IN THE BROWSER. It was a button on the action chooser
that re-opened the whole tree to ask for the second node, and reported an
illegal pair after both screens had closed. It is ctrl+E now. The two ends
bracket what is KEPT: given [1,2,3,4,5,6], pairing 2 with 4 leaves
[2,3,4]. You do not have to remember which end you picked first - the two
ends are always an ancestor and a descendant, so the tree decides. While
one row is marked, every row that cannot be the other end is greyed out. - The offer line says what will happen before you press anything:
"ctrl+E: keep this span, drop the other 3 entries, and move back to it".
It used to read "elide 37 messages", which invites the opposite reading
and undercounted besides. - docs/TREE-EDITOR-MANUAL.md is the manual, and matches.
Keys
- ctrl+C was bound straight to quit, so one mistimed press ended a session
with a draft still in the input. It now stops a running turn, or clears
the input, or offers "press ctrl+C again to exit", or quits - in that
order, depending on where you are. - Esc with nothing generating used to do nothing at all. It offers the tree
browser, and a second press within three seconds opens it. Pressing either
key withdraws the other's offer: there is one header line, and an offer
you can no longer see should not still be answerable.
Appearance
- Four themes, swappable live: mocha (default, unchanged from 0.9.3), latte
(light), gruvbox, and ansi - which uses your terminal's own 16 colours and
paints no backgrounds, because it cannot know whether the terminal is
light or dark. parley.tcss names 25 role variables and contains zero
colour literals, and a test fails on any that appear. - Choose one with "theme" in ~/.tau/config.json, --theme NAME for one run,
or ctrl+p in session, which saves the choice. Write your own as
~/.tau/themes/.json with extends/palette/textual keys. - A broken theme file no longer stops tau from starting: it raises one error
toast naming the file and runs in mocha. A broken file named after a
built-in leaves the built-in standing. - --fun REACHES AN INSTALLED TAU. The default was flipped on by a sed in
package.sh, which builds only the GitHub release tarball; the PyPI wheels
come from python -m build and never ran it, so the flip had never reached
anyone who typed pip install. The default lives in the source now and no
build path rewrites anything. --no-fun gives the fixed tagline.
The agent loop
- The 50-turn ceiling is gone, and the number is yours. It was a default
nobody could change - no CLI flag, no config key, no SDK parameter - and
reaching it ended the run silently with is_error false. The default is
None now, which is also pi's position. State one with --max-turns N,
"max_turns" in config.json or per model entry, or max_turns= to
create_agent_session. --max-turns 0 is refused; "no ceiling" is spelled by
omitting the flag. - A terminating tool in sequential mode did not end the turn. The sequential
executor tracked the flag, used it to skip the rest of the batch, and then
dropped it, so the model was consulted again and called the same tool
again. With the old ceiling that was 50 wasted turns; with none it does
not end at all, which is how it surfaced. - message_start brackets one message again. It was emitted from the text
branch alone, so it fired once per text delta - 2137 of them for one
answer - and never fired at all for a completion that produced only
reasoning or only a tool call.
Providers and gateways
- A present-and-null key is not an absent key. .get(key, default) applies
its default only when the key is MISSING, and gateways send choices, delta
and tool_calls present and null. A delta carrying "tool_calls": null
reached enumerate(None) and killed the turn for every model on that
gateway, whether or not it called a tool. - compat.tool_call_schema: "anthropic" translates a gateway that returns
tool calls in the Anthropic tool_use shape. It is never auto-detected -
guessing wrong would rewrite a tool call tau was handed correctly - and it
translates rather than repairs. Pair it with "stream": false on such a
model; the streamed responses are a different defect no compat field
reaches. - The nameless-tool-call error says which of the two failures it is: a name
absent from every frame, or a name present in the wrong schema.
Sessions
- Splice anchors record where they came from. A compaction now carries
summarizerModelId and summaryUsage; a compaction and an elide both carry
coveredEntries, coveredTokens and agentSpecId. All of it existed at write
time and was dropped there. Nothing reads it back into model input.
Packaging
- pip install ffwf-tau works. Four distributions shipped and none was called
ffwf-tau, so the shortest guess failed with a message that reads as a
wrong interpreter or a wrong index. It is a metapackage now - no code, one
dependency on ffwf-tau-coding-agent[tui]. Five distributions. - Python 3.11, 3.12, 3.13 and 3.14 measured before the tag, not after.
Upgrade notes
- SessionLog implementors: append_compaction and append_elide have grown
keyword-only parameters with NO defaults, so an existing implementation
will not type-check and an existing caller will raise TypeError. Persist
the new values and give them back through entries() as summarizerModelId,
summaryUsage, coveredEntries, coveredTokens and agentSpecId. An elide
takes three of the five: it generates no summary, and a parameter whose
only admissible value is a placeholder is the gap this closes.
agent_spec_in_force() and estimate_span_tokens() compute them. - If you set system_prompt in config.json: it is folded onto the model entry
now instead of stored as the first message, so the prompt the model
receives is longer than what you wrote - the project context and tool list
are appended where they used to be discarded. {{field}} slots in your text
are substituted now, so a literal {{ will either move a section or raise. - If you read token figures off a transcript written before this release:
input_tokens used to include the cached span that cache_read_tokens also
reported, so the pair overlapped and reading them as input + cache gave
roughly 2x. total_tokens was never affected, which is why
total_tokens - output_tokens reads an old transcript correctly and summing
the prompt fields does not. - If you consume message_start: it arrives once per completion instead of
once per text delta. Two shapes are new rather than changed - a completion
producing only reasoning or only a tool call is now bracketed, and one
yielding no delta gets its message_start from the terminal event. - If you relied on the 50-turn ceiling: nothing stops a run at turn 50 any
more. AgentLoopConfig.max_turns accepts int | None where it took int. - If you resume a long conversation: the chat view opens on the last 4 user
turns or 50 messages and says how many it left off screen. Click the row
or run "Show earlier messages" from ctrl+p to mount the rest. - ConversationTree gained message_text(entry_id) - an entry's message
flattened to plain text, "" when it has none. Additive. - If you script tau and depend on a fixed tagline: pass --no-fun.
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com