limbo-code 0.1__tar.gz → 0.1.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. limbo_code-0.1.9/AGENTS.md +94 -0
  2. limbo_code-0.1.9/CONTEXT.md +43 -0
  3. {limbo_code-0.1 → limbo_code-0.1.9}/PKG-INFO +1 -1
  4. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/agent.py +71 -237
  5. limbo_code-0.1.9/src/limbo/attachments.py +65 -0
  6. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/compaction.py +3 -19
  7. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/anthropic_client.py +18 -36
  8. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/openai_client.py +17 -42
  9. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/responses_client.py +15 -35
  10. limbo_code-0.1.9/src/limbo/llm/scaffold.py +92 -0
  11. limbo_code-0.1.9/src/limbo/llm/usage.py +160 -0
  12. limbo_code-0.1.9/src/limbo/model_switch.py +103 -0
  13. limbo_code-0.1.9/src/limbo/prompt.py +103 -0
  14. limbo_code-0.1.9/src/limbo/steer.py +76 -0
  15. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/screens/main.py +25 -55
  16. limbo_code-0.1.9/tests/conftest.py +21 -0
  17. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_agent.py +101 -28
  18. limbo_code-0.1.9/tests/test_attachments.py +78 -0
  19. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_compaction.py +1 -19
  20. limbo_code-0.1.9/tests/test_llm_scaffold.py +89 -0
  21. limbo_code-0.1.9/tests/test_model_switch.py +140 -0
  22. limbo_code-0.1.9/tests/test_prompt.py +85 -0
  23. limbo_code-0.1.9/tests/test_steer.py +66 -0
  24. limbo_code-0.1.9/tests/test_usage.py +159 -0
  25. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_app_smoke.py +9 -4
  26. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_input_attachments.py +5 -1
  27. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_main_screen.py +62 -0
  28. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_steer_ui.py +2 -2
  29. limbo_code-0.1/AGENTS.md +0 -207
  30. {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/grill-with-docs/SKILL.md +0 -0
  31. {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/grill-with-docs/agents/openai.yaml +0 -0
  32. {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +0 -0
  33. {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/improve-codebase-architecture/SKILL.md +0 -0
  34. {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/improve-codebase-architecture/agents/openai.yaml +0 -0
  35. {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/tdd/SKILL.md +0 -0
  36. {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/tdd/agents/openai.yaml +0 -0
  37. {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/tdd/mocking.md +0 -0
  38. {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/tdd/tests.md +0 -0
  39. {limbo_code-0.1 → limbo_code-0.1.9}/.github/workflows/publish.yml +0 -0
  40. {limbo_code-0.1 → limbo_code-0.1.9}/.github/workflows/test.yml +0 -0
  41. {limbo_code-0.1 → limbo_code-0.1.9}/.gitignore +0 -0
  42. {limbo_code-0.1 → limbo_code-0.1.9}/README.md +0 -0
  43. {limbo_code-0.1 → limbo_code-0.1.9}/design/confirm_view.html +0 -0
  44. {limbo_code-0.1 → limbo_code-0.1.9}/design/limbo-ui-minimal.md +0 -0
  45. {limbo_code-0.1 → limbo_code-0.1.9}/design/limbo-ui-redesign.md +0 -0
  46. {limbo_code-0.1 → limbo_code-0.1.9}/design/prototype-minimal-confirm.png +0 -0
  47. {limbo_code-0.1 → limbo_code-0.1.9}/design/prototype-minimal.html +0 -0
  48. {limbo_code-0.1 → limbo_code-0.1.9}/design/prototype-minimal.png +0 -0
  49. {limbo_code-0.1 → limbo_code-0.1.9}/design/prototype.html +0 -0
  50. {limbo_code-0.1 → limbo_code-0.1.9}/design/prototype.png +0 -0
  51. {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/limbo-current-ui.png +0 -0
  52. {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/limbo-new-ui.png +0 -0
  53. {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-dark/1_idle.svg +0 -0
  54. {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-dark/2_thinking_tool_running.svg +0 -0
  55. {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-dark/3_tool_success.svg +0 -0
  56. {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-dark/4_tool_error.svg +0 -0
  57. {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-dark/5_llm_error.svg +0 -0
  58. {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-dark/6_edit_diff.svg +0 -0
  59. {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-light/1_idle.svg +0 -0
  60. {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-light/2_thinking_tool_running.svg +0 -0
  61. {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-light/3_tool_success.svg +0 -0
  62. {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-light/4_tool_error.svg +0 -0
  63. {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-light/5_llm_error.svg +0 -0
  64. {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-light/6_edit_diff.svg +0 -0
  65. {limbo_code-0.1 → limbo_code-0.1.9}/docs/session-management.md +0 -0
  66. {limbo_code-0.1 → limbo_code-0.1.9}/docs/skills.md +0 -0
  67. {limbo_code-0.1 → limbo_code-0.1.9}/docs/ui-redesign-proposal.md +0 -0
  68. {limbo_code-0.1 → limbo_code-0.1.9}/pyproject.toml +0 -0
  69. {limbo_code-0.1 → limbo_code-0.1.9}/scripts/check_contrast.py +0 -0
  70. {limbo_code-0.1 → limbo_code-0.1.9}/scripts/gen_banner.py +0 -0
  71. {limbo_code-0.1 → limbo_code-0.1.9}/scripts/ui_walkthrough.py +0 -0
  72. {limbo_code-0.1 → limbo_code-0.1.9}/skills-lock.json +0 -0
  73. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/__init__.py +0 -0
  74. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/__main__.py +0 -0
  75. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/app.py +0 -0
  76. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/config.py +0 -0
  77. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/history.py +0 -0
  78. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/__init__.py +0 -0
  79. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/catalog.py +0 -0
  80. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/client.py +0 -0
  81. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/factory.py +0 -0
  82. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/retry.py +0 -0
  83. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/sse.py +0 -0
  84. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/models.py +0 -0
  85. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/sessions.py +0 -0
  86. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/skills.py +0 -0
  87. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/__init__.py +0 -0
  88. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/base.py +0 -0
  89. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/bash.py +0 -0
  90. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/edit.py +0 -0
  91. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/find.py +0 -0
  92. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/grep.py +0 -0
  93. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/ignore.py +0 -0
  94. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/ls.py +0 -0
  95. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/mutation_queue.py +0 -0
  96. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/read.py +0 -0
  97. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/registry.py +0 -0
  98. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/write.py +0 -0
  99. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/trace.py +0 -0
  100. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/__init__.py +0 -0
  101. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/app.py +0 -0
  102. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/app.tcss +0 -0
  103. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/banner.py +0 -0
  104. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/clipboard.py +0 -0
  105. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/commands.py +0 -0
  106. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/contrast.py +0 -0
  107. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/screens/__init__.py +0 -0
  108. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/screens/game2048.py +0 -0
  109. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/screens/model_picker.py +0 -0
  110. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/screens/session_picker.py +0 -0
  111. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/syntax.py +0 -0
  112. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/theme.py +0 -0
  113. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/widgets/__init__.py +0 -0
  114. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/widgets/chat.py +0 -0
  115. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/widgets/command_menu.py +0 -0
  116. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/widgets/input.py +0 -0
  117. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/widgets/status_bar.py +0 -0
  118. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/widgets/tool_card.py +0 -0
  119. {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/user_paths.py +0 -0
  120. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_anthropic_client.py +0 -0
  121. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_catalog.py +0 -0
  122. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_cli.py +0 -0
  123. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_clipboard.py +0 -0
  124. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_config.py +0 -0
  125. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_history.py +0 -0
  126. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_integration.py +0 -0
  127. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_llm_client.py +0 -0
  128. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_models.py +0 -0
  129. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_responses_client.py +0 -0
  130. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_retry.py +0 -0
  131. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_sessions.py +0 -0
  132. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_skills.py +0 -0
  133. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_trace.py +0 -0
  134. {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_user_paths.py +0 -0
  135. {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_base.py +0 -0
  136. {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_bash.py +0 -0
  137. {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_edit.py +0 -0
  138. {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_find.py +0 -0
  139. {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_grep.py +0 -0
  140. {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_ignore.py +0 -0
  141. {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_ls.py +0 -0
  142. {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_mutation_queue.py +0 -0
  143. {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_read.py +0 -0
  144. {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_registry.py +0 -0
  145. {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_write.py +0 -0
  146. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/__snapshots__/test_snapshots/test_snapshot_idle.raw +0 -0
  147. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/__snapshots__/test_snapshots/test_snapshot_session_picker.raw +0 -0
  148. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/__snapshots__/test_snapshots/test_snapshot_thinking_and_running_tool.raw +0 -0
  149. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/__snapshots__/test_snapshots/test_snapshot_tool_error_and_error_message.raw +0 -0
  150. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_command_menu.py +0 -0
  151. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_commands.py +0 -0
  152. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_compact_ui.py +0 -0
  153. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_contrast.py +0 -0
  154. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_game2048.py +0 -0
  155. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_input_history.py +0 -0
  156. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_input_paste.py +0 -0
  157. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_model_picker.py +0 -0
  158. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_scroll_follow.py +0 -0
  159. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_sessions_ui.py +0 -0
  160. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_skills_ui.py +0 -0
  161. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_snapshots.py +0 -0
  162. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_startup_art.py +0 -0
  163. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_theme.py +0 -0
  164. {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_widgets.py +0 -0
  165. {limbo_code-0.1 → limbo_code-0.1.9}/uv.lock +0 -0
@@ -0,0 +1,94 @@
1
+ # Limbo — Agent Guide
2
+
3
+ **Limbo** is a minimal terminal AI coding agent (Python 3.11+, Textual). Users converse with an LLM in a TUI to explore, read, edit, and write code via 7 tools.
4
+
5
+ **Tools execute immediately — no confirmation flow.** Guardrails: a workdir fence on file tools (+ session-scoped grants from user-mentioned paths), a sensitive-file blocklist on `read`, and a heuristic dangerous-command filter on `bash` (rejects matches outright; bypassable via subshells/variables — documented in the tool description).
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ pip install -e .
11
+ limbo --workdir /path/to/project # config: ~/.limbo/config.toml
12
+ ```
13
+
14
+ ## Architecture
15
+
16
+ ```
17
+ src/limbo/
18
+ ├── app.py # CLI entry: args, config, launch
19
+ ├── config.py # Pydantic config: llm/ui/safety/tools/compaction/providers
20
+ ├── models.py # Message, Attachment, ToolResult, LLMEvent
21
+ ├── agent.py # Conversation loop (see below)
22
+ ├── attachments.py # Attachment policy: vision gate, inline vs path-reference degrade
23
+ ├── compaction.py # Context compaction decision/prompt logic (LIM-14)
24
+ ├── history.py # tool_call↔result pairing + resume repair
25
+ ├── model_switch.py # /model domain logic: validate, swap client, persist (UI is an adapter)
26
+ ├── prompt.py # System-prompt assembly (tools section derived from the registry)
27
+ ├── steer.py # Mid-turn steer queue (LIM-20): queueing semantics, cancel boundary
28
+ ├── sessions.py # Session JSONL save/load/list/export
29
+ ├── trace.py # Append-only JSONL run log (sessions/traces/)
30
+ ├── skills.py # SKILL.md discovery (user + project dirs)
31
+ ├── user_paths.py # Fence grants from paths in user messages
32
+ ├── llm/ # catalog.py (provider/model specs), factory.py (dialect→client),
33
+ │ # openai_client.py, anthropic_client.py, responses_client.py, retry.py, sse.py,
34
+ │ # usage.py (token accounting: usage normalization + prompt-size estimation),
35
+ │ # scaffold.py (plumbing shared by dialect clients: credentials, retry, images)
36
+ ├── tools/ # base.py (BaseTool + fence + truncation), registry.py (dispatch, grants),
37
+ │ # mutation_queue.py (per-file locks), ignore.py (.gitignore),
38
+ │ # read/bash/edit/write/grep/find/ls.py
39
+ └── ui/ # app.py + app.tcss (ALL styles here; theme vars only, no bare hex),
40
+ # theme.py (limbo-dark/-light, RFC LIM-16), commands.py (slash registry),
41
+ # screens/ (main, session_picker, model_picker, game2048),
42
+ # widgets/ (chat, input, status_bar, tool_card, command_menu)
43
+ ```
44
+
45
+ ## Agent Loop
46
+
47
+ - `Agent.run()` yields `AgentEvent`: `TextDelta`, `ThinkingDelta`, `ToolCallRequest`, `ToolResultEvent`, `ErrorEvent`, `CompactionEvent`, `UsageUpdate`, `SteerEvent`
48
+ - Loop top: auto-compaction check → steer drain → LLM call. Turn ends on a tool-call-free response; `max_iterations` (default 50) cancels pending calls with placeholder results
49
+ - Tool calls in one turn run **concurrently** (`[tools] parallel`); results stream in completion order, recorded in source order; same-file mutations serialized by `mutation_queue`
50
+ - `finish_reason` `length`/`max_tokens` → whole batch failed without execution, model re-issues
51
+ - Mid-turn user input queues as *steer* (LIM-20): injected at loop top, or as turn-end follow-up
52
+ - Reasoning is stored on assistant messages and replayed per dialect (Anthropic thinking signatures, Kimi `reasoning_content`, Responses encrypted items)
53
+ - Usage counters normalize in `llm/usage.py` (`input_tokens`+`cache_read`, `prompt_tokens`, DeepSeek cache hits) and feed the compaction trigger via `PromptSizeEstimator`
54
+
55
+ ## LLM & Config
56
+
57
+ Providers/models live in `llm/catalog.py`; unknown models get generic OpenAI-compatible defaults. Resolution: `[providers.<id>]` overrides → explicit `[llm]` values → catalog defaults → env-var API keys.
58
+
59
+ ```toml
60
+ [llm] # model, api_key, base_url, temperature, max_iterations,
61
+ # max_tokens, thinking_effort, max_retries, timeout, ...
62
+ [tools] # bash_enabled = true, parallel = true
63
+ [compaction] # enabled, reserve_tokens = 16384, keep_recent_tokens = 20000
64
+ [safety] # dangerous_commands, sensitive_files, auto_grant_user_paths
65
+ [ui] # theme, show_banner
66
+ [providers.<id>] # base_url / api_key / api_key_env / headers
67
+ ```
68
+
69
+ `/model` rebuilds the client mid-session (refused while busy) and persists via tomlkit. Sessions resume via `--continue` / `--resume` / `/sessions`; resume repairs dangling tool calls and restores grants. Trace events: `session_start`, `user_message`, `llm_request` (full body), `llm_response`, `tool_call`, `tool_result`, `compaction*`, `error`, `turn_end`. `/export [path]` merges meta + trace + message snapshot (Markdown if `.md`).
70
+
71
+ Skills: `~/.limbo/skills/<name>/SKILL.md` (user) and `<workdir>/.agents/skills/<name>/SKILL.md` (project, wins collisions) — injected as a `<available_skills>` catalog in the system prompt and invocable via `/<name> [args]`.
72
+
73
+ ## UI
74
+
75
+ Single column: status bar (state/elapsed/tokens/model/workdir/queued) → scrolling chat flow (user `❯`, streaming Markdown, tool cards, errors) → input box (Enter submits, Shift+Enter newline, paste markers, `ctrl+v` image attach) → hint line. Slash commands: `/sessions /new /export /compact /model /help /2048` + skill commands. After palette changes run `python scripts/check_contrast.py` (≥ 4.5:1).
76
+
77
+ ## Key Decisions
78
+
79
+ - Async agent, sync tools via `asyncio.to_thread()`
80
+ - System prompt = hardcoded tool guidelines + `<workdir>/AGENTS.md` & `~/.limbo/AGENTS.md` (XML-wrapped) + skills catalog. **This file is injected into every request — keep it accurate and lean.**
81
+ - Session files fully rewritten per save (atomic, 0600); trace holds full-fidelity record
82
+
83
+ ## Testing
84
+
85
+ ```bash
86
+ pytest tests/ -v # pytest-asyncio, respx HTTP mocks, pytest-textual-snapshot
87
+ ruff check src tests && mypy src # lint + type check
88
+ pytest --snapshot-update # after intentional visual changes; review the SVG diff
89
+ ```
90
+
91
+ ## Adding Things
92
+
93
+ - **Tool**: subclass `BaseTool` in `tools/<name>.py` (`name`/`description`/`parameters` + `run()`; use `resolve_existing()`/`resolve_creatable()`, raise `ToolError`), register in `ToolRegistry.__init__()`, wrap mutations in `mutation_lock_for(path)`, add `tests/tools/test_<name>.py`
94
+ - **Provider/model**: add specs in `llm/catalog.py`; new wire format → implement `LLMClient` + `register_client()` in `llm/factory.py`. User-specific endpoint/key tweaks belong in `[providers.<id>]` config, not the catalog
@@ -0,0 +1,43 @@
1
+ # Context — Limbo domain glossary
2
+
3
+ Domain language for Limbo's concepts. Architecture reviews and refactor
4
+ proposals should name modules with these terms.
5
+
6
+ ## Token accounting
7
+
8
+ - **Usage** — the provider-reported token counters on one completion. Each
9
+ dialect reports them differently (OpenAI `prompt_tokens`, Anthropic
10
+ `input_tokens` + cache counters, DeepSeek `prompt_cache_hit_tokens`,
11
+ Responses `input_tokens_details`). Normalized by `llm/usage.py` into
12
+ `UsageTotals` (prompt / cached / total); the agent loop and compaction
13
+ never read provider field names.
14
+ - **Prompt-size estimation** — predicting the *next* request's prompt size:
15
+ the last real usage figure plus the chars/4 estimate of everything
16
+ appended since (the **watermark**). Owned by `PromptSizeEstimator` in
17
+ `llm/usage.py`; invalidated (reset) when compaction rewrites history.
18
+
19
+ ## Conversation
20
+
21
+ - **Turn** — one user input through the agent loop until a tool-call-free
22
+ response (or max_iterations). Turns are the save/trace unit.
23
+ - **Steer** — a user message queued mid-turn, injected at consistency
24
+ points (loop top, turn end, run head). The `SteerQueue` (`steer.py`) owns
25
+ queueing semantics — id generation, FIFO, and the cancel boundary (an
26
+ item can be cancelled by id only until drained); the Agent owns drain
27
+ timing.
28
+ - **Compaction** — summarizing the old region of history into a synthetic
29
+ summary message, keeping the recent tail. Triggered automatically
30
+ (loop-top, before context overflow) or manually (`/compact`).
31
+ - **Attachment policy** — how a submitted attachment reaches the model
32
+ (`attachments.py`): images become multimodal blocks for vision models
33
+ (encoded per dialect, `llm/scaffold.py`), everything else degrades to
34
+ text — small files inline (≤50KB), the rest as path references.
35
+ - **Model switch** — `/model` mid-session (`model_switch.py`): validate the
36
+ target (API key, thinking-effort compatibility), swap the client
37
+ (converging on the latest config model), persist to config.toml. The UI
38
+ screen is an adapter translating verdicts into chat messages.
39
+
40
+ ## Safety
41
+
42
+ - **Fence** — the workdir boundary on file tools, widened by session-scoped
43
+ **grants** (paths a real user mentions in a submitted message).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: limbo-code
3
- Version: 0.1
3
+ Version: 0.1.9
4
4
  Summary: A minimal terminal AI coding agent
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- import collections
7
6
  import os
8
7
  import platform
9
8
  import secrets
@@ -11,16 +10,16 @@ import time
11
10
  import traceback
12
11
  import warnings
13
12
  from collections.abc import AsyncIterator
14
- from dataclasses import dataclass, field
13
+ from dataclasses import dataclass
15
14
  from datetime import datetime, timezone
16
15
  from pathlib import Path
17
16
  from typing import Any
18
17
 
19
18
  from limbo import __version__
19
+ from limbo.attachments import build_user_content
20
20
  from limbo.compaction import (
21
21
  CompactionConfig,
22
22
  build_summary_prompt,
23
- estimate_tokens,
24
23
  find_split_point,
25
24
  make_summary_message,
26
25
  should_compact,
@@ -28,9 +27,10 @@ from limbo.compaction import (
28
27
  from limbo.config import Config
29
28
  from limbo.history import ToolHistory
30
29
  from limbo.history import repair as repair_history
31
- from limbo.llm.catalog import resolve_model
30
+ from limbo.llm.catalog import resolve_base_url, resolve_model
32
31
  from limbo.llm.client import LLMClient
33
32
  from limbo.llm.retry import friendly_message
33
+ from limbo.llm.usage import PromptSizeEstimator, estimate_tokens, normalize_usage
34
34
  from limbo.models import (
35
35
  Attachment,
36
36
  CompletionMeta,
@@ -40,6 +40,7 @@ from limbo.models import (
40
40
  ToolCallEvent,
41
41
  ToolResult,
42
42
  )
43
+ from limbo.prompt import build_system_prompt
43
44
  from limbo.sessions import (
44
45
  CompactionRecord,
45
46
  SessionMeta,
@@ -47,7 +48,7 @@ from limbo.sessions import (
47
48
  load_session,
48
49
  save_session,
49
50
  )
50
- from limbo.skills import discover_skills, format_skills_for_prompt
51
+ from limbo.steer import SteerItem, SteerQueue
51
52
  from limbo.tools.registry import ToolRegistry
52
53
  from limbo.trace import TraceLogger, trace_path_for
53
54
 
@@ -57,21 +58,6 @@ class TextDelta:
57
58
  text: str
58
59
 
59
60
 
60
- # File attachments at or below this size are inlined into the user message
61
- # (UTF-8 decodable); larger or binary files are referenced by path.
62
- ATTACHMENT_INLINE_MAX_BYTES = 50_000
63
-
64
-
65
- def _read_inline_text(path: Path) -> str | None:
66
- """The file's text if it qualifies for inline embedding, else None."""
67
- try:
68
- if not path.is_file() or path.stat().st_size > ATTACHMENT_INLINE_MAX_BYTES:
69
- return None
70
- return path.read_text(encoding="utf-8")
71
- except (OSError, UnicodeDecodeError):
72
- return None
73
-
74
-
75
61
  @dataclass(frozen=True)
76
62
  class ThinkingDelta:
77
63
  """A streamed reasoning/thinking delta (reasoning models only)."""
@@ -121,15 +107,6 @@ class UsageUpdate:
121
107
  total_tokens: int
122
108
 
123
109
 
124
- @dataclass(frozen=True)
125
- class SteerItem:
126
- """A user message queued while the agent is mid-turn (RFC LIM-20)."""
127
-
128
- id: str
129
- text: str
130
- attachments: list[Attachment] = field(default_factory=list)
131
-
132
-
133
110
  @dataclass(frozen=True)
134
111
  class SteerEvent:
135
112
  """A queued steer message was injected into the conversation history.
@@ -154,71 +131,23 @@ AgentEvent = (
154
131
  )
155
132
 
156
133
 
157
- def _extract_prompt_tokens(usage: dict[str, Any] | None) -> int | None:
158
- """Normalize provider-specific prompt-size counters.
134
+ def _resolved_llm_trace_fields(config: Config) -> dict[str, Any]:
135
+ """Resolved provider identity for trace events.
159
136
 
160
- OpenAI reports ``prompt_tokens``; Anthropic reports ``input_tokens``
161
- (which excludes cache reads/creation, so those are added back).
162
- """
163
- if not usage:
164
- return None
165
- prompt = usage.get("prompt_tokens")
166
- if isinstance(prompt, int):
167
- return prompt
168
- input_tokens = usage.get("input_tokens")
169
- if isinstance(input_tokens, int):
170
- total = input_tokens
171
- for key in ("cache_read_input_tokens", "cache_creation_input_tokens"):
172
- extra = usage.get(key)
173
- if isinstance(extra, int):
174
- total += extra
175
- return total
176
- return None
177
-
178
-
179
- def _extract_cached_tokens(usage: dict[str, Any] | None) -> int | None:
180
- """Normalize provider-specific prompt cache-hit counters.
181
-
182
- DeepSeek reports ``prompt_cache_hit_tokens``, OpenAI nests
183
- ``prompt_tokens_details.cached_tokens``, the Responses API nests
184
- ``input_tokens_details.cached_tokens``, Anthropic reports
185
- ``cache_read_input_tokens``.
137
+ The raw ``[llm]`` config values alone can't tell which endpoint/dialect
138
+ actually served a session (provider overrides and catalog defaults win
139
+ over them), so trace events record the resolved spec alongside.
186
140
  """
187
- if not usage:
188
- return None
189
- hit = usage.get("prompt_cache_hit_tokens")
190
- if isinstance(hit, int):
191
- return hit
192
- details = usage.get("prompt_tokens_details")
193
- if isinstance(details, dict):
194
- cached = details.get("cached_tokens")
195
- if isinstance(cached, int):
196
- return cached
197
- input_details = usage.get("input_tokens_details")
198
- if isinstance(input_details, dict):
199
- cached = input_details.get("cached_tokens")
200
- if isinstance(cached, int):
201
- return cached
202
- read = usage.get("cache_read_input_tokens")
203
- return read if isinstance(read, int) else None
204
-
205
-
206
- def _extract_total_tokens(usage: dict[str, Any] | None) -> int | None:
207
- """Normalize provider-specific total-token counters.
208
-
209
- OpenAI-compatible providers report ``total_tokens``; Anthropic reports
210
- separate ``input_tokens`` / ``output_tokens``.
211
- """
212
- if not usage:
213
- return None
214
- total = usage.get("total_tokens")
215
- if isinstance(total, int):
216
- return total
217
- input_tokens = usage.get("input_tokens")
218
- output_tokens = usage.get("output_tokens")
219
- if isinstance(input_tokens, int) or isinstance(output_tokens, int):
220
- return (input_tokens or 0) + (output_tokens or 0)
221
- return None
141
+ spec = resolve_model(config.llm.model)
142
+ return {
143
+ "model": spec.id,
144
+ "provider": spec.provider.id,
145
+ "api": spec.provider.api,
146
+ "base_url": resolve_base_url(spec, config),
147
+ "context_window": spec.context_window,
148
+ "max_tokens": config.llm.max_tokens or spec.max_tokens,
149
+ "thinking_effort": config.llm.thinking_effort,
150
+ }
222
151
 
223
152
 
224
153
  class Agent:
@@ -237,28 +166,25 @@ class Agent:
237
166
  self.workdir = workdir
238
167
  self.registry = ToolRegistry(workdir=workdir, config=config)
239
168
  self._history = ToolHistory([])
240
- # Steer queue (RFC LIM-20): user messages submitted mid-turn. A deque
241
- # (not asyncio.Queue) so individual items can be cancelled by id;
242
- # producers/consumers only ever use non-blocking ops on the single
243
- # Textual event loop.
244
- self._steer_queue: collections.deque[SteerItem] = collections.deque()
169
+ # Steer queue (RFC LIM-20): user messages submitted mid-turn. The
170
+ # queue owns queueing semantics; this loop owns drain timing.
171
+ self._steer_queue = SteerQueue()
245
172
  self._iteration_count = 0
246
173
  self._last_finish_reason: str | None = None
247
174
  self._session_total_tokens = 0
248
175
  self._init_system_message()
249
176
 
250
- # Auto-compaction state (LIM-14). ``_last_prompt_tokens`` is the
251
- # real prompt size reported by the last response; ``_usage_watermark``
252
- # is len(messages) at that moment, so the next prompt can be
253
- # estimated as last + estimate(messages appended since).
177
+ # Auto-compaction state (LIM-14). The estimator holds the real
178
+ # prompt size reported by the last response plus a watermark over
179
+ # the message list, so the next prompt can be estimated as
180
+ # last + estimate(messages appended since).
254
181
  self._context_window = resolve_model(config.llm.model).context_window
255
182
  # Vision gate for image attachments (RFC LIM-17): only models
256
183
  # flagged vision=True in the catalog receive image blocks; others
257
184
  # get a path-reference degradation instead.
258
185
  self._vision = resolve_model(config.llm.model).vision
259
186
  self._compaction_config = self._build_compaction_config(config)
260
- self._last_prompt_tokens: int | None = None
261
- self._usage_watermark: int = 0
187
+ self._prompt_estimator = PromptSizeEstimator()
262
188
  self._compactions: list[CompactionRecord] = []
263
189
  self._previous_summary: str | None = None
264
190
  self._auto_compaction_failed_this_turn = False
@@ -317,6 +243,7 @@ class Agent:
317
243
  "thinking_effort": config.llm.thinking_effort,
318
244
  "bash_enabled": config.tools.bash_enabled,
319
245
  },
246
+ resolved=_resolved_llm_trace_fields(config),
320
247
  )
321
248
 
322
249
  def update_llm(self, llm_client: LLMClient) -> None:
@@ -331,6 +258,11 @@ class Agent:
331
258
  self._context_window = spec.context_window
332
259
  self._vision = spec.vision
333
260
  self._compaction_config = self._build_compaction_config(self.config)
261
+ self.trace.log(
262
+ "model_switch",
263
+ turn=self._turn_count,
264
+ resolved=_resolved_llm_trace_fields(self.config),
265
+ )
334
266
 
335
267
  def _build_compaction_config(self, config: Config) -> CompactionConfig:
336
268
  """Map [compaction] settings, with a cross-check against the window.
@@ -379,63 +311,10 @@ class Agent:
379
311
  return self._meta
380
312
 
381
313
  def _init_system_message(self) -> None:
382
- content_parts = [
383
- (
384
- "You are an expert coding assistant operating inside limbo, "
385
- "a coding agent harness. You help users by reading files, "
386
- "executing commands, editing code, and writing new files.\n\n"
387
- "Available tools:\n"
388
- "- read: Read file contents\n"
389
- "- bash: Execute bash commands\n"
390
- "- edit: Make surgical edits to files (find exact text and replace)\n"
391
- "- write: Create or overwrite files\n"
392
- "- grep: Search file contents for patterns (respects root .gitignore)\n"
393
- "- find: Find files by glob pattern (respects root .gitignore only)\n"
394
- "- ls: List directory contents\n\n"
395
- "Guidelines:\n"
396
- "- Prefer grep/find/ls tools over bash for file exploration\n"
397
- "- Use read to examine files before editing\n"
398
- "- Use edit for precise changes (old_text must match exactly)\n"
399
- "- Use write only for new files or complete rewrites\n"
400
- "- Be concise in your responses\n"
401
- "- Show file paths clearly when working with files"
402
- ),
403
- ]
404
- # Load optional context files (project AGENTS.md, global
405
- # ~/.limbo/AGENTS.md) wrapped in XML boundary tags.
406
- context_files: list[tuple[str, str]] = []
407
- project_md = self.workdir / "AGENTS.md"
408
- if project_md.is_file():
409
- try:
410
- context_files.append((str(project_md), project_md.read_text(encoding="utf-8")))
411
- except OSError:
412
- pass
413
- global_md = Path.home() / ".limbo" / "AGENTS.md"
414
- if global_md.is_file():
415
- try:
416
- context_files.append((str(global_md), global_md.read_text(encoding="utf-8")))
417
- except OSError:
418
- pass
419
- if context_files:
420
- block = "\n\n<project_context>\n\nProject-specific instructions and guidelines:\n\n"
421
- for file_path, content in context_files:
422
- block += (
423
- f'<project_instructions path="{file_path}">\n\n'
424
- f"{content}\n\n</project_instructions>\n\n"
425
- )
426
- block += "</project_context>\n"
427
- content_parts.append(block)
428
- # Skills catalog (progressive disclosure): name/description/location
429
- # only; the model loads full SKILL.md files with read on demand.
430
- # Only injected when the read tool is available.
431
- if self.registry.get("read") is not None:
432
- skills_block = format_skills_for_prompt(discover_skills(self.workdir))
433
- if skills_block:
434
- content_parts.append(skills_block)
435
314
  self.messages.append(
436
315
  Message(
437
316
  role="system",
438
- content="".join(content_parts),
317
+ content=build_system_prompt(self.registry, self.workdir),
439
318
  )
440
319
  )
441
320
 
@@ -456,11 +335,7 @@ class Agent:
456
335
  Non-blocking; safe to call from the UI at any time. Returns the
457
336
  queued item's id (the UI binds its queued card to it).
458
337
  """
459
- item = SteerItem(
460
- id=secrets.token_hex(4), text=text, attachments=attachments or []
461
- )
462
- self._steer_queue.append(item)
463
- return item.id
338
+ return self._steer_queue.offer(text, attachments).id
464
339
 
465
340
  def cancel_steer(self, item_id: str) -> bool:
466
341
  """Cancel a still-queued steer message.
@@ -469,23 +344,22 @@ class Agent:
469
344
  injected — past the boundary it is part of history and cannot be
470
345
  retracted) or the id is unknown.
471
346
  """
472
- for item in self._steer_queue:
473
- if item.id == item_id:
474
- self._steer_queue.remove(item)
475
- self.trace.log(
476
- "steer_cancelled",
477
- turn=self._turn_count,
478
- id=item_id,
479
- text=item.text,
480
- )
481
- return True
482
- return False
347
+ item = self._steer_queue.cancel(item_id)
348
+ if item is None:
349
+ return False
350
+ self.trace.log(
351
+ "steer_cancelled",
352
+ turn=self._turn_count,
353
+ id=item.id,
354
+ text=item.text,
355
+ )
356
+ return True
483
357
 
484
358
  def cancel_latest_steer(self) -> str | None:
485
359
  """Cancel the newest queued steer message (Esc path)."""
486
- if not self._steer_queue:
360
+ item = self._steer_queue.cancel_latest()
361
+ if item is None:
487
362
  return None
488
- item = self._steer_queue.pop()
489
363
  self.trace.log(
490
364
  "steer_cancelled", turn=self._turn_count, id=item.id, text=item.text
491
365
  )
@@ -493,12 +367,10 @@ class Agent:
493
367
 
494
368
  def drain_steer(self) -> list[SteerItem]:
495
369
  """Remove and return all queued items in FIFO order."""
496
- items = list(self._steer_queue)
497
- self._steer_queue.clear()
498
- return items
370
+ return self._steer_queue.drain()
499
371
 
500
372
  def has_pending_steer(self) -> bool:
501
- return bool(self._steer_queue)
373
+ return len(self._steer_queue) > 0
502
374
 
503
375
  @property
504
376
  def queued_count(self) -> int:
@@ -527,40 +399,10 @@ class Agent:
527
399
  def _build_user_content(
528
400
  self, user_input: str, attachments: list[Attachment]
529
401
  ) -> tuple[str, list[Attachment]]:
530
- """Combine the typed text with attachments into message content.
402
+ """Combine text with attachments (policy lives in limbo.attachments)."""
403
+ return build_user_content(user_input, attachments, vision=self._vision)
531
404
 
532
- Returns ``(content, images)``: images only when the current model
533
- supports vision (they become multimodal blocks in the clients);
534
- everything else degrades to text notes — small text files inline,
535
- the rest as path references the model can open with read. Nothing
536
- is silently dropped.
537
- """
538
- images: list[Attachment] = []
539
- notes: list[str] = []
540
- for attachment in attachments:
541
- path = Path(attachment.path)
542
- if attachment.kind == "image":
543
- if self._vision and path.exists():
544
- images.append(attachment)
545
- elif self._vision:
546
- notes.append(f"[图片 {attachment.name} 文件已不存在:{path}]")
547
- else:
548
- notes.append(
549
- f"[图片 {attachment.name} 已保存到 {path};"
550
- f"当前模型不支持图像输入,无法直接查看]"
551
- )
552
- continue
553
- # File attachment: inline small text files, reference the rest.
554
- text = _read_inline_text(path)
555
- if text is not None:
556
- notes.append(f"文件 {attachment.name} 的内容:\n```\n{text}\n```")
557
- else:
558
- notes.append(f"[文件 {attachment.name} 位于 {path},可用 read 工具查看]")
559
- if not notes:
560
- return user_input, images
561
- return user_input + "\n\n" + "\n".join(notes), images
562
-
563
- def _log_turn_end(self) -> None:
405
+ def _log_turn_end(self, status: str = "completed") -> None:
564
406
  if self._turn_start is None:
565
407
  return
566
408
  self.trace.log(
@@ -568,7 +410,7 @@ class Agent:
568
410
  turn=self._turn_count,
569
411
  duration=time.monotonic() - self._turn_start,
570
412
  iterations=self._iteration_count,
571
- status="completed",
413
+ status=status,
572
414
  )
573
415
  self._turn_start = None
574
416
 
@@ -596,11 +438,17 @@ class Agent:
596
438
  "user_message", turn=self._turn_count, content=content
597
439
  )
598
440
 
441
+ completed = False
599
442
  try:
600
443
  async for event in self._conversation_loop():
601
444
  yield event
445
+ completed = True
602
446
  finally:
603
- self._log_turn_end()
447
+ # A turn killed mid-stream (worker cancelled on app quit, or the
448
+ # generator closed) never logs llm_response/llm_error — the
449
+ # turn_end status is the only trace of what happened, so it
450
+ # must not claim "completed".
451
+ self._log_turn_end("completed" if completed else "interrupted")
604
452
  try:
605
453
  await self._save_session()
606
454
  except Exception as e: # noqa: BLE001
@@ -906,10 +754,10 @@ class Agent:
906
754
  meta = event
907
755
 
908
756
  usage = meta.usage if meta else None
757
+ totals = normalize_usage(usage)
909
758
  self._last_finish_reason = meta.finish_reason if meta else None
910
- total = _extract_total_tokens(usage)
911
- if total:
912
- self._session_total_tokens += total
759
+ if totals.total_tokens:
760
+ self._session_total_tokens += totals.total_tokens
913
761
  yield UsageUpdate(total_tokens=self._session_total_tokens)
914
762
  self.trace.log(
915
763
  "llm_response",
@@ -919,7 +767,7 @@ class Agent:
919
767
  ttft=meta.ttft if meta else None,
920
768
  finish_reason=meta.finish_reason if meta else None,
921
769
  usage=usage,
922
- cached_tokens=_extract_cached_tokens(usage),
770
+ cached_tokens=totals.cached_tokens,
923
771
  content_chars=len(assistant_content),
924
772
  reasoning_chars=len(assistant_reasoning),
925
773
  tool_calls=[
@@ -939,27 +787,14 @@ class Agent:
939
787
  )
940
788
  # Record the real prompt size for the compaction trigger, with the
941
789
  # watermark past the assistant message just appended.
942
- prompt_tokens = _extract_prompt_tokens(usage)
943
- if prompt_tokens is not None:
944
- self._last_prompt_tokens = prompt_tokens
945
- self._usage_watermark = len(self.messages)
790
+ if totals.prompt_tokens is not None:
791
+ self._prompt_estimator.record(totals.prompt_tokens, len(self.messages))
946
792
 
947
793
  # -- auto-compaction (LIM-14) --------------------------------------------
948
794
 
949
795
  def _estimated_next_prompt_tokens(self) -> int:
950
- """Estimate the size of the *next* request's prompt.
951
-
952
- With real usage: last prompt + estimated tokens of everything
953
- appended since (new user input, assistant messages, tool results).
954
- This catches the classic overflow case — a huge paste between turns
955
- — that a stale last-response figure alone would miss. Without usage
956
- (first iteration, or a provider that reports none): full estimate.
957
- """
958
- if self._last_prompt_tokens is None:
959
- return estimate_tokens(self.messages)
960
- return self._last_prompt_tokens + estimate_tokens(
961
- self.messages[self._usage_watermark :]
962
- )
796
+ """Estimated size of the *next* request's prompt (see llm.usage)."""
797
+ return self._prompt_estimator.estimate_next(self.messages)
963
798
 
964
799
  async def _generate_summary(self, prompt: list[Message]) -> str:
965
800
  """One-shot summarization call on the current client (no tools)."""
@@ -1074,8 +909,7 @@ class Agent:
1074
909
  self._previous_summary = summary.strip()
1075
910
  # Usage figures predate the rewrite; fall back to full estimation
1076
911
  # until the next response reports real prompt tokens.
1077
- self._last_prompt_tokens = None
1078
- self._usage_watermark = len(self.messages)
912
+ self._prompt_estimator.reset(len(self.messages))
1079
913
  warning = None
1080
914
  if should_compact(after, self._context_window, cfg):
1081
915
  # No re-loop: one warning, the turn continues.