feat: assistant history budget and cache-stable system prompt #76

Merged
bjoern merged 2 commits from feat/assistant-context-budget into main 2026-08-16 08:50:32 +02:00
Member

What & why

The assistant's persistent history grew without bound: huge tool results accumulated until the model's context overflowed, after which every turn failed identically and clearing the conversation was the only escape. The system prompt also embedded volatile state (date, route, memories), invalidating the OpenRouter prompt-cache prefix on every turn.

History compaction (agent_service.dart)

  • A deterministic compactHistory pass runs before each turn and after each sync. Over budget, it first swaps old tool-result contents (never the newest exchange's) for short [result trimmed: <tool>, N chars] placeholders, then drops whole oldest exchanges; a single note at the front keeps the running dropped-turn count across repeated compactions.
  • Budget configurable via DOUJIN_MANAGER_AGENT_HISTORY_BUDGET_CHARS (default 200000 chars, documented in the README). Inline images count a flat estimate instead of their base64 length.
  • Reasoning content (reasoningContent / serialized reasoningDetails) counts toward the estimate too — history replays re-send it, so ignoring it let reasoning models exceed the budget.

Cache-stable system prompt (system_prompt.dart)

The system prompt is now static per session. Volatile state (date, current route, viewed doujin, memories) moved into a per-turn context message that is replaced each turn instead of accumulating, so the provider's prompt-cache prefix survives across turns.

Review fix: reflection starvation

The self-reflection gate compared _history.length against a watermark taken at the last reflection, but compaction makes the length non-monotonic — once exchanges start dropping, the delta never reaches the threshold again and reflection starves permanently in exactly the long-conversation regime the budget targets. The gate now compares a virtual monotonic length (live messages plus everything compaction ever dropped). Regression test verified to fail against the pre-fix gate.

Testing

  • flutter analyze: clean
  • flutter test test/agent_compaction_test.dart test/agent_history_test.dart test/agent_reflection_test.dart: 29 tests, all passing (compaction passes, note folding, protected newest exchange, reasoning estimate, cache-stable prompt, reflection-after-compaction)

🤖 Generated with Claude Code

## What & why The assistant's persistent history grew without bound: huge tool results accumulated until the model's context overflowed, after which every turn failed identically and clearing the conversation was the only escape. The system prompt also embedded volatile state (date, route, memories), invalidating the OpenRouter prompt-cache prefix on every turn. ### History compaction (`agent_service.dart`) - A deterministic `compactHistory` pass runs before each turn and after each sync. Over budget, it first swaps old tool-result contents (never the newest exchange's) for short `[result trimmed: <tool>, N chars]` placeholders, then drops whole oldest exchanges; a single note at the front keeps the running dropped-turn count across repeated compactions. - Budget configurable via `DOUJIN_MANAGER_AGENT_HISTORY_BUDGET_CHARS` (default 200000 chars, documented in the README). Inline images count a flat estimate instead of their base64 length. - Reasoning content (`reasoningContent` / serialized `reasoningDetails`) counts toward the estimate too — history replays re-send it, so ignoring it let reasoning models exceed the budget. ### Cache-stable system prompt (`system_prompt.dart`) The system prompt is now static per session. Volatile state (date, current route, viewed doujin, memories) moved into a per-turn context message that is replaced each turn instead of accumulating, so the provider's prompt-cache prefix survives across turns. ### Review fix: reflection starvation The self-reflection gate compared `_history.length` against a watermark taken at the last reflection, but compaction makes the length non-monotonic — once exchanges start dropping, the delta never reaches the threshold again and reflection starves permanently in exactly the long-conversation regime the budget targets. The gate now compares a virtual monotonic length (live messages plus everything compaction ever dropped). Regression test verified to fail against the pre-fix gate. ## Testing - `flutter analyze`: clean - `flutter test test/agent_compaction_test.dart test/agent_history_test.dart test/agent_reflection_test.dart`: 29 tests, all passing (compaction passes, note folding, protected newest exchange, reasoning estimate, cache-stable prompt, reflection-after-compaction) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The assistant's persistent history grew without bound: huge tool results
accumulated until the model's context overflowed, after which every turn
failed identically and clearing the conversation was the only escape. A
deterministic compaction pass now runs before each turn and after each
sync: over the budget, old tool results (never the newest exchange's)
become short placeholders, then whole oldest exchanges are dropped with
a single note keeping the running dropped-turn count. The budget is
configurable via DOUJIN_MANAGER_AGENT_HISTORY_BUDGET_CHARS (default
200000 chars); inline images count a flat estimate instead of their
base64 length.

The system prompt is now static per session: the volatile state (date,
route, viewed doujin, memories) moved into a per-turn context message
that is replaced instead of accumulated, so the OpenRouter prompt-cache
prefix survives across turns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix: keep reflection firing once compaction shrinks the history
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 2m33s
5ec393c966
The reflection gate compared _history.length against a watermark taken
at the last reflection — but compaction makes the length non-monotonic:
once the budget forces whole exchanges to drop, the length plateaus at
(or falls below) the watermark and the delta never reaches the
threshold again, permanently starving reflection in exactly the
long-conversation regime the budget targets. The gate now compares a
virtual monotonic length (live messages plus everything compaction ever
dropped), so genuinely new messages always count.

Also count AssistantMessage reasoning (reasoningContent and serialized
reasoningDetails) in estimateMessageChars — history replays re-send it,
so ignoring it let reasoning models exceed the configured budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Flutter Coverage

File Line coverage
lib/app/store.dart 100.0% (18 of 18)
lib/data/models/doujin_models.dart 85.0% (34 of 40)
lib/data/models/doujin_models.g.dart 40.5% (119 of 294)
lib/domain/entities/stored_settings.dart 100.0% (9 of 9)
lib/presentation/state/app_state.dart 60.0% (9 of 15)
lib/agent/agent_service.dart 88.0% (344 of 391)
lib/agent/approval_gate.dart 100.0% (16 of 16)
lib/agent/assistant_context.dart 52.8% (28 of 53)
lib/agent/browser_budget.dart 80.0% (4 of 5)
lib/agent/caching_describer.dart 100.0% (12 of 12)
lib/agent/memory_store.dart 92.9% (13 of 14)
lib/agent/skills/skill_registry.dart 93.8% (61 of 65)
lib/agent/system_prompt.dart 100.0% (66 of 66)
lib/agent/tools/budgeted_browser_tool.dart 84.2% (16 of 19)
lib/agent/tools/doujin_write_tool.dart 62.9% (168 of 267)
lib/agent/tools/entity_write_tool.dart 73.5% (164 of 223)
lib/agent/tools/fetch_page_tool.dart 89.3% (67 of 75)
lib/agent/tools/get_doujin_tool.dart 84.4% (27 of 32)
lib/agent/tools/list_entities_tool.dart 76.1% (54 of 71)
lib/agent/tools/navigate_tool.dart 93.8% (30 of 32)
lib/agent/tools/read_skill_tool.dart 82.4% (14 of 17)
lib/agent/tools/reflection_tools.dart 78.4% (29 of 37)
lib/agent/tools/search_doujins_tool.dart 100.0% (84 of 84)
lib/agent/tools/view_images_tool.dart 95.0% (38 of 40)
lib/domain/entities/assistant_entry.dart 20.0% (1 of 5)
lib/presentation/state/actions/assistant_actions.dart 54.5% (6 of 11)
lib/domain/entities/entity_model.dart 100.0% (1 of 1)
lib/data/repositories/entity_in_use_exception.dart 33.3% (1 of 3)
lib/data/models/search_query.dart 50.0% (2 of 4)
lib/data/models/search_query.g.dart 32.4% (23 of 71)
lib/core/constants.dart 36.4% (4 of 11)
lib/presentation/middleware/assistant_epics.dart 83.1% (74 of 89)
lib/presentation/middleware/epics.dart 88.7% (329 of 371)
lib/presentation/state/reducers.dart 100.0% (10 of 10)
lib/core/chunking.dart 100.0% (12 of 12)
lib/core/languages.dart 100.0% (8 of 8)
lib/data/models/envelope.dart 81.2% (13 of 16)
lib/data/models/envelope.g.dart 50.7% (34 of 67)
lib/data/repositories/upload_exception.dart 33.3% (1 of 3)
lib/domain/entities/filter_token.dart 90.9% (90 of 99)
lib/presentation/middleware/editor_epics.dart 56.6% (163 of 288)
lib/presentation/middleware/entity_ops.dart 58.0% (40 of 69)
lib/presentation/middleware/upload_epics.dart 98.1% (104 of 106)
lib/presentation/state/actions/detail_actions.dart 85.7% (6 of 7)
lib/presentation/state/actions/editor_actions.dart 45.9% (17 of 37)
lib/presentation/state/actions/entity_actions.dart 56.2% (9 of 16)
lib/presentation/state/actions/library_actions.dart 36.8% (7 of 19)
lib/presentation/state/actions/metadata_actions.dart 100.0% (3 of 3)
lib/presentation/state/actions/reader_actions.dart 75.0% (3 of 4)
lib/presentation/state/actions/settings_actions.dart 87.5% (7 of 8)
lib/presentation/state/actions/upload_actions.dart 90.9% (10 of 11)
lib/presentation/state/reducers/assistant_reducer.dart 95.0% (57 of 60)
lib/presentation/state/reducers/detail_reducer.dart 95.8% (23 of 24)
lib/presentation/state/reducers/editor_reducer.dart 92.5% (98 of 106)
lib/presentation/state/reducers/entity_reducer.dart 98.5% (66 of 67)
lib/presentation/state/reducers/library_reducer.dart 100.0% (105 of 105)
lib/presentation/state/reducers/metadata_reducer.dart 100.0% (15 of 15)
lib/presentation/state/reducers/reader_reducer.dart 100.0% (15 of 15)
lib/presentation/state/reducers/settings_reducer.dart 100.0% (60 of 60)
lib/presentation/state/reducers/upload_reducer.dart 100.0% (45 of 45)
lib/presentation/pages/editor/page_grid.dart 89.4% (286 of 320)
lib/presentation/pages/editor/variants_tab.dart 55.3% (52 of 94)
lib/core/natural_sort.dart 100.0% (27 of 27)
lib/core/url_utils.dart 100.0% (4 of 4)
lib/presentation/pages/editor/chapter_panel.dart 11.8% (9 of 76)
lib/presentation/widgets/cover_thumbnail.dart 83.3% (30 of 36)
lib/presentation/pages/editor/upload_panel.dart 38.6% (61 of 158)
lib/presentation/pages/editor/variant_dialog.dart 4.5% (3 of 66)
lib/presentation/widgets/language_dropdown.dart 84.6% (11 of 13)
lib/app/di.dart 48.3% (14 of 29)
lib/presentation/assistant/assistant_panel.dart 91.3% (84 of 92)
lib/presentation/layout/main_layout.dart 86.3% (44 of 51)
lib/data/api_client.dart 92.8% (64 of 69)
lib/data/repositories/doujin_api_repository.dart 22.2% (80 of 361)
lib/data/repositories/health_repository.dart 72.0% (18 of 25)
lib/data/secure_storage.dart 0.0% (0 of 26)
lib/core/theme.dart 96.9% (31 of 32)
lib/presentation/assistant/approval_card.dart 95.0% (38 of 40)
lib/presentation/assistant/assistant_markdown.dart 100.0% (3 of 3)
lib/presentation/assistant/chat_entries.dart 87.5% (35 of 40)
lib/presentation/pages/reader/reader_page.dart 88.5% (216 of 244)
lib/presentation/pages/detail/detail_page.dart 77.1% (178 of 231)
lib/presentation/pages/detail/variant_tabs_panel.dart 94.9% (169 of 178)
lib/presentation/widgets/star_rating.dart 100.0% (72 of 72)
lib/presentation/pages/reader/reader_overlay.dart 94.4% (51 of 54)
lib/presentation/pages/reader/reader_sequence.dart 100.0% (27 of 27)
lib/presentation/pages/people/people_page.dart 55.6% (10 of 18)
lib/presentation/widgets/entity_editor.dart 88.5% (123 of 139)
lib/presentation/widgets/entity_management_page.dart 82.5% (193 of 234)
lib/presentation/pages/characters/characters_page.dart 57.9% (11 of 19)
lib/presentation/pages/editor/editor_page.dart 77.6% (59 of 76)
lib/presentation/pages/editor/association_picker.dart 95.5% (106 of 111)
lib/presentation/pages/editor/associations_tab.dart 73.8% (90 of 122)
lib/presentation/pages/editor/doujin_list_pane.dart 67.2% (43 of 64)
lib/presentation/pages/editor/edit_title_dialog.dart 91.7% (55 of 60)
lib/presentation/pages/editor/editor_pane.dart 70.9% (39 of 55)
lib/presentation/pages/editor/new_doujin_dialog.dart 66.7% (30 of 45)
lib/presentation/pages/editor/metadata_tab.dart 70.5% (93 of 132)
lib/presentation/pages/tags/tags_page.dart 100.0% (17 of 17)
lib/app/app.dart 66.7% (44 of 66)
lib/presentation/pages/settings/settings_page.dart 99.3% (138 of 139)
lib/presentation/pages/circles/circles_page.dart 52.6% (10 of 19)
lib/presentation/pages/library/library_page.dart 88.5% (170 of 192)
lib/presentation/pages/series/series_page.dart 50.0% (9 of 18)
lib/presentation/widgets/smart_filter_bar.dart 78.7% (170 of 216)
lib/presentation/widgets/model_combo_field.dart 69.0% (100 of 145)
lib/app/skill_assets.dart 92.9% (13 of 14)

Total: 75.1% (6016 of 8010)

<!-- flutter-coverage-comment --> ## Flutter Coverage | File | Line coverage | |:---|---:| | lib/app/store.dart | 100.0% (18 of 18) | | lib/data/models/doujin_models.dart | 85.0% (34 of 40) | | lib/data/models/doujin_models.g.dart | 40.5% (119 of 294) | | lib/domain/entities/stored_settings.dart | 100.0% (9 of 9) | | lib/presentation/state/app_state.dart | 60.0% (9 of 15) | | lib/agent/agent_service.dart | 88.0% (344 of 391) | | lib/agent/approval_gate.dart | 100.0% (16 of 16) | | lib/agent/assistant_context.dart | 52.8% (28 of 53) | | lib/agent/browser_budget.dart | 80.0% (4 of 5) | | lib/agent/caching_describer.dart | 100.0% (12 of 12) | | lib/agent/memory_store.dart | 92.9% (13 of 14) | | lib/agent/skills/skill_registry.dart | 93.8% (61 of 65) | | lib/agent/system_prompt.dart | 100.0% (66 of 66) | | lib/agent/tools/budgeted_browser_tool.dart | 84.2% (16 of 19) | | lib/agent/tools/doujin_write_tool.dart | 62.9% (168 of 267) | | lib/agent/tools/entity_write_tool.dart | 73.5% (164 of 223) | | lib/agent/tools/fetch_page_tool.dart | 89.3% (67 of 75) | | lib/agent/tools/get_doujin_tool.dart | 84.4% (27 of 32) | | lib/agent/tools/list_entities_tool.dart | 76.1% (54 of 71) | | lib/agent/tools/navigate_tool.dart | 93.8% (30 of 32) | | lib/agent/tools/read_skill_tool.dart | 82.4% (14 of 17) | | lib/agent/tools/reflection_tools.dart | 78.4% (29 of 37) | | lib/agent/tools/search_doujins_tool.dart | 100.0% (84 of 84) | | lib/agent/tools/view_images_tool.dart | 95.0% (38 of 40) | | lib/domain/entities/assistant_entry.dart | 20.0% (1 of 5) | | lib/presentation/state/actions/assistant_actions.dart | 54.5% (6 of 11) | | lib/domain/entities/entity_model.dart | 100.0% (1 of 1) | | lib/data/repositories/entity_in_use_exception.dart | 33.3% (1 of 3) | | lib/data/models/search_query.dart | 50.0% (2 of 4) | | lib/data/models/search_query.g.dart | 32.4% (23 of 71) | | lib/core/constants.dart | 36.4% (4 of 11) | | lib/presentation/middleware/assistant_epics.dart | 83.1% (74 of 89) | | lib/presentation/middleware/epics.dart | 88.7% (329 of 371) | | lib/presentation/state/reducers.dart | 100.0% (10 of 10) | | lib/core/chunking.dart | 100.0% (12 of 12) | | lib/core/languages.dart | 100.0% (8 of 8) | | lib/data/models/envelope.dart | 81.2% (13 of 16) | | lib/data/models/envelope.g.dart | 50.7% (34 of 67) | | lib/data/repositories/upload_exception.dart | 33.3% (1 of 3) | | lib/domain/entities/filter_token.dart | 90.9% (90 of 99) | | lib/presentation/middleware/editor_epics.dart | 56.6% (163 of 288) | | lib/presentation/middleware/entity_ops.dart | 58.0% (40 of 69) | | lib/presentation/middleware/upload_epics.dart | 98.1% (104 of 106) | | lib/presentation/state/actions/detail_actions.dart | 85.7% (6 of 7) | | lib/presentation/state/actions/editor_actions.dart | 45.9% (17 of 37) | | lib/presentation/state/actions/entity_actions.dart | 56.2% (9 of 16) | | lib/presentation/state/actions/library_actions.dart | 36.8% (7 of 19) | | lib/presentation/state/actions/metadata_actions.dart | 100.0% (3 of 3) | | lib/presentation/state/actions/reader_actions.dart | 75.0% (3 of 4) | | lib/presentation/state/actions/settings_actions.dart | 87.5% (7 of 8) | | lib/presentation/state/actions/upload_actions.dart | 90.9% (10 of 11) | | lib/presentation/state/reducers/assistant_reducer.dart | 95.0% (57 of 60) | | lib/presentation/state/reducers/detail_reducer.dart | 95.8% (23 of 24) | | lib/presentation/state/reducers/editor_reducer.dart | 92.5% (98 of 106) | | lib/presentation/state/reducers/entity_reducer.dart | 98.5% (66 of 67) | | lib/presentation/state/reducers/library_reducer.dart | 100.0% (105 of 105) | | lib/presentation/state/reducers/metadata_reducer.dart | 100.0% (15 of 15) | | lib/presentation/state/reducers/reader_reducer.dart | 100.0% (15 of 15) | | lib/presentation/state/reducers/settings_reducer.dart | 100.0% (60 of 60) | | lib/presentation/state/reducers/upload_reducer.dart | 100.0% (45 of 45) | | lib/presentation/pages/editor/page_grid.dart | 89.4% (286 of 320) | | lib/presentation/pages/editor/variants_tab.dart | 55.3% (52 of 94) | | lib/core/natural_sort.dart | 100.0% (27 of 27) | | lib/core/url_utils.dart | 100.0% (4 of 4) | | lib/presentation/pages/editor/chapter_panel.dart | 11.8% (9 of 76) | | lib/presentation/widgets/cover_thumbnail.dart | 83.3% (30 of 36) | | lib/presentation/pages/editor/upload_panel.dart | 38.6% (61 of 158) | | lib/presentation/pages/editor/variant_dialog.dart | 4.5% (3 of 66) | | lib/presentation/widgets/language_dropdown.dart | 84.6% (11 of 13) | | lib/app/di.dart | 48.3% (14 of 29) | | lib/presentation/assistant/assistant_panel.dart | 91.3% (84 of 92) | | lib/presentation/layout/main_layout.dart | 86.3% (44 of 51) | | lib/data/api_client.dart | 92.8% (64 of 69) | | lib/data/repositories/doujin_api_repository.dart | 22.2% (80 of 361) | | lib/data/repositories/health_repository.dart | 72.0% (18 of 25) | | lib/data/secure_storage.dart | 0.0% (0 of 26) | | lib/core/theme.dart | 96.9% (31 of 32) | | lib/presentation/assistant/approval_card.dart | 95.0% (38 of 40) | | lib/presentation/assistant/assistant_markdown.dart | 100.0% (3 of 3) | | lib/presentation/assistant/chat_entries.dart | 87.5% (35 of 40) | | lib/presentation/pages/reader/reader_page.dart | 88.5% (216 of 244) | | lib/presentation/pages/detail/detail_page.dart | 77.1% (178 of 231) | | lib/presentation/pages/detail/variant_tabs_panel.dart | 94.9% (169 of 178) | | lib/presentation/widgets/star_rating.dart | 100.0% (72 of 72) | | lib/presentation/pages/reader/reader_overlay.dart | 94.4% (51 of 54) | | lib/presentation/pages/reader/reader_sequence.dart | 100.0% (27 of 27) | | lib/presentation/pages/people/people_page.dart | 55.6% (10 of 18) | | lib/presentation/widgets/entity_editor.dart | 88.5% (123 of 139) | | lib/presentation/widgets/entity_management_page.dart | 82.5% (193 of 234) | | lib/presentation/pages/characters/characters_page.dart | 57.9% (11 of 19) | | lib/presentation/pages/editor/editor_page.dart | 77.6% (59 of 76) | | lib/presentation/pages/editor/association_picker.dart | 95.5% (106 of 111) | | lib/presentation/pages/editor/associations_tab.dart | 73.8% (90 of 122) | | lib/presentation/pages/editor/doujin_list_pane.dart | 67.2% (43 of 64) | | lib/presentation/pages/editor/edit_title_dialog.dart | 91.7% (55 of 60) | | lib/presentation/pages/editor/editor_pane.dart | 70.9% (39 of 55) | | lib/presentation/pages/editor/new_doujin_dialog.dart | 66.7% (30 of 45) | | lib/presentation/pages/editor/metadata_tab.dart | 70.5% (93 of 132) | | lib/presentation/pages/tags/tags_page.dart | 100.0% (17 of 17) | | lib/app/app.dart | 66.7% (44 of 66) | | lib/presentation/pages/settings/settings_page.dart | 99.3% (138 of 139) | | lib/presentation/pages/circles/circles_page.dart | 52.6% (10 of 19) | | lib/presentation/pages/library/library_page.dart | 88.5% (170 of 192) | | lib/presentation/pages/series/series_page.dart | 50.0% (9 of 18) | | lib/presentation/widgets/smart_filter_bar.dart | 78.7% (170 of 216) | | lib/presentation/widgets/model_combo_field.dart | 69.0% (100 of 145) | | lib/app/skill_assets.dart | 92.9% (13 of 14) | **Total: 75.1% (6016 of 8010)**
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~ A history compactor with a cache-stable prompt split AND a self-aware fix for the starvation your own compaction would have caused? You found the bug your feature creates and fixed it before I could sink my teeth into it. I'm equal parts delighted and deeply disappointed I didn't get to catch it myself~ ♡

Verdict: Looks good to me~

I traced every line of the +652/−66, cross-checked the vendored openrouter_dart message shapes, and ran everything locally (CI hasn't reported for 5ec393c yet). No blockers. Here's the evidence:

Verified locally: flutter analyze → No issues found. Targeted suites 29/29 pass (matches the PR body exactly). Full suite 505/505 pass. And because green tests prove nothing on their own — I ran a mutation probe: reverted the reflection gate to the raw _history.length comparison, and reflection keeps firing after compaction drops exchanges goes red with Expected: non-empty, Actual: []. The regression test is genuinely directional, not a tautology. Restored afterwards, tree clean.

Traced, not trusted:

  • The [-prefix turn-opener convention is load-bearing, so I checked every synthetic UserMessage the vendored agent can inject: the leaked-tool-call corrective ([System] ..., agent.dart:57), tool-image injections (multimodal → text == null), plus your own context message and dropped-note. All four correctly fail _isTurnOpener. The convention holds.
  • Compaction runs twice per turn (pre-request in _runTurn, post-run in _syncHistory) — I traced the counters for double-accounting: the pre-request pass records drops against pre-compaction length, the sync pass re-compacts already-compacted content (no-op). No double-counting of _droppedByCompaction, and the note-folding test pins idempotence across repeated compactions.
  • The virtual-length math: steady-state compaction (add 2, drop 2) pins raw length at the plateau, but _virtualHistoryLength grows +2/turn → delta 8 reaches kReflectionThreshold after four turns. The test drives exactly this regime with historyBudgetOverride = 1500.
  • estimateMessageChars matches what toJson actually re-sends: reasoning content AND serialized reasoningDetails (encrypted blobs included) both counted — that's the correct wire-format view, and the flat kImageBudgetChars per image avoids evicting a conversation over a couple of base64 covers. Both pinned by tests.
  • Cache-stability: the byte-identical system prompt across turns (with changing route!) is asserted in agent_history_test.dartisNot(contains('Current page')) plus messagesOf(0).first['content'] == messagesOf(1).first['content']. Exactly ONE context message per request, replaced not accumulated. That's the claim, proven.
  • clearConversation resets _droppedByCompaction — no stale virtual length leaking into a fresh conversation. ♪

💡 Little ideas (non-blocking)~

  1. agent_service.dart:468 — the assistant entry id 'assistant-${_history.length}' is now non-monotonic (compaction shrinks the length), so two text entries can theoretically share an id across turns. I verified nothing consumes it for identity (the reducer id-matches only tool/approval entries; no ValueKey on panel entries), so it's purely cosmetic — a _messageCounter would make it forever-unique if you ever care.
  2. agent_service.dart _isTurnOpener — a user message that *starts with [ (say a pasted [info] ...) escapes opener detection, so its exchange loses pass-1 protection and its tool results could be placeholder-trimmed one turn early. Mild degradation, no protocol break (the placeholder keeps the toolCallId pairing valid), and the bracket convention is established in the vendored agent — but if you ever want it airtight, marking synthetic messages with a dedicated field instead of a text prefix would remove the collision class entirely.
  3. compactHistory pass 2 — a single exchange larger than the whole budget stays over budget (correctly — never drop the newest), self-healing next turn when it becomes droppable. Fine as designed; just noting I checked it's the documented behavior, not an oversight.

What I liked~

  • The reflection-starvation fix is the star: you diagnosed that your own compaction breaks the watermark's monotonicity assumption and built a virtual length that restores it — with a regression test that provably fails against the old gate. That's the review-fix commit done properly.
  • The note-folding design (one running [Conversation note: N earlier turn(s)...] instead of stacked notes) is exactly the kind of small detail that keeps long conversations clean.
  • Test quality throughout: estimateMessageChars pinned against jsonEncode(detail.toJson()).length (the actual serialized form!), the under-budget test asserting compacted == history identity, the budget-parse junk arms. Nothing is a coverage-tick.
  • Doc comments on every public surface, README row for the env var, and the flat-image-estimate rationale written down where the next person will need it.

Fufu~ merge it when CI agrees with me~ ♡


Automated review by Jibril · 2026-08-15
CI/CD: absent for head SHA 5ec393c · Local checks: analyze 0 issues, 29/29 targeted, 505/505 full suite, mutation probe verified

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ A history compactor with a cache-stable prompt split AND a self-aware fix for the starvation your own compaction would have caused? You found the bug your feature creates and fixed it before I could sink my teeth into it. I'm equal parts delighted and *deeply* disappointed I didn't get to catch it myself~ ♡ ### Verdict: ✅ Looks good to me~ I traced every line of the +652/−66, cross-checked the vendored `openrouter_dart` message shapes, and ran everything locally (CI hasn't reported for `5ec393c` yet). No blockers. Here's the evidence: **Verified locally:** `flutter analyze` → No issues found. Targeted suites 29/29 pass (matches the PR body exactly). Full suite **505/505 pass**. And because green tests prove nothing on their own — I ran a mutation probe: reverted the reflection gate to the raw `_history.length` comparison, and `reflection keeps firing after compaction drops exchanges` goes red with `Expected: non-empty, Actual: []`. The regression test is genuinely directional, not a tautology. Restored afterwards, tree clean. **Traced, not trusted:** - The `[`-prefix turn-opener convention is load-bearing, so I checked every synthetic `UserMessage` the vendored agent can inject: the leaked-tool-call corrective (`[System] ...`, agent.dart:57), tool-image injections (multimodal → `text == null`), plus your own context message and dropped-note. All four correctly fail `_isTurnOpener`. The convention holds. - Compaction runs twice per turn (pre-request in `_runTurn`, post-run in `_syncHistory`) — I traced the counters for double-accounting: the pre-request pass records drops against pre-compaction length, the sync pass re-compacts already-compacted content (no-op). No double-counting of `_droppedByCompaction`, and the note-folding test pins idempotence across repeated compactions. - The virtual-length math: steady-state compaction (add 2, drop 2) pins raw length at the plateau, but `_virtualHistoryLength` grows +2/turn → delta 8 reaches `kReflectionThreshold` after four turns. The test drives exactly this regime with `historyBudgetOverride = 1500`. - `estimateMessageChars` matches what `toJson` actually re-sends: reasoning content AND serialized `reasoningDetails` (encrypted blobs included) both counted — that's the correct wire-format view, and the flat `kImageBudgetChars` per image avoids evicting a conversation over a couple of base64 covers. Both pinned by tests. - Cache-stability: the byte-identical system prompt across turns (with changing route!) is asserted in `agent_history_test.dart` — `isNot(contains('Current page'))` plus `messagesOf(0).first['content'] == messagesOf(1).first['content']`. Exactly ONE context message per request, replaced not accumulated. That's the claim, proven. - `clearConversation` resets `_droppedByCompaction` — no stale virtual length leaking into a fresh conversation. ♪ #### 💡 Little ideas (non-blocking)~ 1. **agent_service.dart:468** — the assistant entry id `'assistant-${_history.length}'` is now non-monotonic (compaction shrinks the length), so two text entries can theoretically share an id across turns. I verified nothing consumes it for identity (the reducer id-matches only tool/approval entries; no `ValueKey` on panel entries), so it's purely cosmetic — a `_messageCounter` would make it forever-unique if you ever care. 2. **agent_service.dart `_isTurnOpener`** — a user message that *starts with `[` (say a pasted `[info] ...`) escapes opener detection, so its exchange loses pass-1 protection and its tool results could be placeholder-trimmed one turn early. Mild degradation, no protocol break (the placeholder keeps the `toolCallId` pairing valid), and the bracket convention is established in the vendored agent — but if you ever want it airtight, marking synthetic messages with a dedicated field instead of a text prefix would remove the collision class entirely. 3. **compactHistory pass 2** — a single exchange larger than the whole budget stays over budget (correctly — never drop the newest), self-healing next turn when it becomes droppable. Fine as designed; just noting I checked it's the documented behavior, not an oversight. #### ✅ What I liked~ - The reflection-starvation fix is the star: you diagnosed that your own compaction breaks the watermark's monotonicity assumption and built a virtual length that restores it — with a regression test that provably fails against the old gate. That's the review-fix commit done *properly*. - The note-folding design (one running `[Conversation note: N earlier turn(s)...]` instead of stacked notes) is exactly the kind of small detail that keeps long conversations clean. - Test quality throughout: `estimateMessageChars` pinned against `jsonEncode(detail.toJson()).length` (the actual serialized form!), the under-budget test asserting `compacted == history` identity, the budget-parse junk arms. Nothing is a coverage-tick. - Doc comments on every public surface, README row for the env var, and the flat-image-estimate rationale written down where the next person will need it. Fufu~ merge it when CI agrees with me~ ♡ --- *Automated review by Jibril · 2026-08-15* *CI/CD: absent for head SHA 5ec393c · Local checks: analyze 0 issues, 29/29 targeted, 505/505 full suite, mutation probe verified*
Member

Verification follow-up since the (and the 505/505 local run) predate the four merges that landed on main today (#72–#75):

  • Confirmed zero file overlap between this branch's touched files and everything that landed on main since the merge-base — which is why mergeable=true holds.
  • Built the actual merged tree (feat/assistant-context-budget ⊕ current main, includes #75's widened DoujinRepository.listDoujins — the interface this PR's agent tests fake around) and ran it:
    • flutter analyze — clean
    • flutter test full suite — 566/566 (your 505-era baseline + the tests #74/#75 added + this PR's net-new)

Nothing on the branch changed; head remains 5ec393c. Ready to merge as-is.

Verification follow-up since the ✅ (and the 505/505 local run) predate the four merges that landed on `main` today (#72–#75): - Confirmed zero file overlap between this branch's touched files and everything that landed on `main` since the merge-base — which is why `mergeable=true` holds. - Built the actual merged tree (`feat/assistant-context-budget` ⊕ current `main`, includes #75's widened `DoujinRepository.listDoujins` — the interface this PR's agent tests fake around) and ran it: - `flutter analyze` — clean - `flutter test` full suite — **566/566** (your 505-era baseline + the tests #74/#75 added + this PR's net-new) Nothing on the branch changed; head remains `5ec393c`. Ready to merge as-is.
bjoern merged commit 59c0a05ddc into main 2026-08-16 08:50:32 +02:00
bjoern deleted branch feat/assistant-context-budget 2026-08-16 08:50:32 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/doujin-manager!76
No description provided.