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,1008 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
6
+ from threading import Event, Thread
7
+
8
+ from typing import Any
9
+
10
+ from .. import controller as _controller_pkg
11
+ from ..atomizer import TaskBudget, preflight
12
+ from ..context_manager import (
13
+ ContextAssembler,
14
+ HarnessState,
15
+ compact_tool_exchanges,
16
+ purge_diff_residues,
17
+ )
18
+ from ..prescriptions import json_syntax_prescription, prescribe_all, tool_policy_prescription
19
+ from ..repository_tools import BoundedRepositoryTools, ToolCancelled, ToolPolicyError
20
+ from ..ollama_adapter import classify_backend_error
21
+ from ..semantic_linter import lint_patch_in_memory
22
+ from ..task import TaskEnvelope
23
+ from ..tool_call_parser import extract_tool_calls
24
+ from ..validators import validate_candidate
25
+ from ._constants import SYSTEM_CONTRACT, TOOL_DEFINITIONS, ModelClient
26
+ from ._post_apply import run_post_apply_checks
27
+
28
+
29
+ # apply_patch is resolved through the package namespace at call time so that
30
+ # tests patching `local_coding_agent.controller.apply_patch` keep working
31
+ # (the name is re-exported by the package __init__).
32
+ def _apply_patch(*args, **kwargs):
33
+ return _controller_pkg.apply_patch(*args, **kwargs)
34
+
35
+
36
+ # Backend rejection when the prompt exceeds the model's context window
37
+ # (llama-server `exceed_context_size_error`, Ollama wording varies).
38
+ _CONTEXT_OVERFLOW_RE = re.compile(r"exceed_context_size_error|exceeds the available context size")
39
+ _OVERFLOW_NUMBERS_RE = re.compile(r"(\d+)\s*tokens\)?\s*exceeds the available context size \((\d+)")
40
+
41
+
42
+ def _is_context_overflow(error: Exception) -> bool:
43
+ return bool(_CONTEXT_OVERFLOW_RE.search(str(error)))
44
+
45
+
46
+ def _context_overflow_message(error: Exception) -> str:
47
+ match = _OVERFLOW_NUMBERS_RE.search(str(error))
48
+ if match:
49
+ needed, allowed = match.group(1), match.group(2)
50
+ return (
51
+ f"model context too small: prompt needs ~{needed} tokens, model allows {allowed}. "
52
+ "Remove files from the task scope or use a profile with a larger num_ctx."
53
+ )
54
+ return (
55
+ "model context too small for this task's prompt. "
56
+ "Remove files from the task scope or use a profile with a larger num_ctx."
57
+ )
58
+
59
+
60
+ class Controller:
61
+ def __init__(
62
+ self,
63
+ model: ModelClient,
64
+ workspace_root: str,
65
+ *,
66
+ max_turns: int = 4,
67
+ max_same_call: int = 1,
68
+ max_tool_result_bytes: int = 64_000,
69
+ max_files: int = 5,
70
+ max_patch_bytes: int = 128_000,
71
+ max_patch_files: int = 2,
72
+ max_context_bytes: int = 128_000,
73
+
74
+ max_retries: int = 1,
75
+
76
+ preflight_budget: TaskBudget = TaskBudget(),
77
+ cancel_event: Event | None = None,
78
+ system_contract: str | None = None,
79
+ blocked_tools: set[str] | None = None,
80
+ ) -> None:
81
+ if max_turns <= 0 or max_same_call <= 0 or max_retries < 0:
82
+ raise ValueError("controller limits are invalid")
83
+ if max_retries > 10:
84
+ raise ValueError("max_retries exceeds hard cap of 10")
85
+ if not isinstance(preflight_budget, TaskBudget):
86
+ raise ValueError("preflight_budget must be a TaskBudget")
87
+ self.model = model
88
+ self.workspace_root = workspace_root
89
+ self.max_turns = max_turns
90
+ self.max_same_call = max_same_call
91
+ self.max_tool_result_bytes = max_tool_result_bytes
92
+ self.max_files = max_files
93
+ self.max_patch_bytes = max_patch_bytes
94
+ self.max_patch_files = max_patch_files
95
+ self.max_context_bytes = max_context_bytes
96
+ self.max_retries = max_retries
97
+ self.preflight_budget = preflight_budget
98
+ self.cancel_event = cancel_event
99
+ self.system_contract = system_contract or SYSTEM_CONTRACT
100
+ self.blocked_tools = set(blocked_tools or ())
101
+
102
+ def run(
103
+ self,
104
+ task: TaskEnvelope,
105
+ *,
106
+ cancel_event: Event | None = None,
107
+ completion_event: Event | None = None,
108
+ apply: bool = False,
109
+ ) -> dict[str, Any]:
110
+ audit: list[dict[str, Any]] = [{"event": "task_received", "task_id": task.id}]
111
+ try:
112
+ report = preflight(task, self.preflight_budget)
113
+ if not report.accepted:
114
+ if completion_event is not None:
115
+ completion_event.set()
116
+ return self._failure(
117
+ "failed",
118
+ "preflight_rejected",
119
+ report.reason or "preflight_rejected",
120
+ audit,
121
+ )
122
+ messages = self._initial_messages(task)
123
+ except ValueError as error:
124
+ if completion_event is not None:
125
+ completion_event.set()
126
+ return self._failure("needs_context", "context_limit", str(error), audit)
127
+
128
+ executor: ThreadPoolExecutor | None = None
129
+ try:
130
+ active_cancel = cancel_event or self.cancel_event
131
+ tools = BoundedRepositoryTools(
132
+ self.workspace_root,
133
+ task,
134
+ max_tool_result_bytes=self.max_tool_result_bytes,
135
+ max_files=self.max_files,
136
+ max_patch_bytes=self.max_patch_bytes,
137
+ max_patch_files=self.max_patch_files,
138
+ cancel_event=active_cancel,
139
+ blocked_tools=self.blocked_tools,
140
+ )
141
+ seen_calls: dict[str, int] = {}
142
+ observed_checks: dict[str, dict[str, Any]] = {}
143
+ attempts: list[dict[str, Any]] = []
144
+ last_validation_issues: list[str] = []
145
+ viewed_files: set[str] = set()
146
+ last_patch: list[str] = []
147
+ retries = 0
148
+ executor = ThreadPoolExecutor(max_workers=1)
149
+ return self._run_turns(
150
+ task,
151
+ messages,
152
+ tools,
153
+ active_cancel,
154
+ seen_calls,
155
+ observed_checks,
156
+ attempts,
157
+ last_validation_issues,
158
+ viewed_files,
159
+ last_patch,
160
+ retries,
161
+ executor,
162
+ audit,
163
+ apply=apply,
164
+ )
165
+ finally:
166
+ if executor is None:
167
+ if completion_event is not None:
168
+ completion_event.set()
169
+ else:
170
+ executor.shutdown(wait=False, cancel_futures=True)
171
+ if completion_event is not None:
172
+ Thread(
173
+ target=self._wait_for_executor,
174
+ args=(executor, completion_event),
175
+ daemon=True,
176
+ ).start()
177
+
178
+ @staticmethod
179
+ def _wait_for_executor(executor: ThreadPoolExecutor, completion_event: Event) -> None:
180
+ try:
181
+ executor.shutdown(wait=True)
182
+ finally:
183
+ completion_event.set()
184
+
185
+ def _run_turns(
186
+ self,
187
+ task: TaskEnvelope,
188
+ messages: list[dict[str, Any]],
189
+ tools: BoundedRepositoryTools,
190
+ active_cancel: Event | None,
191
+ seen_calls: dict[str, int],
192
+ observed_checks: dict[str, dict[str, Any]],
193
+ attempts: list[dict[str, Any]],
194
+ last_validation_issues: list[str],
195
+ viewed_files: set[str],
196
+ last_patch: list[str],
197
+ retries: int,
198
+ executor: ThreadPoolExecutor,
199
+ audit: list[dict[str, Any]],
200
+ *,
201
+ apply: bool = False,
202
+ ) -> dict[str, Any]:
203
+ last_invalid_candidate: dict[str, Any] | None = None
204
+ for turn in range(1, self.max_turns + 1):
205
+ if active_cancel is not None and active_cancel.is_set():
206
+ return self._failure("failed", "cancelled", "task was cancelled", audit)
207
+ if self._messages_size(messages) > self.max_context_bytes:
208
+ messages = self._compact_messages(messages, audit=audit)
209
+ if self._messages_size(messages) > self.max_context_bytes:
210
+ return self._failure(
211
+ "failed",
212
+ "context_limit",
213
+ f"cumulative context exceeds max_context_bytes={self.max_context_bytes}",
214
+ audit,
215
+ )
216
+ audit.append({"event": "model_request", "turn": turn, "message_count": len(messages)})
217
+ try:
218
+ future = executor.submit(
219
+ self.model.chat, messages, tools=self._tools_for_task(task)
220
+ )
221
+ while True:
222
+ try:
223
+ response = future.result(timeout=0.05)
224
+ break
225
+ except (TimeoutError, FuturesTimeoutError):
226
+ if active_cancel is not None and active_cancel.is_set():
227
+ # ponytail: the abandoned chat thread keeps running
228
+ # until its own ~30s HTTP timeout.
229
+ return self._failure("failed", "cancelled", "task was cancelled", audit)
230
+
231
+ except Exception as error: # model boundary: normalize executor failures
232
+ if last_invalid_candidate is not None:
233
+ return last_invalid_candidate
234
+ backend_kind = classify_backend_error(error)
235
+ if backend_kind == "offline":
236
+ return self._failure("failed", "backend_offline", str(error), audit)
237
+ if _is_context_overflow(error):
238
+ return self._failure(
239
+ "failed", "context_overflow", _context_overflow_message(error), audit
240
+ )
241
+ if backend_kind == "server_error":
242
+ return self._failure("failed", "backend_error", str(error), audit)
243
+ return self._failure("failed", "model_error", str(error), audit)
244
+ audit.append({
245
+ "event": "model_response",
246
+ "turn": turn,
247
+ "eval_tokens": int(response.get("eval_count") or 0) if isinstance(response, dict) else 0,
248
+ "eval_duration_ns": int(response.get("eval_duration") or 0) if isinstance(response, dict) else 0,
249
+ })
250
+ message = response.get("message") if isinstance(response, dict) else None
251
+ if not isinstance(message, dict):
252
+ if retries < self.max_retries:
253
+ retries += 1
254
+ attempts.append({"attempt": retries, "reason": "invalid_response"})
255
+ messages.append({"role": "user", "content": "Верни только объект JSON результата задачи."})
256
+ audit.append({"event": "retry", "reason": "invalid_response"})
257
+ continue
258
+ attempts.append({"attempt": retries + 1, "reason": "invalid_response"})
259
+ return self._escalation(
260
+ task,
261
+ reason="invalid_response",
262
+ attempts=attempts,
263
+ validation_issues=last_validation_issues,
264
+ viewed_files=viewed_files,
265
+ last_patch=last_patch,
266
+ observed_checks=observed_checks,
267
+ audit=audit,
268
+ )
269
+
270
+ tool_calls = message.get("tool_calls") or []
271
+ text_parse_errors: list[str] = []
272
+ if not tool_calls:
273
+ compatible_call = self._decode_content_tool_call(message.get("content"))
274
+ if compatible_call is not None:
275
+ message = dict(message)
276
+ message["tool_calls"] = [compatible_call]
277
+ message["content"] = ""
278
+ tool_calls = message["tool_calls"]
279
+ audit.append({"event": "content_tool_call_compatibility", "turn": turn})
280
+ else:
281
+ allowed = [d["function"]["name"] for d in self._tools_for_task(task)]
282
+ found = extract_tool_calls(message.get("content") or "", allowed_names=allowed)
283
+ text_parse_errors = list(found.errors)
284
+ if found.errors:
285
+ audit.append({
286
+ "event": "text_tool_call_parse_errors",
287
+ "turn": turn,
288
+ "errors": list(found.errors),
289
+ })
290
+ if found.calls:
291
+ message = dict(message)
292
+ message["tool_calls"] = found.calls
293
+ message["content"] = found.remaining_text
294
+ tool_calls = message["tool_calls"]
295
+ audit.append({
296
+ "event": "text_tool_call_promoted",
297
+ "turn": turn,
298
+ "formats": found.formats_detected,
299
+ })
300
+ if tool_calls:
301
+ messages.append(message)
302
+ for call in tool_calls:
303
+ name = None
304
+ call_id = None
305
+ try:
306
+ name, arguments, call_id = self._decode_tool_call(call)
307
+ arguments, arg_renames = self._heal_tool_arguments(name, arguments)
308
+ if arg_renames:
309
+ audit.append({
310
+ "event": "tool_arguments_healed",
311
+ "name": name,
312
+ "renames": arg_renames,
313
+ "turn": turn,
314
+ })
315
+ signature_arguments = arguments
316
+ if name == "list_files" and "path" not in signature_arguments:
317
+ signature_arguments = {**arguments, "path": "."}
318
+ signature = json.dumps(
319
+ {"name": name, "arguments": signature_arguments},
320
+ ensure_ascii=False,
321
+ sort_keys=True,
322
+ )
323
+ seen_calls[signature] = seen_calls.get(signature, 0) + 1
324
+ if seen_calls[signature] > self.max_same_call:
325
+ if last_patch:
326
+ result = {
327
+ "ok": True,
328
+ "patch": last_patch[-1],
329
+ "message": f"{name} already executed with valid patch. Conclude now.",
330
+ }
331
+ audit.append({"event": "tool_call_skipped_duplicate", "name": name, "turn": turn})
332
+ else:
333
+ return self._failure(
334
+ "failed",
335
+ "duplicate_tool_call",
336
+ f"repeated tool call: {name}",
337
+ audit,
338
+ )
339
+ else:
340
+ audit.append({"event": "tool_call", "name": name, "arguments": arguments, "turn": turn})
341
+ result = tools.execute(name, arguments)
342
+ if name == "read_file":
343
+ path = arguments.get("path")
344
+ if isinstance(path, str):
345
+ viewed_files.add(path)
346
+ elif name == "search_text":
347
+ for path in (arguments.get("paths") or list(task.files)):
348
+ if isinstance(path, str):
349
+ viewed_files.add(path)
350
+ elif name == "list_files":
351
+ for path in result.get("files", []):
352
+ if isinstance(path, str):
353
+ viewed_files.add(path)
354
+ elif name == "propose_patch":
355
+ if result.get("ok", True) is not False:
356
+ patch = result.get("patch")
357
+ if isinstance(patch, str):
358
+ last_patch[:] = [patch]
359
+ if name == "run_tests" and "passed" in result:
360
+ observed_checks[arguments["command"]] = {
361
+ "command": arguments["command"],
362
+ "passed": result["passed"],
363
+ "evidence": result["evidence"],
364
+ "stdout": result.get("stdout", ""),
365
+ "stderr": result.get("stderr", ""),
366
+ "exit_code": result.get("exit_code"),
367
+ }
368
+ tool_message: dict[str, Any] = {
369
+ "role": "tool",
370
+ "tool_name": name,
371
+ "content": json.dumps(result, ensure_ascii=False),
372
+ }
373
+ if call_id is not None:
374
+ tool_message["tool_call_id"] = call_id
375
+ messages.append(tool_message)
376
+ audit.append({"event": "tool_result", "name": name, "turn": turn})
377
+ except ToolPolicyError as error:
378
+ audit.append({"event": "tool_policy_error", "name": name, "error": str(error), "turn": turn})
379
+ tool_payload = tool_policy_prescription(name, str(error))
380
+ tool_message = {
381
+ "role": "tool",
382
+ "tool_name": name,
383
+ "content": json.dumps(tool_payload, ensure_ascii=False),
384
+ }
385
+ if call_id is not None:
386
+ tool_message["tool_call_id"] = call_id
387
+ messages.append(tool_message)
388
+ audit.append({"event": "tool_result", "name": name, "turn": turn})
389
+ except (ValueError, TypeError, json.JSONDecodeError) as error:
390
+ # Malformed tool-call arguments (e.g. truncated JSON) are
391
+ # fed back as a structured prescription so the model can
392
+ # re-issue a valid tool call within the turn budget,
393
+ # mirroring the ToolPolicyError branch above — never a
394
+ # raw parser error surfaced to the caller.
395
+ audit.append({
396
+ "event": "tool_json_error",
397
+ "name": name,
398
+ "error": str(error),
399
+ "turn": turn,
400
+ })
401
+ tool_payload = {
402
+ "ok": False,
403
+ "status": "error",
404
+ "error_code": "invalid_json",
405
+ "error": str(error),
406
+ "hint": (
407
+ "Твой tool-call не удалось разобрать как валидный JSON. "
408
+ 'Верни аргументы как СТРОГО один JSON-объект, например '
409
+ '{"file": "src/main.py", "search": "...", "replace": "..."}. '
410
+ "Без текста до и после, без markdown-разметки."
411
+ ),
412
+ }
413
+ tool_message: dict[str, Any] = {
414
+ "role": "tool",
415
+ "tool_name": name or "unknown",
416
+ "content": json.dumps(tool_payload, ensure_ascii=False),
417
+ }
418
+ if call_id is not None:
419
+ tool_message["tool_call_id"] = call_id
420
+ messages.append(tool_message)
421
+ audit.append({"event": "tool_result", "name": name or "unknown", "turn": turn})
422
+ except ToolCancelled:
423
+ return self._failure("failed", "cancelled", "task was cancelled", audit)
424
+ if text_parse_errors:
425
+ messages.append({
426
+ "role": "user",
427
+ "content": json.dumps(
428
+ {
429
+ "error": "TOOL_CALL_PARSE_FAILED",
430
+ "issues": text_parse_errors,
431
+ "instruction": "Исправь повреждённые элементы tool-call batch и повтори только их.",
432
+ },
433
+ ensure_ascii=False,
434
+ ),
435
+ })
436
+ continue
437
+
438
+ if text_parse_errors:
439
+ messages.append(message)
440
+ messages.append({
441
+ "role": "user",
442
+ "content": json.dumps(
443
+ {
444
+ "error": "TOOL_CALL_PARSE_FAILED",
445
+ "issues": text_parse_errors,
446
+ "instruction": "Исправь повреждённые элементы tool-call batch и повтори только их.",
447
+ },
448
+ ensure_ascii=False,
449
+ ),
450
+ })
451
+ continue
452
+
453
+ content = message.get("content")
454
+ try:
455
+ result = self._parse_final_result(content)
456
+ except (TypeError, ValueError, json.JSONDecodeError) as error:
457
+ if retries < self.max_retries:
458
+ retries += 1
459
+ attempts.append({"attempt": retries, "reason": "invalid_json"})
460
+ messages.append(message)
461
+ messages.append({
462
+ "role": "user",
463
+ "content": json_syntax_prescription(str(error)),
464
+ })
465
+ audit.append({"event": "retry", "reason": "invalid_json"})
466
+ continue
467
+ attempts.append({"attempt": retries + 1, "reason": "invalid_json"})
468
+ return self._escalation(
469
+ task,
470
+ reason="invalid_json",
471
+ attempts=attempts,
472
+ validation_issues=last_validation_issues,
473
+ viewed_files=viewed_files,
474
+ last_patch=last_patch,
475
+ observed_checks=observed_checks,
476
+ audit=audit,
477
+ )
478
+ result = dict(result)
479
+ for controller_field in (
480
+ "audit",
481
+ "applied",
482
+ "error",
483
+ "post_apply_checks",
484
+ "validation",
485
+ ):
486
+ result.pop(controller_field, None)
487
+ if "status" not in result:
488
+ result["status"] = "candidate"
489
+ if "risks" not in result:
490
+ result["risks"] = []
491
+
492
+ if result.get("edits") and result.get("patch"):
493
+ result.pop("patch", None)
494
+ audit.append({"event": "redundant_patch_normalized_to_edits"})
495
+ if not result.get("patch") and not result.get("edits") and last_patch:
496
+ result["patch"] = last_patch[-1]
497
+ audit.append({"event": "patch_reused_from_tool_proposal"})
498
+
499
+
500
+ result["checks"] = [
501
+ dict(observed_checks[command])
502
+ for command in task.checks
503
+ if command in observed_checks and observed_checks[command].get("passed") is True
504
+ ]
505
+ report = validate_candidate(
506
+ result,
507
+ task,
508
+ max_patch_bytes=self.max_patch_bytes,
509
+ max_patch_files=self.max_patch_files,
510
+ observed_checks=observed_checks,
511
+ workspace_root=self.workspace_root,
512
+ )
513
+
514
+ # Semantic linter pre-gate (R18): catch syntax-level breakage before
515
+ # the patch is accepted or applied, with a targeted prescription.
516
+ lint_issues: list[str] = []
517
+ gate_patch = report.resolved_patch or result.get("patch")
518
+ if report.valid and isinstance(gate_patch, str) and gate_patch.strip():
519
+ lint_report = lint_patch_in_memory(str(self.workspace_root), gate_patch)
520
+ if not lint_report.valid:
521
+ lint_issues = [
522
+ f"{d.file}:{d.line}: {d.message}" for d in lint_report.diagnostics
523
+ ]
524
+
525
+ result["validation"] = {
526
+ "valid": report.valid,
527
+ "changed_files": list(report.changed_files),
528
+ "issues": list(report.issues),
529
+ "lint_issues": lint_issues,
530
+ }
531
+ if report.resolved_patch:
532
+ result["patch"] = report.resolved_patch
533
+ result.pop("edits", None)
534
+
535
+ if not report.valid or lint_issues:
536
+ last_invalid_candidate = dict(result)
537
+ last_invalid_candidate["status"] = "rejected"
538
+ last_invalid_candidate["audit"] = audit
539
+ if turn < self.max_turns and retries < self.max_retries:
540
+ retries += 1
541
+ all_issues = [*report.issues, *lint_issues]
542
+ last_validation_issues[:] = all_issues
543
+ prescription = prescribe_all(list(report.issues)) if not lint_issues else (
544
+ "Исправь синтаксические ошибки: " + "; ".join(lint_issues)
545
+ )
546
+ feedback_msg = {
547
+ "role": "user",
548
+ "content": json.dumps(
549
+ {
550
+ "error": "CANDIDATE_VALIDATION_FAILED" if not lint_issues else "SEMANTIC_LINT_FAILED",
551
+ "issues": all_issues,
552
+ "instruction": f"ОШИБКА ВАЛИДАЦИИ: {prescription} Исправь эти поля и верни скорректированный JSON-объект.",
553
+ },
554
+ ensure_ascii=False,
555
+ ),
556
+ }
557
+ cleaned_assistant_message = dict(message)
558
+ try:
559
+ raw_content = message.get("content")
560
+ if isinstance(raw_content, str) and raw_content.strip():
561
+ parsed_c = json.loads(raw_content)
562
+ if isinstance(parsed_c, dict) and parsed_c.get("patch"):
563
+ parsed_c["patch"] = "<invalid_patch_omitted>"
564
+ cleaned_assistant_message["content"] = json.dumps(parsed_c, ensure_ascii=False)
565
+ except (json.JSONDecodeError, TypeError, KeyError, AttributeError):
566
+ pass
567
+ messages.append(cleaned_assistant_message)
568
+ messages.append(feedback_msg)
569
+ audit.append({
570
+ "event": "templated_feedback",
571
+ "reason": "semantic_lint_failed" if lint_issues else "candidate_validation_failed",
572
+ "issues": [*report.issues, *lint_issues],
573
+ "prescription": prescription,
574
+ "turn": turn,
575
+ })
576
+ continue
577
+
578
+ candidate_valid = bool(report.valid) and not lint_issues
579
+ result["status"] = "accepted" if candidate_valid else "rejected"
580
+ audit.append({"event": "candidate_validated", "valid": candidate_valid})
581
+ if candidate_valid and apply:
582
+ patch = report.resolved_patch or result.get("patch")
583
+ if not isinstance(patch, str) or not patch.strip():
584
+ audit.append({"event": "apply_skipped", "reason": "candidate has no patch"})
585
+ elif not task.checks:
586
+ result["status"] = "rejected"
587
+ self._add_risk(
588
+ result,
589
+ "apply_requires_checks",
590
+ "applying a non-empty patch requires at least one targeted check",
591
+ )
592
+ audit.append({"event": "apply_rejected", "reason": "apply_requires_checks"})
593
+ else:
594
+ applied, apply_detail = _apply_patch(self.workspace_root, patch)
595
+ if not applied:
596
+ result["status"] = "rejected"
597
+ self._add_risk(
598
+ result,
599
+ "apply_failed",
600
+ f"patch could not be applied: {apply_detail}",
601
+ )
602
+ audit.append({"event": "apply_failed", "detail": apply_detail})
603
+ else:
604
+ audit.append({"event": "patch_applied"})
605
+ try:
606
+ post_checks, post_checks_passed = self._run_post_apply_checks(
607
+ task, tools, active_cancel, audit
608
+ )
609
+ except ToolCancelled:
610
+ rollback_ok, rollback_detail = _apply_patch(
611
+ self.workspace_root, patch, reverse=True
612
+ )
613
+ result["status"] = "failed"
614
+ self._add_risk(result, "cancelled", "post-apply checks were cancelled")
615
+ audit.append({"event": "post_apply_cancelled"})
616
+ if rollback_ok:
617
+ audit.append({"event": "patch_rolled_back"})
618
+ else:
619
+ self._mark_rollback_failure(result, rollback_detail)
620
+ audit.append(
621
+ {"event": "rollback_failed", "detail": rollback_detail}
622
+ )
623
+ except ToolPolicyError as error:
624
+ rollback_ok, rollback_detail = _apply_patch(
625
+ self.workspace_root, patch, reverse=True
626
+ )
627
+ result["status"] = "rejected"
628
+ self._add_risk(
629
+ result,
630
+ "post_apply_check_failed",
631
+ f"post-apply check could not complete: {error}",
632
+ )
633
+ audit.append({"event": "post_apply_check_error", "detail": str(error)})
634
+ if rollback_ok:
635
+ audit.append({"event": "patch_rolled_back"})
636
+ else:
637
+ self._mark_rollback_failure(result, rollback_detail)
638
+ audit.append(
639
+ {"event": "rollback_failed", "detail": rollback_detail}
640
+ )
641
+ else:
642
+ result["post_apply_checks"] = post_checks
643
+ if post_checks_passed:
644
+ result["checks"] = post_checks
645
+ result["applied"] = True
646
+ audit.append({"event": "post_apply_checks_passed"})
647
+ else:
648
+ rollback_ok, rollback_detail = _apply_patch(
649
+ self.workspace_root, patch, reverse=True
650
+ )
651
+ result["status"] = "rejected"
652
+ self._add_risk(
653
+ result,
654
+ "post_apply_check_failed",
655
+ "a targeted check failed after applying the patch",
656
+ )
657
+ if rollback_ok:
658
+ audit.append({"event": "patch_rolled_back"})
659
+ else:
660
+ self._mark_rollback_failure(result, rollback_detail)
661
+ audit.append(
662
+ {"event": "rollback_failed", "detail": rollback_detail}
663
+ )
664
+ elif apply:
665
+ audit.append({"event": "apply_skipped", "reason": "candidate rejected"})
666
+ if not report.valid or lint_issues:
667
+ self._add_risk(
668
+ result,
669
+ "semantic_lint_failed" if lint_issues else "validation",
670
+ "; ".join(lint_issues) or "; ".join(report.issues),
671
+ )
672
+ result["audit"] = audit
673
+ return result
674
+
675
+ if last_patch:
676
+ candidate: dict[str, Any] = {
677
+ "status": "candidate",
678
+ "summary": "Propose patch completed before max turns",
679
+ "patch": last_patch[-1],
680
+ "checks": [
681
+ dict(observed_checks[command])
682
+ for command in task.checks
683
+ if command in observed_checks and observed_checks[command].get("passed") is True
684
+ ],
685
+ "risks": [],
686
+ }
687
+ report = validate_candidate(
688
+ candidate,
689
+ task,
690
+ max_patch_bytes=self.max_patch_bytes,
691
+ max_patch_files=self.max_patch_files,
692
+ observed_checks=observed_checks,
693
+ workspace_root=self.workspace_root,
694
+ )
695
+ # Same semantic lint gate as the in-loop path: a salvaged patch
696
+ # must never be accepted with syntax errors.
697
+ salvage_lint_issues: list[str] = []
698
+ if report.valid and isinstance(candidate.get("patch"), str) and candidate["patch"].strip():
699
+ salvage_lint = lint_patch_in_memory(str(self.workspace_root), candidate["patch"])
700
+ if not salvage_lint.valid:
701
+ salvage_lint_issues = [
702
+ f"{d.file}:{d.line}: {d.message}" for d in salvage_lint.diagnostics
703
+ ]
704
+ candidate["validation"] = {
705
+ "valid": report.valid,
706
+ "changed_files": list(report.changed_files),
707
+ "issues": list(report.issues),
708
+ "lint_issues": salvage_lint_issues,
709
+ }
710
+ salvaged_valid = bool(report.valid) and not salvage_lint_issues
711
+ candidate["status"] = "accepted" if salvaged_valid else "rejected"
712
+ if salvage_lint_issues:
713
+ self._add_risk(candidate, "semantic_lint_failed", "; ".join(salvage_lint_issues))
714
+ candidate["audit"] = audit
715
+ audit.append({"event": "candidate_salvaged_from_last_patch", "valid": salvaged_valid})
716
+ return candidate
717
+
718
+ if attempts:
719
+ return self._escalation(
720
+ task,
721
+ reason="max_turns",
722
+ attempts=attempts,
723
+ validation_issues=last_validation_issues,
724
+ viewed_files=viewed_files,
725
+ last_patch=last_patch,
726
+ observed_checks=observed_checks,
727
+ audit=audit,
728
+ )
729
+ return self._failure("failed", "max_turns", f"max_turns={self.max_turns} exceeded", audit)
730
+
731
+ def _run_post_apply_checks(
732
+ self,
733
+ task: TaskEnvelope,
734
+ tools: BoundedRepositoryTools,
735
+ active_cancel: Event | None,
736
+ audit: list[dict[str, Any]],
737
+ ) -> tuple[list[dict[str, Any]], bool]:
738
+ return run_post_apply_checks(task, tools, active_cancel=active_cancel, audit=audit)
739
+
740
+ @staticmethod
741
+ def _add_risk(result: dict[str, Any], kind: str, message: str) -> None:
742
+ risks = result.get("risks")
743
+ if not isinstance(risks, list):
744
+ risks = []
745
+ result["risks"] = risks
746
+ risks.append({"kind": kind, "message": message})
747
+
748
+ @classmethod
749
+ def _mark_rollback_failure(cls, result: dict[str, Any], detail: str) -> None:
750
+ result["workspace_modified"] = True
751
+ cls._add_risk(result, "rollback_failed", f"patch rollback failed: {detail}")
752
+
753
+ def _messages_size(self, messages) -> int:
754
+ return len(json.dumps(messages, ensure_ascii=False).encode("utf-8"))
755
+
756
+ def _compact_messages(
757
+ self, messages: list[dict[str, Any]], *, audit: list[dict[str, Any]]
758
+ ) -> list[dict[str, Any]]:
759
+ """Compact messages by evicting historical tool-call exchanges older than 1 turn."""
760
+ compacted, dropped = compact_tool_exchanges(messages, max_bytes=self.max_context_bytes)
761
+ if dropped:
762
+ tool_names = [m.get("tool_name") for m in dropped if m.get("role") == "tool"]
763
+ audit.append({
764
+ "event": "context_compacted",
765
+ "dropped_messages_count": len(dropped),
766
+ "dropped_tool_names": tool_names,
767
+ })
768
+ return compacted
769
+
770
+ @staticmethod
771
+ def _find_tool_exchange_blocks(messages: list[dict[str, Any]]) -> list[tuple[int, int]]:
772
+ """Find atomic ranges [start, end) of assistant(tool_calls) and their corresponding tool results.
773
+
774
+ Preserves messages[0] (system) and messages[1] (initial task envelope).
775
+ """
776
+ blocks: list[tuple[int, int]] = []
777
+ i = 2
778
+ n = len(messages)
779
+ while i < n:
780
+ msg = messages[i]
781
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
782
+ start = i
783
+ j = i + 1
784
+ while j < n and messages[j].get("role") == "tool":
785
+ j += 1
786
+ blocks.append((start, j))
787
+ i = j
788
+ else:
789
+ i += 1
790
+ return blocks
791
+
792
+ def _initial_messages(self, task: TaskEnvelope) -> list[dict[str, Any]]:
793
+ payload = {
794
+ "id": task.id,
795
+ "goal": task.goal,
796
+ "files": list(task.files),
797
+ "context": task.context,
798
+ "constraints": list(task.constraints),
799
+ "checks": list(task.checks),
800
+ "acceptance": list(task.acceptance),
801
+ "limits": {
802
+ "max_turns": self.max_turns,
803
+ "max_same_call": self.max_same_call,
804
+ "max_tool_result_bytes": self.max_tool_result_bytes,
805
+ "max_files": self.max_files,
806
+ "max_patch_files": self.max_patch_files,
807
+ },
808
+ }
809
+ content = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
810
+ if len(content.encode("utf-8")) > self.max_context_bytes:
811
+ raise ValueError(f"task context exceeds max_context_bytes={self.max_context_bytes}")
812
+ return [
813
+ {"role": "system", "content": self.system_contract},
814
+ {"role": "user", "content": content},
815
+ ]
816
+
817
+ @staticmethod
818
+ def _decode_tool_call(call: Any) -> tuple[str, dict[str, Any], str | None]:
819
+ if not isinstance(call, dict):
820
+ raise ValueError("tool call must be an object")
821
+ call_id = call.get("id")
822
+ if call_id is not None and not isinstance(call_id, str):
823
+ raise ValueError("tool call id must be a string")
824
+ function = call.get("function")
825
+ if not isinstance(function, dict):
826
+ raise ValueError("tool call has no function object")
827
+ name = function.get("name")
828
+ if not isinstance(name, str) or not name:
829
+ raise ValueError("tool call has no function name")
830
+ arguments = function.get("arguments", {})
831
+ if isinstance(arguments, str):
832
+ arguments = json.loads(arguments)
833
+ if not isinstance(arguments, dict):
834
+ raise ValueError("tool arguments must be an object")
835
+ return name, arguments, call_id
836
+
837
+ # Wrong-but-close argument names small models emit instead of the declared
838
+ # schema properties (e.g. "filepath" for "path", "pattern" for "query").
839
+ _TOOL_ARG_ALIASES = {
840
+ "file": "path",
841
+ "filename": "path",
842
+ "filepath": "path",
843
+ "pattern": "query",
844
+ "text": "query",
845
+ "cmd": "command",
846
+ "diff": "patch",
847
+ }
848
+
849
+ @classmethod
850
+ def _heal_tool_arguments(
851
+ cls, name: str, arguments: dict[str, Any]
852
+ ) -> tuple[dict[str, Any], list[str]]:
853
+ """Repair wrong-but-close tool argument names against the tool schema.
854
+
855
+ Matches a case/underscore-insensitive schema lookup plus an explicit
856
+ alias map. Renames only when the candidate is a real property of this
857
+ tool and not already present; ambiguous keys pass through unchanged so
858
+ the normal error path still teaches the model the real schema.
859
+ """
860
+ if not isinstance(arguments, dict) or not arguments:
861
+ return arguments, []
862
+ definition = next(
863
+ (d for d in TOOL_DEFINITIONS if d["function"]["name"] == name), None
864
+ )
865
+ if definition is None:
866
+ return arguments, []
867
+ properties = set(
868
+ (definition["function"].get("parameters") or {}).get("properties") or ()
869
+ )
870
+ if not properties:
871
+ return arguments, []
872
+
873
+ def norm(key: str) -> str:
874
+ return re.sub(r"[_\-\s]", "", key).lower()
875
+
876
+ by_norm = {norm(k): k for k in properties}
877
+ healed: dict[str, Any] = {}
878
+ renames: list[str] = []
879
+ for key, value in arguments.items():
880
+ target = key
881
+ if key not in properties:
882
+ flat = norm(str(key))
883
+ candidate = by_norm.get(flat)
884
+ if candidate is None:
885
+ candidate = cls._TOOL_ARG_ALIASES.get(flat)
886
+ if (
887
+ candidate is not None
888
+ and candidate in properties
889
+ and candidate not in arguments
890
+ and candidate not in healed
891
+ ):
892
+ renames.append(f"{key} -> {candidate}")
893
+ target = candidate
894
+ healed[target] = value
895
+ return healed, renames
896
+
897
+ @staticmethod
898
+ def _decode_content_tool_call(content: Any) -> dict[str, Any] | None:
899
+ if not isinstance(content, str) or not content.strip():
900
+ return None
901
+ try:
902
+ payload = json.loads(content)
903
+ except json.JSONDecodeError:
904
+ return None
905
+ if not isinstance(payload, dict):
906
+ return None
907
+ name = payload.get("name")
908
+ arguments = payload.get("arguments")
909
+ if not isinstance(name, str) or not name or not isinstance(arguments, (dict, str)):
910
+ return None
911
+ return {"function": {"name": name, "arguments": arguments}}
912
+
913
+ def _tools_for_task(self, task: TaskEnvelope) -> list[dict[str, Any]]:
914
+ if task.checks:
915
+ candidates = TOOL_DEFINITIONS
916
+ else:
917
+ candidates = [
918
+ definition
919
+ for definition in TOOL_DEFINITIONS
920
+ if definition["function"]["name"] != "run_tests"
921
+ ]
922
+ # Don't advertise tools that read-only/plan mode blocks — saves the model
923
+ # from wasting turns on calls that are guaranteed to raise ToolPolicyError.
924
+ if self.blocked_tools:
925
+ candidates = [
926
+ definition
927
+ for definition in candidates
928
+ if definition["function"]["name"] not in self.blocked_tools
929
+ ]
930
+ return candidates
931
+
932
+ @staticmethod
933
+ def _parse_final_result(content: Any) -> dict[str, Any]:
934
+ if not isinstance(content, str) or not content.strip():
935
+ raise ValueError("final model response has no JSON content")
936
+ cleaned = content.strip()
937
+ if cleaned.startswith("```"):
938
+ lines = cleaned.splitlines()
939
+ if lines and lines[0].startswith("```"):
940
+ lines = lines[1:]
941
+ if lines and lines[-1].startswith("```"):
942
+ lines = lines[:-1]
943
+ cleaned = "\n".join(lines).strip()
944
+ try:
945
+ result = json.loads(cleaned)
946
+ except json.JSONDecodeError:
947
+ start = cleaned.find("{")
948
+ end = cleaned.rfind("}")
949
+ if start != -1 and end != -1 and end > start:
950
+ result = json.loads(cleaned[start : end + 1])
951
+ else:
952
+ raise
953
+ if not isinstance(result, dict):
954
+ raise ValueError("final model response must be a JSON object")
955
+ return result
956
+
957
+ @staticmethod
958
+ def _failure(status: str, kind: str, message: str, audit: list[dict[str, Any]]) -> dict[str, Any]:
959
+ return {
960
+ "status": status,
961
+ "summary": message,
962
+ "patch": "",
963
+ "checks": [],
964
+ "risks": [{"kind": kind, "message": message}],
965
+ "error": {"kind": kind, "message": message},
966
+ "audit": audit,
967
+ }
968
+
969
+ def _escalation(
970
+ self,
971
+ task: TaskEnvelope,
972
+ *,
973
+ reason: str,
974
+ attempts: list[dict[str, Any]],
975
+ validation_issues: list[str] | None = None,
976
+ viewed_files: set[str],
977
+ last_patch: list[str],
978
+ observed_checks: dict[str, dict[str, Any]],
979
+ audit: list[dict[str, Any]],
980
+ ) -> dict[str, Any]:
981
+ audit.append({"event": "escalation", "reason": reason, "attempts": len(attempts)})
982
+ return {
983
+ "status": "failed",
984
+ "summary": f"retry budget exhausted: {reason}",
985
+ "patch": "",
986
+ "checks": [],
987
+ "risks": [],
988
+ "error": {"kind": "retry_budget_exhausted", "message": reason},
989
+ "escalation": {
990
+ "reason": reason,
991
+ "task": {
992
+ "id": task.id,
993
+ "goal": task.goal,
994
+ "files": list(task.files),
995
+ "context": task.context,
996
+ "constraints": list(task.constraints),
997
+ "checks": list(task.checks),
998
+ "acceptance": list(task.acceptance),
999
+ },
1000
+ "attempts": list(attempts),
1001
+ "viewed_files": sorted(viewed_files),
1002
+ "last_patch": last_patch[-1] if last_patch else "",
1003
+ "validation_issues": list(validation_issues or ()),
1004
+ "external_evidence": dict(observed_checks),
1005
+ "risks": [],
1006
+ },
1007
+ "audit": audit,
1008
+ }