sisyphusfy 0.1.0__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 (60) hide show
  1. sisyphusfy-0.1.0/LICENSE +21 -0
  2. sisyphusfy-0.1.0/PKG-INFO +10 -0
  3. sisyphusfy-0.1.0/README.md +331 -0
  4. sisyphusfy-0.1.0/pyproject.toml +25 -0
  5. sisyphusfy-0.1.0/setup.cfg +4 -0
  6. sisyphusfy-0.1.0/src/sisyphusfy/__init__.py +6 -0
  7. sisyphusfy-0.1.0/src/sisyphusfy/adapters.py +221 -0
  8. sisyphusfy-0.1.0/src/sisyphusfy/cli.py +637 -0
  9. sisyphusfy-0.1.0/src/sisyphusfy/config.py +234 -0
  10. sisyphusfy-0.1.0/src/sisyphusfy/distribution.py +157 -0
  11. sisyphusfy-0.1.0/src/sisyphusfy/hooks.py +210 -0
  12. sisyphusfy-0.1.0/src/sisyphusfy/human.py +489 -0
  13. sisyphusfy-0.1.0/src/sisyphusfy/loop.py +587 -0
  14. sisyphusfy-0.1.0/src/sisyphusfy/result.py +45 -0
  15. sisyphusfy-0.1.0/src/sisyphusfy/runner.py +95 -0
  16. sisyphusfy-0.1.0/src/sisyphusfy/workflows.py +360 -0
  17. sisyphusfy-0.1.0/src/sisyphusfy.egg-info/PKG-INFO +10 -0
  18. sisyphusfy-0.1.0/src/sisyphusfy.egg-info/SOURCES.txt +58 -0
  19. sisyphusfy-0.1.0/src/sisyphusfy.egg-info/dependency_links.txt +1 -0
  20. sisyphusfy-0.1.0/src/sisyphusfy.egg-info/entry_points.txt +2 -0
  21. sisyphusfy-0.1.0/src/sisyphusfy.egg-info/requires.txt +4 -0
  22. sisyphusfy-0.1.0/src/sisyphusfy.egg-info/top_level.txt +1 -0
  23. sisyphusfy-0.1.0/tests/test_adapter_contracts.py +159 -0
  24. sisyphusfy-0.1.0/tests/test_adapters.py +197 -0
  25. sisyphusfy-0.1.0/tests/test_audit_gaps.py +238 -0
  26. sisyphusfy-0.1.0/tests/test_audit_workflow.py +600 -0
  27. sisyphusfy-0.1.0/tests/test_cli.py +274 -0
  28. sisyphusfy-0.1.0/tests/test_commit_staging.py +134 -0
  29. sisyphusfy-0.1.0/tests/test_config.py +182 -0
  30. sisyphusfy-0.1.0/tests/test_context.py +151 -0
  31. sisyphusfy-0.1.0/tests/test_distribution.py +194 -0
  32. sisyphusfy-0.1.0/tests/test_documentation_contracts.py +157 -0
  33. sisyphusfy-0.1.0/tests/test_documented_links.py +64 -0
  34. sisyphusfy-0.1.0/tests/test_dryrun.py +80 -0
  35. sisyphusfy-0.1.0/tests/test_dryrun_boundaries.py +177 -0
  36. sisyphusfy-0.1.0/tests/test_fallback_command.py +161 -0
  37. sisyphusfy-0.1.0/tests/test_hooks.py +311 -0
  38. sisyphusfy-0.1.0/tests/test_human.py +261 -0
  39. sisyphusfy-0.1.0/tests/test_loop_cli.py +156 -0
  40. sisyphusfy-0.1.0/tests/test_loop_completion.py +136 -0
  41. sisyphusfy-0.1.0/tests/test_loop_dryrun.py +187 -0
  42. sisyphusfy-0.1.0/tests/test_loop_fresh_process.py +117 -0
  43. sisyphusfy-0.1.0/tests/test_loop_results.py +130 -0
  44. sisyphusfy-0.1.0/tests/test_loop_state.py +145 -0
  45. sisyphusfy-0.1.0/tests/test_loop_stopping.py +157 -0
  46. sisyphusfy-0.1.0/tests/test_loop_verification.py +161 -0
  47. sisyphusfy-0.1.0/tests/test_model_fallback.py +172 -0
  48. sisyphusfy-0.1.0/tests/test_npm_launcher.py +128 -0
  49. sisyphusfy-0.1.0/tests/test_posix_installer.py +83 -0
  50. sisyphusfy-0.1.0/tests/test_powershell_installer.py +124 -0
  51. sisyphusfy-0.1.0/tests/test_process.py +147 -0
  52. sisyphusfy-0.1.0/tests/test_project_directory.py +205 -0
  53. sisyphusfy-0.1.0/tests/test_release_quality.py +54 -0
  54. sisyphusfy-0.1.0/tests/test_release_targets.py +179 -0
  55. sisyphusfy-0.1.0/tests/test_spec_validation.py +53 -0
  56. sisyphusfy-0.1.0/tests/test_unit.py +86 -0
  57. sisyphusfy-0.1.0/tests/test_verification_ordering.py +231 -0
  58. sisyphusfy-0.1.0/tests/test_version_dryrun.py +83 -0
  59. sisyphusfy-0.1.0/tests/test_version_sync.py +82 -0
  60. sisyphusfy-0.1.0/tests/test_workflow_adapters.py +356 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sisyphusfy contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: sisyphusfy
3
+ Version: 0.1.0
4
+ Summary: Project-agnostic supervisor for disposable AI-agent sessions
5
+ Requires-Python: >=3.10
6
+ License-File: LICENSE
7
+ Provides-Extra: dev
8
+ Requires-Dist: pytest>=7.0; extra == "dev"
9
+ Requires-Dist: pyyaml>=6.0; extra == "dev"
10
+ Dynamic: license-file
@@ -0,0 +1,331 @@
1
+ # Sisyphusfy
2
+
3
+ Fresh agent sessions. Persistent project progress.
4
+
5
+ Sisyphusfy is a project-agnostic runner for executing software tasks through disposable AI-agent sessions. It keeps durable state in project files, supports multiple agent CLIs and model fallback, and avoids carrying an expensive conversation history from one task or specification to the next.
6
+
7
+ ## Why
8
+
9
+ AI coding sessions become expensive and fragile when conversation history grows across unrelated specifications. Sisyphusfy treats the workspace as the source of truth:
10
+
11
+ - one session performs a small unit of work;
12
+ - task state and a concise handoff are written to disk;
13
+ - failures, quota exhaustion, and timeouts can switch to another model;
14
+ - the next session reconstructs only the necessary context;
15
+ - a new specification starts with a fresh conversation.
16
+
17
+ ## Design principles
18
+
19
+ 1. The runner is not tied to a language, framework, repository layout, or OpenSpec.
20
+ 2. Agent CLIs are adapters, not dependencies of the loop engine.
21
+ 3. Task state is machine-checkable; handoff text is compact recovery context.
22
+ 4. Verification is performed by the runner, not merely claimed by the agent.
23
+ 5. Permission prompts and ambiguous choices stop safely instead of hanging forever.
24
+ 6. Commit, archive, and push are opt-in operations.
25
+ 7. Fresh context is the default; conversation continuation is explicit.
26
+
27
+ ## Installation
28
+
29
+ **Repository**: https://github.com/lileililiwen/sisyphusfy
30
+ **Website**: https://sisyphusfy.dev/
31
+ **Releases**: https://github.com/lileililiwen/sisyphusfy/releases
32
+
33
+ ### pip / pipx
34
+
35
+ ```bash
36
+ pip install sisyphusfy
37
+ # or
38
+ pipx install sisyphusfy
39
+ ```
40
+
41
+ ### npm
42
+
43
+ ```bash
44
+ npm install -g sisyphusfy
45
+ ```
46
+
47
+ ### POSIX installer
48
+
49
+ ```bash
50
+ curl -fsSL https://raw.githubusercontent.com/lileililiwen/sisyphusfy/main/install.sh | sh
51
+ ```
52
+
53
+ ### PowerShell installer (Windows)
54
+
55
+ ```powershell
56
+ irm https://raw.githubusercontent.com/lileililiwen/sisyphusfy/main/install.ps1 | iex
57
+ ```
58
+
59
+ ### From a checkout
60
+
61
+ ```bash
62
+ pip install -e .
63
+ sisyphusfy --help
64
+ ```
65
+
66
+ The package requires Python 3.10 or newer. OpenCode, CodeBuddy, and any
67
+ project-specific verification tools are installed separately.
68
+
69
+ ## Quick start: one invocation
70
+
71
+ Run a command with timeout:
72
+
73
+ ```bash
74
+ sisyphusfy -t 30 -- echo "hello world"
75
+ ```
76
+
77
+ Dry-run (shows command without executing):
78
+
79
+ ```bash
80
+ sisyphusfy --dry-run -- echo "hello world"
81
+ ```
82
+
83
+ JSON output for automation:
84
+
85
+ ```bash
86
+ sisyphusfy --json -- python3 script.py
87
+ ```
88
+
89
+ Deliver a prompt via stdin:
90
+
91
+ ```bash
92
+ sisyphusfy -p "explain this repository" -- opencode run
93
+ ```
94
+
95
+ Set working directory and environment variables:
96
+
97
+ ```bash
98
+ sisyphusfy -d /path/to/project -t 60 -e API_KEY=abc123 -e DEBUG=1 -- my-agent
99
+ ```
100
+
101
+ ## Quick start: human-friendly workflow
102
+
103
+ The high-level commands provide a simpler interface for common workflows.
104
+ They automatically discover tasks, configuration, and agent settings.
105
+
106
+ Initialize a project:
107
+
108
+ ```bash
109
+ sisyphusfy init
110
+ ```
111
+
112
+ Run an OpenSpec change:
113
+
114
+ ```bash
115
+ sisyphusfy run my-change
116
+ ```
117
+
118
+ Resume from durable state:
119
+
120
+ ```bash
121
+ sisyphusfy resume
122
+ ```
123
+
124
+ Check project status:
125
+
126
+ ```bash
127
+ sisyphusfy status
128
+ ```
129
+
130
+ Diagnose configuration issues:
131
+
132
+ ```bash
133
+ sisyphusfy doctor
134
+ ```
135
+
136
+ Dry-run to preview planned actions:
137
+
138
+ ```bash
139
+ sisyphusfy run my-change --dry-run
140
+ ```
141
+
142
+ Use JSON output for automation:
143
+
144
+ ```bash
145
+ sisyphusfy status --json
146
+ ```
147
+
148
+ Configuration precedence: CLI flags > `.sisyphusfy.toml` > user defaults > built-in defaults.
149
+
150
+ The selected project directory (`--project-dir`, default `.`) is the working
151
+ directory for every subprocess: agent, verification, workflow validation, and
152
+ completion hooks. Relative paths resolve against it.
153
+
154
+ ## Documentation
155
+
156
+ | Document | Contents |
157
+ |----------|----------|
158
+ | [docs/configuration.md](docs/configuration.md) | Every `.sisyphusfy.toml` field: type, default, precedence, safety |
159
+ | [docs/adapters.md](docs/adapters.md) | `AgentAdapter` protocol, registry, command construction, model fallback, failure classification, workflow adapters |
160
+ | [docs/examples.md](docs/examples.md) | Minimal end-to-end setups for Python, Rust, JavaScript, Flutter, and .NET |
161
+ | [docs/ci.md](docs/ci.md) | Continuous-integration usage and JSON result consumption |
162
+ | [docs/security.md](docs/security.md) | Command-execution model and review checklist |
163
+ | [docs/deployment.md](docs/deployment.md) | GitHub Pages and custom domain setup |
164
+
165
+ ## Quick start: durable loop
166
+
167
+ The loop starts a fresh agent process for every iteration. The task file and
168
+ handoff file are the durable state; previous agent conversation history is not
169
+ restored.
170
+
171
+ For a Markdown checklist:
172
+
173
+ ```bash
174
+ sisyphusfy loop \
175
+ --task-path TASKS.md \
176
+ --handoff-path HANDOFF.md \
177
+ --completion-strategy markdown \
178
+ --adapter opencode \
179
+ --model-chain mimo-v2.5 minimax-3 \
180
+ --agent-timeout 2700 \
181
+ --max-iterations 20 \
182
+ --verification-command pytest
183
+ ```
184
+
185
+ Use CodeBuddy instead:
186
+
187
+ ```bash
188
+ sisyphusfy loop \
189
+ --task-path TASKS.md \
190
+ --handoff-path HANDOFF.md \
191
+ --completion-strategy markdown \
192
+ --adapter codebuddy \
193
+ --model-chain minimax-3
194
+ ```
195
+
196
+ For an OpenSpec change, point the workflow adapter at the change directory:
197
+
198
+ ```bash
199
+ sisyphusfy loop \
200
+ --task-path openspec/changes/my-change/tasks.md \
201
+ --handoff-path HANDOFF.md \
202
+ --workflow-type openspec \
203
+ --workflow-change-dir openspec/changes/my-change \
204
+ --workflow-validation-command openspec validate my-change --strict \
205
+ --adapter opencode \
206
+ --model-chain mimo-v2.5 minimax-3
207
+ ```
208
+
209
+ The default prompt is intentionally short. It asks the agent to read the
210
+ configured task and handoff files, implement one task, update state, and stop
211
+ when blocked. Override it with `--prompt-template` when a project needs a
212
+ different instruction contract.
213
+
214
+ ## Dry-run and machine output
215
+
216
+ Preview a single command without executing it:
217
+
218
+ ```bash
219
+ sisyphusfy --dry-run -- echo "hello world"
220
+ ```
221
+
222
+ Preview the loop and hooks:
223
+
224
+ ```bash
225
+ sisyphusfy loop --dry-run \
226
+ --task-path TASKS.md \
227
+ --completion-strategy markdown \
228
+ --adapter opencode \
229
+ --model-chain mimo-v2.5
230
+ ```
231
+
232
+ Use `--json` for automation and CI consumers.
233
+
234
+ ## Optional completion hooks
235
+
236
+ Archive and commit hooks are disabled by default. Enable them only after
237
+ verification, and always provide an explicit commit allowlist:
238
+
239
+ ```bash
240
+ sisyphusfy loop \
241
+ --task-path openspec/changes/my-change/tasks.md \
242
+ --workflow-type openspec \
243
+ --workflow-change-dir openspec/changes/my-change \
244
+ --workflow-validation-command openspec validate my-change --strict \
245
+ --adapter opencode \
246
+ --archive-command openspec archive my-change -y \
247
+ --commit-command git commit -m "feat: complete my change" \
248
+ --commit-allowed-files src README.md
249
+ ```
250
+
251
+ Review the dry-run output before enabling these hooks. Sisyphusfy does not
252
+ provide automatic push support.
253
+
254
+ ## Programmatic usage
255
+
256
+ ```python
257
+ from sisyphusfy.runner import run_agent
258
+ from sisyphusfy.result import Classification
259
+
260
+ result = run_agent(
261
+ ["python3", "script.py"],
262
+ working_directory="/path/to/project",
263
+ prompt="do the thing",
264
+ timeout=30.0,
265
+ env={"API_KEY": "abc123"},
266
+ )
267
+
268
+ if result.classification == Classification.SUCCESS:
269
+ print("Done:", result.stdout)
270
+ else:
271
+ print("Failed:", result.classification.value)
272
+ ```
273
+
274
+ ## Development
275
+
276
+ Development is tracked with OpenSpec. See `openspec/changes/` for active work and `openspec/specs/` for accepted capability specifications.
277
+
278
+ ```bash
279
+ openspec list
280
+ openspec list
281
+ openspec validate --all --strict
282
+ ```
283
+
284
+ The repository is initialized for both OpenCode and CodeBuddy workflows. Those integrations are development conveniences; the product itself remains agent-neutral.
285
+
286
+ ## Supported release targets
287
+
288
+ Installers download a prebuilt archive that matches the host platform and
289
+ architecture. The published set is the same set CI builds, and every target is
290
+ built on a runner whose native architecture matches the artifact label.
291
+
292
+ | Platform | Architecture | CI runner |
293
+ |----------|--------------|-----------|
294
+ | linux | x86_64 | `ubuntu-latest` |
295
+ | darwin | x86_64 | `macos-15-intel` |
296
+ | darwin | aarch64 | `macos-14` |
297
+ | win32 | x86_64 | `windows-latest` |
298
+
299
+ `linux/aarch64` is deferred: it is not published because no runner in the
300
+ current matrix produces it natively. Install with `pip install sisyphusfy` on
301
+ that platform. The installer refuses a deferred target instead of downloading a
302
+ mislabeled binary.
303
+
304
+ ## Status
305
+
306
+ This repository contains the product design, roadmap, core implementation, and
307
+ OpenSpec history. The runner, loop engine, agent adapters, workflow
308
+ integrations, and completion hooks are implemented and tested.
309
+
310
+ ### Implemented capabilities
311
+
312
+ - Fresh agent sessions with no conversation history carry-over.
313
+ - Durable iteration loop with configurable completion, verification, and timeout.
314
+ - OpenCode adapter (`opencode run --model <model>`), CodeBuddy adapter (`codebuddy -p`), and generic command adapter.
315
+ - Model-chain fallback with retryable/non-retryable failure classification.
316
+ - Workflow adapters: Markdown checklists, JSON predicates, external commands, OpenSpec tasks.
317
+ - Configurable blocked-signal detection across stdout and stderr.
318
+ - Opt-in archive and commit hooks with explicit file allowlists.
319
+ - Default token-efficient prompt when no custom template is configured.
320
+ - Human-friendly CLI: `init`, `run`, `resume`, `status`, `doctor` subcommands.
321
+ - Project configuration in `.sisyphusfy.toml` with documented precedence.
322
+ - Automatic OpenSpec change discovery and task/handoff file detection.
323
+ - Human-readable progress, fallback, blocker, and next-action output.
324
+
325
+ ## Name
326
+
327
+ Sisyphus is the figure from Greek mythology condemned to repeatedly push a boulder uphill. Sisyphusfy turns that image into a practical engineering loop: repeated work is allowed, but durable state makes each attempt advance the project.
328
+
329
+ ## License
330
+
331
+ Sisyphusfy is released under the [MIT License](LICENSE).
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sisyphusfy"
7
+ description = "Project-agnostic supervisor for disposable AI-agent sessions"
8
+ requires-python = ">=3.10"
9
+ dependencies = []
10
+ dynamic = ["version"]
11
+
12
+ [project.optional-dependencies]
13
+ dev = ["pytest>=7.0", "pyyaml>=6.0"]
14
+
15
+ [project.scripts]
16
+ sisyphusfy = "sisyphusfy.cli:main"
17
+
18
+ [tool.setuptools.dynamic]
19
+ version = { attr = "sisyphusfy.__version__" }
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+
24
+ [tool.pytest.ini_options]
25
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """Sisyphusfy: project-agnostic supervisor for disposable AI-agent sessions."""
2
+
3
+ # Single source of truth for the release version. pyproject.toml reads it
4
+ # dynamically, and tests/test_version_sync.py enforces that every other
5
+ # artifact (npm manifest, npm launcher, installers) agrees with it.
6
+ __version__ = "0.1.0"
@@ -0,0 +1,221 @@
1
+ """Agent adapter protocol, registry, and built-in implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Protocol, runtime_checkable
8
+
9
+
10
+ class FailureClass(str, Enum):
11
+ RETRYABLE_PROVIDER = "retryable_provider"
12
+ RETRYABLE_PROCESS = "retryable_process"
13
+ NON_RETRYABLE = "non_retryable"
14
+
15
+
16
+ @runtime_checkable
17
+ class AgentAdapter(Protocol):
18
+ def build_command(
19
+ self,
20
+ working_directory: str,
21
+ prompt: str | None = None,
22
+ ) -> list[str]: ...
23
+
24
+ def supports_model(self, model: str) -> bool: ...
25
+
26
+ def classify_failure(self, exit_status: int, stderr: str) -> FailureClass: ...
27
+
28
+
29
+ @dataclass
30
+ class AdapterConfig:
31
+ name: str
32
+ model: str | None = None
33
+ command: list[str] = field(default_factory=list)
34
+
35
+
36
+ class AdapterError(Exception):
37
+ pass
38
+
39
+
40
+ class ModelChainExhausted(Exception):
41
+ def __init__(self, model_chain: list[str], attempts: list[str]) -> None:
42
+ self.model_chain = model_chain
43
+ self.attempts = attempts
44
+ msg = f"all models exhausted: {', '.join(attempts)}"
45
+ super().__init__(msg)
46
+
47
+
48
+ _RETRYABLE_PROVIDER_MARKERS = (
49
+ "quota",
50
+ "rate_limit",
51
+ "rate limit",
52
+ "429",
53
+ "503",
54
+ "502",
55
+ "provider",
56
+ "overloaded",
57
+ "capacity",
58
+ )
59
+
60
+
61
+ class GenericCommandAdapter:
62
+ def __init__(
63
+ self,
64
+ command: list[str],
65
+ model: str | None = None,
66
+ ) -> None:
67
+ self.command = command
68
+ self.model = model
69
+
70
+ def build_command(
71
+ self,
72
+ working_directory: str,
73
+ prompt: str | None = None,
74
+ ) -> list[str]:
75
+ return list(self.command)
76
+
77
+ def supports_model(self, model: str) -> bool:
78
+ return True
79
+
80
+ def classify_failure(self, exit_status: int, stderr: str) -> FailureClass:
81
+ lower = stderr.lower()
82
+ for marker in _RETRYABLE_PROVIDER_MARKERS:
83
+ if marker in lower:
84
+ return FailureClass.RETRYABLE_PROVIDER
85
+ if exit_status == -1 or exit_status > 128:
86
+ return FailureClass.RETRYABLE_PROCESS
87
+ return FailureClass.NON_RETRYABLE
88
+
89
+
90
+ class OpenCodeAdapter:
91
+ def __init__(self, model: str | None = None) -> None:
92
+ self.model = model
93
+
94
+ def build_command(
95
+ self,
96
+ working_directory: str,
97
+ prompt: str | None = None,
98
+ ) -> list[str]:
99
+ cmd = ["opencode", "run"]
100
+ if self.model:
101
+ cmd.extend(["--model", self.model])
102
+ if prompt:
103
+ cmd.append(prompt)
104
+ return cmd
105
+
106
+ def supports_model(self, model: str) -> bool:
107
+ return True
108
+
109
+ def classify_failure(self, exit_status: int, stderr: str) -> FailureClass:
110
+ lower = stderr.lower()
111
+ for marker in _RETRYABLE_PROVIDER_MARKERS:
112
+ if marker in lower:
113
+ return FailureClass.RETRYABLE_PROVIDER
114
+ if exit_status == -1 or exit_status > 128:
115
+ return FailureClass.RETRYABLE_PROCESS
116
+ return FailureClass.NON_RETRYABLE
117
+
118
+
119
+ class CodeBuddyAdapter:
120
+ def __init__(self, model: str | None = None) -> None:
121
+ self.model = model
122
+
123
+ def build_command(
124
+ self,
125
+ working_directory: str,
126
+ prompt: str | None = None,
127
+ ) -> list[str]:
128
+ cmd = ["codebuddy", "-p"]
129
+ if self.model:
130
+ cmd.extend(["--model", self.model])
131
+ if prompt:
132
+ cmd.append(prompt)
133
+ return cmd
134
+
135
+ def supports_model(self, model: str) -> bool:
136
+ return True
137
+
138
+ def classify_failure(self, exit_status: int, stderr: str) -> FailureClass:
139
+ lower = stderr.lower()
140
+ for marker in _RETRYABLE_PROVIDER_MARKERS:
141
+ if marker in lower:
142
+ return FailureClass.RETRYABLE_PROVIDER
143
+ if exit_status == -1 or exit_status > 128:
144
+ return FailureClass.RETRYABLE_PROCESS
145
+ return FailureClass.NON_RETRYABLE
146
+
147
+
148
+ _BUILTIN_ADAPTERS: dict[str, type] = {
149
+ "opencode": OpenCodeAdapter,
150
+ "codebuddy": CodeBuddyAdapter,
151
+ "generic": GenericCommandAdapter,
152
+ }
153
+
154
+
155
+ class AdapterRegistry:
156
+ def __init__(self) -> None:
157
+ self._adapters: dict[str, type] = dict(_BUILTIN_ADAPTERS)
158
+
159
+ def register(self, name: str, adapter_cls: type) -> None:
160
+ self._adapters[name] = adapter_cls
161
+
162
+ def get(self, name: str) -> type | None:
163
+ return self._adapters.get(name)
164
+
165
+ def list_names(self) -> list[str]:
166
+ return sorted(self._adapters.keys())
167
+
168
+
169
+ def resolve_adapter(
170
+ config: AdapterConfig,
171
+ agent_command: list[str] | None = None,
172
+ registry: AdapterRegistry | None = None,
173
+ ) -> AgentAdapter:
174
+ reg = registry or AdapterRegistry()
175
+ cls = reg.get(config.name)
176
+ if cls is None:
177
+ raise AdapterError(
178
+ f"unknown adapter: {config.name!r} "
179
+ f"(available: {', '.join(reg.list_names())})"
180
+ )
181
+ if config.name == "generic":
182
+ command = config.command if config.command else (agent_command or [])
183
+ return GenericCommandAdapter(command=command, model=config.model)
184
+ return cls(model=config.model)
185
+
186
+
187
+ def try_fallback(
188
+ adapter: AgentAdapter,
189
+ model_chain: list[str],
190
+ working_directory: str,
191
+ prompt: str | None,
192
+ run_fn,
193
+ *,
194
+ max_retries: int = 0,
195
+ ) -> tuple[object, list[str]]:
196
+ attempts: list[str] = []
197
+
198
+ models = model_chain if model_chain else [adapter.model or "default"]
199
+
200
+ for model in models:
201
+ if not adapter.supports_model(model):
202
+ continue
203
+ attempts.append(model)
204
+ result = run_fn(model=model)
205
+
206
+ raw_exit = getattr(result, "exit_status", None)
207
+ exit_status = raw_exit if raw_exit is not None else -1
208
+ stderr = getattr(result, "stderr", "") or ""
209
+ classification = adapter.classify_failure(exit_status, stderr)
210
+
211
+ if exit_status == 0:
212
+ return result, attempts
213
+ if classification == FailureClass.NON_RETRYABLE:
214
+ return result, attempts
215
+ if classification == FailureClass.RETRYABLE_PROVIDER:
216
+ continue
217
+ if classification == FailureClass.RETRYABLE_PROCESS:
218
+ continue
219
+ return result, attempts
220
+
221
+ raise ModelChainExhausted(model_chain=model_chain, attempts=attempts)