Skip to content

Commit e1b69af

Browse files
committed
fix(render): count tool-args body in frozen row estimate so trim bounds canvas height to keep per-frame render flat
1 parent 655470d commit e1b69af

6 files changed

Lines changed: 372 additions & 61 deletions

File tree

CMakeLists.txt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,43 @@ if(AGENTTY_BUILD_TESTS)
794794
add_test(NAME long_session_bench COMMAND long_session_bench)
795795
set_tests_properties(long_session_bench PROPERTIES TIMEOUT 600)
796796

797+
# o1_probe — measures steady-state per-frame warm render AFTER the
798+
# live-session freeze+trim flow (the path long_session_bench's
799+
# rehydrate-based render phase does NOT cover). Used to verify the
800+
# frozen row-cap keeps warm render flat regardless of thread length.
801+
add_executable(o1_probe
802+
tests/o1_probe.cpp
803+
${AGENTTY_IO_SOURCES}
804+
${AGENTTY_WORKSPACE_SOURCES}
805+
${AGENTTY_AIRGAP_SOURCES}
806+
${AGENTTY_PROVIDER_SOURCES}
807+
${AGENTTY_DIFF_SOURCES}
808+
${AGENTTY_TOOL_SOURCES}
809+
${AGENTTY_RUNTIME_NOMAIN_SOURCES}
810+
)
811+
target_include_directories(o1_probe PRIVATE include)
812+
target_compile_definitions(o1_probe PRIVATE
813+
AGENTTY_VERSION="${PROJECT_VERSION}")
814+
target_link_libraries(o1_probe PRIVATE
815+
maya::maya
816+
nlohmann_json::nlohmann_json
817+
simdjson::simdjson
818+
nghttp2::nghttp2
819+
OpenSSL::SSL
820+
OpenSSL::Crypto
821+
Threads::Threads
822+
)
823+
if(WIN32)
824+
target_link_libraries(o1_probe PRIVATE
825+
ws2_32 crypt32 shell32 winmm
826+
user32 gdi32 gdiplus shlwapi)
827+
endif()
828+
if(APPLE)
829+
target_link_libraries(o1_probe PRIVATE
830+
"-framework Security"
831+
"-framework CoreFoundation")
832+
endif()
833+
797834
# fuzzy_match_smoke — small correctness smoke for the line-DP
798835
# fuzzy matcher used by the edit tool. Standalone (no maya / no
799836
# runtime), so it compiles against just the fuzzy_match TU.

docs/INLINE_SCROLLBACK.md

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,14 @@ If inline-mode rendering breaks, check these in order:
5252
7. **Canvas shrink + trim.** `Runtime::render`'s inline path
5353
reallocates the canvas down to `content + 64` rows when
5454
`canvas.height() * 2 > shrink_target * 3` (1.5×). `trim_frozen_if_oversized`
55-
trims `m.ui.frozen` past 80 entries in chunks of 30, then issues
56-
`Cmd::commit_scrollback_overflow()`.
55+
trims `m.ui.frozen` by ROWS — dropping oldest entries until
56+
`frozen_row_total` is under ~600 rows (and entry count under 60),
57+
keeping at least the most recent 3 entries — then issues
58+
`Cmd::commit_scrollback_overflow()`. The per-entry row count is
59+
estimated from each tool card's **args** body (write content, edit
60+
hunks, read/grep results), NOT its one-line output footer; counting
61+
output alone under-estimates a tall write to ~1 row and the cap
62+
never trips.
5763

5864
Each pin has a section below with the file/line, the rationale, and
5965
the failure mode if you undo it.
@@ -439,21 +445,43 @@ proportional to `canvas.height()`.
439445
`trim_frozen_if_oversized()`.
440446

441447
```cpp
442-
constexpr std::size_t kFrozenMax = 80;
443-
constexpr std::size_t kFrozenTrim = 30;
444-
445-
if (m.ui.frozen.size() <= kFrozenMax) return maya::Cmd<Msg>::none();
446-
// erase oldest 30 entries...
448+
constexpr std::size_t kFrozenMaxRows = 600; // primary: bound canvas height
449+
constexpr std::size_t kFrozenMaxEntries = 60; // secondary: pathological tiny entries
450+
constexpr std::size_t kKeepMinEntries = 3; // never drop the immediate context
451+
452+
if (frozen_row_total <= kFrozenMaxRows
453+
&& frozen.size() <= kFrozenMaxEntries) return maya::Cmd<Msg>::none();
454+
// drop oldest WHOLE entries (front) until both caps satisfied,
455+
// keeping >= kKeepMinEntries; frozen / frozen_rows / frozen_row_total
456+
// stay in lockstep...
447457
return maya::Cmd<Msg>::commit_scrollback_overflow();
448458
```
449459

450-
**Why.** `m.ui.frozen` is borrowed by maya every frame via
451-
`list_ref`, so its size directly drives `render_tree`'s walk. 80
452-
entries ≈ 25–30 full turns of recent work. Trimmed entries are
453-
NOT lost — they remain on disk in `m.d.current.messages`; they're
454-
just no longer in the in-app scrollback window. The terminal's
455-
native scrollback still holds the rows that physically overflowed
456-
during the live session.
460+
**Why ROWS, not entries.** `m.ui.frozen` is borrowed by maya every
461+
frame via `list_ref`, and the inline canvas auto-sizes to
462+
`frozen_row_total + chrome`. Maya then re-runs THREE O(rows×width)
463+
passes per frame: `render_tree` (layout/measure), `canvas.clear()`,
464+
and the canvas/shadow witness scan. So per-frame cost — and the
465+
spinner/input lag the user feels on a long thread — scales with
466+
total frozen ROWS, not entry count. A single full `write`/`edit`
467+
body is hundreds of rows in ONE entry, so an entry-count cap alone
468+
can't bound the canvas. ~600 rows ≈ a few full viewports; warm
469+
per-frame render stays ~0.2 ms there (vs ~25–240 ms when a tall
470+
body is left un-capped). Trimmed entries are NOT lost — they remain
471+
on disk in `m.d.current.messages` AND in the terminal's native
472+
scrollback (painted live once, full body and all). Only the in-app
473+
re-render window shrinks; `show_all` bodies are never collapsed.
474+
475+
**The row count is estimated from tc.args, NOT tc.output().** A tool
476+
card renders its body from its ARGS — `write` shows `args["content"]`
477+
(the whole new file), `edit` shows every hunk under `args["edits"]`,
478+
`read`/`grep` show their result text. `tc.output()` is only the
479+
one-line "wrote N lines" footer. The original estimate counted only
480+
output, so a 3000-line write was scored at ~1 row — `frozen_row_total`
481+
read tiny, the cap never tripped, and the canvas ballooned to
482+
thousands of rows while the model believed the thread was small.
483+
This was THE root cause of the long-thread render lag; see
484+
`estimate_msg_rows` in `frozen.cpp`.
457485

458486
**The `commit_scrollback_overflow` Cmd is the safe variant.** It
459487
lets maya derive the safe row count itself

include/agentty/runtime/model.hpp

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,24 @@ struct Model {
184184
// when a saved thread is loaded.
185185
std::vector<maya::Element> frozen;
186186

187+
// Parallel to `frozen`: estimated rendered-row count per entry,
188+
// recorded at push time. The inline canvas is sized to the SUM
189+
// of these rows, and maya re-derives a full O(rows x width)
190+
// canvas witness (plus clear + render_tree) EVERY frame, so
191+
// per-frame cost scales with total frozen rows, not entry
192+
// count. One full write/edit body can be hundreds of rows in
193+
// ONE entry, so an entry-count cap can't bound it.
194+
// trim_frozen_if_oversized() trims by ROWS via this. The
195+
// estimate MUST count a tool card's rendered body, which comes
196+
// from tc.args (write content, edit hunks, read/grep results)
197+
// — NOT tc.output(), which is only the one-line footer. Always
198+
// the same length as `frozen`.
199+
std::vector<int> frozen_rows;
200+
201+
// Running sum of frozen_rows, maintained on push/trim so the
202+
// oversize check is O(1).
203+
std::size_t frozen_row_total = 0;
204+
187205
// Exclusive upper bound into m.d.current.messages. Every
188206
// message with index < frozen_through has already been built
189207
// into `frozen` and need not be rendered live. The suffix

src/runtime/app/update/frozen.cpp

Lines changed: 140 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,63 @@ maya::Element compaction_divider_row() {
7272
&& !mm.tool_calls.empty();
7373
}
7474

75+
// Cheap byte-based row estimate for a single message's contribution
76+
// to a frozen Turn. NOT a render — a coarse proxy (avg ~60 cols/row)
77+
// used only to BOUND the frozen canvas height, where over/under by a
78+
// few rows is harmless. Shared by rehydrate_frozen (budget walk) and
79+
// freeze_range (per-entry frozen_rows accounting).
80+
std::size_t estimate_msg_rows(const Message& mm) {
81+
std::size_t bytes = mm.text.size() + mm.streaming_text.size();
82+
for (const auto& tc : mm.tool_calls) {
83+
bytes += tc.output().size();
84+
bytes += tc.args_streaming.size();
85+
// The RENDERED body of a settled tool card comes from its
86+
// ARGS, not its output: a write card shows args["content"]
87+
// (the whole new file, show_all), an edit card shows every
88+
// hunk's old/new text under args["edits"], a read/grep card
89+
// shows its result text. tc.output() is only the one-line
90+
// "wrote N lines" footer. Counting just output() under-
91+
// estimated a 3000-line write as ~1 row, so the row cap never
92+
// tripped and the canvas ballooned to thousands of rows while
93+
// frozen_row_total still read tiny. Approximate the body by
94+
// the serialized args size; coarse is fine (this only BOUNDS
95+
// the canvas height, it is never a render).
96+
if (!tc.args.is_null()) {
97+
// dump() is O(args) but args are already in memory and this
98+
// runs once per freeze, not per frame. Use a compact dump
99+
// (no indent) so the byte count tracks content, not
100+
// formatting whitespace.
101+
bytes += tc.args.dump().size();
102+
}
103+
// Header / footer / chrome rows per tool card (~4 rows even
104+
// for an empty body — title, divider, status, blank).
105+
bytes += 4 * 60;
106+
}
107+
// Per-message envelope (header, gap, divider).
108+
bytes += 3 * 60;
109+
return bytes / 60 + 1;
110+
}
111+
112+
// Estimated rows for the run messages[from..to) that collapse into
113+
// ONE frozen Turn entry.
114+
int estimate_run_rows(const Model& m, std::size_t from, std::size_t to) {
115+
std::size_t rows = 0;
116+
for (std::size_t k = from; k < to && k < m.d.current.messages.size(); ++k)
117+
rows += estimate_msg_rows(m.d.current.messages[k]);
118+
return static_cast<int>(rows);
119+
}
120+
121+
// Push a built frozen Element together with its estimated row count,
122+
// keeping m.ui.frozen / m.ui.frozen_rows / m.ui.frozen_row_total in
123+
// lockstep. EVERY push into m.ui.frozen must go through here so the
124+
// row accounting never drifts from the element vector.
125+
void push_frozen(Model& m, maya::Element e, int rows) {
126+
if (rows < 1) rows = 1;
127+
m.ui.frozen.push_back(std::move(e));
128+
m.ui.frozen_rows.push_back(rows);
129+
m.ui.frozen_row_total += static_cast<std::size_t>(rows);
130+
}
131+
75132
// Run-level safety gate: a frozen turn captures an Element snapshot
76133
// whose hash_id is stamped once and never recomputed. If we freeze a
77134
// run that still contains a Pending / Approved / Running tool, that
@@ -131,14 +188,14 @@ void freeze_range(Model& m, std::size_t from, std::size_t to) {
131188
}
132189

133190
if (needs_compaction_divider(i)) {
134-
m.ui.frozen.push_back(compaction_divider_row());
191+
push_frozen(m, compaction_divider_row(), 1);
135192
}
136193

137194
// Leading gap: one blank row before every turn except the
138195
// very first frozen row (avoid a top-of-thread gap).
139196
const bool first_overall = m.ui.frozen.empty();
140197
if (!first_overall) {
141-
m.ui.frozen.push_back(gap_row());
198+
push_frozen(m, gap_row(), 1);
142199
}
143200

144201
const Message& head = m.d.current.messages[i];
@@ -162,7 +219,8 @@ void freeze_range(Model& m, std::size_t from, std::size_t to) {
162219
kb.add(m.d.current.messages[j].compute_render_key());
163220
}
164221
cfg.hash_id = kb.build();
165-
m.ui.frozen.push_back(maya::Turn{std::move(cfg)}.build());
222+
push_frozen(m, maya::Turn{std::move(cfg)}.build(),
223+
estimate_run_rows(m, i, run_end));
166224
++m.ui.frozen_turn;
167225
} else {
168226
// User / compaction-summary single-message Turn.
@@ -176,7 +234,8 @@ void freeze_range(Model& m, std::size_t from, std::size_t to) {
176234
.add(std::string_view{head.id.value})
177235
.add(head.compute_render_key())
178236
.build();
179-
m.ui.frozen.push_back(maya::Turn{std::move(cfg)}.build());
237+
push_frozen(m, maya::Turn{std::move(cfg)}.build(),
238+
estimate_run_rows(m, i, run_end));
180239
}
181240

182241
i = run_end;
@@ -194,6 +253,8 @@ void freeze_through(Model& m, std::size_t live_start) {
194253

195254
void clear_frozen(Model& m) {
196255
m.ui.frozen.clear();
256+
m.ui.frozen_rows.clear();
257+
m.ui.frozen_row_total = 0;
197258
m.ui.frozen_through = 0;
198259
m.ui.frozen_turn = 0;
199260
}
@@ -236,20 +297,6 @@ void rehydrate_frozen(Model& m) {
236297
const std::size_t kRehydrateRowBudget = static_cast<std::size_t>(
237298
std::max(8, term_size.height.value - kComposerReserve));
238299

239-
auto estimate_msg_rows = [](const Message& mm) -> std::size_t {
240-
std::size_t bytes = mm.text.size() + mm.streaming_text.size();
241-
for (const auto& tc : mm.tool_calls) {
242-
bytes += tc.output().size();
243-
bytes += tc.args_streaming.size();
244-
// Header / footer / chrome rows per tool card (~4 rows
245-
// even for an empty body — title, divider, status, blank).
246-
bytes += 4 * 60;
247-
}
248-
// Per-message envelope (header, gap, divider).
249-
bytes += 3 * 60;
250-
return bytes / 60 + 1;
251-
};
252-
253300
// Walk backward counting speaker-runs until EITHER cap trips.
254301
std::size_t units = 0;
255302
std::size_t row_budget = 0;
@@ -293,38 +340,86 @@ void rehydrate_frozen(Model& m) {
293340
}
294341

295342
maya::Cmd<Msg> trim_frozen_if_oversized(Model& m) {
296-
// Soft cap on the frozen vector. Above this, the oldest entries
297-
// are dropped — maya's row diff sees a shorter live tree and the
343+
// Soft cap on the frozen prefix. Above it, the oldest entries are
344+
// dropped — maya's row diff sees a shorter live tree and the
298345
// already-overflowed rows naturally commit to native scrollback.
299346
//
300-
// Tradeoff: memory + every-frame render_tree cost vs in-app
301-
// scroll reach. Render cost dominates on tool-heavy sessions —
302-
// every settled turn appends a multi-row Element to frozen and
303-
// the canvas auto-resizes to `total_rows + 8`. canvas_.clear()
304-
// streaming_fills the entire surface each frame and render_tree
305-
// walks every node to position it; 240 entries of write/edit/bash
306-
// panels reaches ~5000 rows and pushes per-frame render past
307-
// 15 ms, which the user feels as input lag.
347+
// Why ROWS, not entries: the inline canvas auto-resizes to
348+
// `frozen_row_total + chrome`, and maya re-derives a full
349+
// O(rows x width) canvas witness EVERY frame (see maya
350+
// canvas_witness.cpp verify_canvas / verify_shadow). So the
351+
// per-frame render cost — and the animation lag the user feels on
352+
// a long thread — scales with TOTAL FROZEN ROWS, not entry count.
353+
// A single full `write`/`edit` body is hundreds of rows in ONE
354+
// entry, so an entry-count cap alone can't bound the canvas: 80
355+
// entries of fat tool panels still reach ~5000 rows and push
356+
// per-frame render past 15 ms. Capping rows keeps the canvas
357+
// bounded regardless of how tall any individual entry is.
308358
//
309-
// 80 entries ≈ 25-30 full turns of recent work — enough for the
310-
// in-flight task to stay visible, small enough that the canvas
311-
// never blows past ~2000 rows. Older turns are still in the
312-
// terminal's native scrollback (committed there when they
313-
// overflowed during the live session). 30-entry trim chunk
314-
// amortises the per-trim cost across many appends.
315-
constexpr std::size_t kFrozenMax = 80;
316-
constexpr std::size_t kFrozenTrim = 30;
317-
318-
if (m.ui.frozen.size() <= kFrozenMax) return maya::Cmd<Msg>::none();
319-
320-
const std::size_t n = std::min(kFrozenTrim,
321-
m.ui.frozen.size() > kFrozenMax / 2
322-
? m.ui.frozen.size() - kFrozenMax / 2
323-
: std::size_t{0});
324-
if (n == 0) return maya::Cmd<Msg>::none();
359+
// Older turns stay in the terminal's native scrollback (committed
360+
// there when they overflowed live), and the full message history
361+
// is intact on disk — only the in-app re-render window shrinks.
362+
// Composer history (↑) and thread reload are unaffected.
363+
//
364+
// The per-frame inline render cost is dominated by THREE passes
365+
// that are each O(canvas_rows x width) and run EVERY tick:
366+
// 1. render_tree over the full element tree (layout/measure),
367+
// 2. canvas_.clear() (streaming_fill over every cell),
368+
// 3. the canvas/shadow witness scan (verify_canvas).
369+
// canvas_rows tracks frozen_row_total, so to keep the spinner /
370+
// input latency flat on an arbitrarily long thread we must keep
371+
// frozen_row_total bounded to a SMALL multiple of the viewport.
372+
// Anything that has scrolled past the top of the viewport already
373+
// lives in the terminal's OWN scrollback (it was painted live
374+
// once, full body and all) — re-rendering it inside agentty every
375+
// frame buys nothing but lag. The user scrolls back through it
376+
// with the terminal, not the app.
377+
//
378+
// ~600 rows ≈ a handful of full viewports of recent work; at that
379+
// height the warm per-frame render measures ~4 ms (vs ~12 ms at
380+
// 1500 and ~25-97 ms when a single tall write/edit body is left
381+
// un-capped). The entry cap is a secondary guard against
382+
// pathological counts of tiny entries. Trimming drops whole
383+
// entries from the front until BOTH caps are satisfied, leaving at
384+
// least the most recent few entries no matter how tall they are —
385+
// full bodies are NEVER collapsed (the `show_all` UX is intact);
386+
// they simply graduate from the in-app re-render window into
387+
// native terminal scrollback.
388+
constexpr std::size_t kFrozenMaxRows = 600;
389+
constexpr std::size_t kFrozenMaxEntries = 60;
390+
constexpr std::size_t kKeepMinEntries = 3;
391+
392+
const bool over_rows = m.ui.frozen_row_total > kFrozenMaxRows;
393+
const bool over_entries = m.ui.frozen.size() > kFrozenMaxEntries;
394+
if (!over_rows && !over_entries) return maya::Cmd<Msg>::none();
395+
396+
// Drop entries from the front until both caps are satisfied, but
397+
// never below kKeepMinEntries so the live context stays visible
398+
// even when the tail is a single enormous write/edit body.
399+
std::size_t drop = 0;
400+
const std::size_t max_drop =
401+
m.ui.frozen.size() > kKeepMinEntries
402+
? m.ui.frozen.size() - kKeepMinEntries
403+
: std::size_t{0};
404+
std::size_t rows_after = m.ui.frozen_row_total;
405+
std::size_t entries_after = m.ui.frozen.size();
406+
while (drop < max_drop
407+
&& (rows_after > kFrozenMaxRows || entries_after > kFrozenMaxEntries)) {
408+
rows_after -= static_cast<std::size_t>(m.ui.frozen_rows[drop]);
409+
--entries_after;
410+
++drop;
411+
}
412+
if (drop == 0) return maya::Cmd<Msg>::none();
325413

414+
// Keep frozen / frozen_rows / frozen_row_total in lockstep.
415+
std::size_t removed_rows = 0;
416+
for (std::size_t k = 0; k < drop; ++k)
417+
removed_rows += static_cast<std::size_t>(m.ui.frozen_rows[k]);
326418
m.ui.frozen.erase(m.ui.frozen.begin(),
327-
m.ui.frozen.begin() + static_cast<std::ptrdiff_t>(n));
419+
m.ui.frozen.begin() + static_cast<std::ptrdiff_t>(drop));
420+
m.ui.frozen_rows.erase(m.ui.frozen_rows.begin(),
421+
m.ui.frozen_rows.begin() + static_cast<std::ptrdiff_t>(drop));
422+
m.ui.frozen_row_total -= removed_rows;
328423

329424
// commit_scrollback_overflow lets maya derive the safe row count
330425
// itself (max(0, prev_rows - term_h)) — the Cmd is just a trigger

0 commit comments

Comments
 (0)