limbo-code 0.1.0__tar.gz → 0.1.1__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.1/PKG-INFO +172 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/pyproject.toml +2 -1
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/config.py +68 -1
- limbo_code-0.1.1/src/limbo/llm/retry.py +198 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_config.py +66 -0
- limbo_code-0.1.1/tests/test_retry.py +302 -0
- limbo_code-0.1.0/PKG-INFO +0 -16
- {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/grill-with-docs/SKILL.md +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/grill-with-docs/agents/openai.yaml +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/improve-codebase-architecture/SKILL.md +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/improve-codebase-architecture/agents/openai.yaml +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/tdd/SKILL.md +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/tdd/agents/openai.yaml +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/tdd/mocking.md +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/tdd/tests.md +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/.github/workflows/publish.yml +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/.github/workflows/test.yml +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/.gitignore +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/AGENTS.md +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/README.md +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/design/confirm_view.html +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/design/limbo-ui-minimal.md +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/design/limbo-ui-redesign.md +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/design/prototype-minimal-confirm.png +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/design/prototype-minimal.html +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/design/prototype-minimal.png +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/design/prototype.html +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/design/prototype.png +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/docs/assets/limbo-current-ui.png +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/docs/assets/limbo-new-ui.png +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/docs/session-management.md +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/docs/skills.md +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/docs/ui-redesign-proposal.md +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/skills-lock.json +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/__init__.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/__main__.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/agent.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/app.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/history.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/llm/__init__.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/llm/anthropic_client.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/llm/catalog.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/llm/client.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/llm/factory.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/llm/openai_client.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/models.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/sessions.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/skills.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/__init__.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/base.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/bash.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/edit.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/find.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/grep.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/ignore.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/ls.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/read.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/registry.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/write.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/trace.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/__init__.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/app.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/app.tcss +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/banner.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/commands.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/screens/__init__.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/screens/game2048.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/screens/main.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/screens/session_picker.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/widgets/__init__.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/widgets/chat.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/widgets/command_menu.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/widgets/input.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/widgets/status_bar.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/widgets/tool_card.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_agent.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_anthropic_client.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_catalog.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_cli.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_history.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_integration.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_llm_client.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_models.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_sessions.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_skills.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_trace.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_base.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_bash.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_edit.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_find.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_grep.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_ignore.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_ls.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_read.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_registry.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_write.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_app_smoke.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_command_menu.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_commands.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_game2048.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_input_history.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_main_screen.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_sessions_ui.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_skills_ui.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_startup_art.py +0 -0
- {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_widgets.py +0 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: limbo-code
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A minimal terminal AI coding agent
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Requires-Dist: openai>=1.30
|
|
8
|
+
Requires-Dist: pydantic>=2.0
|
|
9
|
+
Requires-Dist: textual>=0.58
|
|
10
|
+
Requires-Dist: toml>=0.10
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# Limbo
|
|
20
|
+
|
|
21
|
+
A minimal terminal AI coding agent.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Requires Python 3.11 or later.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e .
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Configure
|
|
32
|
+
|
|
33
|
+
Create `~/.limbo/config.toml`:
|
|
34
|
+
|
|
35
|
+
```toml
|
|
36
|
+
[llm]
|
|
37
|
+
api_key = "your-api-key"
|
|
38
|
+
model = "deepseek-chat"
|
|
39
|
+
base_url = "https://api.deepseek.com/v1"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Limbo speaks to LLMs through a **provider/model catalog**
|
|
43
|
+
(`src/limbo/llm/catalog.py`). Each provider declares its API dialect,
|
|
44
|
+
endpoint, and credential env var; each model carries its own context window,
|
|
45
|
+
max output tokens, and thinking/reasoning behavior. A client factory
|
|
46
|
+
(`src/limbo/llm/factory.py`) picks the client implementation from the
|
|
47
|
+
provider's API dialect, so non-OpenAI dialects can be added without touching
|
|
48
|
+
call sites. Models not in the catalog fall back to generic
|
|
49
|
+
OpenAI-compatible defaults driven by `base_url` + `model` + `api_key`.
|
|
50
|
+
|
|
51
|
+
Built-in providers:
|
|
52
|
+
|
|
53
|
+
| Provider | API dialect | Endpoint | Key env var |
|
|
54
|
+
|----------|-------------|----------|-------------|
|
|
55
|
+
| `deepseek` | openai-completions | `https://api.deepseek.com/v1` | `DEEPSEEK_API_KEY` |
|
|
56
|
+
| `moonshotai` (Kimi) | openai-completions | `https://api.moonshot.ai/v1` | `MOONSHOT_API_KEY` |
|
|
57
|
+
| `kimi-coding` (Kimi For Coding) | anthropic-messages | `https://api.kimi.com/coding` | `KIMI_API_KEY` |
|
|
58
|
+
|
|
59
|
+
Built-in Kimi models include `kimi-k3` (1M context, pay-per-token), and for
|
|
60
|
+
Kimi For Coding subscriptions: `k3` (1M context), `kimi-for-coding`, and
|
|
61
|
+
`kimi-for-coding-highspeed` (256K context). The two use different endpoints
|
|
62
|
+
and keys — a `sk-kimi-*` Kimi For Coding key only works with the
|
|
63
|
+
`kimi-coding` models (`k3`, ...) and vice versa.
|
|
64
|
+
|
|
65
|
+
Switching to a catalog model only requires changing `model` — the provider's
|
|
66
|
+
endpoint and key env var are picked up automatically:
|
|
67
|
+
|
|
68
|
+
```toml
|
|
69
|
+
[llm]
|
|
70
|
+
model = "k3" # Kimi For Coding; base_url/api_key resolve from the catalog
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The `anthropic-messages` dialect is served by a dedicated client
|
|
74
|
+
(`src/limbo/llm/anthropic_client.py`, plain httpx SSE) selected by the
|
|
75
|
+
client factory. It converts OpenAI-style tool definitions to Anthropic's
|
|
76
|
+
shape, merges consecutive tool results into a single user turn, and replays
|
|
77
|
+
assistant thinking blocks with their signature.
|
|
78
|
+
|
|
79
|
+
For the mainland-China Moonshot endpoint, set `base_url =
|
|
80
|
+
"https://api.moonshot.cn/v1"` explicitly (a configured `base_url` always
|
|
81
|
+
wins over the catalog).
|
|
82
|
+
|
|
83
|
+
### Optional LLM settings
|
|
84
|
+
|
|
85
|
+
```toml
|
|
86
|
+
[llm]
|
|
87
|
+
temperature = 0.2 # 0.0 - 2.0, default 0.2
|
|
88
|
+
max_iterations = 10 # safety limit on tool-turn loops, default 10
|
|
89
|
+
max_tokens = 8192 # output cap; default = the model's catalog value
|
|
90
|
+
thinking_effort = "high" # reasoning control; default = provider behavior
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`thinking_effort` is interpreted per model dialect:
|
|
94
|
+
|
|
95
|
+
- `k3`, `kimi-for-coding*` (Anthropic adaptive thinking): `low` | `high` |
|
|
96
|
+
`max` → `thinking: {type: adaptive}` + `output_config.effort`. Thinking
|
|
97
|
+
cannot be disabled; temperature is omitted while thinking is enabled.
|
|
98
|
+
- `kimi-k3` (moonshotai, OpenAI-style): `low` | `high` | `max` → sent as
|
|
99
|
+
`reasoning_effort`. Thinking cannot be disabled on K3.
|
|
100
|
+
- `kimi-k2-thinking`, `kimi-k2.5`+ (DeepSeek-style): any non-`off` value →
|
|
101
|
+
`thinking: {type: enabled}`; `"off"` → `thinking: {type: disabled}`
|
|
102
|
+
(except `kimi-k2.7-code*`, where thinking is always on).
|
|
103
|
+
- Non-reasoning models: ignored.
|
|
104
|
+
|
|
105
|
+
Reasoning output streams into the chat as muted thinking blocks and is
|
|
106
|
+
stored on the assistant message so it can be replayed to APIs that require
|
|
107
|
+
it (Kimi K3 rejects tool-call replays without `reasoning_content`).
|
|
108
|
+
|
|
109
|
+
Optional tool settings:
|
|
110
|
+
|
|
111
|
+
```toml
|
|
112
|
+
[tools]
|
|
113
|
+
bash_enabled = true
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Session storage
|
|
117
|
+
|
|
118
|
+
Conversations are saved as JSONL files in `~/.limbo/sessions/` so you can
|
|
119
|
+
review or debug them later. Use the `--session-dir` argument to redirect them
|
|
120
|
+
to another location.
|
|
121
|
+
|
|
122
|
+
Old session files are not automatically cleaned up; remove them manually when
|
|
123
|
+
you no longer need them.
|
|
124
|
+
|
|
125
|
+
## Run
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
limbo --workdir /path/to/project
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Safety
|
|
132
|
+
|
|
133
|
+
Limbo executes every tool call immediately, without asking for confirmation.
|
|
134
|
+
File tools (`read`, `edit`, `write`, `grep`, `find`, `ls`) are bounded to the
|
|
135
|
+
current working directory and reject paths that escape it, including via
|
|
136
|
+
symlinks. The boundary check resolves the path before each operation, so a
|
|
137
|
+
symlink swapped between the check and the operation (a time-of-check-to-time-of-use
|
|
138
|
+
race) could escape the workdir. **This is a known limitation for the MVP.**
|
|
139
|
+
|
|
140
|
+
Bash is an exception: it is started in the working directory but is **not**
|
|
141
|
+
sandboxed. Commands can `cd ..`, use absolute paths, and read or write outside
|
|
142
|
+
the workdir. In addition, commands that match dangerous patterns such as `rm`
|
|
143
|
+
or `git reset --hard` are **rejected outright**.
|
|
144
|
+
The pattern list is configurable but cannot be disabled from the UI. Bash
|
|
145
|
+
commands are filtered with a simple heuristic, but that filter can be bypassed
|
|
146
|
+
by subshells (`bash -c 'rm -rf /'`), command substitution (`$(rm -rf /)`),
|
|
147
|
+
variable indirection, options before the command name
|
|
148
|
+
(`git -C /foo reset --hard`), variable assignments before the command name
|
|
149
|
+
(`VAR=1 rm -rf /`), and similar shell constructs. Only run Limbo with
|
|
150
|
+
trusted commands and in repositories you can afford to modify or lose.
|
|
151
|
+
|
|
152
|
+
If you need to work with untrusted projects, disable the bash tool entirely:
|
|
153
|
+
|
|
154
|
+
```toml
|
|
155
|
+
[tools]
|
|
156
|
+
bash_enabled = false
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
Run tests:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pytest tests/ -v
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Run linting and type checks:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
ruff check src tests
|
|
171
|
+
mypy src
|
|
172
|
+
```
|
|
@@ -7,7 +7,13 @@ from pathlib import Path
|
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
9
|
import toml # type: ignore[import-untyped]
|
|
10
|
-
from pydantic import
|
|
10
|
+
from pydantic import (
|
|
11
|
+
BaseModel,
|
|
12
|
+
Field,
|
|
13
|
+
ValidationError,
|
|
14
|
+
ValidationInfo,
|
|
15
|
+
field_validator,
|
|
16
|
+
)
|
|
11
17
|
from toml import TomlDecodeError
|
|
12
18
|
|
|
13
19
|
DEFAULT_CONFIG_PATH = Path.home() / ".limbo" / "config.toml"
|
|
@@ -29,6 +35,11 @@ class LLMConfig(BaseModel):
|
|
|
29
35
|
thinking_effort: str | None = None
|
|
30
36
|
# Per-request output token cap; None = use the model catalog default.
|
|
31
37
|
max_tokens: int | None = None
|
|
38
|
+
# LLM request retry/timeout knobs (see limbo.llm.retry).
|
|
39
|
+
max_retries: int = 3
|
|
40
|
+
retry_base_delay: float = 1.0
|
|
41
|
+
timeout: float = 600.0
|
|
42
|
+
connect_timeout: float = 30.0
|
|
32
43
|
|
|
33
44
|
@field_validator("max_iterations")
|
|
34
45
|
@classmethod
|
|
@@ -37,6 +48,62 @@ class LLMConfig(BaseModel):
|
|
|
37
48
|
raise ValueError("max_iterations must be at least 1")
|
|
38
49
|
return value
|
|
39
50
|
|
|
51
|
+
# The retry/timeout fields below clamp-and-warn instead of raising: a
|
|
52
|
+
# single invalid value must not discard the whole config (load_config
|
|
53
|
+
# falls back to *all* defaults on ValidationError).
|
|
54
|
+
|
|
55
|
+
@field_validator("max_retries")
|
|
56
|
+
@classmethod
|
|
57
|
+
def _max_retries_clamped(cls, value: int) -> int:
|
|
58
|
+
if value < 0:
|
|
59
|
+
warnings.warn(
|
|
60
|
+
f"max_retries={value} is invalid; clamped to 0 (retries disabled).",
|
|
61
|
+
stacklevel=2,
|
|
62
|
+
)
|
|
63
|
+
return 0
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
@field_validator("retry_base_delay")
|
|
67
|
+
@classmethod
|
|
68
|
+
def _retry_base_delay_clamped(cls, value: float) -> float:
|
|
69
|
+
if value <= 0:
|
|
70
|
+
warnings.warn(
|
|
71
|
+
f"retry_base_delay={value} is invalid; reset to 1.0.",
|
|
72
|
+
stacklevel=2,
|
|
73
|
+
)
|
|
74
|
+
return 1.0
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
@field_validator("timeout")
|
|
78
|
+
@classmethod
|
|
79
|
+
def _timeout_clamped(cls, value: float) -> float:
|
|
80
|
+
if value <= 0:
|
|
81
|
+
warnings.warn(
|
|
82
|
+
f"timeout={value} is invalid; reset to 600.0.",
|
|
83
|
+
stacklevel=2,
|
|
84
|
+
)
|
|
85
|
+
return 600.0
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
@field_validator("connect_timeout")
|
|
89
|
+
@classmethod
|
|
90
|
+
def _connect_timeout_clamped(cls, value: float, info: ValidationInfo) -> float:
|
|
91
|
+
if value <= 0:
|
|
92
|
+
warnings.warn(
|
|
93
|
+
f"connect_timeout={value} is invalid; reset to 30.0.",
|
|
94
|
+
stacklevel=2,
|
|
95
|
+
)
|
|
96
|
+
return 30.0
|
|
97
|
+
timeout = info.data.get("timeout")
|
|
98
|
+
if isinstance(timeout, (int, float)) and value > timeout:
|
|
99
|
+
warnings.warn(
|
|
100
|
+
f"connect_timeout={value} exceeds timeout={timeout}; "
|
|
101
|
+
f"clamped to {timeout}.",
|
|
102
|
+
stacklevel=2,
|
|
103
|
+
)
|
|
104
|
+
return float(timeout)
|
|
105
|
+
return value
|
|
106
|
+
|
|
40
107
|
@field_validator("max_tokens")
|
|
41
108
|
@classmethod
|
|
42
109
|
def _max_tokens_must_be_positive(cls, value: int | None) -> int | None:
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Unified retry helper for LLM streaming requests.
|
|
2
|
+
|
|
3
|
+
Shared by both provider clients (OpenAI-compatible and Anthropic) so that
|
|
4
|
+
429/5xx/connection/timeout handling behaves identically on every path.
|
|
5
|
+
|
|
6
|
+
Core contract of :func:`stream_with_retry`: retries only happen *before the
|
|
7
|
+
first event is yielded*. Once any event has been emitted to the consumer,
|
|
8
|
+
subsequent exceptions propagate untouched — this makes duplicate streamed
|
|
9
|
+
output (and thus duplicated UI text) impossible by construction.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import random
|
|
16
|
+
from collections.abc import AsyncIterator, Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from email.utils import parsedate_to_datetime
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
from openai import APIConnectionError, APIStatusError, APITimeoutError
|
|
23
|
+
|
|
24
|
+
from limbo.config import LLMConfig
|
|
25
|
+
from limbo.models import LLMEvent
|
|
26
|
+
|
|
27
|
+
# Hard cap for a single backoff sleep. Not configurable in the MVP: it bounds
|
|
28
|
+
# the total wait budget of one turn (~90s worst case with max_retries=3).
|
|
29
|
+
DEFAULT_MAX_DELAY = 30.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class RetryPolicy:
|
|
34
|
+
"""Retry tuning: max_retries=3 means at most 4 attempts total."""
|
|
35
|
+
|
|
36
|
+
max_retries: int = 3
|
|
37
|
+
base_delay: float = 1.0
|
|
38
|
+
max_delay: float = DEFAULT_MAX_DELAY
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_config(cls, cfg: LLMConfig) -> RetryPolicy:
|
|
42
|
+
return cls(max_retries=cfg.max_retries, base_delay=cfg.retry_base_delay)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LLMHttpError(Exception):
|
|
46
|
+
"""Normalized non-200 HTTP response, carrying structured retry metadata."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
status_code: int,
|
|
51
|
+
message: str = "",
|
|
52
|
+
retry_after: float | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__(message or f"HTTP {status_code}")
|
|
55
|
+
self.status_code = status_code
|
|
56
|
+
self.retry_after = retry_after
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LLMOverloadedError(Exception):
|
|
60
|
+
"""Provider reports overload (e.g. Anthropic SSE ``overloaded_error``)."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_retryable(exc: BaseException) -> bool:
|
|
64
|
+
"""Classify whether retrying the request could succeed.
|
|
65
|
+
|
|
66
|
+
Retryable: 429, 408, 5xx, connection errors, read/connect timeouts,
|
|
67
|
+
provider overload. Everything else (other 4xx, cancellations) is not.
|
|
68
|
+
"""
|
|
69
|
+
if isinstance(exc, (asyncio.CancelledError, KeyboardInterrupt)):
|
|
70
|
+
return False
|
|
71
|
+
if isinstance(exc, LLMOverloadedError):
|
|
72
|
+
return True
|
|
73
|
+
if isinstance(exc, LLMHttpError):
|
|
74
|
+
return _status_retryable(exc.status_code)
|
|
75
|
+
if isinstance(exc, APIStatusError):
|
|
76
|
+
# Includes RateLimitError (429) and InternalServerError (5xx).
|
|
77
|
+
return _status_retryable(exc.status_code)
|
|
78
|
+
if isinstance(exc, (APIConnectionError, APITimeoutError)):
|
|
79
|
+
return True
|
|
80
|
+
if isinstance(exc, (httpx.ConnectError, httpx.ReadTimeout, httpx.ConnectTimeout)):
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _status_retryable(status_code: int) -> bool:
|
|
86
|
+
return status_code in (408, 429) or status_code >= 500
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def retry_after(exc: BaseException) -> float | None:
|
|
90
|
+
"""Extract the server-provided Retry-After (seconds), if any."""
|
|
91
|
+
if isinstance(exc, LLMHttpError):
|
|
92
|
+
return exc.retry_after
|
|
93
|
+
if isinstance(exc, APIStatusError):
|
|
94
|
+
return parse_retry_after(exc.response.headers.get("retry-after"))
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
99
|
+
"""Parse a Retry-After header value (delta-seconds or HTTP-date).
|
|
100
|
+
|
|
101
|
+
Returns None on any parse failure — a malformed header must never
|
|
102
|
+
break the retry flow.
|
|
103
|
+
"""
|
|
104
|
+
if not value:
|
|
105
|
+
return None
|
|
106
|
+
value = value.strip()
|
|
107
|
+
try:
|
|
108
|
+
seconds = float(value)
|
|
109
|
+
return max(0.0, seconds)
|
|
110
|
+
except ValueError:
|
|
111
|
+
pass
|
|
112
|
+
try:
|
|
113
|
+
date = parsedate_to_datetime(value)
|
|
114
|
+
except (TypeError, ValueError):
|
|
115
|
+
return None
|
|
116
|
+
if date is None:
|
|
117
|
+
return None
|
|
118
|
+
if date.tzinfo is None:
|
|
119
|
+
# HTTP-date is always GMT; assume UTC if parsing dropped the tz.
|
|
120
|
+
date = date.replace(tzinfo=timezone.utc)
|
|
121
|
+
return max(0.0, (date - datetime.now(timezone.utc)).total_seconds())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def compute_delay(
|
|
125
|
+
attempt: int,
|
|
126
|
+
policy: RetryPolicy,
|
|
127
|
+
retry_after: float | None = None,
|
|
128
|
+
) -> float:
|
|
129
|
+
"""Full-jitter exponential backoff: uniform(0, min(max, base * 2**attempt)).
|
|
130
|
+
|
|
131
|
+
A server-provided Retry-After raises the floor (max of the two), still
|
|
132
|
+
capped at max_delay — bounding the total wait budget of a turn.
|
|
133
|
+
"""
|
|
134
|
+
computed = random.uniform(0.0, min(policy.max_delay, policy.base_delay * 2**attempt))
|
|
135
|
+
if retry_after is not None:
|
|
136
|
+
computed = max(retry_after, computed)
|
|
137
|
+
return min(policy.max_delay, computed)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def stream_with_retry(
|
|
141
|
+
factory: Callable[[], AsyncIterator[LLMEvent]],
|
|
142
|
+
policy: RetryPolicy,
|
|
143
|
+
) -> AsyncIterator[LLMEvent]:
|
|
144
|
+
"""Yield events from factory(), retrying pre-first-event failures.
|
|
145
|
+
|
|
146
|
+
Each attempt calls factory() to build a fresh async generator (a brand
|
|
147
|
+
new HTTP request). Exceptions raised before the first yield are retried
|
|
148
|
+
when :func:`is_retryable`; once anything has been yielded, exceptions
|
|
149
|
+
propagate untouched. CancelledError/KeyboardInterrupt always propagate
|
|
150
|
+
immediately (they are BaseExceptions, never caught here).
|
|
151
|
+
"""
|
|
152
|
+
attempt = 0
|
|
153
|
+
while True:
|
|
154
|
+
yielded = False
|
|
155
|
+
try:
|
|
156
|
+
async for event in factory():
|
|
157
|
+
yielded = True
|
|
158
|
+
yield event
|
|
159
|
+
return
|
|
160
|
+
except Exception as e:
|
|
161
|
+
if yielded or attempt >= policy.max_retries or not is_retryable(e):
|
|
162
|
+
raise
|
|
163
|
+
await asyncio.sleep(compute_delay(attempt, policy, retry_after(e)))
|
|
164
|
+
attempt += 1
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def friendly_message(exc: BaseException) -> str | None:
|
|
168
|
+
"""User-facing Chinese hint for an exhausted/unretryable LLM failure.
|
|
169
|
+
|
|
170
|
+
Returns None when there is no better advice than the raw error text;
|
|
171
|
+
the raw exception always stays in the trace log either way.
|
|
172
|
+
"""
|
|
173
|
+
if isinstance(exc, LLMOverloadedError):
|
|
174
|
+
return "模型服务过载,已自动重试仍失败,可稍后重发上一条消息"
|
|
175
|
+
if isinstance(exc, LLMHttpError):
|
|
176
|
+
if exc.status_code == 429:
|
|
177
|
+
return "服务限流,已自动重试仍失败,可稍后重发上一条消息"
|
|
178
|
+
if exc.status_code >= 500:
|
|
179
|
+
return "模型服务异常,已自动重试仍失败,可稍后重发上一条消息"
|
|
180
|
+
if exc.status_code == 408:
|
|
181
|
+
return "请求超时,已自动重试仍失败,请检查网络后重发上一条消息"
|
|
182
|
+
return None
|
|
183
|
+
if isinstance(exc, APIStatusError):
|
|
184
|
+
# RateLimitError (429) is an APIStatusError subclass.
|
|
185
|
+
if exc.status_code == 429:
|
|
186
|
+
return "服务限流,已自动重试仍失败,可稍后重发上一条消息"
|
|
187
|
+
if exc.status_code >= 500:
|
|
188
|
+
return "模型服务异常,已自动重试仍失败,可稍后重发上一条消息"
|
|
189
|
+
if exc.status_code == 408:
|
|
190
|
+
return "请求超时,已自动重试仍失败,请检查网络后重发上一条消息"
|
|
191
|
+
return None
|
|
192
|
+
if isinstance(exc, APITimeoutError) or isinstance(
|
|
193
|
+
exc, (httpx.ReadTimeout, httpx.ConnectTimeout)
|
|
194
|
+
):
|
|
195
|
+
return "请求超时,已自动重试仍失败,请检查网络后重发上一条消息"
|
|
196
|
+
if isinstance(exc, (APIConnectionError, httpx.ConnectError)):
|
|
197
|
+
return "网络连接异常,已自动重试仍失败,请检查网络后重发上一条消息"
|
|
198
|
+
return None
|
|
@@ -81,3 +81,69 @@ def test_llm_config_rejects_non_positive_max_iterations():
|
|
|
81
81
|
with pytest.raises(ValueError, match="max_iterations"):
|
|
82
82
|
LLMConfig(max_iterations=-1)
|
|
83
83
|
assert LLMConfig(max_iterations=1).max_iterations == 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_llm_config_retry_defaults():
|
|
87
|
+
from limbo.config import LLMConfig
|
|
88
|
+
|
|
89
|
+
cfg = LLMConfig()
|
|
90
|
+
assert cfg.max_retries == 3
|
|
91
|
+
assert cfg.retry_base_delay == 1.0
|
|
92
|
+
assert cfg.timeout == 600.0
|
|
93
|
+
assert cfg.connect_timeout == 30.0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_load_config_retry_fields_from_toml(tmp_path):
|
|
97
|
+
path = tmp_path / "retry.toml"
|
|
98
|
+
path.write_text(
|
|
99
|
+
"[llm]\n"
|
|
100
|
+
"max_retries = 5\n"
|
|
101
|
+
"retry_base_delay = 2.5\n"
|
|
102
|
+
"timeout = 120.0\n"
|
|
103
|
+
"connect_timeout = 10.0\n"
|
|
104
|
+
)
|
|
105
|
+
cfg = load_config(path)
|
|
106
|
+
assert cfg.llm.max_retries == 5
|
|
107
|
+
assert cfg.llm.retry_base_delay == 2.5
|
|
108
|
+
assert cfg.llm.timeout == 120.0
|
|
109
|
+
assert cfg.llm.connect_timeout == 10.0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_llm_config_clamps_negative_max_retries():
|
|
113
|
+
from limbo.config import LLMConfig
|
|
114
|
+
|
|
115
|
+
with pytest.warns(UserWarning, match="max_retries"):
|
|
116
|
+
cfg = LLMConfig(max_retries=-1)
|
|
117
|
+
assert cfg.max_retries == 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_llm_config_clamps_non_positive_delays():
|
|
121
|
+
from limbo.config import LLMConfig
|
|
122
|
+
|
|
123
|
+
with pytest.warns(UserWarning, match="retry_base_delay"):
|
|
124
|
+
cfg = LLMConfig(retry_base_delay=0)
|
|
125
|
+
assert cfg.retry_base_delay == 1.0
|
|
126
|
+
with pytest.warns(UserWarning, match="timeout"):
|
|
127
|
+
cfg = LLMConfig(timeout=-5)
|
|
128
|
+
assert cfg.timeout == 600.0
|
|
129
|
+
with pytest.warns(UserWarning, match="connect_timeout"):
|
|
130
|
+
cfg = LLMConfig(connect_timeout=0)
|
|
131
|
+
assert cfg.connect_timeout == 30.0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_llm_config_connect_timeout_clamped_to_timeout():
|
|
135
|
+
from limbo.config import LLMConfig
|
|
136
|
+
|
|
137
|
+
with pytest.warns(UserWarning, match="connect_timeout"):
|
|
138
|
+
cfg = LLMConfig(timeout=10.0, connect_timeout=60.0)
|
|
139
|
+
assert cfg.connect_timeout == 10.0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_load_config_invalid_retry_value_does_not_discard_other_fields(tmp_path):
|
|
143
|
+
"""A bad retry_base_delay must clamp+warn, not reset the whole config."""
|
|
144
|
+
path = tmp_path / "partial.toml"
|
|
145
|
+
path.write_text('[llm]\nmodel = "gpt-4o"\nretry_base_delay = -1\n')
|
|
146
|
+
with pytest.warns(UserWarning, match="retry_base_delay"):
|
|
147
|
+
cfg = load_config(path)
|
|
148
|
+
assert cfg.llm.model == "gpt-4o"
|
|
149
|
+
assert cfg.llm.retry_base_delay == 1.0
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Unit tests for limbo.llm.retry (no network; sleeps are monkeypatched)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from email.utils import formatdate
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
from openai import (
|
|
13
|
+
APIConnectionError,
|
|
14
|
+
APIStatusError,
|
|
15
|
+
APITimeoutError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from limbo.config import LLMConfig
|
|
20
|
+
from limbo.llm.retry import (
|
|
21
|
+
LLMHttpError,
|
|
22
|
+
LLMOverloadedError,
|
|
23
|
+
RetryPolicy,
|
|
24
|
+
compute_delay,
|
|
25
|
+
friendly_message,
|
|
26
|
+
is_retryable,
|
|
27
|
+
parse_retry_after,
|
|
28
|
+
retry_after,
|
|
29
|
+
stream_with_retry,
|
|
30
|
+
)
|
|
31
|
+
from limbo.models import TextChunk
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _request() -> httpx.Request:
|
|
35
|
+
return httpx.Request("POST", "http://test/v1/chat/completions")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _status_error(status: int, headers: dict[str, str] | None = None) -> APIStatusError:
|
|
39
|
+
resp = httpx.Response(status, headers=headers or {}, request=_request())
|
|
40
|
+
return APIStatusError(f"status {status}", response=resp, body=None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _rate_limit(headers: dict[str, str] | None = None) -> RateLimitError:
|
|
44
|
+
resp = httpx.Response(429, headers=headers or {}, request=_request())
|
|
45
|
+
return RateLimitError("rate limited", response=resp, body=None)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# is_retryable classification matrix
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize(
|
|
54
|
+
("exc", "expected"),
|
|
55
|
+
[
|
|
56
|
+
(_rate_limit(), True),
|
|
57
|
+
(_status_error(408), True),
|
|
58
|
+
(_status_error(500), True),
|
|
59
|
+
(_status_error(503), True),
|
|
60
|
+
(_status_error(400), False),
|
|
61
|
+
(_status_error(401), False),
|
|
62
|
+
(_status_error(404), False),
|
|
63
|
+
(_status_error(409), False),
|
|
64
|
+
(APIConnectionError(message="conn", request=_request()), True),
|
|
65
|
+
(APITimeoutError(request=_request()), True),
|
|
66
|
+
(LLMHttpError(429), True),
|
|
67
|
+
(LLMHttpError(408), True),
|
|
68
|
+
(LLMHttpError(500), True),
|
|
69
|
+
(LLMHttpError(400), False),
|
|
70
|
+
(LLMHttpError(401), False),
|
|
71
|
+
(LLMOverloadedError("overloaded"), True),
|
|
72
|
+
(httpx.ConnectError("boom", request=_request()), True),
|
|
73
|
+
(httpx.ReadTimeout("boom", request=_request()), True),
|
|
74
|
+
(httpx.ConnectTimeout("boom", request=_request()), True),
|
|
75
|
+
(asyncio.CancelledError(), False),
|
|
76
|
+
(KeyboardInterrupt(), False),
|
|
77
|
+
(ValueError("nope"), False),
|
|
78
|
+
(RuntimeError("nope"), False),
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
def test_is_retryable(exc: BaseException, expected: bool):
|
|
82
|
+
assert is_retryable(exc) is expected
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# retry_after / parse_retry_after
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_retry_after_from_normalized_error():
|
|
91
|
+
assert retry_after(LLMHttpError(429, retry_after=5.0)) == 5.0
|
|
92
|
+
assert retry_after(LLMHttpError(429)) is None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_retry_after_from_openai_error_delta_seconds():
|
|
96
|
+
assert retry_after(_rate_limit({"retry-after": "5"})) == 5.0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_retry_after_from_openai_error_http_date():
|
|
100
|
+
headers = {"retry-after": formatdate(time.time() + 5, usegmt=True)}
|
|
101
|
+
value = retry_after(_rate_limit(headers))
|
|
102
|
+
assert value is not None
|
|
103
|
+
assert 3.0 < value <= 5.0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_retry_after_garbage_and_missing_headers():
|
|
107
|
+
assert retry_after(_rate_limit({"retry-after": "not-a-date"})) is None
|
|
108
|
+
assert retry_after(_rate_limit()) is None
|
|
109
|
+
assert retry_after(ValueError("x")) is None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_parse_retry_after_past_http_date_clamps_to_zero():
|
|
113
|
+
headers = formatdate(time.time() - 60, usegmt=True)
|
|
114
|
+
assert parse_retry_after(headers) == 0.0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# compute_delay
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _capture_uniform(monkeypatch: pytest.MonkeyPatch) -> list[tuple[float, float]]:
|
|
123
|
+
bounds: list[tuple[float, float]] = []
|
|
124
|
+
|
|
125
|
+
def fake_uniform(a: float, b: float) -> float:
|
|
126
|
+
bounds.append((a, b))
|
|
127
|
+
return 0.0
|
|
128
|
+
|
|
129
|
+
monkeypatch.setattr(random, "uniform", fake_uniform)
|
|
130
|
+
return bounds
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_compute_delay_exponential_bounds(monkeypatch: pytest.MonkeyPatch):
|
|
134
|
+
bounds = _capture_uniform(monkeypatch)
|
|
135
|
+
policy = RetryPolicy(base_delay=1.0)
|
|
136
|
+
for attempt, expected_upper in ((0, 1.0), (1, 2.0), (2, 4.0)):
|
|
137
|
+
compute_delay(attempt, policy)
|
|
138
|
+
assert bounds[-1] == (0.0, expected_upper)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_compute_delay_capped_at_max_delay(monkeypatch: pytest.MonkeyPatch):
|
|
142
|
+
bounds = _capture_uniform(monkeypatch)
|
|
143
|
+
policy = RetryPolicy(base_delay=1.0, max_delay=30.0)
|
|
144
|
+
compute_delay(10, policy) # 2**10 = 1024 >> 30
|
|
145
|
+
assert bounds[-1] == (0.0, 30.0)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_compute_delay_retry_after_raises_floor(monkeypatch: pytest.MonkeyPatch):
|
|
149
|
+
_capture_uniform(monkeypatch) # uniform returns 0.0
|
|
150
|
+
policy = RetryPolicy(base_delay=1.0)
|
|
151
|
+
assert compute_delay(0, policy, retry_after=5.0) == 5.0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_compute_delay_retry_after_capped_at_max_delay(monkeypatch: pytest.MonkeyPatch):
|
|
155
|
+
_capture_uniform(monkeypatch)
|
|
156
|
+
policy = RetryPolicy(max_delay=30.0)
|
|
157
|
+
assert compute_delay(0, policy, retry_after=120.0) == 30.0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_retry_policy_from_config():
|
|
161
|
+
cfg = LLMConfig(max_retries=5, retry_base_delay=2.0)
|
|
162
|
+
policy = RetryPolicy.from_config(cfg)
|
|
163
|
+
assert policy.max_retries == 5
|
|
164
|
+
assert policy.base_delay == 2.0
|
|
165
|
+
assert policy.max_delay == 30.0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# stream_with_retry
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _make_factory(behaviors: list[tuple[list[TextChunk], BaseException | None]]):
|
|
174
|
+
"""Each entry: events to yield, then optional error to raise."""
|
|
175
|
+
calls = 0
|
|
176
|
+
|
|
177
|
+
def factory():
|
|
178
|
+
nonlocal calls
|
|
179
|
+
events, error = behaviors[min(calls, len(behaviors) - 1)]
|
|
180
|
+
calls += 1
|
|
181
|
+
|
|
182
|
+
async def gen():
|
|
183
|
+
for e in events:
|
|
184
|
+
yield e
|
|
185
|
+
if error is not None:
|
|
186
|
+
raise error
|
|
187
|
+
|
|
188
|
+
return gen()
|
|
189
|
+
|
|
190
|
+
return factory, lambda: calls
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@pytest.fixture
|
|
194
|
+
def sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
|
|
195
|
+
recorded: list[float] = []
|
|
196
|
+
|
|
197
|
+
async def fake_sleep(delay: float) -> None:
|
|
198
|
+
recorded.append(delay)
|
|
199
|
+
|
|
200
|
+
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
|
201
|
+
return recorded
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@pytest.mark.asyncio
|
|
205
|
+
async def test_retry_succeeds_after_transient_failures(sleeps: list[float]):
|
|
206
|
+
events = [TextChunk(text="hello")]
|
|
207
|
+
factory, calls = _make_factory(
|
|
208
|
+
[([], _rate_limit()), ([], _status_error(503)), (events, None)]
|
|
209
|
+
)
|
|
210
|
+
result = [e async for e in stream_with_retry(factory, RetryPolicy())]
|
|
211
|
+
assert result == events
|
|
212
|
+
assert calls() == 3
|
|
213
|
+
assert len(sleeps) == 2
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@pytest.mark.asyncio
|
|
217
|
+
async def test_non_retryable_error_raises_immediately(sleeps: list[float]):
|
|
218
|
+
factory, calls = _make_factory([([], _status_error(400))])
|
|
219
|
+
with pytest.raises(APIStatusError):
|
|
220
|
+
[e async for e in stream_with_retry(factory, RetryPolicy())]
|
|
221
|
+
assert calls() == 1
|
|
222
|
+
assert sleeps == []
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@pytest.mark.asyncio
|
|
226
|
+
async def test_error_after_first_event_passes_through(sleeps: list[float]):
|
|
227
|
+
"""Core regression: once an event was yielded, no retry may happen."""
|
|
228
|
+
factory, calls = _make_factory([([TextChunk(text="partial")], _rate_limit())])
|
|
229
|
+
collected = []
|
|
230
|
+
with pytest.raises(RateLimitError):
|
|
231
|
+
async for e in stream_with_retry(factory, RetryPolicy()):
|
|
232
|
+
collected.append(e)
|
|
233
|
+
assert collected == [TextChunk(text="partial")]
|
|
234
|
+
assert calls() == 1
|
|
235
|
+
assert sleeps == []
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@pytest.mark.asyncio
|
|
239
|
+
async def test_max_retries_zero_disables_retry(sleeps: list[float]):
|
|
240
|
+
factory, calls = _make_factory([([], _rate_limit())])
|
|
241
|
+
with pytest.raises(RateLimitError):
|
|
242
|
+
[e async for e in stream_with_retry(factory, RetryPolicy(max_retries=0))]
|
|
243
|
+
assert calls() == 1
|
|
244
|
+
assert sleeps == []
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@pytest.mark.asyncio
|
|
248
|
+
async def test_exhausted_retries_raise_last_error(sleeps: list[float]):
|
|
249
|
+
factory, calls = _make_factory([([], _status_error(500))])
|
|
250
|
+
policy = RetryPolicy(max_retries=3)
|
|
251
|
+
with pytest.raises(APIStatusError):
|
|
252
|
+
[e async for e in stream_with_retry(factory, policy)]
|
|
253
|
+
assert calls() == 4 # max_retries + 1 attempts
|
|
254
|
+
assert len(sleeps) == 3
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@pytest.mark.asyncio
|
|
258
|
+
async def test_cancelled_error_passes_through(sleeps: list[float]):
|
|
259
|
+
"""Cancellation must never be retried or delayed."""
|
|
260
|
+
factory, calls = _make_factory([([], asyncio.CancelledError())])
|
|
261
|
+
with pytest.raises(asyncio.CancelledError):
|
|
262
|
+
[e async for e in stream_with_retry(factory, RetryPolicy())]
|
|
263
|
+
assert calls() == 1
|
|
264
|
+
assert sleeps == []
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# friendly_message
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_friendly_message_rate_limit():
|
|
273
|
+
msg = friendly_message(_rate_limit())
|
|
274
|
+
assert msg is not None
|
|
275
|
+
assert "限流" in msg and "稍后重发" in msg
|
|
276
|
+
msg = friendly_message(LLMHttpError(429))
|
|
277
|
+
assert msg is not None and "限流" in msg
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_friendly_message_server_error():
|
|
281
|
+
assert "服务异常" in (friendly_message(_status_error(500)) or "")
|
|
282
|
+
assert "服务异常" in (friendly_message(LLMHttpError(503)) or "")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_friendly_message_timeout_and_connection():
|
|
286
|
+
assert "超时" in (friendly_message(APITimeoutError(request=_request())) or "")
|
|
287
|
+
assert "超时" in (friendly_message(LLMHttpError(408)) or "")
|
|
288
|
+
conn = APIConnectionError(message="c", request=_request())
|
|
289
|
+
assert "网络连接异常" in (friendly_message(conn) or "")
|
|
290
|
+
assert "网络连接异常" in (
|
|
291
|
+
friendly_message(httpx.ConnectError("c", request=_request())) or ""
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_friendly_message_overloaded():
|
|
296
|
+
assert "过载" in (friendly_message(LLMOverloadedError("x")) or "")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_friendly_message_returns_none_for_other_errors():
|
|
300
|
+
assert friendly_message(_status_error(400)) is None
|
|
301
|
+
assert friendly_message(LLMHttpError(401)) is None
|
|
302
|
+
assert friendly_message(ValueError("x")) is None
|
limbo_code-0.1.0/PKG-INFO
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: limbo-code
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: A minimal terminal AI coding agent
|
|
5
|
-
Requires-Python: >=3.11
|
|
6
|
-
Requires-Dist: httpx>=0.27
|
|
7
|
-
Requires-Dist: openai>=1.30
|
|
8
|
-
Requires-Dist: pydantic>=2.0
|
|
9
|
-
Requires-Dist: textual>=0.58
|
|
10
|
-
Requires-Dist: toml>=0.10
|
|
11
|
-
Provides-Extra: dev
|
|
12
|
-
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
13
|
-
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
14
|
-
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
15
|
-
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
16
|
-
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
File without changes
|
|
File without changes
|
{limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/improve-codebase-architecture/HTML-REPORT.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|