Files
LLeMbas/src/lembas/services/steps.py
T
Jaroslav Beneš 30ddcba787 A thinking block that says how long and how much
Each block reports its own round now. `reasoning_ms` was the reply's first
burst, written once, so on the fifteen-block reply GPT-OSS actually produces
only the first could claim a duration and the other fourteen said "Thought" and
nothing at all. `Generation.thinking_ms` accumulates per round and `close_step`
stamps it cumulatively, so steps.py diffs it exactly as it already diffs the
three lengths beside it.

The interval between a round's first and last reasoning delta, deliberately, not
a sum of gaps between deltas -- that would count the network's latency as the
model's thinking.

While it runs: "Thinking" with an ellipsis that types itself, and the seconds
and tokens climbing beside it. The ellipsis is a `content` keyframe, so there is
no timer to start, stop or clean up when the block is swapped away -- it stops
existing when the element does. The numbers come from a `think` frame, and
`round_thinking_ms` is written by the producer rather than computed by the
follower from a start time: a model that has stopped thinking and moved on to a
tool should show a settled number, not a clock that keeps running.

Tokens read exactly up to 200 and as `0.4k` above it, from one helper shared by
the live label and the stored one, so the two cannot drift into two conventions.
The live duration is terser than the finished one -- `6s` against `6 seconds` --
because it sits beside an animating word and changes every second, where "less
than a second" flickering into "1 second" reads as a glitch.

Checked against the real endpoint: fourteen marks carrying 919ms through
14223ms, per-block labels from "less than a second · 111" to "4 seconds · 0.5k",
and the live frames resetting each round rather than accumulating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:03:25 +02:00

331 lines
13 KiB
Python

"""A reply as the sequence of steps it actually was.
The three stores a reply writes into -- text, reasoning and tool events -- are
each append-only and each correct. What none of them records is *interleaving*:
where round three's thinking sat relative to round three's command and to the
sentence that came after it. So a bubble was rendered as three zones, all the
thinking, then all the tools, then all the prose, which reads fine on a two-round
answer and is unusable on a forty-round one.
The fix is a table of contents rather than a fourth copy of anything. A **mark**
is written when a round's contribution ends, holding the cumulative length of
each store at that moment; the text between two marks is one step's prose, and so
on. Nothing is duplicated, so `build_messages`, compaction, titling and the copy
button all still see `message.content` as the single string it always was.
**No marks means the old layout.** Every reply written before this existed reads
back an empty list, and `_build` answers that with thinking, then tools, then
text -- exactly what those bubbles have always shown. There is no version flag
and no branch in the template.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from lembas.services import tokens as tokens_service
from lembas.services.markdown import open_fence, render_markdown
from lembas.services.reasoning import format_duration
KIND_THINKING = "thinking"
KIND_TEXT = "text"
KIND_TOOLS = "tools"
# Below this a token count is printed exactly; above it, as `1.4k`. The point is
# where the digits stop meaning anything to a reader.
TOKENS_EXACT_BELOW = 200
@dataclass(frozen=True)
class Step:
"""One thing that happened, in the order it happened.
`index` is the position of the mark this came from, and the trailing step --
the one still being written -- takes the index one past the last mark. It is
what every DOM id in the transcript is derived from, which is what lets an
open block survive both a stream frame and the `done` frame that replaces the
whole bubble: the marks are append-only, so index N always means the same
step, live and afterwards alike.
"""
index: int
kind: str
open: bool = False
text: str = ""
html: str = ""
events: tuple[dict, ...] = field(default_factory=tuple)
# How long this block thought for and roughly how much it produced. Only on
# a thinking step, and only where there is something to say -- a block whose
# duration was never recorded (every row written before the marks carried
# one) shows the word alone rather than "Thought for 0 seconds".
label: str = ""
def format_tokens(count: int) -> str:
"""A token count as a person reads it: `86`, or `0.2k` once it is worth it.
The threshold is where the exact number stops carrying information: nobody
acts on the difference between 214 and 219 tokens of thinking, and four
digits beside a spinner is noise. One helper, so the live label and the
stored one cannot drift into two conventions.
"""
if count <= 0:
return ""
if count <= TOKENS_EXACT_BELOW:
return str(count)
return f"{count / 1000:.1f}k"
def thinking_label(*, ms: int, tokens: int, live: bool) -> str:
"""What a thinking block says about itself.
The two states are one function because they are one sentence with a
different tense, and because the running one becomes the finished one in
place -- a reader watching the numbers climb should see them settle, not be
replaced by something formatted differently.
The word itself is not here: the live block animates its own ellipsis in CSS
and the template owns that. This is only what follows it.
"""
parts = []
if ms > 0:
parts.append(format_duration(ms) if not live else _short_duration(ms))
if counted := format_tokens(tokens):
parts.append(counted)
return " · ".join(parts)
def _short_duration(ms: int) -> str:
"""`6s`, `1m 04s`. Terser than `format_duration` because it sits beside an
animating word and changes every second; "less than a second" flickering
into "1 second" reads as a glitch rather than as a measurement."""
seconds = max(0, ms) // 1000
if seconds < 60:
return f"{seconds}s"
minutes, remainder = divmod(seconds, 60)
return f"{minutes}m {remainder:02d}s"
def for_message(message: Any) -> list[Step]:
"""Every step of a finished reply, read off the row.
A Jinja global (see `web/templating.py`) for the reason `tool_label` is: the
bubble is rendered from four different handlers, and a fifth thing each of
them had to remember to pass is a fifth thing one of them would forget.
"""
return _build(
text=message.content or "",
thinking=(message.reasoning or "") if not message.error else "",
events=list(message.tool_calls_json or []),
marks=list(getattr(message, "steps_json", None) or []),
whole_ms=int(getattr(message, "reasoning_ms", 0) or 0),
# A stored reply has a trailing step and it is not being written. Those
# are two facts and they used to be one flag: `include_open` both
# emitted the tail and marked it live, so every finished bubble ending
# in prose carried `msg__body--live` and blinked a caret for ever.
live=False,
)
def closed_from(generation: Any, since: int) -> list[Step]:
"""The finished steps of a running reply, from mark `since` onwards.
Only the new ones, because `_follow` keeps what it has already rendered. A
closed step never changes again -- that is what closing one means -- so the
whole prefix does not have to be re-rendered twelve times a second, which is
what the tool block used to cost on a long reply.
"""
return _build(
text=generation.text,
thinking=generation.thinking,
events=list(generation.tool_events),
marks=list(generation.steps),
since=since,
include_open=False,
)
def tail(generation: Any) -> tuple[str, str]:
"""What is being written right now: `(thinking, text)` past the last mark.
The text carries a fence reopener where one is needed, so a code block
started before the last tool call goes on rendering as a code block instead
of the prose underneath it briefly becoming one.
"""
marks = list(generation.steps)
last = marks[-1] if marks else {}
thinking = generation.thinking[_at(last, "thinking_to") :]
text = generation.text[_at(last, "text_to") :]
carry = _carry_before(generation.text, marks)
return thinking, (f"{carry}\n{text}" if carry and text else text)
def _at(mark: dict, key: str) -> int:
value = mark.get(key, 0)
return value if isinstance(value, int) and value > 0 else 0
def _build(
*,
text: str,
thinking: str,
events: list[dict],
marks: list[dict],
since: int = 0,
include_open: bool = True,
live: bool = True,
whole_ms: int = 0,
) -> list[Step]:
"""The shared walk.
Two flags, because they are two questions and conflating them put a blinking
caret on every finished reply:
* `include_open` -- emit the trailing step at all. `closed_from` says no,
because the step still being written is carried by its own frames.
* `live` -- mark that trailing step as still being written. Only ever true
of a running generation. A stored reply has a tail and it is finished.
Every offset is clamped and nothing here raises. A `steps_json` that
disagrees with the three stores -- a row half-written when the process died,
a hand-edited one -- has to degrade to a slightly odd order, never to a
transcript that will not render at all.
"""
steps: list[Step] = []
if not marks:
# The compatibility layout, and the layout of any reply that called
# nothing: for that one the two orders are the same list, because there
# are no tool blocks to sit between the prose.
if thinking:
# No marks means no per-round timing was ever recorded, so the
# duration falls back to whatever the row knows about the reply as a
# whole -- which for a single-round answer is exactly right, and is
# what every row written before the marks existed carries.
steps.append(
Step(
index=0,
kind=KIND_THINKING,
text=thinking,
# Nothing baked in while the reply runs: the live block's
# numbers come from the `think` frame, which knows the
# clock. A label rendered here would be one that never
# moved again.
label=""
if live
else thinking_label(
ms=whole_ms, tokens=tokens_service.estimate(thinking), live=False
),
)
)
if events:
steps.append(Step(index=0, kind=KIND_TOOLS, events=tuple(events)))
if text:
steps.append(
Step(index=0, kind=KIND_TEXT, open=live, html=render_markdown(text))
)
return steps
thought_from = 0
text_from = 0
tools_from = 0
thought_ms_from = 0
carry = ""
for index, mark in enumerate(marks):
thought_to = min(max(_at(mark, "thinking_to"), thought_from), len(thinking))
text_to = min(max(_at(mark, "text_to"), text_from), len(text))
tools_to = min(max(_at(mark, "tools_to"), tools_from), len(events))
thought = thinking[thought_from:thought_to]
said = text[text_from:text_to]
ran = events[tools_from:tools_to]
# Computed for every step even when this one is not being returned:
# `closed_from` renders a suffix, and whether a fence is open depends on
# everything before it.
source = f"{carry}\n{said}" if carry and said else said
marker, info = open_fence(source)
if marker:
source = f"{source}\n{marker}"
if index >= since:
# Thinking, then prose, then tools -- the order a model emits them.
if thought:
steps.append(
Step(
index=index,
kind=KIND_THINKING,
text=thought,
label=thinking_label(
ms=_at(mark, "thinking_ms") - thought_ms_from,
tokens=tokens_service.estimate(thought),
live=False,
),
)
)
if said:
steps.append(Step(index=index, kind=KIND_TEXT, html=render_markdown(source)))
if ran:
steps.append(Step(index=index, kind=KIND_TOOLS, events=tuple(ran)))
carry = f"{marker}{info}" if marker else ""
thought_from, text_from, tools_from = thought_to, text_to, tools_to
# Cumulative on the mark, so a block's own duration is the difference --
# the same rule the three lengths above follow.
thought_ms_from = max(_at(mark, "thinking_ms"), thought_ms_from)
if not include_open:
return steps
# Everything past the last mark. Implicit rather than written, in both the
# live path and the stored one -- one rule instead of two that could drift.
index = len(marks)
if trailing_thought := thinking[thought_from:]:
# The last round closes no mark -- it is the round that stopped calling
# tools -- so its duration is whatever the reply spent thinking beyond
# the last one that did. Zero while the reply is running, where the live
# block carries its own label instead.
steps.append(
Step(
index=index,
kind=KIND_THINKING,
text=trailing_thought,
label=""
if live
else thinking_label(
ms=max(0, whole_ms - thought_ms_from),
tokens=tokens_service.estimate(trailing_thought),
live=False,
),
)
)
if trailing_text := text[text_from:]:
source = f"{carry}\n{trailing_text}" if carry else trailing_text
steps.append(
Step(index=index, kind=KIND_TEXT, open=live, html=render_markdown(source))
)
if trailing_events := events[tools_from:]:
steps.append(Step(index=index, kind=KIND_TOOLS, events=tuple(trailing_events)))
return steps
def _carry_before(text: str, marks: list[dict]) -> str:
"""The fence still open when the last mark was written, if any."""
if not marks:
return ""
carry = ""
start = 0
for mark in marks:
end = min(max(_at(mark, "text_to"), start), len(text))
source = f"{carry}\n{text[start:end]}" if carry else text[start:end]
marker, info = open_fence(source)
carry = f"{marker}{info}" if marker else ""
start = end
return carry
__all__ = ["KIND_TEXT", "KIND_THINKING", "KIND_TOOLS", "Step", "closed_from", "for_message", "tail"]