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.
Files changed (67) hide show
  1. agentctl/__init__.py +0 -0
  2. agentctl/adapters/__init__.py +0 -0
  3. agentctl/adapters/litellm/__init__.py +9 -0
  4. agentctl/adapters/litellm/hook.py +49 -0
  5. agentctl/adapters/litellm/recorder.py +187 -0
  6. agentctl/adapters/openhands/__init__.py +169 -0
  7. agentctl/adapters/openhands/handoff.py +155 -0
  8. agentctl/adapters/openhands/seam_b.py +259 -0
  9. agentctl/adapters/openhands/seam_c.py +209 -0
  10. agentctl/cli.py +1450 -0
  11. agentctl/control/__init__.py +0 -0
  12. agentctl/control/cost/__init__.py +4 -0
  13. agentctl/control/cost/ledger.py +210 -0
  14. agentctl/control/dash.py +697 -0
  15. agentctl/control/keys.py +440 -0
  16. agentctl/control/matrix/__init__.py +0 -0
  17. agentctl/control/matrix/data/tools.yaml +149 -0
  18. agentctl/control/policy/__init__.py +10 -0
  19. agentctl/control/policy/compile.py +258 -0
  20. agentctl/control/policy/data/policy.compiled.json +38 -0
  21. agentctl/control/policy/data/policy.yaml +46 -0
  22. agentctl/control/probe.py +399 -0
  23. agentctl/control/providers.py +293 -0
  24. agentctl/control/proxy.py +536 -0
  25. agentctl/control/proxyenv.py +309 -0
  26. agentctl/control/replay/__init__.py +14 -0
  27. agentctl/control/replay/cassette.py +281 -0
  28. agentctl/control/replay/server.py +109 -0
  29. agentctl/demo/__init__.py +214 -0
  30. agentctl/demo/child.py +84 -0
  31. agentctl/demo/mock.py +79 -0
  32. agentctl/demo/tool.py +62 -0
  33. agentctl/gha.py +488 -0
  34. agentctl/kernel/__init__.py +0 -0
  35. agentctl/kernel/classify.py +170 -0
  36. agentctl/kernel/gate.py +391 -0
  37. agentctl/kernel/hook.py +229 -0
  38. agentctl/kernel/ledger/__init__.py +0 -0
  39. agentctl/kernel/ledger/models.py +160 -0
  40. agentctl/kernel/ledger/schema.sql +62 -0
  41. agentctl/kernel/ledger/store.py +596 -0
  42. agentctl/kernel/paths.py +203 -0
  43. agentctl/kernel/policy.py +160 -0
  44. agentctl/kernel/reconcile/__init__.py +31 -0
  45. agentctl/kernel/reconcile/base.py +106 -0
  46. agentctl/kernel/reconcile/external.py +137 -0
  47. agentctl/kernel/reconcile/filesystem.py +162 -0
  48. agentctl/kernel/reconcile/git.py +162 -0
  49. agentctl/runtime/__init__.py +20 -0
  50. agentctl/runtime/citations.py +179 -0
  51. agentctl/runtime/config.py +97 -0
  52. agentctl/runtime/doctor.py +335 -0
  53. agentctl/runtime/init.py +148 -0
  54. agentctl/runtime/lease.py +143 -0
  55. agentctl/runtime/orchestrate.py +187 -0
  56. agentctl/runtime/plugins.py +130 -0
  57. agentctl/runtime/report.py +361 -0
  58. agentctl/runtime/runner.py +787 -0
  59. agentctl/runtime/runs.py +191 -0
  60. agentctl/runtime/subagent.py +274 -0
  61. agentctl/runtime/tools.py +350 -0
  62. handcode-0.3.0rc1.dist-info/METADATA +659 -0
  63. handcode-0.3.0rc1.dist-info/RECORD +67 -0
  64. handcode-0.3.0rc1.dist-info/WHEEL +5 -0
  65. handcode-0.3.0rc1.dist-info/entry_points.txt +3 -0
  66. handcode-0.3.0rc1.dist-info/licenses/LICENSE +21 -0
  67. 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)