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.
- limbo_code-0.1.9/AGENTS.md +94 -0
- limbo_code-0.1.9/CONTEXT.md +43 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/PKG-INFO +1 -1
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/agent.py +71 -237
- limbo_code-0.1.9/src/limbo/attachments.py +65 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/compaction.py +3 -19
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/anthropic_client.py +18 -36
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/openai_client.py +17 -42
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/responses_client.py +15 -35
- limbo_code-0.1.9/src/limbo/llm/scaffold.py +92 -0
- limbo_code-0.1.9/src/limbo/llm/usage.py +160 -0
- limbo_code-0.1.9/src/limbo/model_switch.py +103 -0
- limbo_code-0.1.9/src/limbo/prompt.py +103 -0
- limbo_code-0.1.9/src/limbo/steer.py +76 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/screens/main.py +25 -55
- limbo_code-0.1.9/tests/conftest.py +21 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_agent.py +101 -28
- limbo_code-0.1.9/tests/test_attachments.py +78 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_compaction.py +1 -19
- limbo_code-0.1.9/tests/test_llm_scaffold.py +89 -0
- limbo_code-0.1.9/tests/test_model_switch.py +140 -0
- limbo_code-0.1.9/tests/test_prompt.py +85 -0
- limbo_code-0.1.9/tests/test_steer.py +66 -0
- limbo_code-0.1.9/tests/test_usage.py +159 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_app_smoke.py +9 -4
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_input_attachments.py +5 -1
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_main_screen.py +62 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_steer_ui.py +2 -2
- limbo_code-0.1/AGENTS.md +0 -207
- {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/grill-with-docs/SKILL.md +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/grill-with-docs/agents/openai.yaml +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/improve-codebase-architecture/SKILL.md +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/improve-codebase-architecture/agents/openai.yaml +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/tdd/SKILL.md +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/tdd/agents/openai.yaml +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/tdd/mocking.md +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/.agents/skills/tdd/tests.md +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/.github/workflows/publish.yml +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/.github/workflows/test.yml +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/.gitignore +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/README.md +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/design/confirm_view.html +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/design/limbo-ui-minimal.md +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/design/limbo-ui-redesign.md +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/design/prototype-minimal-confirm.png +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/design/prototype-minimal.html +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/design/prototype-minimal.png +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/design/prototype.html +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/design/prototype.png +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/limbo-current-ui.png +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/limbo-new-ui.png +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-dark/1_idle.svg +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-dark/2_thinking_tool_running.svg +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-dark/3_tool_success.svg +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-dark/4_tool_error.svg +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-dark/5_llm_error.svg +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-dark/6_edit_diff.svg +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-light/1_idle.svg +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-light/2_thinking_tool_running.svg +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-light/3_tool_success.svg +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-light/4_tool_error.svg +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-light/5_llm_error.svg +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/assets/walkthrough/limbo-light/6_edit_diff.svg +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/session-management.md +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/skills.md +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/docs/ui-redesign-proposal.md +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/pyproject.toml +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/scripts/check_contrast.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/scripts/gen_banner.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/scripts/ui_walkthrough.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/skills-lock.json +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/__init__.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/__main__.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/app.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/config.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/history.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/__init__.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/catalog.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/client.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/factory.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/retry.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/llm/sse.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/models.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/sessions.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/skills.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/__init__.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/base.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/bash.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/edit.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/find.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/grep.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/ignore.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/ls.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/mutation_queue.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/read.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/registry.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/tools/write.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/trace.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/__init__.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/app.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/app.tcss +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/banner.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/clipboard.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/commands.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/contrast.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/screens/__init__.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/screens/game2048.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/screens/model_picker.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/screens/session_picker.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/syntax.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/theme.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/widgets/__init__.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/widgets/chat.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/widgets/command_menu.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/widgets/input.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/widgets/status_bar.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/ui/widgets/tool_card.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/src/limbo/user_paths.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_anthropic_client.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_catalog.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_cli.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_clipboard.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_config.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_history.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_integration.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_llm_client.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_models.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_responses_client.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_retry.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_sessions.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_skills.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_trace.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/test_user_paths.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_base.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_bash.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_edit.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_find.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_grep.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_ignore.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_ls.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_mutation_queue.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_read.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_registry.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/tools/test_write.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/__snapshots__/test_snapshots/test_snapshot_idle.raw +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/__snapshots__/test_snapshots/test_snapshot_session_picker.raw +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/__snapshots__/test_snapshots/test_snapshot_thinking_and_running_tool.raw +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/__snapshots__/test_snapshots/test_snapshot_tool_error_and_error_message.raw +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_command_menu.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_commands.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_compact_ui.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_contrast.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_game2048.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_input_history.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_input_paste.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_model_picker.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_scroll_follow.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_sessions_ui.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_skills_ui.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_snapshots.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_startup_art.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_theme.py +0 -0
- {limbo_code-0.1 → limbo_code-0.1.9}/tests/ui/test_widgets.py +0 -0
- {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).
|
|
@@ -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
|
|
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.
|
|
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
|
|
158
|
-
"""
|
|
134
|
+
def _resolved_llm_trace_fields(config: Config) -> dict[str, Any]:
|
|
135
|
+
"""Resolved provider identity for trace events.
|
|
159
136
|
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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.
|
|
241
|
-
#
|
|
242
|
-
|
|
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).
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
#
|
|
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.
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
402
|
+
"""Combine text with attachments (policy lives in limbo.attachments)."""
|
|
403
|
+
return build_user_content(user_input, attachments, vision=self._vision)
|
|
531
404
|
|
|
532
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
911
|
-
|
|
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=
|
|
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
|
|
943
|
-
|
|
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
|
-
"""
|
|
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.
|
|
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.
|