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,350 @@
|
|
|
1
|
+
"""A minimal real toolset: bash, read, write.
|
|
2
|
+
|
|
3
|
+
Deliberately not `openhands-tools`. That package pulls ~55 dependencies
|
|
4
|
+
(browser-use, google APIs, three model SDKs) and downgrades `mcp` below what
|
|
5
|
+
the SDK needs — the hazard in `docs/0021` §7. These three tools need nothing
|
|
6
|
+
new, and they are exactly the names `control/matrix/data/tools.yaml` already
|
|
7
|
+
classifies, so the gate is meaningful the moment they run.
|
|
8
|
+
|
|
9
|
+
execute_bash EXTERNAL by default, reclassified per-command by the matrix
|
|
10
|
+
(`ls` -> PURE_READ, `git commit` -> NON_IDEMPOTENT_WRITE,
|
|
11
|
+
`rm -rf` -> DESTRUCTIVE)
|
|
12
|
+
read_file PURE_READ
|
|
13
|
+
write_file IDEMPOTENT_WRITE
|
|
14
|
+
|
|
15
|
+
**The gate prevents duplicates, not danger.** A first-time `rm -rf` is not a
|
|
16
|
+
replay, so the ledger admits it. Authorization is a separate concern — see
|
|
17
|
+
`confirm_destructive` in `runner.py`.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
from contextvars import ContextVar
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Sequence
|
|
27
|
+
|
|
28
|
+
from pydantic import Field
|
|
29
|
+
|
|
30
|
+
from openhands.sdk.tool import (
|
|
31
|
+
Action,
|
|
32
|
+
Observation,
|
|
33
|
+
ToolDefinition,
|
|
34
|
+
ToolExecutor,
|
|
35
|
+
register_tool,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
WORKSPACE_ENV = "AGENTCTL_WORKSPACE"
|
|
39
|
+
TIMEOUT_ENV = "AGENTCTL_BASH_TIMEOUT"
|
|
40
|
+
MAX_OUTPUT = 30_000
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
#: A workspace scoped to the current task rather than the whole process.
|
|
44
|
+
#:
|
|
45
|
+
#: The env var below is the configured default and stays that way. This exists
|
|
46
|
+
#: because a *subagent* runs on its own workspace, and setting the process-wide
|
|
47
|
+
#: variable to say so leaked both ways: concurrently, a subagent scoped to one
|
|
48
|
+
#: workspace read a file from another and reported its contents without error
|
|
49
|
+
#: — read-only bounds what an agent can change, not what it can see, so that
|
|
50
|
+
#: is a confidentiality failure, not an inconvenience. Sequentially, the
|
|
51
|
+
#: parent's next `read_file` resolved against the subagent's workspace.
|
|
52
|
+
#:
|
|
53
|
+
#: A `ContextVar` is the right shape because the SDK's parallel executor
|
|
54
|
+
#: copies the calling context into each worker thread
|
|
55
|
+
#: (`sdk/agent/parallel_executor.py` imports `contextvars`), so a value set
|
|
56
|
+
#: here follows the task rather than the process.
|
|
57
|
+
#:
|
|
58
|
+
#: `tests/conftest.py` restores `AGENTCTL_WORKSPACE` between tests, which is
|
|
59
|
+
#: exactly why the suite never caught this.
|
|
60
|
+
_scoped_workspace: ContextVar[str | None] = ContextVar(
|
|
61
|
+
"agentctl_workspace", default=None)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _workspace() -> Path:
|
|
65
|
+
"""Where work happens. Configuration, never model input (`docs/0023` §3)."""
|
|
66
|
+
scoped = _scoped_workspace.get()
|
|
67
|
+
if scoped:
|
|
68
|
+
return Path(scoped).resolve()
|
|
69
|
+
return Path(os.environ.get(WORKSPACE_ENV, ".")).resolve()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _text(s: str):
|
|
73
|
+
"""Wrap a string as the SDK's content blocks.
|
|
74
|
+
|
|
75
|
+
The model sees `Observation.content` via `to_llm_content`. An observation
|
|
76
|
+
that stores its text anywhere else renders as "[no text content]" and the
|
|
77
|
+
agent concludes the tool returned nothing (`docs/0025`).
|
|
78
|
+
"""
|
|
79
|
+
from openhands.sdk.llm import TextContent
|
|
80
|
+
return [TextContent(text=s)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _clip(s: str) -> str:
|
|
84
|
+
return s if len(s) <= MAX_OUTPUT else s[:MAX_OUTPUT] + "\n<TRUNCATED>"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── execute_bash ───────────────────────────────────────────────────────
|
|
88
|
+
class BashAction(Action):
|
|
89
|
+
command: str = Field(description="The shell command to run.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BashObservation(Observation):
|
|
93
|
+
exit_code: int = Field(default=0)
|
|
94
|
+
output: str = Field(default="")
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def make(cls, exit_code: int, output: str) -> "BashObservation":
|
|
98
|
+
return cls(exit_code=exit_code, output=output,
|
|
99
|
+
content=_text(f"exit={exit_code}\n{output}"),
|
|
100
|
+
is_error=exit_code != 0)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _shell() -> list[str] | None:
|
|
104
|
+
r"""The argv prefix for running a bash command, or None if bash is absent.
|
|
105
|
+
|
|
106
|
+
`shell=True` uses `COMSPEC` on Windows — **cmd.exe** — and this tool is
|
|
107
|
+
called `execute_bash`. The gap is not cosmetic:
|
|
108
|
+
|
|
109
|
+
* the model writes bash, and cmd.exe rejects it. A real run produced
|
|
110
|
+
`<< was unexpected at this time.` five times from heredocs, wrote
|
|
111
|
+
nothing, and still reported success (`docs/0031` §9).
|
|
112
|
+
* the capability matrix splits commands on `&&`, `||`, `;`, `|` and matches
|
|
113
|
+
`rm -rf`, `git reset --hard`, `>` redirects. Those are **bash** rules.
|
|
114
|
+
Under cmd.exe the classifier would be guarding a shell nobody is running,
|
|
115
|
+
and `del /s /q` — the thing that actually deletes — matches nothing.
|
|
116
|
+
|
|
117
|
+
So bash is used explicitly when present. Git for Windows ships one, so this
|
|
118
|
+
is usually satisfied even on Windows.
|
|
119
|
+
"""
|
|
120
|
+
exe = shutil.which("bash") or shutil.which("sh")
|
|
121
|
+
return [exe, "-c"] if exe else None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _kill_tree(proc: subprocess.Popen) -> None:
|
|
125
|
+
r"""Kill the process AND everything it started.
|
|
126
|
+
|
|
127
|
+
Killing only the shell is not enough. `subprocess.run(timeout=...)` kills
|
|
128
|
+
its direct child and then waits to drain the pipes -- but a grandchild
|
|
129
|
+
still holds the write end, so the drain blocks until that grandchild exits
|
|
130
|
+
on its own. Measured: a 3-second timeout returned after 60.1 seconds.
|
|
131
|
+
|
|
132
|
+
That made the timeout advisory. A model that writes
|
|
133
|
+
`cd / && find . -name x` can hang the agent for as long as the scan takes,
|
|
134
|
+
which is exactly what happened on a live run (`docs/0036`).
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
if os.name == "nt":
|
|
138
|
+
# taskkill /T walks the child tree; there are no process groups
|
|
139
|
+
# to signal on Windows.
|
|
140
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
141
|
+
capture_output=True, timeout=15)
|
|
142
|
+
else:
|
|
143
|
+
import signal
|
|
144
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
145
|
+
except Exception: # noqa: BLE001
|
|
146
|
+
pass
|
|
147
|
+
try:
|
|
148
|
+
proc.kill()
|
|
149
|
+
except Exception: # noqa: BLE001
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class BashExecutor(ToolExecutor):
|
|
154
|
+
def __call__(self, action, conversation=None):
|
|
155
|
+
argv = _shell()
|
|
156
|
+
limit = float(os.environ.get(TIMEOUT_ENV, "120"))
|
|
157
|
+
popen_kwargs: dict = {}
|
|
158
|
+
if os.name != "nt":
|
|
159
|
+
# Its own process group, so the whole tree can be signalled.
|
|
160
|
+
popen_kwargs["start_new_session"] = True
|
|
161
|
+
try:
|
|
162
|
+
proc = subprocess.Popen(
|
|
163
|
+
(argv + [action.command]) if argv else action.command,
|
|
164
|
+
shell=argv is None, cwd=str(_workspace()),
|
|
165
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
166
|
+
text=True, encoding="utf-8", errors="replace",
|
|
167
|
+
**popen_kwargs)
|
|
168
|
+
except Exception as e: # noqa: BLE001
|
|
169
|
+
return BashObservation.make(1, f"{type(e).__name__}: {e}")
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
out, _ = proc.communicate(timeout=limit)
|
|
173
|
+
return BashObservation.make(proc.returncode, _clip((out or "").strip()))
|
|
174
|
+
except subprocess.TimeoutExpired:
|
|
175
|
+
_kill_tree(proc)
|
|
176
|
+
try:
|
|
177
|
+
# Bounded: if something STILL holds the pipe, report the
|
|
178
|
+
# timeout without the output rather than wait forever. A
|
|
179
|
+
# missing tail is a far smaller loss than an agent that never
|
|
180
|
+
# returns.
|
|
181
|
+
out, _ = proc.communicate(timeout=10)
|
|
182
|
+
except Exception: # noqa: BLE001
|
|
183
|
+
out = ""
|
|
184
|
+
tail = _clip((out or "").strip())
|
|
185
|
+
return BashObservation.make(
|
|
186
|
+
124, f"timed out after {limit:.0f}s and was killed"
|
|
187
|
+
+ (f"\n{tail}" if tail else ""))
|
|
188
|
+
except Exception as e: # noqa: BLE001
|
|
189
|
+
_kill_tree(proc)
|
|
190
|
+
return BashObservation.make(1, f"{type(e).__name__}: {e}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class BashTool(ToolDefinition[BashAction, BashObservation]):
|
|
194
|
+
@classmethod
|
|
195
|
+
def create(cls, conv_state=None, **params) -> Sequence["BashTool"]:
|
|
196
|
+
return [cls(name="execute_bash",
|
|
197
|
+
description="Run a shell command in the workspace and "
|
|
198
|
+
"return its exit code and output.",
|
|
199
|
+
action_type=BashAction, observation_type=BashObservation,
|
|
200
|
+
executor=BashExecutor())]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── read_file ──────────────────────────────────────────────────────────
|
|
204
|
+
class ReadAction(Action):
|
|
205
|
+
path: str = Field(description="File to read, relative to the workspace.")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class ReadObservation(Observation):
|
|
209
|
+
# NOT `content`: Observation.content is list[TextContent | ImageContent],
|
|
210
|
+
# and shadowing it with a str validates here then explodes when the message
|
|
211
|
+
# is assembled -- the same trap as docs/0018 §4 C1. `content` is reserved.
|
|
212
|
+
file_text: str = Field(default="")
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def make(cls, text: str, is_error: bool = False) -> "ReadObservation":
|
|
216
|
+
return cls(file_text=text, content=_text(text), is_error=is_error)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class ReadExecutor(ToolExecutor):
|
|
220
|
+
def __call__(self, action, conversation=None):
|
|
221
|
+
p = _workspace() / action.path
|
|
222
|
+
try:
|
|
223
|
+
return ReadObservation.make(
|
|
224
|
+
_clip(p.read_text(encoding="utf-8", errors="replace")))
|
|
225
|
+
except Exception as e: # noqa: BLE001
|
|
226
|
+
return ReadObservation.make(f"error: {type(e).__name__}: {e}",
|
|
227
|
+
is_error=True)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class ReadFileTool(ToolDefinition[ReadAction, ReadObservation]):
|
|
231
|
+
@classmethod
|
|
232
|
+
def create(cls, conv_state=None, **params) -> Sequence["ReadFileTool"]:
|
|
233
|
+
return [cls(name="read_file", description="Read a file's contents.",
|
|
234
|
+
action_type=ReadAction, observation_type=ReadObservation,
|
|
235
|
+
executor=ReadExecutor())]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ── write_file ─────────────────────────────────────────────────────────
|
|
239
|
+
class WriteAction(Action):
|
|
240
|
+
path: str = Field(description="File to write, relative to the workspace.")
|
|
241
|
+
content: str = Field(description="Full new contents of the file.")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class WriteObservation(Observation):
|
|
245
|
+
status: str = Field(default="ok")
|
|
246
|
+
bytes_written: int = Field(default=0)
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
def make(cls, status: str, n: int = 0) -> "WriteObservation":
|
|
250
|
+
return cls(status=status, bytes_written=n,
|
|
251
|
+
content=_text(f"{status} ({n} bytes)"),
|
|
252
|
+
is_error=status.startswith("error"))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _existing_newline(path: Path) -> str | None:
|
|
256
|
+
r"""The line ending a file already uses, or None if it is new.
|
|
257
|
+
|
|
258
|
+
A model sends content with `\n`. Writing that over a CRLF file rewrites
|
|
259
|
+
every line, so an eight-line change arrives as a 149-line diff: unreviewable,
|
|
260
|
+
and it pollutes the history of any repository checked out on Windows. That
|
|
261
|
+
happened on the first real edit this tool made to its own repo
|
|
262
|
+
(`docs/0035`).
|
|
263
|
+
|
|
264
|
+
The dominant ending wins rather than the first one found, because a file
|
|
265
|
+
with a couple of stray endings should not flip the whole file to match its
|
|
266
|
+
own typo.
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
raw = path.read_bytes()
|
|
270
|
+
except Exception: # noqa: BLE001
|
|
271
|
+
return None
|
|
272
|
+
crlf = raw.count(b"\r\n")
|
|
273
|
+
lf = raw.count(b"\n") - crlf
|
|
274
|
+
if crlf == 0 and lf == 0:
|
|
275
|
+
return None
|
|
276
|
+
return "\r\n" if crlf > lf else "\n"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class WriteExecutor(ToolExecutor):
|
|
280
|
+
"""Writes the WHOLE file, which is what makes it IDEMPOTENT_WRITE.
|
|
281
|
+
|
|
282
|
+
An append would be non-idempotent and would need a probe; replacing the
|
|
283
|
+
entire contents can be repeated safely, so a crash mid-write costs nothing.
|
|
284
|
+
|
|
285
|
+
The file's existing line endings are preserved, so editing one rule in a
|
|
286
|
+
CRLF file produces a one-rule diff.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
def __call__(self, action, conversation=None):
|
|
290
|
+
p = _workspace() / action.path
|
|
291
|
+
try:
|
|
292
|
+
existing = _existing_newline(p) if p.exists() else None
|
|
293
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
# Normalise to `\n` FIRST, always. The model may send either, so
|
|
295
|
+
# replacing `\n` without stripping `\r` turns `\r\n` into `\r\r\n`
|
|
296
|
+
# -- and only converting toward CRLF leaves an LF file holding the
|
|
297
|
+
# CRLF the model happened to send.
|
|
298
|
+
text = action.content.replace("\r\n", "\n")
|
|
299
|
+
if existing == "\r\n":
|
|
300
|
+
text = text.replace("\n", "\r\n")
|
|
301
|
+
data = text.encode("utf-8")
|
|
302
|
+
p.write_bytes(data)
|
|
303
|
+
return WriteObservation.make("written", len(data))
|
|
304
|
+
except Exception as e: # noqa: BLE001
|
|
305
|
+
return WriteObservation.make(f"error: {type(e).__name__}: {e}")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class WriteFileTool(ToolDefinition[WriteAction, WriteObservation]):
|
|
309
|
+
@classmethod
|
|
310
|
+
def create(cls, conv_state=None, **params) -> Sequence["WriteFileTool"]:
|
|
311
|
+
return [cls(name="write_file",
|
|
312
|
+
description="Replace a file's entire contents.",
|
|
313
|
+
action_type=WriteAction, observation_type=WriteObservation,
|
|
314
|
+
executor=WriteExecutor())]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
TOOLS: dict[str, type] = {
|
|
318
|
+
"execute_bash": BashTool,
|
|
319
|
+
"read_file": ReadFileTool,
|
|
320
|
+
"write_file": WriteFileTool,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def register_all(overwrite: bool = True) -> list[str]:
|
|
325
|
+
"""Register the plain tools. Returns the names registered.
|
|
326
|
+
|
|
327
|
+
`overwrite=False` is not a convenience — it closes a silent gate bypass.
|
|
328
|
+
|
|
329
|
+
Seam C installs GATED versions of these three under the SAME names
|
|
330
|
+
(`adapters/openhands/seam_c.py::install`), and the SDK's registry is a
|
|
331
|
+
process-global whose `register_tool` replaces a duplicate with only a
|
|
332
|
+
log warning; its own source carries a TODO saying it should raise. So a
|
|
333
|
+
later plain registration silently un-gates every tool the guard had
|
|
334
|
+
wrapped, and nothing fails — the next effect simply goes ungated.
|
|
335
|
+
|
|
336
|
+
That is the hazard shape this project keeps finding: correct-looking,
|
|
337
|
+
silent, and only visible by reading two files at once (`docs/0038` §4.3
|
|
338
|
+
closed the same shape for the concurrency pin).
|
|
339
|
+
"""
|
|
340
|
+
registered = []
|
|
341
|
+
existing = set()
|
|
342
|
+
if not overwrite:
|
|
343
|
+
from openhands.sdk.tool import list_registered_tools
|
|
344
|
+
existing = set(list_registered_tools())
|
|
345
|
+
for name, cls in TOOLS.items():
|
|
346
|
+
if name in existing:
|
|
347
|
+
continue
|
|
348
|
+
register_tool(name, cls)
|
|
349
|
+
registered.append(name)
|
|
350
|
+
return registered
|