handcode 0.3.0rc1__py3-none-any.whl
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.
- agentctl/__init__.py +0 -0
- agentctl/adapters/__init__.py +0 -0
- agentctl/adapters/litellm/__init__.py +9 -0
- agentctl/adapters/litellm/hook.py +49 -0
- agentctl/adapters/litellm/recorder.py +187 -0
- agentctl/adapters/openhands/__init__.py +169 -0
- agentctl/adapters/openhands/handoff.py +155 -0
- agentctl/adapters/openhands/seam_b.py +259 -0
- agentctl/adapters/openhands/seam_c.py +209 -0
- agentctl/cli.py +1450 -0
- agentctl/control/__init__.py +0 -0
- agentctl/control/cost/__init__.py +4 -0
- agentctl/control/cost/ledger.py +210 -0
- agentctl/control/dash.py +697 -0
- agentctl/control/keys.py +440 -0
- agentctl/control/matrix/__init__.py +0 -0
- agentctl/control/matrix/data/tools.yaml +149 -0
- agentctl/control/policy/__init__.py +10 -0
- agentctl/control/policy/compile.py +258 -0
- agentctl/control/policy/data/policy.compiled.json +38 -0
- agentctl/control/policy/data/policy.yaml +46 -0
- agentctl/control/probe.py +399 -0
- agentctl/control/providers.py +293 -0
- agentctl/control/proxy.py +536 -0
- agentctl/control/proxyenv.py +309 -0
- agentctl/control/replay/__init__.py +14 -0
- agentctl/control/replay/cassette.py +281 -0
- agentctl/control/replay/server.py +109 -0
- agentctl/demo/__init__.py +214 -0
- agentctl/demo/child.py +84 -0
- agentctl/demo/mock.py +79 -0
- agentctl/demo/tool.py +62 -0
- agentctl/gha.py +488 -0
- agentctl/kernel/__init__.py +0 -0
- agentctl/kernel/classify.py +170 -0
- agentctl/kernel/gate.py +391 -0
- agentctl/kernel/hook.py +229 -0
- agentctl/kernel/ledger/__init__.py +0 -0
- agentctl/kernel/ledger/models.py +160 -0
- agentctl/kernel/ledger/schema.sql +62 -0
- agentctl/kernel/ledger/store.py +596 -0
- agentctl/kernel/paths.py +203 -0
- agentctl/kernel/policy.py +160 -0
- agentctl/kernel/reconcile/__init__.py +31 -0
- agentctl/kernel/reconcile/base.py +106 -0
- agentctl/kernel/reconcile/external.py +137 -0
- agentctl/kernel/reconcile/filesystem.py +162 -0
- agentctl/kernel/reconcile/git.py +162 -0
- agentctl/runtime/__init__.py +20 -0
- agentctl/runtime/citations.py +179 -0
- agentctl/runtime/config.py +97 -0
- agentctl/runtime/doctor.py +335 -0
- agentctl/runtime/init.py +148 -0
- agentctl/runtime/lease.py +143 -0
- agentctl/runtime/orchestrate.py +187 -0
- agentctl/runtime/plugins.py +130 -0
- agentctl/runtime/report.py +361 -0
- agentctl/runtime/runner.py +787 -0
- agentctl/runtime/runs.py +191 -0
- agentctl/runtime/subagent.py +274 -0
- agentctl/runtime/tools.py +350 -0
- handcode-0.3.0rc1.dist-info/METADATA +659 -0
- handcode-0.3.0rc1.dist-info/RECORD +67 -0
- handcode-0.3.0rc1.dist-info/WHEEL +5 -0
- handcode-0.3.0rc1.dist-info/entry_points.txt +3 -0
- handcode-0.3.0rc1.dist-info/licenses/LICENSE +21 -0
- handcode-0.3.0rc1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
r"""A local OpenAI-compatible endpoint that answers from a cassette.
|
|
2
|
+
|
|
3
|
+
Point `--base-url` at it and the agent runs normally: same SDK, same tools,
|
|
4
|
+
same gate, same ledger — but every completion comes from disk. No API key, no
|
|
5
|
+
network, no tokens, no sampling.
|
|
6
|
+
|
|
7
|
+
Stdlib only, deliberately. `docs/0021` §7 is what a casual dependency costs on
|
|
8
|
+
this project, and a test fixture is the last place to spend that.
|
|
9
|
+
|
|
10
|
+
**On a miss the server returns 502, and that is correct.** A miss means the
|
|
11
|
+
agent asked something the recording never contains, which is a real
|
|
12
|
+
divergence. Answering it with a plausible-looking completion would produce a
|
|
13
|
+
run that looks like a replay and is not one -- the same shape as the failures
|
|
14
|
+
in `docs/0024` and `docs/0028`, where a component returned something usable
|
|
15
|
+
instead of admitting it could not answer.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import threading
|
|
21
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .cassette import Cassette
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ReplayServer:
|
|
28
|
+
"""Serves one cassette on localhost for the life of a `with` block."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, cassette: Cassette, port: int = 0,
|
|
31
|
+
on_miss: Any = None):
|
|
32
|
+
self.cassette = cassette
|
|
33
|
+
self.on_miss = on_miss
|
|
34
|
+
self._srv = ThreadingHTTPServer(("127.0.0.1", port),
|
|
35
|
+
_handler_for(self))
|
|
36
|
+
self._thread: threading.Thread | None = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def port(self) -> int:
|
|
40
|
+
return self._srv.server_address[1]
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def base_url(self) -> str:
|
|
44
|
+
"""What to pass as `--base-url`. The SDK appends `/chat/completions`."""
|
|
45
|
+
return f"http://127.0.0.1:{self.port}/v1"
|
|
46
|
+
|
|
47
|
+
def start(self) -> "ReplayServer":
|
|
48
|
+
self._thread = threading.Thread(target=self._srv.serve_forever,
|
|
49
|
+
daemon=True)
|
|
50
|
+
self._thread.start()
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def stop(self) -> None:
|
|
54
|
+
self._srv.shutdown()
|
|
55
|
+
self._srv.server_close()
|
|
56
|
+
if self._thread:
|
|
57
|
+
self._thread.join(timeout=5)
|
|
58
|
+
|
|
59
|
+
def __enter__(self) -> "ReplayServer":
|
|
60
|
+
return self.start()
|
|
61
|
+
|
|
62
|
+
def __exit__(self, *exc) -> None:
|
|
63
|
+
self.stop()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _handler_for(server: ReplayServer):
|
|
67
|
+
class Handler(BaseHTTPRequestHandler):
|
|
68
|
+
protocol_version = "HTTP/1.1"
|
|
69
|
+
|
|
70
|
+
def do_POST(self): # noqa: N802
|
|
71
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
72
|
+
try:
|
|
73
|
+
request = json.loads(self.rfile.read(length) or b"{}")
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
return self._send(400, {"error": {"message": "bad json"}})
|
|
76
|
+
|
|
77
|
+
turn = server.cassette.match(request)
|
|
78
|
+
if turn is None:
|
|
79
|
+
miss = server.cassette.misses[-1]
|
|
80
|
+
if server.on_miss:
|
|
81
|
+
server.on_miss(miss)
|
|
82
|
+
# 502, not a fabricated completion: see the module docstring.
|
|
83
|
+
return self._send(502, {"error": {
|
|
84
|
+
"type": "agentctl_replay_miss",
|
|
85
|
+
"message": f"cassette miss -- {miss.describe()}",
|
|
86
|
+
}})
|
|
87
|
+
return self._send(200, turn.response)
|
|
88
|
+
|
|
89
|
+
def do_GET(self): # noqa: N802
|
|
90
|
+
"""`/v1/models` and health checks, so clients that probe are happy."""
|
|
91
|
+
if self.path.rstrip("/").endswith("/models"):
|
|
92
|
+
models = sorted({t.model for t in server.cassette if t.model})
|
|
93
|
+
return self._send(200, {"object": "list", "data": [
|
|
94
|
+
{"id": m, "object": "model"} for m in models]})
|
|
95
|
+
return self._send(200, {"status": "ok",
|
|
96
|
+
"turns": len(server.cassette)})
|
|
97
|
+
|
|
98
|
+
def _send(self, code: int, body: dict) -> None:
|
|
99
|
+
payload = json.dumps(body).encode("utf-8")
|
|
100
|
+
self.send_response(code)
|
|
101
|
+
self.send_header("Content-Type", "application/json")
|
|
102
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
103
|
+
self.end_headers()
|
|
104
|
+
self.wfile.write(payload)
|
|
105
|
+
|
|
106
|
+
def log_message(self, *_args) -> None:
|
|
107
|
+
"""Silence. The cassette records what happened, not stderr."""
|
|
108
|
+
|
|
109
|
+
return Handler
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""`agentctl demo`: the one thing this project exists for, in a minute, for $0.
|
|
2
|
+
|
|
3
|
+
`docs/0043` Phase 5. An agent runs `git commit`; its process is killed after
|
|
4
|
+
the commit has landed but before the result was recorded; the run is resumed.
|
|
5
|
+
Plain OpenHands re-drives the pending action and commits AGAIN (`docs/0014`
|
|
6
|
+
reproduced it). With agentctl the git probe sees the commit already landed and
|
|
7
|
+
hands the agent its result instead.
|
|
8
|
+
|
|
9
|
+
Both arms run for real: real git, real process death (`kill`, not a polite
|
|
10
|
+
shutdown), the real OpenHands SDK. Only the MODEL is scripted -- a local mock
|
|
11
|
+
that asks for the commit once and then stops -- which is what makes it free,
|
|
12
|
+
offline, deterministic, and the same on every OS. A recorded cassette was the
|
|
13
|
+
alternative and could not be: the SDK writes the shell name into the system
|
|
14
|
+
prompt, so a cassette replays only on the platform it was recorded on
|
|
15
|
+
(`docs/0029` §6), and git hashes and timings in tool output would break an
|
|
16
|
+
exact replay anyway (`docs/0050`).
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import stat
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
MARKER = "commit_landed.marker"
|
|
30
|
+
SLEEP_S = 8.0 # the crash window: committed, result not recorded
|
|
31
|
+
TIMEOUT_S = 150.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
|
|
35
|
+
return subprocess.run(["git", *args], cwd=str(repo), capture_output=True,
|
|
36
|
+
text=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _commits(repo: Path) -> int:
|
|
40
|
+
r = _git(repo, "rev-list", "--count", "HEAD")
|
|
41
|
+
return int(r.stdout.strip()) if r.returncode == 0 and r.stdout.strip() else 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _seed(ws: Path) -> Path:
|
|
45
|
+
repo = ws / "repo"
|
|
46
|
+
repo.mkdir(parents=True)
|
|
47
|
+
_git(repo, "init", "-q")
|
|
48
|
+
# Local identity: the demo must not depend on, or touch, the user's own.
|
|
49
|
+
_git(repo, "config", "user.name", "agentctl demo")
|
|
50
|
+
_git(repo, "config", "user.email", "demo@agentctl.invalid")
|
|
51
|
+
(repo / "README.md").write_text("demo\n", encoding="utf-8")
|
|
52
|
+
_git(repo, "add", "-A")
|
|
53
|
+
_git(repo, "commit", "-qm", "seed")
|
|
54
|
+
return repo
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _child(arm: str, mode: str, ws: Path, base_url: str) -> subprocess.Popen:
|
|
58
|
+
env = {**os.environ, "OPENHANDS_SUPPRESS_BANNER": "1",
|
|
59
|
+
"PYTHONIOENCODING": "utf-8",
|
|
60
|
+
# A private run index: the demo's runs are not the user's.
|
|
61
|
+
"AGENTCTL_HOME": str(ws / "home")}
|
|
62
|
+
out = open(ws / f"{arm}_{mode}.log", "w", encoding="utf-8", errors="replace")
|
|
63
|
+
p = subprocess.Popen(
|
|
64
|
+
[sys.executable, "-m", "agentctl.demo.child", "--arm", arm, "--mode", mode,
|
|
65
|
+
"--ws", str(ws), "--base-url", base_url],
|
|
66
|
+
stdout=out, stderr=subprocess.STDOUT, env=env, stdin=subprocess.DEVNULL)
|
|
67
|
+
p._log = out # type: ignore[attr-defined]
|
|
68
|
+
return p
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _wait_marker(ws: Path, p: subprocess.Popen) -> bool:
|
|
72
|
+
t0 = time.time()
|
|
73
|
+
while time.time() - t0 < TIMEOUT_S:
|
|
74
|
+
if (ws / MARKER).exists():
|
|
75
|
+
return True
|
|
76
|
+
if p.poll() is not None:
|
|
77
|
+
return False
|
|
78
|
+
time.sleep(0.05)
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _tail(path: Path, n: int = 12) -> str:
|
|
83
|
+
try:
|
|
84
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
85
|
+
except OSError:
|
|
86
|
+
return ""
|
|
87
|
+
return "\n".join(" " + l for l in lines[-n:])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _arm(arm: str, root: Path, base_url: str, say) -> dict:
|
|
91
|
+
"""Commit, kill inside the window, resume. Count commits at each step."""
|
|
92
|
+
ws = root / arm
|
|
93
|
+
ws.mkdir()
|
|
94
|
+
repo = _seed(ws)
|
|
95
|
+
before = _commits(repo)
|
|
96
|
+
|
|
97
|
+
p = _child(arm, "fresh", ws, base_url)
|
|
98
|
+
if not _wait_marker(ws, p):
|
|
99
|
+
p.kill(); p.wait(); p._log.close()
|
|
100
|
+
return {"error": "the commit never ran", "log": _tail(ws / f"{arm}_fresh.log")}
|
|
101
|
+
time.sleep(0.5)
|
|
102
|
+
p.kill() # not a shutdown: no finally, no flush, no atexit
|
|
103
|
+
p.wait(); p._log.close()
|
|
104
|
+
after_crash = _commits(repo) - before
|
|
105
|
+
say(f" the agent committed, then its process was killed ... "
|
|
106
|
+
f"{after_crash} new commit")
|
|
107
|
+
|
|
108
|
+
(ws / MARKER).unlink(missing_ok=True)
|
|
109
|
+
p = _child(arm, "resume", ws, base_url)
|
|
110
|
+
try:
|
|
111
|
+
p.wait(timeout=TIMEOUT_S)
|
|
112
|
+
except subprocess.TimeoutExpired:
|
|
113
|
+
p.kill(); p.wait()
|
|
114
|
+
p._log.close()
|
|
115
|
+
after_resume = _commits(repo) - before
|
|
116
|
+
return {"after_crash": after_crash, "after_resume": after_resume,
|
|
117
|
+
"ws": ws, "log": _tail(ws / f"{arm}_resume.log")}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _rmtree(path: Path) -> None:
|
|
121
|
+
def onerror(func, p, _exc): # git marks objects read-only
|
|
122
|
+
os.chmod(p, stat.S_IWRITE)
|
|
123
|
+
func(p)
|
|
124
|
+
shutil.rmtree(path, onerror=onerror)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def run_demo(keep: bool = False) -> int:
|
|
128
|
+
from agentctl.demo.mock import MockModel
|
|
129
|
+
|
|
130
|
+
def say(msg=""):
|
|
131
|
+
print(msg, flush=True)
|
|
132
|
+
|
|
133
|
+
if shutil.which("git") is None:
|
|
134
|
+
say(" the demo needs git on PATH.")
|
|
135
|
+
return 2
|
|
136
|
+
|
|
137
|
+
say("agentctl demo (no API key, no network, $0)")
|
|
138
|
+
say("")
|
|
139
|
+
say(" An agent runs `git commit`. Its process is killed AFTER the commit")
|
|
140
|
+
say(" lands but BEFORE the result is recorded. Then the run is resumed.")
|
|
141
|
+
say(" Real git, real OpenHands, real process death; only the model is")
|
|
142
|
+
say(" scripted, so this costs nothing and runs the same everywhere.")
|
|
143
|
+
say("")
|
|
144
|
+
|
|
145
|
+
root = Path(tempfile.mkdtemp(prefix="agentctl-demo-"))
|
|
146
|
+
results: dict = {}
|
|
147
|
+
try:
|
|
148
|
+
with MockModel() as model:
|
|
149
|
+
say(" 1. Plain OpenHands, no agentctl")
|
|
150
|
+
results["bare"] = r = _arm("bare", root, model.base_url, say)
|
|
151
|
+
if "error" not in r:
|
|
152
|
+
say(f" resumed ............................................ "
|
|
153
|
+
f"{r['after_resume']} new commits"
|
|
154
|
+
+ (" <- the same commit, twice" if r["after_resume"] > 1 else ""))
|
|
155
|
+
say("")
|
|
156
|
+
say(" 2. With agentctl")
|
|
157
|
+
results["guarded"] = g = _arm("guarded", root, model.base_url, say)
|
|
158
|
+
if "error" not in g:
|
|
159
|
+
say(f" resumed ............................................ "
|
|
160
|
+
f"{g['after_resume']} new commit"
|
|
161
|
+
+ (" <- once" if g["after_resume"] == 1 else ""))
|
|
162
|
+
ledger = _ledger_summary(g["ws"])
|
|
163
|
+
if ledger:
|
|
164
|
+
say(f" {ledger}")
|
|
165
|
+
say("")
|
|
166
|
+
return _verdict(results, say, root)
|
|
167
|
+
finally:
|
|
168
|
+
if keep:
|
|
169
|
+
say(f" kept: {root}")
|
|
170
|
+
else:
|
|
171
|
+
try:
|
|
172
|
+
_rmtree(root)
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _ledger_summary(ws: Path) -> str:
|
|
178
|
+
try:
|
|
179
|
+
from agentctl.kernel.ledger.store import LedgerStore
|
|
180
|
+
with LedgerStore(ws / "ledger.db", holder="demo") as s:
|
|
181
|
+
rows = s._db.execute("SELECT state, probe_verdict FROM effect_record"
|
|
182
|
+
).fetchall()
|
|
183
|
+
blocked = len(s.blocked())
|
|
184
|
+
parts = [f"{r['state']}" + (f" (git probe: {r['probe_verdict']})"
|
|
185
|
+
if r["probe_verdict"] else "") for r in rows]
|
|
186
|
+
return ("the ledger: the commit is " + ", ".join(parts)
|
|
187
|
+
+ f" · {blocked} waiting on you")
|
|
188
|
+
except Exception: # noqa: BLE001
|
|
189
|
+
return ""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _verdict(results: dict, say, root: Path) -> int:
|
|
193
|
+
bare, guarded = results.get("bare", {}), results.get("guarded", {})
|
|
194
|
+
for name, r in (("plain OpenHands", bare), ("agentctl", guarded)):
|
|
195
|
+
if "error" in r:
|
|
196
|
+
say(f" the demo could not run the {name} arm: {r['error']}.")
|
|
197
|
+
say(r.get("log", ""))
|
|
198
|
+
say(" This is a broken demo, not a result. `agentctl doctor` checks "
|
|
199
|
+
"the install.")
|
|
200
|
+
return 2
|
|
201
|
+
if guarded["after_resume"] == 1 and bare["after_resume"] >= 2:
|
|
202
|
+
say(" Without agentctl the crash cost a duplicate commit. With it, the")
|
|
203
|
+
say(" agent got the commit's result back and carried on, and nothing")
|
|
204
|
+
say(" needed a human.")
|
|
205
|
+
say("")
|
|
206
|
+
say(" On a real task: agentctl init")
|
|
207
|
+
say(' agentctl run "<task>" --accept "<your tests>"')
|
|
208
|
+
return 0
|
|
209
|
+
# Never claim what was not shown.
|
|
210
|
+
say(f" The demo did NOT show what it claims: plain OpenHands made "
|
|
211
|
+
f"{bare['after_resume']} commit(s), agentctl {guarded['after_resume']}.")
|
|
212
|
+
say(guarded.get("log", ""))
|
|
213
|
+
say(f" Please report this with the logs in {root} (re-run with --keep).")
|
|
214
|
+
return 1
|
agentctl/demo/child.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""One run of the demo, in its own process, so it can be killed for real.
|
|
2
|
+
|
|
3
|
+
python -m agentctl.demo.child --arm bare|guarded --mode fresh|resume \\
|
|
4
|
+
--ws DIR --base-url URL
|
|
5
|
+
|
|
6
|
+
`bare` is plain OpenHands. `guarded` adds agentctl through `protect()`, the
|
|
7
|
+
same public embed API the README documents -- not a demo-only path.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import os
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from agentctl.demo import MARKER, SLEEP_S
|
|
17
|
+
|
|
18
|
+
#: The demo tool is a commit: not safe to repeat, and git can be asked whether
|
|
19
|
+
#: it landed.
|
|
20
|
+
MATRIX = {"version": 1, "defaults": {"unknown_tool": "EXTERNAL"},
|
|
21
|
+
"tools": {"commit": {"class": "NON_IDEMPOTENT_WRITE", "probe": "git"}}}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> int:
|
|
25
|
+
ap = argparse.ArgumentParser()
|
|
26
|
+
ap.add_argument("--arm", choices=("bare", "guarded"), required=True)
|
|
27
|
+
ap.add_argument("--mode", choices=("fresh", "resume"), required=True)
|
|
28
|
+
ap.add_argument("--ws", type=Path, required=True)
|
|
29
|
+
ap.add_argument("--base-url", required=True)
|
|
30
|
+
a = ap.parse_args()
|
|
31
|
+
|
|
32
|
+
from agentctl.demo import tool
|
|
33
|
+
from agentctl.runtime.runner import _build_agent
|
|
34
|
+
from openhands.sdk import LLM, Conversation
|
|
35
|
+
|
|
36
|
+
ws, repo = a.ws, a.ws / "repo"
|
|
37
|
+
os.environ[tool.REPO_ENV] = str(repo)
|
|
38
|
+
os.environ[tool.MARKER_ENV] = str(ws / MARKER)
|
|
39
|
+
os.environ[tool.SLEEP_ENV] = str(SLEEP_S)
|
|
40
|
+
|
|
41
|
+
cid_file = ws / "conversation_id.txt"
|
|
42
|
+
if a.mode == "fresh":
|
|
43
|
+
cid = uuid.uuid4()
|
|
44
|
+
cid_file.write_text(str(cid), encoding="utf-8")
|
|
45
|
+
else:
|
|
46
|
+
cid = uuid.UUID(cid_file.read_text(encoding="utf-8").strip())
|
|
47
|
+
|
|
48
|
+
callbacks, guard = [], None
|
|
49
|
+
if a.arm == "guarded":
|
|
50
|
+
from agentctl.adapters.openhands import protect
|
|
51
|
+
from agentctl.runtime.lease import claim
|
|
52
|
+
|
|
53
|
+
ledger = ws / "ledger.db"
|
|
54
|
+
# The crashed holder is dead, so the resume may take its lease without
|
|
55
|
+
# being told to (`docs/0046`).
|
|
56
|
+
steal = claim(ledger, str(cid)).takeover if a.mode == "resume" else False
|
|
57
|
+
guard = protect(ledger=ledger, conversation_id=str(cid),
|
|
58
|
+
tools={tool.NAME: tool.CommitTool}, matrix=MATRIX,
|
|
59
|
+
repo_root=repo, takeover=steal)
|
|
60
|
+
callbacks = [guard.seam_b]
|
|
61
|
+
else:
|
|
62
|
+
tool.register()
|
|
63
|
+
|
|
64
|
+
llm = LLM(model="openai/demo-model", api_key="not-needed", base_url=a.base_url,
|
|
65
|
+
service_id="agentctl-demo", temperature=0.0, num_retries=1)
|
|
66
|
+
# Through the one place allowed to build an Agent, which pins
|
|
67
|
+
# tool_concurrency_limit to 1 (`docs/0038` §4.3) -- the demo too.
|
|
68
|
+
agent = _build_agent(llm, [tool.NAME])
|
|
69
|
+
conv = Conversation(agent=agent, workspace=str(repo),
|
|
70
|
+
persistence_dir=str(ws / "state"), conversation_id=cid,
|
|
71
|
+
delete_on_close=False, stuck_detection=False,
|
|
72
|
+
visualizer=None, callbacks=callbacks)
|
|
73
|
+
if guard is not None:
|
|
74
|
+
guard.attach(conv)
|
|
75
|
+
if a.mode == "fresh":
|
|
76
|
+
conv.send_message("Commit the fix with the message 'fix the typo'.")
|
|
77
|
+
conv.run()
|
|
78
|
+
if guard is not None:
|
|
79
|
+
guard.close()
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
raise SystemExit(main())
|
agentctl/demo/mock.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""The demo's scripted model: an OpenAI-compatible server on 127.0.0.1.
|
|
2
|
+
|
|
3
|
+
It does one thing: asks for the `commit` tool once, then says it is done. The
|
|
4
|
+
tool call id is FIXED, so a resumed run re-drives the very same action -- which
|
|
5
|
+
is the case under test. Adapted from `experiments/0000-falsification`'s mock,
|
|
6
|
+
reduced to what the demo needs.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
+
|
|
16
|
+
CALL_ID = "call_demo_commit_0001"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _completion(model: str, message: dict, finish: str) -> dict:
|
|
20
|
+
return {"id": f"chatcmpl-{uuid.uuid4().hex[:12]}", "object": "chat.completion",
|
|
21
|
+
"created": int(time.time()), "model": model,
|
|
22
|
+
"choices": [{"index": 0, "finish_reason": finish, "message": message}],
|
|
23
|
+
"usage": {"prompt_tokens": 100, "completion_tokens": 10,
|
|
24
|
+
"total_tokens": 110}}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
28
|
+
def log_message(self, *_):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
def _send(self, body: dict) -> None:
|
|
32
|
+
raw = json.dumps(body).encode()
|
|
33
|
+
self.send_response(200)
|
|
34
|
+
self.send_header("Content-Type", "application/json")
|
|
35
|
+
self.send_header("Content-Length", str(len(raw)))
|
|
36
|
+
self.end_headers()
|
|
37
|
+
self.wfile.write(raw)
|
|
38
|
+
|
|
39
|
+
def do_GET(self): # noqa: N802
|
|
40
|
+
self._send({"object": "list", "data": [{"id": "demo-model",
|
|
41
|
+
"object": "model"}]})
|
|
42
|
+
|
|
43
|
+
def do_POST(self): # noqa: N802
|
|
44
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
45
|
+
try:
|
|
46
|
+
body = json.loads(self.rfile.read(n) if n else b"{}")
|
|
47
|
+
except json.JSONDecodeError:
|
|
48
|
+
body = {}
|
|
49
|
+
model = body.get("model") or "demo-model"
|
|
50
|
+
done = any(m.get("role") == "tool" for m in body.get("messages") or [])
|
|
51
|
+
name = next(((t.get("function") or {}).get("name")
|
|
52
|
+
for t in body.get("tools") or [] if t.get("function")), "commit")
|
|
53
|
+
if done:
|
|
54
|
+
msg = {"role": "assistant", "content": "Committed. Done."}
|
|
55
|
+
self._send(_completion(model, msg, "stop"))
|
|
56
|
+
else:
|
|
57
|
+
msg = {"role": "assistant", "content": None, "tool_calls": [{
|
|
58
|
+
"id": CALL_ID, "type": "function",
|
|
59
|
+
"function": {"name": name,
|
|
60
|
+
"arguments": json.dumps({"message": "fix the typo"})}}]}
|
|
61
|
+
self._send(_completion(model, msg, "tool_calls"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MockModel:
|
|
65
|
+
def __init__(self):
|
|
66
|
+
self._srv = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
|
67
|
+
self._thread = threading.Thread(target=self._srv.serve_forever, daemon=True)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def base_url(self) -> str:
|
|
71
|
+
return f"http://127.0.0.1:{self._srv.server_address[1]}/v1"
|
|
72
|
+
|
|
73
|
+
def __enter__(self) -> "MockModel":
|
|
74
|
+
self._thread.start()
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __exit__(self, *_) -> None:
|
|
78
|
+
self._srv.shutdown()
|
|
79
|
+
self._srv.server_close()
|
agentctl/demo/tool.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The demo's side-effecting tool: a real `git commit`, with a crash window.
|
|
2
|
+
|
|
3
|
+
Module level on purpose: on resume the SDK resolves these classes to
|
|
4
|
+
deserialize the persisted ActionEvent, and a class defined inside a function
|
|
5
|
+
is not importable -- the resume then silently does nothing (`docs/0017`).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Sequence
|
|
14
|
+
|
|
15
|
+
from pydantic import Field
|
|
16
|
+
|
|
17
|
+
from openhands.sdk.tool import (Action, Observation, ToolDefinition, ToolExecutor,
|
|
18
|
+
register_tool)
|
|
19
|
+
|
|
20
|
+
NAME = "commit"
|
|
21
|
+
REPO_ENV, MARKER_ENV, SLEEP_ENV = ("AGENTCTL_DEMO_REPO", "AGENTCTL_DEMO_MARKER",
|
|
22
|
+
"AGENTCTL_DEMO_SLEEP")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CommitAction(Action):
|
|
26
|
+
# Only the message is the model's. WHERE a commit lands never is
|
|
27
|
+
# (`docs/0023` §3: a real model filled a `cwd` field with ".").
|
|
28
|
+
message: str = Field(default="agent work", description="Commit message.")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CommitObservation(Observation):
|
|
32
|
+
status: str = Field(default="ok")
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def agent_observation(self):
|
|
36
|
+
from openhands.sdk.llm import TextContent
|
|
37
|
+
return [TextContent(text=f"commit {self.status}")]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CommitExecutor(ToolExecutor):
|
|
41
|
+
def __call__(self, action, conversation=None):
|
|
42
|
+
repo = Path(os.environ[REPO_ENV])
|
|
43
|
+
subprocess.run(["git", "commit", "--allow-empty", "-q", "-m", action.message],
|
|
44
|
+
cwd=str(repo), capture_output=True, text=True)
|
|
45
|
+
# Signal only AFTER the commit landed, so the kill falls in the window
|
|
46
|
+
# where the effect happened and the record of it did not.
|
|
47
|
+
if (marker := os.environ.get(MARKER_ENV)):
|
|
48
|
+
Path(marker).write_text(str(time.time()), encoding="utf-8")
|
|
49
|
+
time.sleep(float(os.environ.get(SLEEP_ENV, "8")))
|
|
50
|
+
return CommitObservation(status="ok")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CommitTool(ToolDefinition[CommitAction, CommitObservation]):
|
|
54
|
+
@classmethod
|
|
55
|
+
def create(cls, conv_state=None, **params) -> Sequence["CommitTool"]:
|
|
56
|
+
return [cls(name=NAME, description="Make a git commit in the repo.",
|
|
57
|
+
action_type=CommitAction, observation_type=CommitObservation,
|
|
58
|
+
executor=CommitExecutor())]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def register() -> None:
|
|
62
|
+
register_tool(NAME, CommitTool)
|