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.
Files changed (107) hide show
  1. limbo_code-0.1.1/PKG-INFO +172 -0
  2. {limbo_code-0.1.0 → limbo_code-0.1.1}/pyproject.toml +2 -1
  3. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/config.py +68 -1
  4. limbo_code-0.1.1/src/limbo/llm/retry.py +198 -0
  5. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_config.py +66 -0
  6. limbo_code-0.1.1/tests/test_retry.py +302 -0
  7. limbo_code-0.1.0/PKG-INFO +0 -16
  8. {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/grill-with-docs/SKILL.md +0 -0
  9. {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/grill-with-docs/agents/openai.yaml +0 -0
  10. {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +0 -0
  11. {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/improve-codebase-architecture/SKILL.md +0 -0
  12. {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/improve-codebase-architecture/agents/openai.yaml +0 -0
  13. {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/tdd/SKILL.md +0 -0
  14. {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/tdd/agents/openai.yaml +0 -0
  15. {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/tdd/mocking.md +0 -0
  16. {limbo_code-0.1.0 → limbo_code-0.1.1}/.agents/skills/tdd/tests.md +0 -0
  17. {limbo_code-0.1.0 → limbo_code-0.1.1}/.github/workflows/publish.yml +0 -0
  18. {limbo_code-0.1.0 → limbo_code-0.1.1}/.github/workflows/test.yml +0 -0
  19. {limbo_code-0.1.0 → limbo_code-0.1.1}/.gitignore +0 -0
  20. {limbo_code-0.1.0 → limbo_code-0.1.1}/AGENTS.md +0 -0
  21. {limbo_code-0.1.0 → limbo_code-0.1.1}/README.md +0 -0
  22. {limbo_code-0.1.0 → limbo_code-0.1.1}/design/confirm_view.html +0 -0
  23. {limbo_code-0.1.0 → limbo_code-0.1.1}/design/limbo-ui-minimal.md +0 -0
  24. {limbo_code-0.1.0 → limbo_code-0.1.1}/design/limbo-ui-redesign.md +0 -0
  25. {limbo_code-0.1.0 → limbo_code-0.1.1}/design/prototype-minimal-confirm.png +0 -0
  26. {limbo_code-0.1.0 → limbo_code-0.1.1}/design/prototype-minimal.html +0 -0
  27. {limbo_code-0.1.0 → limbo_code-0.1.1}/design/prototype-minimal.png +0 -0
  28. {limbo_code-0.1.0 → limbo_code-0.1.1}/design/prototype.html +0 -0
  29. {limbo_code-0.1.0 → limbo_code-0.1.1}/design/prototype.png +0 -0
  30. {limbo_code-0.1.0 → limbo_code-0.1.1}/docs/assets/limbo-current-ui.png +0 -0
  31. {limbo_code-0.1.0 → limbo_code-0.1.1}/docs/assets/limbo-new-ui.png +0 -0
  32. {limbo_code-0.1.0 → limbo_code-0.1.1}/docs/session-management.md +0 -0
  33. {limbo_code-0.1.0 → limbo_code-0.1.1}/docs/skills.md +0 -0
  34. {limbo_code-0.1.0 → limbo_code-0.1.1}/docs/ui-redesign-proposal.md +0 -0
  35. {limbo_code-0.1.0 → limbo_code-0.1.1}/skills-lock.json +0 -0
  36. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/__init__.py +0 -0
  37. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/__main__.py +0 -0
  38. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/agent.py +0 -0
  39. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/app.py +0 -0
  40. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/history.py +0 -0
  41. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/llm/__init__.py +0 -0
  42. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/llm/anthropic_client.py +0 -0
  43. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/llm/catalog.py +0 -0
  44. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/llm/client.py +0 -0
  45. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/llm/factory.py +0 -0
  46. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/llm/openai_client.py +0 -0
  47. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/models.py +0 -0
  48. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/sessions.py +0 -0
  49. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/skills.py +0 -0
  50. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/__init__.py +0 -0
  51. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/base.py +0 -0
  52. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/bash.py +0 -0
  53. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/edit.py +0 -0
  54. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/find.py +0 -0
  55. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/grep.py +0 -0
  56. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/ignore.py +0 -0
  57. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/ls.py +0 -0
  58. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/read.py +0 -0
  59. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/registry.py +0 -0
  60. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/tools/write.py +0 -0
  61. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/trace.py +0 -0
  62. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/__init__.py +0 -0
  63. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/app.py +0 -0
  64. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/app.tcss +0 -0
  65. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/banner.py +0 -0
  66. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/commands.py +0 -0
  67. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/screens/__init__.py +0 -0
  68. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/screens/game2048.py +0 -0
  69. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/screens/main.py +0 -0
  70. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/screens/session_picker.py +0 -0
  71. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/widgets/__init__.py +0 -0
  72. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/widgets/chat.py +0 -0
  73. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/widgets/command_menu.py +0 -0
  74. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/widgets/input.py +0 -0
  75. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/widgets/status_bar.py +0 -0
  76. {limbo_code-0.1.0 → limbo_code-0.1.1}/src/limbo/ui/widgets/tool_card.py +0 -0
  77. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_agent.py +0 -0
  78. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_anthropic_client.py +0 -0
  79. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_catalog.py +0 -0
  80. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_cli.py +0 -0
  81. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_history.py +0 -0
  82. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_integration.py +0 -0
  83. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_llm_client.py +0 -0
  84. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_models.py +0 -0
  85. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_sessions.py +0 -0
  86. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_skills.py +0 -0
  87. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/test_trace.py +0 -0
  88. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_base.py +0 -0
  89. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_bash.py +0 -0
  90. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_edit.py +0 -0
  91. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_find.py +0 -0
  92. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_grep.py +0 -0
  93. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_ignore.py +0 -0
  94. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_ls.py +0 -0
  95. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_read.py +0 -0
  96. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_registry.py +0 -0
  97. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/tools/test_write.py +0 -0
  98. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_app_smoke.py +0 -0
  99. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_command_menu.py +0 -0
  100. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_commands.py +0 -0
  101. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_game2048.py +0 -0
  102. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_input_history.py +0 -0
  103. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_main_screen.py +0 -0
  104. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_sessions_ui.py +0 -0
  105. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_skills_ui.py +0 -0
  106. {limbo_code-0.1.0 → limbo_code-0.1.1}/tests/ui/test_startup_art.py +0 -0
  107. {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
+ ```
@@ -1,7 +1,8 @@
1
1
  [project]
2
2
  name = "limbo-code"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  description = "A minimal terminal AI coding agent"
5
+ readme = "README.md"
5
6
  requires-python = ">=3.11"
6
7
  dependencies = [
7
8
  "textual>=0.58",
@@ -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 BaseModel, Field, ValidationError, field_validator
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
File without changes
File without changes
File without changes
File without changes
File without changes