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.
Files changed (45) hide show
  1. sid_harness/__init__.py +13 -0
  2. sid_harness/__main__.py +4 -0
  3. sid_harness/_native.py +1639 -0
  4. sid_harness/brave_proof.py +246 -0
  5. sid_harness/bridge.py +851 -0
  6. sid_harness/cli.py +937 -0
  7. sid_harness/client.py +161 -0
  8. sid_harness/companion.py +151 -0
  9. sid_harness/config.py +68 -0
  10. sid_harness/context/__init__.py +40 -0
  11. sid_harness/context/index.py +267 -0
  12. sid_harness/context/retrieval.py +74 -0
  13. sid_harness/editor.py +231 -0
  14. sid_harness/gateway.py +106 -0
  15. sid_harness/git_write.py +598 -0
  16. sid_harness/local_workspace.py +421 -0
  17. sid_harness/loop.py +394 -0
  18. sid_harness/lsp.py +598 -0
  19. sid_harness/policy/__init__.py +44 -0
  20. sid_harness/policy/repo_policy.py +626 -0
  21. sid_harness/receipt.py +142 -0
  22. sid_harness/runner.py +468 -0
  23. sid_harness/selftest.py +149 -0
  24. sid_harness/session/__init__.py +33 -0
  25. sid_harness/session/bounds.py +140 -0
  26. sid_harness/session/diff_gate.py +93 -0
  27. sid_harness/session/session.py +427 -0
  28. sid_harness/session/store.py +88 -0
  29. sid_harness/tools/__init__.py +52 -0
  30. sid_harness/tools/mcp_client.py +285 -0
  31. sid_harness/tools/plan.py +138 -0
  32. sid_harness/tools/registry.py +159 -0
  33. sid_harness/tools/skills.py +185 -0
  34. sid_harness/tools/subagents.py +112 -0
  35. sid_harness/tui.py +96 -0
  36. sid_harness/workspace.py +107 -0
  37. sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
  38. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
  39. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
  40. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
  41. sid_harness-0.3.0.dist-info/METADATA +229 -0
  42. sid_harness-0.3.0.dist-info/RECORD +45 -0
  43. sid_harness-0.3.0.dist-info/WHEEL +5 -0
  44. sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
  45. 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