local-coding-agent 1.1.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 (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,259 @@
1
+ """Benchmark execution: run one model against a disposable fixture and judge it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tempfile
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from time import perf_counter_ns
10
+ from typing import Any, Mapping, Protocol, Sequence
11
+
12
+ from ..controller import Controller
13
+
14
+ from ._cases import BenchmarkCase, default_cases
15
+ from ._judge import _judge_patch, _validate_patch_for_case
16
+
17
+
18
+ class ChatModel(Protocol):
19
+ def chat(self, messages: list[dict[str, Any]], *, tools: list[dict[str, Any]]) -> dict[str, Any]: ...
20
+
21
+
22
+ class InstrumentedModel:
23
+ """Collect metrics returned by Ollama without changing the model response."""
24
+
25
+ def __init__(self, model: ChatModel) -> None:
26
+ self.model = model
27
+ self.model_calls = 0
28
+ self.total_duration_ns = 0
29
+ self.load_duration_ns = 0
30
+ self.prompt_tokens = 0
31
+ self.prompt_eval_duration_ns = 0
32
+ self.eval_count = 0
33
+ self.eval_duration_ns = 0
34
+ self.proposed_patches: list[str] = []
35
+ self.proposed_edits: list[list[dict[str, Any]]] = []
36
+
37
+ def chat(self, messages: list[dict[str, Any]], *, tools: list[dict[str, Any]]) -> dict[str, Any]:
38
+ response = self.model.chat(messages, tools=tools)
39
+ self.model_calls += 1
40
+ self._record_proposed_patches(response)
41
+ self.total_duration_ns += _metric_int(response, "total_duration")
42
+ self.load_duration_ns += _metric_int(response, "load_duration")
43
+ self.prompt_tokens += _metric_int(response, "prompt_eval_count")
44
+ self.prompt_eval_duration_ns += _metric_int(response, "prompt_eval_duration")
45
+ self.eval_count += _metric_int(response, "eval_count")
46
+ self.eval_duration_ns += _metric_int(response, "eval_duration")
47
+ return response
48
+
49
+ def _record_proposed_patches(self, response: Mapping[str, Any]) -> None:
50
+ message = response.get("message")
51
+ if not isinstance(message, Mapping):
52
+ return
53
+ calls = message.get("tool_calls") or []
54
+ if not calls:
55
+ compatible_call = _decode_content_tool_call(message.get("content"))
56
+ if compatible_call is not None:
57
+ calls = [compatible_call]
58
+ for call in calls:
59
+ if not isinstance(call, Mapping):
60
+ continue
61
+ function = call.get("function")
62
+ if not isinstance(function, Mapping) or function.get("name") != "propose_patch":
63
+ continue
64
+ arguments = function.get("arguments")
65
+ if isinstance(arguments, str):
66
+ try:
67
+ arguments = json.loads(arguments)
68
+ except json.JSONDecodeError:
69
+ continue
70
+ if isinstance(arguments, Mapping):
71
+ if isinstance(arguments.get("patch"), str):
72
+ self.proposed_patches.append(arguments["patch"])
73
+ if isinstance(arguments.get("edits"), list):
74
+ self.proposed_edits.append(arguments["edits"])
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class BenchmarkCaseResult:
79
+ case_id: str
80
+ status: str
81
+ correct: bool
82
+ loop_reliable: bool
83
+ validation_valid: bool
84
+ patch_applied: bool
85
+ patch_source: str
86
+ patch_error: str
87
+ wall_time_ms: float
88
+ model_calls: int
89
+ tool_calls: int
90
+ total_duration_ns: int
91
+ load_duration_ns: int
92
+ prompt_tokens: int
93
+ prompt_eval_duration_ns: int
94
+ eval_count: int
95
+ eval_duration_ns: int
96
+ result: Mapping[str, Any]
97
+
98
+ def as_dict(self) -> dict[str, Any]:
99
+ prompt_eval_tps = (
100
+ round(self.prompt_tokens * 1e9 / self.prompt_eval_duration_ns, 2)
101
+ if self.prompt_eval_duration_ns > 0
102
+ else 0.0
103
+ )
104
+ eval_tps = (
105
+ round(self.eval_count * 1e9 / self.eval_duration_ns, 2)
106
+ if self.eval_duration_ns > 0
107
+ else 0.0
108
+ )
109
+ return {
110
+ "case_id": self.case_id,
111
+ "status": self.status,
112
+ "correct": self.correct,
113
+ "loop_reliable": self.loop_reliable,
114
+ "validation_valid": self.validation_valid,
115
+ "patch_applied": self.patch_applied,
116
+ "patch_source": self.patch_source,
117
+ "patch_error": self.patch_error,
118
+ "wall_time_ms": self.wall_time_ms,
119
+ "model_calls": self.model_calls,
120
+ "tool_calls": self.tool_calls,
121
+ "total_duration_ns": self.total_duration_ns,
122
+ "load_duration_ns": self.load_duration_ns,
123
+ "prompt_tokens": self.prompt_tokens,
124
+ "prompt_eval_duration_ns": self.prompt_eval_duration_ns,
125
+ "prompt_eval_tps": prompt_eval_tps,
126
+ "eval_count": self.eval_count,
127
+ "eval_duration_ns": self.eval_duration_ns,
128
+ "eval_tps": eval_tps,
129
+ "result": dict(self.result),
130
+ }
131
+
132
+
133
+ def _decode_content_tool_call(content: Any) -> dict[str, Any] | None:
134
+ if not isinstance(content, str) or not content.strip():
135
+ return None
136
+ try:
137
+ payload = json.loads(content)
138
+ except json.JSONDecodeError:
139
+ return None
140
+ if not isinstance(payload, Mapping):
141
+ return None
142
+ name = payload.get("name")
143
+ arguments = payload.get("arguments")
144
+ if not isinstance(name, str) or not name or not isinstance(arguments, (Mapping, str)):
145
+ return None
146
+ return {"function": {"name": name, "arguments": arguments}}
147
+
148
+
149
+ def run_case(
150
+ model: ChatModel,
151
+ case: BenchmarkCase,
152
+ *,
153
+ max_turns: int = 4,
154
+ ) -> BenchmarkCaseResult:
155
+ """Run one model against a disposable fixture and judge its proposal externally."""
156
+
157
+ instrumented = model if isinstance(model, InstrumentedModel) else InstrumentedModel(model)
158
+ with tempfile.TemporaryDirectory(prefix="local-benchmark-") as temp_dir:
159
+ workspace = Path(temp_dir)
160
+ for raw_path, content in case.fixture.items():
161
+ target = workspace / raw_path
162
+ target.parent.mkdir(parents=True, exist_ok=True)
163
+ target.write_text(content, encoding="utf-8", newline="")
164
+
165
+ started = perf_counter_ns()
166
+ result = Controller(instrumented, workspace, max_turns=max_turns).run(case.task)
167
+ wall_time_ms = (perf_counter_ns() - started) / 1_000_000
168
+ fallback_patch = instrumented.proposed_patches[-1] if instrumented.proposed_patches else ""
169
+ fallback_edits = instrumented.proposed_edits[-1] if instrumented.proposed_edits else None
170
+ patch_applied, correct, patch_error, patch_source = _judge_patch(
171
+ result,
172
+ case,
173
+ workspace,
174
+ fallback_patch=fallback_patch,
175
+ fallback_edits=fallback_edits,
176
+ )
177
+ tool_calls = sum(
178
+ 1
179
+ for event in result.get("audit", [])
180
+ if isinstance(event, Mapping) and event.get("event") == "tool_call"
181
+ )
182
+ validation = result.get("validation")
183
+ validation_valid = isinstance(validation, Mapping) and validation.get("valid") is True
184
+ if patch_source == "tool_proposal":
185
+ validation_valid = _validate_patch_for_case(fallback_patch, case).valid
186
+ status = result.get("status") if isinstance(result.get("status"), str) else "failed"
187
+ loop_reliable = status == "accepted" and not _has_loop_error(result)
188
+ return BenchmarkCaseResult(
189
+ case_id=case.id,
190
+ status=status,
191
+ correct=correct,
192
+ loop_reliable=loop_reliable,
193
+ validation_valid=validation_valid,
194
+ patch_applied=patch_applied,
195
+ patch_source=patch_source,
196
+ patch_error=patch_error,
197
+ wall_time_ms=wall_time_ms,
198
+ model_calls=instrumented.model_calls,
199
+ tool_calls=tool_calls,
200
+ total_duration_ns=instrumented.total_duration_ns,
201
+ load_duration_ns=instrumented.load_duration_ns,
202
+ prompt_tokens=instrumented.prompt_tokens,
203
+ prompt_eval_duration_ns=instrumented.prompt_eval_duration_ns,
204
+ eval_count=instrumented.eval_count,
205
+ eval_duration_ns=instrumented.eval_duration_ns,
206
+ result=result,
207
+ )
208
+
209
+
210
+ def run_benchmark(
211
+ model_name: str,
212
+ model: ChatModel,
213
+ *,
214
+ cases: Sequence[BenchmarkCase] | None = None,
215
+ repeats: int = 1,
216
+ max_turns: int = 4,
217
+ ) -> dict[str, Any]:
218
+ if repeats <= 0:
219
+ raise ValueError("repeats must be positive")
220
+ from ._summary import summarize_results
221
+ selected = tuple(cases or default_cases())
222
+ results = [
223
+ run_case(model, case, max_turns=max_turns)
224
+ for _ in range(repeats)
225
+ for case in selected
226
+ ]
227
+ return {
228
+ "model": model_name,
229
+ "repeats": repeats,
230
+ "cases": [result.as_dict() for result in results],
231
+ "summary": summarize_results(model_name, results),
232
+ }
233
+
234
+
235
+ def write_artifact(path: str | Path, artifact: Mapping[str, Any]) -> None:
236
+ target = Path(path)
237
+ target.parent.mkdir(parents=True, exist_ok=True)
238
+ target.write_bytes(
239
+ (json.dumps(artifact, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
240
+ )
241
+
242
+
243
+ def _has_loop_error(result: Mapping[str, Any]) -> bool:
244
+ error = result.get("error")
245
+ if not isinstance(error, Mapping):
246
+ return False
247
+ return error.get("kind") in {
248
+ "duplicate_tool_call",
249
+ "max_turns",
250
+ "model_error",
251
+ "policy",
252
+ "invalid_json",
253
+ "invalid_response",
254
+ }
255
+
256
+
257
+ def _metric_int(response: Mapping[str, Any], key: str) -> int:
258
+ value = response.get(key)
259
+ return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
@@ -0,0 +1,22 @@
1
+ """Statistical helpers used by benchmark summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+
8
+ def _percent(numerator: int, denominator: int) -> float:
9
+ return round(numerator * 100.0 / denominator, 2)
10
+
11
+
12
+ def _wilson_score_interval(successes: int, total: int, confidence_z: float = 1.96) -> tuple[float, float]:
13
+ if total <= 0:
14
+ return 0.0, 0.0
15
+ p = successes / total
16
+ z = confidence_z
17
+ denominator = 1.0 + (z * z) / total
18
+ center = (p + (z * z) / (2.0 * total)) / denominator
19
+ margin = (z * math.sqrt((p * (1.0 - p) + (z * z) / (4.0 * total)) / total)) / denominator
20
+ low = max(0.0, center - margin)
21
+ high = min(1.0, center + margin)
22
+ return round(low * 100.0, 2), round(high * 100.0, 2)
@@ -0,0 +1,143 @@
1
+ """Benchmark result summaries and failure taxonomy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from statistics import mean, median
6
+ from typing import Any, Mapping, Sequence
7
+
8
+ from ._runner import BenchmarkCaseResult
9
+ from ._stats import _percent, _wilson_score_interval
10
+
11
+
12
+ def summarize_results(model_name: str, results: Sequence[BenchmarkCaseResult]) -> dict[str, Any]:
13
+ total = len(results)
14
+ if total == 0:
15
+ raise ValueError("cannot summarize an empty benchmark")
16
+ total_eval_tokens = sum(result.eval_count for result in results)
17
+ total_eval_duration_ns = sum(result.eval_duration_ns for result in results)
18
+ eval_tps = (
19
+ round(total_eval_tokens * 1_000_000_000.0 / total_eval_duration_ns, 2)
20
+ if total_eval_duration_ns > 0
21
+ else 0.0
22
+ )
23
+ total_prompt_tokens = sum(result.prompt_tokens for result in results)
24
+ total_prompt_eval_duration_ns = sum(result.prompt_eval_duration_ns for result in results)
25
+ prompt_eval_tps = (
26
+ round(total_prompt_tokens * 1_000_000_000.0 / total_prompt_eval_duration_ns, 2)
27
+ if total_prompt_eval_duration_ns > 0
28
+ else 0.0
29
+ )
30
+ correct_count = sum(result.correct for result in results)
31
+ loop_reliable_count = sum(result.loop_reliable for result in results)
32
+ patch_applied_count = sum(result.patch_applied for result in results)
33
+ return {
34
+ "model": model_name,
35
+ "cases": total,
36
+ "correctness_percent": _percent(correct_count, total),
37
+ "correctness_ci_95": _wilson_score_interval(correct_count, total),
38
+ "tool_loop_reliability_percent": _percent(loop_reliable_count, total),
39
+ "tool_loop_reliability_ci_95": _wilson_score_interval(loop_reliable_count, total),
40
+ "validation_percent": _percent(sum(result.validation_valid for result in results), total),
41
+ "patch_apply_percent": _percent(patch_applied_count, total),
42
+ "patch_apply_ci_95": _wilson_score_interval(patch_applied_count, total),
43
+ "error_categories": _categorize_errors(results),
44
+ "failure_taxonomy": categorize_failure_taxonomy(results),
45
+ "average_wall_time_ms": round(mean(result.wall_time_ms for result in results), 3),
46
+ "median_wall_time_ms": round(median(result.wall_time_ms for result in results), 3),
47
+ "model_calls": sum(result.model_calls for result in results),
48
+ "tool_calls": sum(result.tool_calls for result in results),
49
+ "total_duration_ms": round(sum(result.total_duration_ns for result in results) / 1_000_000, 3),
50
+ "load_duration_ms": round(sum(result.load_duration_ns for result in results) / 1_000_000, 3),
51
+ "prompt_tokens": total_prompt_tokens,
52
+ "eval_tokens": total_eval_tokens,
53
+ "prompt_eval_duration_ms": round(total_prompt_eval_duration_ns / 1_000_000, 3),
54
+ "eval_duration_ms": round(total_eval_duration_ns / 1_000_000, 3),
55
+ "eval_tokens_per_second": eval_tps,
56
+ "prompt_tokens_per_second": prompt_eval_tps,
57
+ }
58
+
59
+
60
+ def _categorize_errors(results: Sequence[BenchmarkCaseResult]) -> dict[str, int]:
61
+ categories: dict[str, int] = {}
62
+ for r in results:
63
+ if r.correct and r.loop_reliable:
64
+ continue
65
+ err_msg = r.patch_error or ""
66
+ error_kind = (
67
+ r.result.get("error", {}).get("kind")
68
+ if isinstance(r.result.get("error"), Mapping)
69
+ else None
70
+ )
71
+ if "not line-aligned" in err_msg:
72
+ cat = "search_not_line_aligned"
73
+ elif "search block not found" in err_msg or "not found" in err_msg:
74
+ cat = "search_not_found"
75
+ elif "ambiguous" in err_msg:
76
+ cat = "search_ambiguous"
77
+ elif "git apply exited" in err_msg or "does not apply" in err_msg:
78
+ cat = "git_apply_failed"
79
+ elif "oracle mismatch" in err_msg:
80
+ cat = "oracle_mismatch"
81
+ elif error_kind:
82
+ cat = str(error_kind)
83
+ elif not r.patch_applied:
84
+ cat = "patch_not_applied"
85
+ else:
86
+ cat = "other"
87
+ categories[cat] = categories.get(cat, 0) + 1
88
+ return dict(sorted(categories.items()))
89
+
90
+
91
+ CONTRACT_FRICTION_CATEGORIES: frozenset[str] = frozenset({
92
+ "search_not_line_aligned",
93
+ "search_not_found",
94
+ "search_ambiguous",
95
+ "git_apply_failed",
96
+ "patch_not_applied",
97
+ "invalid_json",
98
+ "invalid_response",
99
+ "duplicate_tool_call",
100
+ "preflight_rejected",
101
+ "context_limit",
102
+ "policy",
103
+ })
104
+
105
+ CAPABILITY_FAILURE_CATEGORIES: frozenset[str] = frozenset({
106
+ "oracle_mismatch",
107
+ "test_check_failed",
108
+ "check_failed",
109
+ "model_error",
110
+ "timeout",
111
+ "cancelled",
112
+ })
113
+
114
+
115
+ def categorize_failure_taxonomy(results: Sequence[BenchmarkCaseResult]) -> dict[str, Any]:
116
+ categories = _categorize_errors(results)
117
+ contract_friction: dict[str, int] = {}
118
+ capability_failures: dict[str, int] = {}
119
+ other: dict[str, int] = {}
120
+
121
+ for cat, count in categories.items():
122
+ if cat in CONTRACT_FRICTION_CATEGORIES:
123
+ contract_friction[cat] = count
124
+ elif cat in CAPABILITY_FAILURE_CATEGORIES:
125
+ capability_failures[cat] = count
126
+ else:
127
+ other[cat] = count
128
+
129
+ friction_count = sum(contract_friction.values())
130
+ capability_count = sum(capability_failures.values())
131
+ other_count = sum(other.values())
132
+ total_failures = friction_count + capability_count + other_count
133
+
134
+ return {
135
+ "total_failures": total_failures,
136
+ "contract_friction_count": friction_count,
137
+ "capability_failure_count": capability_count,
138
+ "friction_ratio": round(friction_count / total_failures, 2) if total_failures else 0.0,
139
+ "capability_ratio": round(capability_count / total_failures, 2) if total_failures else 0.0,
140
+ "contract_friction": dict(sorted(contract_friction.items())),
141
+ "capability_failures": dict(sorted(capability_failures.items())),
142
+ "other": dict(sorted(other.items())),
143
+ }
@@ -0,0 +1,169 @@
1
+ """Restricted child process for benchmark correctness oracles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import contextlib
7
+ import io
8
+ import json
9
+ import sys
10
+ from pathlib import Path as HostPath
11
+ from typing import Any
12
+
13
+
14
+ class _RestrictedPath:
15
+ def __init__(self, root: str, relative: str = "") -> None:
16
+ self._root_text = str(HostPath(root).resolve())
17
+ self._relative = relative.replace("\\", "/").strip("/")
18
+
19
+ def __truediv__(self, child: str) -> "_RestrictedPath":
20
+ if not isinstance(child, str):
21
+ raise TypeError("restricted path component must be a string")
22
+ relative = "/".join(part for part in (self._relative, child) if part)
23
+ return _RestrictedPath(self._root_text, relative)
24
+
25
+ @property
26
+ def name(self) -> str:
27
+ return self._relative.rsplit("/", 1)[-1]
28
+
29
+ def _host_path(self) -> HostPath:
30
+ root = HostPath(self._root_text)
31
+ candidate = (root / self._relative).resolve()
32
+ try:
33
+ candidate.relative_to(root)
34
+ except ValueError as error:
35
+ raise PermissionError("oracle path escapes the disposable workspace") from error
36
+ return candidate
37
+
38
+ def read_text(self, encoding: str = "utf-8") -> str:
39
+ return self._host_path().read_text(encoding=encoding)
40
+
41
+ def __str__(self) -> str:
42
+ return str(self._host_path())
43
+
44
+
45
+ def _restricted_open(file: Any, mode: str = "r", *args: Any, **kwargs: Any):
46
+ if not isinstance(file, _RestrictedPath):
47
+ raise PermissionError("oracle file access is limited to the disposable workspace")
48
+ if any(flag in mode for flag in ("w", "a", "+", "x")):
49
+ raise PermissionError("oracle workspace is read-only")
50
+ return open(file._host_path(), mode, *args, **kwargs)
51
+
52
+
53
+ def _restricted_import(name: str, *args: Any, **kwargs: Any) -> Any:
54
+ if name != "json":
55
+ raise ImportError(f"oracle import is not allowlisted: {name}")
56
+ return builtins.__import__(name, *args, **kwargs)
57
+
58
+
59
+ class _BoundedText(io.StringIO):
60
+ def __init__(self, limit: int = 4096) -> None:
61
+ super().__init__()
62
+ self._limit = limit
63
+
64
+ def write(self, value: str) -> int:
65
+ remaining = max(0, self._limit - len(self.getvalue()))
66
+ return super().write(value[:remaining])
67
+
68
+
69
+ def _fixture_builtins() -> dict[str, Any]:
70
+ allowed = {
71
+ key: getattr(builtins, key)
72
+ for key in (
73
+ "bool",
74
+ "dict",
75
+ "enumerate",
76
+ "Exception",
77
+ "float",
78
+ "ImportError",
79
+ "int",
80
+ "isinstance",
81
+ "len",
82
+ "list",
83
+ "range",
84
+ "set",
85
+ "str",
86
+ "sum",
87
+ "tuple",
88
+ "type",
89
+ "ValueError",
90
+ "abs",
91
+ "all",
92
+ "any",
93
+ "max",
94
+ "min",
95
+ "reversed",
96
+ "sorted",
97
+ "zip",
98
+ "__build_class__",
99
+ )
100
+ }
101
+ allowed["open"] = _restricted_open
102
+ allowed["__import__"] = _restricted_import
103
+ return allowed
104
+
105
+
106
+ def _load_function(path: _RestrictedPath, name: str) -> Any:
107
+ source = path.read_text(encoding="utf-8")
108
+ namespace: dict[str, Any] = {
109
+ "__builtins__": _fixture_builtins(),
110
+ "__name__": "benchmark_fixture",
111
+ "__file__": str(path),
112
+ }
113
+ exec(compile(source, str(path), "exec"), namespace)
114
+ function = namespace.get(name)
115
+ if not callable(function):
116
+ raise ValueError(f"external oracle could not load {name} from {path.name}")
117
+ return function
118
+
119
+
120
+ def main() -> int:
121
+ payload = json.loads(sys.stdin.buffer.read().decode("utf-8"))
122
+ root = payload["workspace"]
123
+ oracle_name = payload["oracle_name"]
124
+ oracle_source = payload["oracle_source"]
125
+ if not isinstance(root, str) or not isinstance(oracle_name, str) or not isinstance(oracle_source, str):
126
+ raise ValueError("oracle worker payload is invalid")
127
+ workspace = _RestrictedPath(root)
128
+ namespace: dict[str, Any] = {
129
+ "__builtins__": {
130
+ key: getattr(builtins, key)
131
+ for key in (
132
+ "bool",
133
+ "dict",
134
+ "Exception",
135
+ "isinstance",
136
+ "str",
137
+ "tuple",
138
+ "ValueError",
139
+ "__build_class__",
140
+ )
141
+ },
142
+ "Path": _RestrictedPath,
143
+ "_load_function": _load_function,
144
+ }
145
+ namespace["__builtins__"]["__import__"] = _restricted_import
146
+ exec(compile(oracle_source, "<benchmark-oracle>", "exec"), namespace)
147
+ oracle = namespace.get(oracle_name)
148
+ if not callable(oracle):
149
+ raise ValueError(f"oracle function not found: {oracle_name}")
150
+ correct, detail = oracle(workspace)
151
+ if not isinstance(correct, bool) or not isinstance(detail, str):
152
+ raise ValueError("oracle must return (bool, str)")
153
+ print(json.dumps({"ok": True, "correct": correct, "detail": detail}, ensure_ascii=False))
154
+ return 0
155
+
156
+
157
+ if __name__ == "__main__":
158
+ output = _BoundedText()
159
+ error_output = _BoundedText()
160
+ try:
161
+ with contextlib.redirect_stdout(output), contextlib.redirect_stderr(error_output):
162
+ exit_code = main()
163
+ except BaseException as error: # worker boundary: serialize oracle failure
164
+ exit_code = 0
165
+ output = _BoundedText()
166
+ output.write(json.dumps({"ok": False, "error": str(error)}, ensure_ascii=False))
167
+ sys.stdout.write(output.getvalue())
168
+ sys.stderr.write(error_output.getvalue())
169
+ raise SystemExit(exit_code)