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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
}
|