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,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