sid-harness 0.3.0__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.
- sid_harness/__init__.py +13 -0
- sid_harness/__main__.py +4 -0
- sid_harness/_native.py +1639 -0
- sid_harness/brave_proof.py +246 -0
- sid_harness/bridge.py +851 -0
- sid_harness/cli.py +937 -0
- sid_harness/client.py +161 -0
- sid_harness/companion.py +151 -0
- sid_harness/config.py +68 -0
- sid_harness/context/__init__.py +40 -0
- sid_harness/context/index.py +267 -0
- sid_harness/context/retrieval.py +74 -0
- sid_harness/editor.py +231 -0
- sid_harness/gateway.py +106 -0
- sid_harness/git_write.py +598 -0
- sid_harness/local_workspace.py +421 -0
- sid_harness/loop.py +394 -0
- sid_harness/lsp.py +598 -0
- sid_harness/policy/__init__.py +44 -0
- sid_harness/policy/repo_policy.py +626 -0
- sid_harness/receipt.py +142 -0
- sid_harness/runner.py +468 -0
- sid_harness/selftest.py +149 -0
- sid_harness/session/__init__.py +33 -0
- sid_harness/session/bounds.py +140 -0
- sid_harness/session/diff_gate.py +93 -0
- sid_harness/session/session.py +427 -0
- sid_harness/session/store.py +88 -0
- sid_harness/tools/__init__.py +52 -0
- sid_harness/tools/mcp_client.py +285 -0
- sid_harness/tools/plan.py +138 -0
- sid_harness/tools/registry.py +159 -0
- sid_harness/tools/skills.py +185 -0
- sid_harness/tools/subagents.py +112 -0
- sid_harness/tui.py +96 -0
- sid_harness/workspace.py +107 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
- sid_harness-0.3.0.dist-info/METADATA +229 -0
- sid_harness-0.3.0.dist-info/RECORD +45 -0
- sid_harness-0.3.0.dist-info/WHEEL +5 -0
- sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
- sid_harness-0.3.0.dist-info/top_level.txt +1 -0
sid_harness/loop.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Drive SID's EXISTING native loop with SID as the only model backend.
|
|
2
|
+
|
|
3
|
+
The loop body, its tool contract, and its bounds are ``native_agent``'s. This
|
|
4
|
+
module only wires the ``complete`` callback to SID's ``/v1``, executes the
|
|
5
|
+
human-approved test argv locally (SUB-80), and optionally forwards a tool
|
|
6
|
+
registry (SUB-56) and configurable bounds (SUB-58) into the ONE loop.
|
|
7
|
+
|
|
8
|
+
Local test execution is deliberately narrow: a LIST of tokens (never a shell
|
|
9
|
+
string), a recognised test-runner allowlist, the approved workspace root as
|
|
10
|
+
``cwd``, a bounded wall-clock timeout, and a bounded output capture. The runner
|
|
11
|
+
never commits and never pushes.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import replace
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .client import SIDClient
|
|
26
|
+
from .gateway import native_agent
|
|
27
|
+
|
|
28
|
+
DEFAULT_TEST_TIMEOUT_SECONDS = 120.0
|
|
29
|
+
MAX_TEST_TIMEOUT_SECONDS = 900.0
|
|
30
|
+
MAX_TEST_OUTPUT_CHARS = 6000
|
|
31
|
+
TEST_BACKEND = "local"
|
|
32
|
+
|
|
33
|
+
TEST_NOT_EXECUTED_DETAIL = (
|
|
34
|
+
"no approved test command was provided; pass one (sid run --test-cmd ...) "
|
|
35
|
+
"to run tests locally."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# The approved command must be a recognised test-runner invocation. Matching is
|
|
39
|
+
# prefix-based on the normalised executable plus the tokens that identify the
|
|
40
|
+
# runner, so extra flags (``-q``, ``--maxfail=1``) are allowed but an arbitrary
|
|
41
|
+
# executable is not. This is the mechanical "human-approved" boundary: a test
|
|
42
|
+
# command, not a shell.
|
|
43
|
+
_ALLOWED_TEST_PREFIXES: tuple[tuple[str, ...], ...] = (
|
|
44
|
+
("python", "-m", "pytest"),
|
|
45
|
+
("python3", "-m", "pytest"),
|
|
46
|
+
("pytest",),
|
|
47
|
+
("py.test",),
|
|
48
|
+
("python", "-m", "unittest"),
|
|
49
|
+
("python3", "-m", "unittest"),
|
|
50
|
+
("npm", "test"),
|
|
51
|
+
("npm", "run", "test"),
|
|
52
|
+
("yarn", "test"),
|
|
53
|
+
("pnpm", "test"),
|
|
54
|
+
("cargo", "test"),
|
|
55
|
+
("go", "test"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
_PYTHON_VERSIONED = re.compile(r"^python3(\.[0-9]+)?$")
|
|
59
|
+
_WINDOWS_ABSOLUTE = re.compile(r"^[A-Za-z]:")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _empty_result(**overrides: Any) -> dict[str, Any]:
|
|
63
|
+
result: dict[str, Any] = {
|
|
64
|
+
"executed": False,
|
|
65
|
+
"passed": None,
|
|
66
|
+
"isolated": False,
|
|
67
|
+
"backend": "none",
|
|
68
|
+
"argv": None,
|
|
69
|
+
"cwd": None,
|
|
70
|
+
"exit_code": None,
|
|
71
|
+
"timed_out": False,
|
|
72
|
+
"duration_ms": 0,
|
|
73
|
+
"output": "",
|
|
74
|
+
"output_truncated": False,
|
|
75
|
+
"detail": None,
|
|
76
|
+
}
|
|
77
|
+
result.update(overrides)
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def not_executed_test(*, detail: str = TEST_NOT_EXECUTED_DETAIL) -> dict[str, Any]:
|
|
82
|
+
"""Result for a run with no approved test command — never claims a test ran."""
|
|
83
|
+
return _empty_result(detail=detail)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _program_name(token: str) -> str:
|
|
87
|
+
"""Normalise an executable token to its bare program name.
|
|
88
|
+
|
|
89
|
+
``C:\\Python312\\python.exe``, ``python3.12``, and ``python`` all normalise to
|
|
90
|
+
a name the allowlist can match, so a caller may pass ``sys.executable``.
|
|
91
|
+
"""
|
|
92
|
+
name = token.replace("\\", "/").rsplit("/", 1)[-1].lower()
|
|
93
|
+
if name.endswith(".exe"):
|
|
94
|
+
name = name[: -len(".exe")]
|
|
95
|
+
if _PYTHON_VERSIONED.match(name):
|
|
96
|
+
return "python3" if name == "python3" else "python"
|
|
97
|
+
return name
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def match_test_allowlist(argv: list[str]) -> tuple[str, ...] | None:
|
|
101
|
+
"""Return the allowlisted runner prefix matching ``argv``, else ``None``."""
|
|
102
|
+
if not argv:
|
|
103
|
+
return None
|
|
104
|
+
tokens = [_program_name(str(argv[0])), *[str(token) for token in argv[1:]]]
|
|
105
|
+
for prefix in _ALLOWED_TEST_PREFIXES:
|
|
106
|
+
if len(tokens) < len(prefix):
|
|
107
|
+
continue
|
|
108
|
+
if all(tokens[index] == prefix[index] for index in range(len(prefix))):
|
|
109
|
+
return prefix
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _escapes_workspace(token: str) -> bool:
|
|
114
|
+
normalized = token.replace("\\", "/")
|
|
115
|
+
if normalized.startswith("/") or _WINDOWS_ABSOLUTE.match(normalized):
|
|
116
|
+
return True
|
|
117
|
+
return ".." in normalized.split("/")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _capture_bounded(
|
|
121
|
+
argv: list[str], *, cwd: Path, timeout: float, output_limit: int
|
|
122
|
+
) -> tuple[int | None, bool, bytes, bool]:
|
|
123
|
+
"""Run ``argv`` in ``cwd``; return (exit, timed_out, output, truncated).
|
|
124
|
+
|
|
125
|
+
Output is drained on a reader thread and only the first ``output_limit``
|
|
126
|
+
bytes are kept, so a chatty test command cannot exhaust memory.
|
|
127
|
+
"""
|
|
128
|
+
process = subprocess.Popen(
|
|
129
|
+
list(argv),
|
|
130
|
+
cwd=str(cwd),
|
|
131
|
+
shell=False,
|
|
132
|
+
stdin=subprocess.DEVNULL,
|
|
133
|
+
stdout=subprocess.PIPE,
|
|
134
|
+
stderr=subprocess.STDOUT,
|
|
135
|
+
)
|
|
136
|
+
kept: list[bytes] = []
|
|
137
|
+
state = {"kept": 0, "truncated": False}
|
|
138
|
+
|
|
139
|
+
def drain() -> None:
|
|
140
|
+
stream = process.stdout
|
|
141
|
+
if stream is None:
|
|
142
|
+
return
|
|
143
|
+
while True:
|
|
144
|
+
block = stream.read(65536)
|
|
145
|
+
if not block:
|
|
146
|
+
break
|
|
147
|
+
room = output_limit - state["kept"]
|
|
148
|
+
if room > 0:
|
|
149
|
+
chunk = block[:room]
|
|
150
|
+
kept.append(chunk)
|
|
151
|
+
state["kept"] += len(chunk)
|
|
152
|
+
if len(chunk) < len(block):
|
|
153
|
+
state["truncated"] = True
|
|
154
|
+
else:
|
|
155
|
+
state["truncated"] = True
|
|
156
|
+
|
|
157
|
+
reader = threading.Thread(target=drain, name="sid-test-output", daemon=True)
|
|
158
|
+
reader.start()
|
|
159
|
+
timed_out = False
|
|
160
|
+
try:
|
|
161
|
+
exit_code: int | None = process.wait(timeout=timeout)
|
|
162
|
+
except subprocess.TimeoutExpired:
|
|
163
|
+
timed_out = True
|
|
164
|
+
process.kill()
|
|
165
|
+
try:
|
|
166
|
+
exit_code = process.wait(timeout=5)
|
|
167
|
+
except subprocess.TimeoutExpired:
|
|
168
|
+
exit_code = None
|
|
169
|
+
reader.join(timeout=10)
|
|
170
|
+
if process.stdout is not None:
|
|
171
|
+
try:
|
|
172
|
+
process.stdout.close()
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|
|
175
|
+
return exit_code, timed_out, b"".join(kept), state["truncated"]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def execute_test_argv(
|
|
179
|
+
argv: list[str] | tuple[str, ...] | None,
|
|
180
|
+
*,
|
|
181
|
+
root: str | Path | None,
|
|
182
|
+
timeout_seconds: float = DEFAULT_TEST_TIMEOUT_SECONDS,
|
|
183
|
+
output_limit: int = MAX_TEST_OUTPUT_CHARS,
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
"""Execute the human-approved test argv in the workspace root.
|
|
186
|
+
|
|
187
|
+
Refuses a string (a shell command line), a non-allowlisted runner, or argv
|
|
188
|
+
that points outside the workspace. Bounded timeout and bounded output; never
|
|
189
|
+
touches git, never commits, never pushes.
|
|
190
|
+
"""
|
|
191
|
+
if argv is None:
|
|
192
|
+
return not_executed_test()
|
|
193
|
+
if isinstance(argv, str):
|
|
194
|
+
return _empty_result(detail="test argv must be a list of tokens, never a shell string")
|
|
195
|
+
if not isinstance(argv, (list, tuple)) or not all(isinstance(token, str) for token in argv):
|
|
196
|
+
return _empty_result(detail="test argv must be a list of strings")
|
|
197
|
+
tokens = list(argv)
|
|
198
|
+
if not tokens:
|
|
199
|
+
return not_executed_test()
|
|
200
|
+
if any(not token for token in tokens):
|
|
201
|
+
return _empty_result(detail="test argv must not contain empty tokens")
|
|
202
|
+
if any("\x00" in token for token in tokens):
|
|
203
|
+
return _empty_result(detail="test argv must not contain NUL bytes")
|
|
204
|
+
|
|
205
|
+
prefix = match_test_allowlist(tokens)
|
|
206
|
+
if prefix is None:
|
|
207
|
+
allowed = ", ".join(" ".join(item) for item in _ALLOWED_TEST_PREFIXES)
|
|
208
|
+
return _empty_result(
|
|
209
|
+
argv=tokens,
|
|
210
|
+
detail=f"test command is not allowlisted ({tokens[0]!r}); allowed runners: {allowed}",
|
|
211
|
+
)
|
|
212
|
+
for token in tokens[1:]:
|
|
213
|
+
if _escapes_workspace(token):
|
|
214
|
+
return _empty_result(
|
|
215
|
+
argv=tokens,
|
|
216
|
+
detail=f"test argv must stay inside the workspace: {token!r}",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if root is None:
|
|
220
|
+
return _empty_result(
|
|
221
|
+
argv=tokens,
|
|
222
|
+
detail="no local workspace root was supplied; refusing to run tests on the host",
|
|
223
|
+
)
|
|
224
|
+
workspace_root = Path(root)
|
|
225
|
+
if not workspace_root.is_dir():
|
|
226
|
+
return _empty_result(
|
|
227
|
+
argv=tokens,
|
|
228
|
+
detail=f"workspace root is not a directory: {workspace_root}",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
timeout = float(timeout_seconds)
|
|
233
|
+
except (TypeError, ValueError):
|
|
234
|
+
timeout = DEFAULT_TEST_TIMEOUT_SECONDS
|
|
235
|
+
if timeout <= 0:
|
|
236
|
+
timeout = DEFAULT_TEST_TIMEOUT_SECONDS
|
|
237
|
+
timeout = min(timeout, MAX_TEST_TIMEOUT_SECONDS)
|
|
238
|
+
limit = max(1, int(output_limit))
|
|
239
|
+
|
|
240
|
+
started = time.monotonic()
|
|
241
|
+
try:
|
|
242
|
+
exit_code, timed_out, raw, truncated = _capture_bounded(
|
|
243
|
+
tokens, cwd=workspace_root, timeout=timeout, output_limit=limit
|
|
244
|
+
)
|
|
245
|
+
except FileNotFoundError:
|
|
246
|
+
return _empty_result(
|
|
247
|
+
backend=TEST_BACKEND,
|
|
248
|
+
argv=tokens,
|
|
249
|
+
cwd=str(workspace_root),
|
|
250
|
+
detail=f"test command not found: {tokens[0]!r}",
|
|
251
|
+
)
|
|
252
|
+
except OSError as exc:
|
|
253
|
+
return _empty_result(
|
|
254
|
+
backend=TEST_BACKEND,
|
|
255
|
+
argv=tokens,
|
|
256
|
+
cwd=str(workspace_root),
|
|
257
|
+
detail=f"could not start test command: {type(exc).__name__}: {exc}",
|
|
258
|
+
)
|
|
259
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
260
|
+
output = raw.decode("utf-8", errors="replace")
|
|
261
|
+
if len(output) > limit:
|
|
262
|
+
output = output[:limit]
|
|
263
|
+
truncated = True
|
|
264
|
+
|
|
265
|
+
if timed_out:
|
|
266
|
+
return _empty_result(
|
|
267
|
+
executed=True,
|
|
268
|
+
passed=False,
|
|
269
|
+
backend=TEST_BACKEND,
|
|
270
|
+
argv=tokens,
|
|
271
|
+
cwd=str(workspace_root),
|
|
272
|
+
exit_code=exit_code,
|
|
273
|
+
timed_out=True,
|
|
274
|
+
duration_ms=duration_ms,
|
|
275
|
+
output=output,
|
|
276
|
+
output_truncated=truncated,
|
|
277
|
+
detail=f"test command timed out after {timeout:g}s",
|
|
278
|
+
)
|
|
279
|
+
return _empty_result(
|
|
280
|
+
executed=True,
|
|
281
|
+
passed=exit_code == 0,
|
|
282
|
+
backend=TEST_BACKEND,
|
|
283
|
+
argv=tokens,
|
|
284
|
+
cwd=str(workspace_root),
|
|
285
|
+
exit_code=exit_code,
|
|
286
|
+
duration_ms=duration_ms,
|
|
287
|
+
output=output,
|
|
288
|
+
output_truncated=truncated,
|
|
289
|
+
detail="tests passed" if exit_code == 0 else f"test command exited {exit_code}",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def run_loop(
|
|
294
|
+
*,
|
|
295
|
+
client: SIDClient,
|
|
296
|
+
task: str,
|
|
297
|
+
workspace_files: dict[str, str],
|
|
298
|
+
acceptance_criteria: list[str],
|
|
299
|
+
out_of_scope: list[str],
|
|
300
|
+
test_argv: list[str],
|
|
301
|
+
workspace_root: str | Path | None = None,
|
|
302
|
+
test_timeout_seconds: float = DEFAULT_TEST_TIMEOUT_SECONDS,
|
|
303
|
+
test_output_limit: int = MAX_TEST_OUTPUT_CHARS,
|
|
304
|
+
model: str = "auto",
|
|
305
|
+
max_tokens: int = 8000,
|
|
306
|
+
reasoning_effort: str | None = None,
|
|
307
|
+
registry: Any | None = None,
|
|
308
|
+
bounds: Any | None = None,
|
|
309
|
+
shell_policy: Any | None = None,
|
|
310
|
+
plan_only: bool = False,
|
|
311
|
+
subagent_tasks: list[Any] | None = None,
|
|
312
|
+
subagent_max_concurrency: int = 4,
|
|
313
|
+
) -> dict[str, Any]:
|
|
314
|
+
async def complete(messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> dict[str, Any]:
|
|
315
|
+
return await asyncio.to_thread(
|
|
316
|
+
client.chat,
|
|
317
|
+
messages=messages,
|
|
318
|
+
tools=tools,
|
|
319
|
+
model=model,
|
|
320
|
+
max_tokens=max_tokens,
|
|
321
|
+
reasoning_effort=reasoning_effort,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
async def run_tests(files: dict[str, str]) -> dict[str, Any]:
|
|
325
|
+
# SUB-80: the approved argv runs locally against the real workspace
|
|
326
|
+
# root on a worker thread, so a slow suite never blocks the event loop.
|
|
327
|
+
return await asyncio.to_thread(
|
|
328
|
+
execute_test_argv,
|
|
329
|
+
test_argv,
|
|
330
|
+
root=workspace_root,
|
|
331
|
+
timeout_seconds=test_timeout_seconds,
|
|
332
|
+
output_limit=test_output_limit,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# SUB-94: wire the shipped subagent fan-out into the run path. Each sub-task
|
|
336
|
+
# runs through THIS same loop with the same callbacks and workspace (a copy
|
|
337
|
+
# per sub-run); the findings are handed to the main run as data. Subagent
|
|
338
|
+
# edits are never applied — only the main run's changed files reach the
|
|
339
|
+
# caller's write path.
|
|
340
|
+
subagent_results: list[dict[str, Any]] = []
|
|
341
|
+
if subagent_tasks:
|
|
342
|
+
from .tools.subagents import fan_out
|
|
343
|
+
|
|
344
|
+
subagent_results = await fan_out(
|
|
345
|
+
tasks=subagent_tasks,
|
|
346
|
+
complete=complete,
|
|
347
|
+
run_tests=run_tests,
|
|
348
|
+
workspace_files=workspace_files,
|
|
349
|
+
max_concurrency=subagent_max_concurrency,
|
|
350
|
+
)
|
|
351
|
+
findings = "\n".join(
|
|
352
|
+
f"- {result.get('id')}: {result.get('final_response') or '(no response)'}"
|
|
353
|
+
+ (
|
|
354
|
+
f" (changed: {', '.join(result.get('changed') or [])})"
|
|
355
|
+
if result.get("changed")
|
|
356
|
+
else ""
|
|
357
|
+
)
|
|
358
|
+
for result in subagent_results
|
|
359
|
+
)
|
|
360
|
+
task = (
|
|
361
|
+
f"{task}\n\nPARALLEL SUBAGENT FINDINGS (read-only; their edits are "
|
|
362
|
+
f"not applied by this run):\n{findings}"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# SUB-58: forward configurable bounds into the one loop (defaults = module
|
|
366
|
+
# constants). SUB-56: forward a tool registry through the single dispatch
|
|
367
|
+
# seam; the registry routes base tools back to native_agent._tool_result.
|
|
368
|
+
native_kwargs: dict[str, Any] = dict(bounds.to_native_kwargs()) if bounds is not None else {}
|
|
369
|
+
tool_schemas = registry.schemas() if registry is not None else None
|
|
370
|
+
tool_dispatch = registry.dispatch if registry is not None else None
|
|
371
|
+
|
|
372
|
+
# Sandbox-by-default: the local workspace root IS the shell sandbox. A
|
|
373
|
+
# policy without an explicit root is filled from the run's root; a run with
|
|
374
|
+
# no root still refuses (the loop's run_shell_tool never runs on the host).
|
|
375
|
+
if shell_policy is not None and getattr(shell_policy, "root", None) is None:
|
|
376
|
+
if workspace_root is not None:
|
|
377
|
+
shell_policy = replace(shell_policy, root=str(workspace_root))
|
|
378
|
+
|
|
379
|
+
result = await native_agent.run_native_agent(
|
|
380
|
+
task=task,
|
|
381
|
+
workspace_files=workspace_files,
|
|
382
|
+
acceptance_criteria=acceptance_criteria,
|
|
383
|
+
out_of_scope=out_of_scope,
|
|
384
|
+
test_argv=test_argv,
|
|
385
|
+
complete=complete,
|
|
386
|
+
run_tests=run_tests,
|
|
387
|
+
tool_schemas=tool_schemas,
|
|
388
|
+
tool_dispatch=tool_dispatch,
|
|
389
|
+
shell_policy=shell_policy,
|
|
390
|
+
plan_only=plan_only,
|
|
391
|
+
**native_kwargs,
|
|
392
|
+
)
|
|
393
|
+
result["subagents"] = subagent_results
|
|
394
|
+
return result
|