kaizen-loop 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.
- kaizen_loop-0.1.0/.github/workflows/ci.yml +29 -0
- kaizen_loop-0.1.0/.github/workflows/release.yml +32 -0
- kaizen_loop-0.1.0/.gitignore +7 -0
- kaizen_loop-0.1.0/LICENSE +21 -0
- kaizen_loop-0.1.0/PKG-INFO +10 -0
- kaizen_loop-0.1.0/README.md +184 -0
- kaizen_loop-0.1.0/justfile +17 -0
- kaizen_loop-0.1.0/pyproject.toml +22 -0
- kaizen_loop-0.1.0/src/kaizen/__init__.py +1 -0
- kaizen_loop-0.1.0/src/kaizen/__main__.py +4 -0
- kaizen_loop-0.1.0/src/kaizen/agent.py +349 -0
- kaizen_loop-0.1.0/src/kaizen/cli.py +92 -0
- kaizen_loop-0.1.0/src/kaizen/config.py +31 -0
- kaizen_loop-0.1.0/src/kaizen/findings.py +53 -0
- kaizen_loop-0.1.0/src/kaizen/git.py +208 -0
- kaizen_loop-0.1.0/src/kaizen/loop.py +248 -0
- kaizen_loop-0.1.0/src/kaizen/orchestrator.py +159 -0
- kaizen_loop-0.1.0/src/kaizen/review_prompt.py +66 -0
- kaizen_loop-0.1.0/src/kaizen/run.py +93 -0
- kaizen_loop-0.1.0/src/kaizen/steps/__init__.py +11 -0
- kaizen_loop-0.1.0/src/kaizen/steps/pr.py +96 -0
- kaizen_loop-0.1.0/src/kaizen/steps/push.py +23 -0
- kaizen_loop-0.1.0/src/kaizen/steps/review.py +55 -0
- kaizen_loop-0.1.0/src/kaizen/work_prompt.py +43 -0
- kaizen_loop-0.1.0/tests/test_import.py +6 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
lint:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: astral-sh/setup-uv@v8.2.0
|
|
15
|
+
- run: uvx ruff check src/
|
|
16
|
+
|
|
17
|
+
test:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
strategy:
|
|
20
|
+
matrix:
|
|
21
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- uses: astral-sh/setup-uv@v8.2.0
|
|
25
|
+
with:
|
|
26
|
+
python-version: ${{ matrix.python-version }}
|
|
27
|
+
- run: uv venv
|
|
28
|
+
- run: uv pip install -e ".[dev]"
|
|
29
|
+
- run: uv run python -m pytest
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: astral-sh/setup-uv@v8.2.0
|
|
13
|
+
with:
|
|
14
|
+
python-version: "3.12"
|
|
15
|
+
- run: uv build
|
|
16
|
+
- uses: actions/upload-artifact@v4
|
|
17
|
+
with:
|
|
18
|
+
name: dist
|
|
19
|
+
path: dist/
|
|
20
|
+
|
|
21
|
+
publish:
|
|
22
|
+
needs: build
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
environment: pypi
|
|
25
|
+
permissions:
|
|
26
|
+
id-token: write
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/download-artifact@v4
|
|
29
|
+
with:
|
|
30
|
+
name: dist
|
|
31
|
+
path: dist/
|
|
32
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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: kaizen-loop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Continuous code improvement: autonomous work → review → fix → ship
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
10
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# kaizen-loop
|
|
2
|
+
|
|
3
|
+
Continuous code improvement: autonomous work → review → fix → ship.
|
|
4
|
+
|
|
5
|
+
A zero-dependency Python harness that drives [opencode](https://opencode.ai) through the full cycle of writing, validating, and shipping code — fully autonomous.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Install
|
|
11
|
+
pip install -e .
|
|
12
|
+
|
|
13
|
+
# Run
|
|
14
|
+
kaizen "add a --json flag to the status command"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That's it. kaizen creates an isolated branch, the agent does the work, the pipeline reviews it, fixes what it can, pushes to origin, and opens a PR.
|
|
18
|
+
|
|
19
|
+
### Prerequisites
|
|
20
|
+
|
|
21
|
+
- Python 3.10+
|
|
22
|
+
- [opencode](https://opencode.ai) (`curl -fsSL https://opencode.ai/install | bash`)
|
|
23
|
+
- `git`
|
|
24
|
+
- `gh` CLI (for PR creation)
|
|
25
|
+
|
|
26
|
+
### Options
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
kaizen "prompt" [-C /path/to/repo] [--max-iterations N] [--max-review-rounds N] [--no-worktree] [--server-url URL]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| Option | Meaning |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `-C, --directory` | Path to git repo (default: current dir) |
|
|
35
|
+
| `--max-iterations` | Max work iterations |
|
|
36
|
+
| `--max-review-rounds` | Max review rounds |
|
|
37
|
+
| `--no-worktree` | Work in current tree instead of a worktree |
|
|
38
|
+
| `--opencode-bin` | Path to opencode binary |
|
|
39
|
+
| `--server-url` | Reuse an existing `opencode serve` instance (e.g. `http://127.0.0.1:4096`) |
|
|
40
|
+
|
|
41
|
+
## How it works
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
kaizen "add --json flag"
|
|
45
|
+
│
|
|
46
|
+
┌───────────────────────────┐
|
|
47
|
+
│ SETUP │
|
|
48
|
+
│ fetch origin/main │
|
|
49
|
+
│ create branch kaizen/<slug>│
|
|
50
|
+
│ create isolated worktree │
|
|
51
|
+
└────────────┬──────────────┘
|
|
52
|
+
│
|
|
53
|
+
┌───────────────────────────────┐
|
|
54
|
+
│ WORK │
|
|
55
|
+
│ agent reads prompt + notes │
|
|
56
|
+
│ makes changes, commits │
|
|
57
|
+
│ repeats until done or limit │
|
|
58
|
+
└───────────────┬───────────────┘
|
|
59
|
+
│
|
|
60
|
+
┌────────────────────────────┐
|
|
61
|
+
│ REVIEW │
|
|
62
|
+
│ agent reviews diff │
|
|
63
|
+
│ returns structured findings │
|
|
64
|
+
│ ┌────────────────────────┐ │
|
|
65
|
+
│ │ auto-fix findings? │ │
|
|
66
|
+
│ │ → agent fixes, re-review│ │
|
|
67
|
+
│ └────────────────────────┘ │
|
|
68
|
+
└────────────┬───────────────┘
|
|
69
|
+
│
|
|
70
|
+
┌──────────────────┐
|
|
71
|
+
│ SHIP │
|
|
72
|
+
│ push to origin │
|
|
73
|
+
│ create PR (gh) │
|
|
74
|
+
└────────┬─────────┘
|
|
75
|
+
│
|
|
76
|
+
┌──────────────────┐
|
|
77
|
+
│ CLEANUP │
|
|
78
|
+
│ remove worktree │
|
|
79
|
+
│ delete branch │
|
|
80
|
+
│ print PR URL │
|
|
81
|
+
└──────────────────┘
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Findings
|
|
85
|
+
|
|
86
|
+
The review step returns structured findings with actions:
|
|
87
|
+
|
|
88
|
+
| Action | Meaning | Who handles it |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `no-op` | Informational | Silently accepted |
|
|
91
|
+
| `auto-fix` | Mechanical fix (typos, dead code, missing error handling, behavioral changes) | Agent fixes automatically |
|
|
92
|
+
|
|
93
|
+
### Worktree isolation
|
|
94
|
+
|
|
95
|
+
By default kaizen creates a git worktree for each run. Your working directory stays clean while the agent operates in isolation. The worktree is removed after the run completes.
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
my-project/ ← your tree, untouched
|
|
99
|
+
.kaizen/worktrees/<slug>/ ← agent works here
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The opencode server starts in the main repo directory; individual sessions point at the worktree. This lets opencode see the full project context while the agent modifies only the isolated branch.
|
|
103
|
+
|
|
104
|
+
### Iteration memory
|
|
105
|
+
|
|
106
|
+
The work phase uses a `notes.md` file to carry context across iterations. Each iteration the agent reads prior notes, does one incremental piece of work, and appends its summary. Failed iterations still record learnings.
|
|
107
|
+
|
|
108
|
+
## Configuration
|
|
109
|
+
|
|
110
|
+
`~/.kaizen/config.json` (created automatically on first run):
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"max_work_iterations": null,
|
|
115
|
+
"max_review_rounds": 3,
|
|
116
|
+
"max_consecutive_failures": 3,
|
|
117
|
+
"opencode_bin": "opencode",
|
|
118
|
+
"use_worktree": true,
|
|
119
|
+
"server_url": null
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Shared server
|
|
124
|
+
|
|
125
|
+
By default each kaizen run spawns its own `opencode serve` process. For batch work, start a server once and reuse it across runs:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
opencode serve --hostname 127.0.0.1 --port 4096 &
|
|
129
|
+
kaizen "fix issue #1" --server-url http://127.0.0.1:4096
|
|
130
|
+
kaizen "fix issue #2" --server-url http://127.0.0.1:4096
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Or set it in config: `"server_url": "http://127.0.0.1:4096"`.
|
|
134
|
+
|
|
135
|
+
When `--server-url` is set, kaizen skips server startup/teardown — it creates and destroys sessions on the existing server instead.
|
|
136
|
+
|
|
137
|
+
**Checking for an existing server.** To find any running opencode servers on the machine (the TUI and `opencode serve` both start one):
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
pgrep -a opencode
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
To check a specific port (e.g. the default `4096`):
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
curl -sf http://127.0.0.1:4096/global/health
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
A `200` response means a server is running and ready. Note: the TUI starts its own internal server on a random port (unless overridden with `--port`).
|
|
150
|
+
|
|
151
|
+
**Closing the server.** When you're done, stop the background process:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
kill %1 # if launched with & in the current shell
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Or send `POST /instance/dispose` to shut the server down cleanly over HTTP:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
curl -X POST http://127.0.0.1:4096/instance/dispose
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Note: the TUI runs its own server internally — disposing it will also close the TUI. Only shut down a server you started yourself.
|
|
164
|
+
|
|
165
|
+
## Project structure
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
src/kaizen/
|
|
169
|
+
agent.py # opencode HTTP server integration
|
|
170
|
+
git.py # git operations
|
|
171
|
+
config.py # ~/.kaizen/config.json
|
|
172
|
+
run.py # run state + notes
|
|
173
|
+
orchestrator.py # work iteration loop
|
|
174
|
+
work_prompt.py # iteration prompt builder
|
|
175
|
+
findings.py # finding types and action classification
|
|
176
|
+
review_prompt.py # review + fix prompt builders
|
|
177
|
+
loop.py # coordinates work → review → fix → ship
|
|
178
|
+
cli.py # CLI entry point
|
|
179
|
+
steps/ # review, push, pr
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kaizen-loop"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Continuous code improvement: autonomous work → review → fix → ship"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
dev = ["pytest", "ruff"]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
kaizen = "kaizen.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/kaizen"]
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import http.client
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import socket
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from urllib.parse import quote, urlparse
|
|
11
|
+
|
|
12
|
+
_RETRYABLE_STATUS = frozenset({502, 503, 504})
|
|
13
|
+
_MAX_RETRIES = 2
|
|
14
|
+
_RETRY_BASE_DELAY = 0.5
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _validate_server_url(url: str | None) -> None:
|
|
18
|
+
if url is None:
|
|
19
|
+
return
|
|
20
|
+
parsed = urlparse(url)
|
|
21
|
+
if parsed.scheme not in ("http", "https"):
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"Invalid server URL: {url!r}. Must start with http:// or https://"
|
|
24
|
+
)
|
|
25
|
+
if not parsed.hostname:
|
|
26
|
+
raise ValueError(f"Invalid server URL: {url!r}. No hostname found.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AgentResult:
|
|
31
|
+
output: dict
|
|
32
|
+
text: str = ""
|
|
33
|
+
input_tokens: int = 0
|
|
34
|
+
output_tokens: int = 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Session:
|
|
38
|
+
def __init__(self, agent: "OpenCodeAgent", session_id: str):
|
|
39
|
+
self._agent = agent
|
|
40
|
+
self._id = session_id
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def id(self) -> str:
|
|
44
|
+
return self._id
|
|
45
|
+
|
|
46
|
+
def send(self, prompt: str, schema: dict | None = None) -> AgentResult:
|
|
47
|
+
try:
|
|
48
|
+
return self._agent._send_message(self._id, prompt, schema)
|
|
49
|
+
except Exception:
|
|
50
|
+
self._agent._abort_session(self._id)
|
|
51
|
+
raise
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class OpenCodeAgent:
|
|
55
|
+
def __init__(self, bin_path: str = "opencode", server_url: str | None = None):
|
|
56
|
+
_validate_server_url(server_url)
|
|
57
|
+
self.bin_path = bin_path
|
|
58
|
+
self._external_server = server_url
|
|
59
|
+
self._process: subprocess.Popen | None = None
|
|
60
|
+
self._base_url: str | None = server_url
|
|
61
|
+
self._port: int | None = None
|
|
62
|
+
self._conn: http.client.HTTPConnection | None = None
|
|
63
|
+
|
|
64
|
+
def _get_conn(self) -> http.client.HTTPConnection:
|
|
65
|
+
if self._conn is not None:
|
|
66
|
+
return self._conn
|
|
67
|
+
url = self._base_url
|
|
68
|
+
if not url:
|
|
69
|
+
raise RuntimeError("No server URL configured")
|
|
70
|
+
parsed = urlparse(url)
|
|
71
|
+
self._conn = http.client.HTTPConnection(
|
|
72
|
+
parsed.hostname or "127.0.0.1",
|
|
73
|
+
parsed.port or 80,
|
|
74
|
+
timeout=300,
|
|
75
|
+
)
|
|
76
|
+
return self._conn
|
|
77
|
+
|
|
78
|
+
def _reconnect(self) -> http.client.HTTPConnection:
|
|
79
|
+
self._close_conn()
|
|
80
|
+
return self._get_conn()
|
|
81
|
+
|
|
82
|
+
def _close_conn(self) -> None:
|
|
83
|
+
if self._conn:
|
|
84
|
+
try:
|
|
85
|
+
self._conn.close()
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
self._conn = None
|
|
89
|
+
|
|
90
|
+
def _http_request(
|
|
91
|
+
self,
|
|
92
|
+
method: str,
|
|
93
|
+
path: str,
|
|
94
|
+
body: dict | None = None,
|
|
95
|
+
timeout: float | None = None,
|
|
96
|
+
max_retries: int = _MAX_RETRIES,
|
|
97
|
+
) -> dict:
|
|
98
|
+
conn = self._get_conn()
|
|
99
|
+
if timeout is not None:
|
|
100
|
+
conn.timeout = timeout
|
|
101
|
+
headers = {"Accept": "application/json"}
|
|
102
|
+
data = None
|
|
103
|
+
if body is not None:
|
|
104
|
+
data = json.dumps(body).encode()
|
|
105
|
+
headers["Content-Type"] = "application/json"
|
|
106
|
+
|
|
107
|
+
last_err: Exception | None = None
|
|
108
|
+
for attempt in range(max_retries + 1):
|
|
109
|
+
try:
|
|
110
|
+
conn.request(method, path, body=data, headers=headers)
|
|
111
|
+
resp = conn.getresponse()
|
|
112
|
+
resp_data = resp.read()
|
|
113
|
+
if 200 <= resp.status < 300:
|
|
114
|
+
return json.loads(resp_data)
|
|
115
|
+
if resp.status in _RETRYABLE_STATUS and attempt < max_retries:
|
|
116
|
+
last_err = RuntimeError(f"HTTP {resp.status}")
|
|
117
|
+
time.sleep(_RETRY_BASE_DELAY * (2**attempt))
|
|
118
|
+
conn = self._reconnect()
|
|
119
|
+
continue
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
f"HTTP {resp.status}: {resp_data.decode(errors='replace')}"
|
|
122
|
+
)
|
|
123
|
+
except (
|
|
124
|
+
ConnectionError,
|
|
125
|
+
OSError,
|
|
126
|
+
http.client.HTTPException,
|
|
127
|
+
) as e:
|
|
128
|
+
if attempt < max_retries:
|
|
129
|
+
last_err = e
|
|
130
|
+
time.sleep(_RETRY_BASE_DELAY * (2**attempt))
|
|
131
|
+
conn = self._reconnect()
|
|
132
|
+
continue
|
|
133
|
+
raise RuntimeError(
|
|
134
|
+
f"Request failed after {max_retries} retries: {e}"
|
|
135
|
+
) from e
|
|
136
|
+
raise last_err # type: ignore[misc]
|
|
137
|
+
|
|
138
|
+
def _request(
|
|
139
|
+
self,
|
|
140
|
+
path: str,
|
|
141
|
+
method: str = "GET",
|
|
142
|
+
body: dict | None = None,
|
|
143
|
+
timeout: float = 300,
|
|
144
|
+
) -> dict:
|
|
145
|
+
return self._http_request(method, path, body=body, timeout=timeout)
|
|
146
|
+
|
|
147
|
+
def _ensure_server(self, server_cwd: str) -> None:
|
|
148
|
+
if self._base_url:
|
|
149
|
+
self._check_external_server()
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
self._port = _get_free_port()
|
|
153
|
+
env = {**os.environ}
|
|
154
|
+
env.pop("OPENCODE_SERVER_USERNAME", None)
|
|
155
|
+
env.pop("OPENCODE_SERVER_PASSWORD", None)
|
|
156
|
+
|
|
157
|
+
self._process = subprocess.Popen(
|
|
158
|
+
[
|
|
159
|
+
self.bin_path,
|
|
160
|
+
"serve",
|
|
161
|
+
"--hostname",
|
|
162
|
+
"127.0.0.1",
|
|
163
|
+
"--port",
|
|
164
|
+
str(self._port),
|
|
165
|
+
"--print-logs",
|
|
166
|
+
],
|
|
167
|
+
cwd=server_cwd,
|
|
168
|
+
stdin=subprocess.DEVNULL,
|
|
169
|
+
stdout=subprocess.PIPE,
|
|
170
|
+
stderr=subprocess.PIPE,
|
|
171
|
+
env=env,
|
|
172
|
+
start_new_session=True,
|
|
173
|
+
)
|
|
174
|
+
self._base_url = f"http://127.0.0.1:{self._port}"
|
|
175
|
+
self._wait_healthy(timeout=30)
|
|
176
|
+
|
|
177
|
+
def _check_external_server(self) -> None:
|
|
178
|
+
url = self._base_url
|
|
179
|
+
if not url:
|
|
180
|
+
raise RuntimeError("No server URL configured")
|
|
181
|
+
try:
|
|
182
|
+
self._http_request(
|
|
183
|
+
"GET", "/global/health", timeout=5, max_retries=1
|
|
184
|
+
)
|
|
185
|
+
except (
|
|
186
|
+
RuntimeError,
|
|
187
|
+
ConnectionError,
|
|
188
|
+
OSError,
|
|
189
|
+
http.client.HTTPException,
|
|
190
|
+
) as e:
|
|
191
|
+
port = url.split(":")[-1].rstrip("/")
|
|
192
|
+
raise RuntimeError(
|
|
193
|
+
f"Shared server at {url} is not reachable. "
|
|
194
|
+
f"Start it with: opencode serve --hostname 127.0.0.1 --port {port} | Error: {e}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def _wait_healthy(self, timeout: float = 30) -> None:
|
|
198
|
+
deadline = time.monotonic() + timeout
|
|
199
|
+
while time.monotonic() < deadline:
|
|
200
|
+
if self._process and self._process.poll() is not None:
|
|
201
|
+
raise RuntimeError("opencode server exited during startup")
|
|
202
|
+
try:
|
|
203
|
+
conn = self._reconnect()
|
|
204
|
+
conn.timeout = 2
|
|
205
|
+
conn.request("GET", "/global/health")
|
|
206
|
+
resp = conn.getresponse()
|
|
207
|
+
resp.read()
|
|
208
|
+
if resp.status == 200:
|
|
209
|
+
return
|
|
210
|
+
except (ConnectionError, OSError, http.client.HTTPException):
|
|
211
|
+
pass
|
|
212
|
+
time.sleep(0.25)
|
|
213
|
+
raise RuntimeError(
|
|
214
|
+
f"opencode server did not become healthy on port {self._port}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _create_session(self, session_dir: str) -> str:
|
|
218
|
+
resp = self._request(
|
|
219
|
+
f"/session?directory={quote(session_dir, safe='')}",
|
|
220
|
+
method="POST",
|
|
221
|
+
body={},
|
|
222
|
+
timeout=10,
|
|
223
|
+
)
|
|
224
|
+
return resp.get("id", "")
|
|
225
|
+
|
|
226
|
+
@contextmanager
|
|
227
|
+
def session(self, work_dir: str, repo_dir: str | None = None):
|
|
228
|
+
server_cwd = repo_dir or work_dir
|
|
229
|
+
self._ensure_server(server_cwd)
|
|
230
|
+
session_id = self._create_session(work_dir)
|
|
231
|
+
try:
|
|
232
|
+
yield _Session(self, session_id)
|
|
233
|
+
finally:
|
|
234
|
+
self._delete_session(session_id)
|
|
235
|
+
|
|
236
|
+
def run(
|
|
237
|
+
self,
|
|
238
|
+
prompt: str,
|
|
239
|
+
work_dir: str,
|
|
240
|
+
schema: dict | None = None,
|
|
241
|
+
repo_dir: str | None = None,
|
|
242
|
+
) -> AgentResult:
|
|
243
|
+
server_cwd = repo_dir or work_dir
|
|
244
|
+
self._ensure_server(server_cwd)
|
|
245
|
+
session_id = self._create_session(work_dir)
|
|
246
|
+
try:
|
|
247
|
+
return self._send_message(session_id, prompt, schema)
|
|
248
|
+
except Exception:
|
|
249
|
+
self._abort_session(session_id)
|
|
250
|
+
raise
|
|
251
|
+
finally:
|
|
252
|
+
self._delete_session(session_id)
|
|
253
|
+
|
|
254
|
+
def _send_message(
|
|
255
|
+
self, session_id: str, prompt: str, schema: dict | None = None
|
|
256
|
+
) -> AgentResult:
|
|
257
|
+
body: dict = {
|
|
258
|
+
"role": "user",
|
|
259
|
+
"parts": [{"type": "text", "text": prompt}],
|
|
260
|
+
}
|
|
261
|
+
if schema:
|
|
262
|
+
body["format"] = {
|
|
263
|
+
"type": "json_schema",
|
|
264
|
+
"schema": schema,
|
|
265
|
+
"retryCount": 1,
|
|
266
|
+
}
|
|
267
|
+
result = self._request(
|
|
268
|
+
f"/session/{session_id}/message",
|
|
269
|
+
method="POST",
|
|
270
|
+
body=body,
|
|
271
|
+
timeout=600,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
info = result.get("info", {})
|
|
275
|
+
structured = info.get("structured")
|
|
276
|
+
tokens = info.get("tokens", {})
|
|
277
|
+
|
|
278
|
+
if structured:
|
|
279
|
+
return AgentResult(
|
|
280
|
+
output=structured,
|
|
281
|
+
input_tokens=tokens.get("input", 0),
|
|
282
|
+
output_tokens=tokens.get("output", 0),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
text = ""
|
|
286
|
+
for part in result.get("parts", []):
|
|
287
|
+
if part.get("type") == "text" and part.get("text"):
|
|
288
|
+
text = part["text"]
|
|
289
|
+
|
|
290
|
+
if not text:
|
|
291
|
+
raise RuntimeError("No structured output or text in agent response")
|
|
292
|
+
|
|
293
|
+
raise RuntimeError(f"Agent returned unstructured text: {text[:200]}")
|
|
294
|
+
|
|
295
|
+
def _abort_session(self, session_id: str) -> None:
|
|
296
|
+
try:
|
|
297
|
+
self._request(
|
|
298
|
+
f"/session/{session_id}/abort", method="POST", timeout=3
|
|
299
|
+
)
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
def _delete_session(self, session_id: str) -> None:
|
|
304
|
+
try:
|
|
305
|
+
self._request(
|
|
306
|
+
f"/session/{session_id}", method="DELETE", timeout=3
|
|
307
|
+
)
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
def close(self) -> None:
|
|
312
|
+
if self._external_server:
|
|
313
|
+
self._close_conn()
|
|
314
|
+
return
|
|
315
|
+
if self._base_url:
|
|
316
|
+
try:
|
|
317
|
+
self._http_request(
|
|
318
|
+
"POST", "/instance/dispose", timeout=5, max_retries=0
|
|
319
|
+
)
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
self._close_conn()
|
|
323
|
+
if self._process and self._process.poll() is None:
|
|
324
|
+
try:
|
|
325
|
+
self._process.wait(timeout=5)
|
|
326
|
+
except subprocess.TimeoutExpired:
|
|
327
|
+
try:
|
|
328
|
+
os.killpg(os.getpgid(self._process.pid), signal.SIGTERM)
|
|
329
|
+
except OSError:
|
|
330
|
+
self._process.terminate()
|
|
331
|
+
try:
|
|
332
|
+
self._process.wait(timeout=5)
|
|
333
|
+
except subprocess.TimeoutExpired:
|
|
334
|
+
try:
|
|
335
|
+
os.killpg(
|
|
336
|
+
os.getpgid(self._process.pid), signal.SIGKILL
|
|
337
|
+
)
|
|
338
|
+
except OSError:
|
|
339
|
+
self._process.kill()
|
|
340
|
+
self._process.wait(timeout=2)
|
|
341
|
+
self._process = None
|
|
342
|
+
self._base_url = None
|
|
343
|
+
self._port = None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _get_free_port() -> int:
|
|
347
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
348
|
+
s.bind(("127.0.0.1", 0))
|
|
349
|
+
return s.getsockname()[1]
|