Skip to content

v0.9.4

Latest

Choose a tag to compare

@jmccardle jmccardle released this 25 Aug 01:52

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 through tau. 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