akernel-runtime 0.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.
- akernel_runtime-0.1.0.dist-info/METADATA +270 -0
- akernel_runtime-0.1.0.dist-info/RECORD +40 -0
- akernel_runtime-0.1.0.dist-info/WHEEL +5 -0
- akernel_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- akernel_runtime-0.1.0.dist-info/licenses/LICENSE +201 -0
- akernel_runtime-0.1.0.dist-info/licenses/NOTICE +4 -0
- akernel_runtime-0.1.0.dist-info/top_level.txt +1 -0
- context_kernel/__init__.py +4 -0
- context_kernel/__main__.py +5 -0
- context_kernel/agent_reports.py +188 -0
- context_kernel/benchmarks.py +493 -0
- context_kernel/budget.py +72 -0
- context_kernel/cli.py +2953 -0
- context_kernel/context.py +161 -0
- context_kernel/evals.py +347 -0
- context_kernel/global_memory.py +126 -0
- context_kernel/loop.py +1617 -0
- context_kernel/marketplace.py +194 -0
- context_kernel/marketplace_data/skills/context_budget.json +27 -0
- context_kernel/marketplace_data/skills/context_compaction.json +27 -0
- context_kernel/marketplace_data/skills/edit_file.json +27 -0
- context_kernel/marketplace_data/skills/index.json +66 -0
- context_kernel/marketplace_data/skills/long_task_planning.json +27 -0
- context_kernel/marketplace_data/skills/multi_file_bugfix.json +28 -0
- context_kernel/memory.py +515 -0
- context_kernel/models.py +144 -0
- context_kernel/planner.py +155 -0
- context_kernel/policy.py +271 -0
- context_kernel/project.py +317 -0
- context_kernel/providers.py +1264 -0
- context_kernel/report_costs.py +375 -0
- context_kernel/runner.py +78 -0
- context_kernel/skills.py +318 -0
- context_kernel/state_writer.py +108 -0
- context_kernel/storage.py +171 -0
- context_kernel/tasks.py +549 -0
- context_kernel/text.py +42 -0
- context_kernel/tokenizer.py +22 -0
- context_kernel/tools.py +544 -0
- context_kernel/verifier.py +77 -0
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Protocol
|
|
11
|
+
|
|
12
|
+
from .policy import command_root_candidates
|
|
13
|
+
from .tokenizer import estimate_tokens
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ProviderResponse:
|
|
18
|
+
text: str
|
|
19
|
+
input_tokens: int
|
|
20
|
+
output_tokens: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ModelProvider(Protocol):
|
|
24
|
+
name: str
|
|
25
|
+
|
|
26
|
+
def run(self, packet: dict[str, Any]) -> ProviderResponse:
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MockProvider:
|
|
31
|
+
name = "mock"
|
|
32
|
+
|
|
33
|
+
def run(self, packet: dict[str, Any]) -> ProviderResponse:
|
|
34
|
+
if str(packet.get("agent", {}).get("mode", "")) == "aux_review_v1":
|
|
35
|
+
return self._run_aux_review(packet)
|
|
36
|
+
if str(packet.get("agent", {}).get("mode", "")).startswith("tool_planning_v"):
|
|
37
|
+
return self._run_tool_planning(packet)
|
|
38
|
+
skill_names = [item["contract"]["name"] for item in packet.get("skills", [])]
|
|
39
|
+
memory_count = len(packet.get("memory", []))
|
|
40
|
+
text = (
|
|
41
|
+
"Mock provider received a minimal context packet.\n"
|
|
42
|
+
f"Request: {packet['request']}\n"
|
|
43
|
+
f"Selected skills: {', '.join(skill_names) if skill_names else 'none'}\n"
|
|
44
|
+
f"Selected memories: {memory_count}\n"
|
|
45
|
+
f"Estimated input tokens: {packet['budget']['estimated_used']}"
|
|
46
|
+
)
|
|
47
|
+
return ProviderResponse(
|
|
48
|
+
text=text,
|
|
49
|
+
input_tokens=estimate_tokens(packet),
|
|
50
|
+
output_tokens=estimate_tokens(text),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def _run_aux_review(self, packet: dict[str, Any]) -> ProviderResponse:
|
|
54
|
+
budget = packet.get("budget", {})
|
|
55
|
+
review = packet.get("agent", {}).get("review", {})
|
|
56
|
+
warnings = review.get("warnings", [])
|
|
57
|
+
risk = "high" if budget.get("over_budget") else "medium" if warnings else "low"
|
|
58
|
+
recommendation = "reduce_context" if budget.get("over_budget") else "continue"
|
|
59
|
+
payload = {
|
|
60
|
+
"ok": not bool(budget.get("over_budget")),
|
|
61
|
+
"risk": risk,
|
|
62
|
+
"recommendation": recommendation,
|
|
63
|
+
"notes": [
|
|
64
|
+
f"route={review.get('route_mode', 'unknown')}",
|
|
65
|
+
f"complexity={review.get('complexity', 'unknown')}",
|
|
66
|
+
f"estimated_tokens={budget.get('estimated_used', 0)}",
|
|
67
|
+
],
|
|
68
|
+
}
|
|
69
|
+
text = json.dumps(payload, ensure_ascii=False)
|
|
70
|
+
return ProviderResponse(
|
|
71
|
+
text=text,
|
|
72
|
+
input_tokens=estimate_tokens(packet),
|
|
73
|
+
output_tokens=estimate_tokens(text),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _run_tool_planning(self, packet: dict[str, Any]) -> ProviderResponse:
|
|
77
|
+
request = str(packet.get("request", ""))
|
|
78
|
+
linked_tools = packet.get("task", {}).get("brief", {}).get("linked_tool_traces", [])
|
|
79
|
+
allowed_roots = set(packet.get("runtime", {}).get("command_policy", {}).get("allowed_roots", []))
|
|
80
|
+
project = packet.get("runtime", {}).get("project")
|
|
81
|
+
project_commands = project.get("commands", {}) if isinstance(project, dict) else {}
|
|
82
|
+
payload = (
|
|
83
|
+
self._mock_batch_patch_verify_action(request, linked_tools, allowed_roots, project_commands)
|
|
84
|
+
or self._mock_patch_verify_action(request, linked_tools, allowed_roots, project_commands)
|
|
85
|
+
or self._mock_write_verify_action(request, linked_tools, allowed_roots, project_commands)
|
|
86
|
+
or self._mock_read_file_action(request, linked_tools)
|
|
87
|
+
or self._mock_fix_failing_tests_action(request, linked_tools, allowed_roots, project_commands)
|
|
88
|
+
or self._mock_run_command_action(request, linked_tools, allowed_roots, project_commands)
|
|
89
|
+
or self._mock_write_file_action(request, linked_tools)
|
|
90
|
+
or self._mock_batch_patch_action(request, linked_tools)
|
|
91
|
+
or self._mock_patch_file_action(request, linked_tools)
|
|
92
|
+
or {
|
|
93
|
+
"action": "respond",
|
|
94
|
+
"message": f"Mock agent response for request: {request}",
|
|
95
|
+
"reason": "No tool action was needed.",
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
text = json.dumps(payload, ensure_ascii=False)
|
|
99
|
+
return ProviderResponse(
|
|
100
|
+
text=text,
|
|
101
|
+
input_tokens=estimate_tokens(packet),
|
|
102
|
+
output_tokens=estimate_tokens(text),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _mock_batch_patch_verify_action(
|
|
106
|
+
self,
|
|
107
|
+
request: str,
|
|
108
|
+
linked_tools: list[dict[str, Any]],
|
|
109
|
+
allowed_roots: set[str],
|
|
110
|
+
project_commands: dict[str, str],
|
|
111
|
+
) -> dict[str, Any] | None:
|
|
112
|
+
edits = extract_batch_patch_requests(request)
|
|
113
|
+
command = resolve_requested_command(request, project_commands)
|
|
114
|
+
if len(edits) < 2 or not command:
|
|
115
|
+
return None
|
|
116
|
+
prior = find_tool_trace(linked_tools, "batch_patch", str(edits[0]["path"]))
|
|
117
|
+
command_trace = find_command_trace(linked_tools, command)
|
|
118
|
+
if not prior:
|
|
119
|
+
return {
|
|
120
|
+
"action": "batch_patch",
|
|
121
|
+
"edits": edits,
|
|
122
|
+
"reason": "Apply the requested multi-file patch batch before verification.",
|
|
123
|
+
}
|
|
124
|
+
summary = prior.get("output_summary") or "batch patch completed"
|
|
125
|
+
if prior.get("blocked") or not prior.get("ok", False):
|
|
126
|
+
return {
|
|
127
|
+
"action": "respond",
|
|
128
|
+
"message": f"Batch patch could not be completed: {summary}",
|
|
129
|
+
"reason": "Stop because the batch patch did not succeed.",
|
|
130
|
+
}
|
|
131
|
+
if not command_trace:
|
|
132
|
+
return run_command_or_policy_response(
|
|
133
|
+
command,
|
|
134
|
+
allowed_roots,
|
|
135
|
+
reason="Run the requested verification command after the batch patch.",
|
|
136
|
+
)
|
|
137
|
+
command_summary = command_trace.get("output_summary") or "command completed"
|
|
138
|
+
if command_trace.get("blocked") or not command_trace.get("ok", False):
|
|
139
|
+
return {
|
|
140
|
+
"action": "respond",
|
|
141
|
+
"message": f"Batch patch completed: {summary}. Verification command `{command}` failed: {command_summary}",
|
|
142
|
+
"reason": "Stop after surfacing the verification result.",
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
"action": "respond",
|
|
146
|
+
"message": f"Batch patch completed: {summary}. Verification command `{command}` succeeded: {command_summary}",
|
|
147
|
+
"reason": "The batch patch and verification command have completed.",
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
def _mock_patch_verify_action(
|
|
151
|
+
self,
|
|
152
|
+
request: str,
|
|
153
|
+
linked_tools: list[dict[str, Any]],
|
|
154
|
+
allowed_roots: set[str],
|
|
155
|
+
project_commands: dict[str, str],
|
|
156
|
+
) -> dict[str, Any] | None:
|
|
157
|
+
patch = extract_patch_request(request)
|
|
158
|
+
command = resolve_requested_command(request, project_commands)
|
|
159
|
+
if not patch or not command:
|
|
160
|
+
return None
|
|
161
|
+
path = str(patch["path"])
|
|
162
|
+
patch_trace = find_tool_trace(linked_tools, "patch_file", path)
|
|
163
|
+
write_trace = find_tool_trace(linked_tools, "write_file", path)
|
|
164
|
+
read_trace = find_tool_trace(linked_tools, "read_file", path)
|
|
165
|
+
command_trace = find_command_trace(linked_tools, command)
|
|
166
|
+
|
|
167
|
+
if not patch_trace:
|
|
168
|
+
return patch_action_from_request(patch, reason="Apply the requested patch before verification.")
|
|
169
|
+
|
|
170
|
+
if patch_trace.get("blocked"):
|
|
171
|
+
patch_summary = patch_trace.get("output_summary") or "patch was blocked"
|
|
172
|
+
return {
|
|
173
|
+
"action": "respond",
|
|
174
|
+
"message": f"Patch for {path} could not be applied: {patch_summary}",
|
|
175
|
+
"reason": "Stop because the requested patch was blocked.",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
patch_summary = patch_trace.get("output_summary") or "patch completed"
|
|
179
|
+
if not patch_trace.get("ok", False):
|
|
180
|
+
return self._recover_patch_failure(
|
|
181
|
+
patch,
|
|
182
|
+
command,
|
|
183
|
+
patch_summary,
|
|
184
|
+
read_trace,
|
|
185
|
+
write_trace,
|
|
186
|
+
command_trace,
|
|
187
|
+
allowed_roots,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if not command_trace:
|
|
191
|
+
return run_command_or_policy_response(
|
|
192
|
+
command,
|
|
193
|
+
allowed_roots,
|
|
194
|
+
reason="Run the requested verification command after the patch.",
|
|
195
|
+
)
|
|
196
|
+
command_summary = command_trace.get("output_summary") or "command completed"
|
|
197
|
+
if command_trace.get("blocked"):
|
|
198
|
+
return {
|
|
199
|
+
"action": "respond",
|
|
200
|
+
"message": (
|
|
201
|
+
f"Patched {path}: {patch_summary}. "
|
|
202
|
+
f"Verification command `{command}` failed: {command_summary}"
|
|
203
|
+
),
|
|
204
|
+
"reason": "Stop after surfacing the blocked verification result.",
|
|
205
|
+
}
|
|
206
|
+
if not command_trace.get("ok", False):
|
|
207
|
+
return self._recover_verification_failure(
|
|
208
|
+
path=path,
|
|
209
|
+
desired_text=str(patch["new"]),
|
|
210
|
+
command=command,
|
|
211
|
+
success_summary=patch_summary,
|
|
212
|
+
failure_summary=command_summary,
|
|
213
|
+
read_trace=read_trace,
|
|
214
|
+
write_trace=write_trace,
|
|
215
|
+
allowed_roots=allowed_roots,
|
|
216
|
+
)
|
|
217
|
+
return {
|
|
218
|
+
"action": "respond",
|
|
219
|
+
"message": (
|
|
220
|
+
f"Patched {path}: {patch_summary}. "
|
|
221
|
+
f"Verification command `{command}` succeeded: {command_summary}"
|
|
222
|
+
),
|
|
223
|
+
"reason": "Both the patch and verification command have completed.",
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
def _mock_write_verify_action(
|
|
227
|
+
self,
|
|
228
|
+
request: str,
|
|
229
|
+
linked_tools: list[dict[str, Any]],
|
|
230
|
+
allowed_roots: set[str],
|
|
231
|
+
project_commands: dict[str, str],
|
|
232
|
+
) -> dict[str, Any] | None:
|
|
233
|
+
write = extract_write_instruction(request)
|
|
234
|
+
command = resolve_requested_command(request, project_commands)
|
|
235
|
+
if not write or not command:
|
|
236
|
+
return None
|
|
237
|
+
path, text = write
|
|
238
|
+
write_trace = find_tool_trace(linked_tools, "write_file", path)
|
|
239
|
+
read_trace = find_tool_trace(linked_tools, "read_file", path)
|
|
240
|
+
command_trace = find_command_trace(linked_tools, command)
|
|
241
|
+
|
|
242
|
+
if not write_trace:
|
|
243
|
+
return {
|
|
244
|
+
"action": "write_file",
|
|
245
|
+
"path": path,
|
|
246
|
+
"text": text,
|
|
247
|
+
"reason": "Create the requested file before verification.",
|
|
248
|
+
}
|
|
249
|
+
write_summary = write_trace.get("output_summary") or "write completed"
|
|
250
|
+
if write_trace.get("blocked"):
|
|
251
|
+
return {
|
|
252
|
+
"action": "respond",
|
|
253
|
+
"message": f"File {path} could not be written: {write_summary}",
|
|
254
|
+
"reason": "Stop because the requested file write was blocked.",
|
|
255
|
+
}
|
|
256
|
+
if not write_trace.get("ok", False):
|
|
257
|
+
if read_trace:
|
|
258
|
+
recovered_text = read_content(read_trace.get("output_summary", ""))
|
|
259
|
+
if recovered_text != text:
|
|
260
|
+
return {
|
|
261
|
+
"action": "write_file",
|
|
262
|
+
"path": path,
|
|
263
|
+
"text": text,
|
|
264
|
+
"reason": "Retry the requested file write after inspecting the current contents.",
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
"action": "respond",
|
|
268
|
+
"message": f"File {path} could not be written: {write_summary}",
|
|
269
|
+
"reason": "Stop because the requested file write did not succeed.",
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if not command_trace:
|
|
273
|
+
return run_command_or_policy_response(
|
|
274
|
+
command,
|
|
275
|
+
allowed_roots,
|
|
276
|
+
reason="Run the requested verification command after writing the file.",
|
|
277
|
+
)
|
|
278
|
+
command_summary = command_trace.get("output_summary") or "command completed"
|
|
279
|
+
if command_trace.get("blocked"):
|
|
280
|
+
return {
|
|
281
|
+
"action": "respond",
|
|
282
|
+
"message": (
|
|
283
|
+
f"Wrote {path}: {write_summary}. "
|
|
284
|
+
f"Verification command `{command}` failed: {command_summary}"
|
|
285
|
+
),
|
|
286
|
+
"reason": "Stop after surfacing the blocked verification result.",
|
|
287
|
+
}
|
|
288
|
+
if not command_trace.get("ok", False):
|
|
289
|
+
return self._recover_verification_failure(
|
|
290
|
+
path=path,
|
|
291
|
+
desired_text=text,
|
|
292
|
+
command=command,
|
|
293
|
+
success_summary=write_summary,
|
|
294
|
+
failure_summary=command_summary,
|
|
295
|
+
read_trace=read_trace,
|
|
296
|
+
write_trace=write_trace,
|
|
297
|
+
allowed_roots=allowed_roots,
|
|
298
|
+
)
|
|
299
|
+
return {
|
|
300
|
+
"action": "respond",
|
|
301
|
+
"message": (
|
|
302
|
+
f"Wrote {path}: {write_summary}. "
|
|
303
|
+
f"Verification command `{command}` succeeded: {command_summary}"
|
|
304
|
+
),
|
|
305
|
+
"reason": "Both the file write and verification command have completed.",
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
def _recover_patch_failure(
|
|
309
|
+
self,
|
|
310
|
+
patch: dict[str, Any],
|
|
311
|
+
command: str,
|
|
312
|
+
patch_summary: str,
|
|
313
|
+
read_trace: dict[str, Any] | None,
|
|
314
|
+
write_trace: dict[str, Any] | None,
|
|
315
|
+
command_trace: dict[str, Any] | None,
|
|
316
|
+
allowed_roots: set[str],
|
|
317
|
+
) -> dict[str, Any]:
|
|
318
|
+
path = str(patch["path"])
|
|
319
|
+
if not read_trace:
|
|
320
|
+
return {
|
|
321
|
+
"action": "read_file",
|
|
322
|
+
"path": path,
|
|
323
|
+
"max_chars": 4000,
|
|
324
|
+
"reason": "Inspect the file after the patch failure so we can rewrite it safely.",
|
|
325
|
+
}
|
|
326
|
+
summary = read_trace.get("output_summary", "")
|
|
327
|
+
current_text = read_content(summary)
|
|
328
|
+
if patch.get("start_anchor"):
|
|
329
|
+
rewritten = rewrite_anchor_block_from_summary(
|
|
330
|
+
summary,
|
|
331
|
+
str(patch.get("start_anchor", "")),
|
|
332
|
+
str(patch.get("end_anchor", "")),
|
|
333
|
+
str(patch["new"]),
|
|
334
|
+
include_anchors=bool(patch.get("include_anchors", False)),
|
|
335
|
+
)
|
|
336
|
+
else:
|
|
337
|
+
old = str(patch.get("old", ""))
|
|
338
|
+
matches = current_text.count(old)
|
|
339
|
+
if matches > 1 and not patch.get("replace_all"):
|
|
340
|
+
retry_patch = dict(patch)
|
|
341
|
+
retry_patch["replace_all"] = True
|
|
342
|
+
retry_patch["occurrence"] = None
|
|
343
|
+
return patch_action_from_request(
|
|
344
|
+
retry_patch,
|
|
345
|
+
reason="Recover from the failed single-match patch by replacing all inspected matches.",
|
|
346
|
+
)
|
|
347
|
+
rewritten = rewrite_text_from_summary(summary, old, str(patch["new"]))
|
|
348
|
+
if rewritten is None:
|
|
349
|
+
return {
|
|
350
|
+
"action": "respond",
|
|
351
|
+
"message": f"Patch for {path} could not be applied: {patch_summary}",
|
|
352
|
+
"reason": "Stop because the file contents do not support a safe rewrite recovery.",
|
|
353
|
+
}
|
|
354
|
+
if not write_trace:
|
|
355
|
+
return {
|
|
356
|
+
"action": "write_file",
|
|
357
|
+
"path": path,
|
|
358
|
+
"text": rewritten,
|
|
359
|
+
"reason": "Recover from the failed patch by rewriting the file from inspected contents.",
|
|
360
|
+
}
|
|
361
|
+
write_summary = write_trace.get("output_summary") or "write completed"
|
|
362
|
+
if write_trace.get("blocked") or not write_trace.get("ok", False):
|
|
363
|
+
return {
|
|
364
|
+
"action": "respond",
|
|
365
|
+
"message": f"Patch recovery for {path} failed: {write_summary}",
|
|
366
|
+
"reason": "Stop because the rewrite recovery did not succeed.",
|
|
367
|
+
}
|
|
368
|
+
if not command_trace:
|
|
369
|
+
return run_command_or_policy_response(
|
|
370
|
+
command,
|
|
371
|
+
allowed_roots,
|
|
372
|
+
reason="Run the requested verification command after rewrite recovery.",
|
|
373
|
+
)
|
|
374
|
+
command_summary = command_trace.get("output_summary") or "command completed"
|
|
375
|
+
if command_trace.get("blocked") or not command_trace.get("ok", False):
|
|
376
|
+
return self._recover_verification_failure(
|
|
377
|
+
path=path,
|
|
378
|
+
desired_text=str(patch["new"]),
|
|
379
|
+
command=command,
|
|
380
|
+
success_summary=write_summary,
|
|
381
|
+
failure_summary=command_summary,
|
|
382
|
+
read_trace=read_trace,
|
|
383
|
+
write_trace=write_trace,
|
|
384
|
+
allowed_roots=allowed_roots,
|
|
385
|
+
)
|
|
386
|
+
return {
|
|
387
|
+
"action": "respond",
|
|
388
|
+
"message": (
|
|
389
|
+
f"Recovered {path} with a rewrite: {write_summary}. "
|
|
390
|
+
f"Verification command `{command}` succeeded: {command_summary}"
|
|
391
|
+
),
|
|
392
|
+
"reason": "Patch recovery and verification have completed successfully.",
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
def _recover_verification_failure(
|
|
396
|
+
self,
|
|
397
|
+
*,
|
|
398
|
+
path: str,
|
|
399
|
+
desired_text: str,
|
|
400
|
+
command: str,
|
|
401
|
+
success_summary: str,
|
|
402
|
+
failure_summary: str,
|
|
403
|
+
read_trace: dict[str, Any] | None,
|
|
404
|
+
write_trace: dict[str, Any] | None,
|
|
405
|
+
allowed_roots: set[str],
|
|
406
|
+
) -> dict[str, Any]:
|
|
407
|
+
if not read_trace:
|
|
408
|
+
return {
|
|
409
|
+
"action": "read_file",
|
|
410
|
+
"path": path,
|
|
411
|
+
"max_chars": 4000,
|
|
412
|
+
"reason": "Inspect the file after verification failed before attempting another edit.",
|
|
413
|
+
}
|
|
414
|
+
current_text = read_content(read_trace.get("output_summary", ""))
|
|
415
|
+
if desired_text not in current_text:
|
|
416
|
+
if not write_trace or desired_text not in str(write_trace.get("output_summary", "")):
|
|
417
|
+
return {
|
|
418
|
+
"action": "write_file",
|
|
419
|
+
"path": path,
|
|
420
|
+
"text": rewrite_to_include_text(current_text, desired_text),
|
|
421
|
+
"reason": "Retry the edit because verification failed and the current file still lacks the expected text.",
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
"action": "respond",
|
|
425
|
+
"message": (
|
|
426
|
+
f"Updated {path}: {success_summary}. "
|
|
427
|
+
f"Verification command `{command}` still failed: {failure_summary}"
|
|
428
|
+
),
|
|
429
|
+
"reason": "Stop because the file appears updated and the remaining failure is likely outside the edit itself.",
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
def _mock_read_file_action(self, request: str, linked_tools: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
433
|
+
match = re.search(r"(?i)\bread\s+([^\s,;]+)", request)
|
|
434
|
+
if not match:
|
|
435
|
+
return None
|
|
436
|
+
path = match.group(1).strip().strip("'\"")
|
|
437
|
+
prior = find_tool_trace(linked_tools, "read_file", path)
|
|
438
|
+
if prior:
|
|
439
|
+
summary = prior.get("output_summary") or "file was read"
|
|
440
|
+
return {
|
|
441
|
+
"action": "respond",
|
|
442
|
+
"message": f"Read {path}: {summary}",
|
|
443
|
+
"reason": "The requested file content is already available in task state.",
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
"action": "read_file",
|
|
447
|
+
"path": path,
|
|
448
|
+
"max_chars": 2000,
|
|
449
|
+
"reason": "Need the file contents before responding.",
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
def _mock_run_command_action(
|
|
453
|
+
self,
|
|
454
|
+
request: str,
|
|
455
|
+
linked_tools: list[dict[str, Any]],
|
|
456
|
+
allowed_roots: set[str],
|
|
457
|
+
project_commands: dict[str, str],
|
|
458
|
+
) -> dict[str, Any] | None:
|
|
459
|
+
command = resolve_requested_command(request, project_commands)
|
|
460
|
+
if not command:
|
|
461
|
+
return None
|
|
462
|
+
prior = find_command_trace(linked_tools, command)
|
|
463
|
+
if prior:
|
|
464
|
+
summary = prior.get("output_summary") or "command completed"
|
|
465
|
+
return {
|
|
466
|
+
"action": "respond",
|
|
467
|
+
"message": f"Command `{command}` result: {summary}",
|
|
468
|
+
"reason": "The command output is already available in task state.",
|
|
469
|
+
}
|
|
470
|
+
return run_command_or_policy_response(
|
|
471
|
+
command,
|
|
472
|
+
allowed_roots,
|
|
473
|
+
reason="Need command output before responding.",
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def _mock_fix_failing_tests_action(
|
|
477
|
+
self,
|
|
478
|
+
request: str,
|
|
479
|
+
linked_tools: list[dict[str, Any]],
|
|
480
|
+
allowed_roots: set[str],
|
|
481
|
+
project_commands: dict[str, str],
|
|
482
|
+
) -> dict[str, Any] | None:
|
|
483
|
+
if not asks_to_fix_tests(request):
|
|
484
|
+
return None
|
|
485
|
+
command = resolve_requested_command(request, project_commands)
|
|
486
|
+
if not command:
|
|
487
|
+
return None
|
|
488
|
+
command_traces = find_command_traces(linked_tools, command)
|
|
489
|
+
if not command_traces:
|
|
490
|
+
return run_command_or_policy_response(
|
|
491
|
+
command,
|
|
492
|
+
allowed_roots,
|
|
493
|
+
reason="Run the project test command before attempting a fix.",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
latest_command = command_traces[-1]
|
|
497
|
+
command_summary = latest_command.get("output_summary") or "command completed"
|
|
498
|
+
if latest_command.get("ok") and not latest_command.get("blocked"):
|
|
499
|
+
return {
|
|
500
|
+
"action": "respond",
|
|
501
|
+
"message": f"Project tests passed: {command_summary}",
|
|
502
|
+
"reason": "The project test command now succeeds.",
|
|
503
|
+
}
|
|
504
|
+
if latest_command.get("blocked"):
|
|
505
|
+
return {
|
|
506
|
+
"action": "respond",
|
|
507
|
+
"message": f"Project test command `{command}` was blocked: {command_summary}",
|
|
508
|
+
"reason": "Stop because verification is blocked by policy.",
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
failure_paths = failure_paths_from_summary(command_summary)
|
|
512
|
+
failure_path = failure_paths[0] if failure_paths else None
|
|
513
|
+
read_traces = [trace for path in failure_paths if (trace := find_tool_trace(linked_tools, "read_file", path))]
|
|
514
|
+
unread_path = next((path for path in failure_paths if not find_tool_trace(linked_tools, "read_file", path)), None)
|
|
515
|
+
if unread_path:
|
|
516
|
+
return {
|
|
517
|
+
"action": "read_file",
|
|
518
|
+
"path": unread_path,
|
|
519
|
+
"max_chars": 4000,
|
|
520
|
+
"reason": "Read the file referenced by the failing test output before patching.",
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
patch_trace = find_tool_trace(linked_tools, "patch_file", failure_path) if failure_path else None
|
|
524
|
+
batch_patch_trace = find_tool_trace(linked_tools, "batch_patch", failure_path) if failure_path else None
|
|
525
|
+
if (patch_trace or batch_patch_trace) and len(command_traces) == 1:
|
|
526
|
+
return run_command_or_policy_response(
|
|
527
|
+
command,
|
|
528
|
+
allowed_roots,
|
|
529
|
+
reason="Re-run the project test command after applying the fix.",
|
|
530
|
+
)
|
|
531
|
+
if (patch_trace or batch_patch_trace) and len(command_traces) > 1:
|
|
532
|
+
return {
|
|
533
|
+
"action": "respond",
|
|
534
|
+
"message": f"Applied a candidate fix, but `{command}` still failed: {command_summary}",
|
|
535
|
+
"reason": "Stop after one verified fix attempt to avoid looping.",
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if read_traces:
|
|
539
|
+
multi_patch = infer_batch_patch_from_failures(read_traces, command_summary)
|
|
540
|
+
if multi_patch:
|
|
541
|
+
return multi_patch
|
|
542
|
+
patch = infer_patch_from_failure(read_traces[0], command_summary)
|
|
543
|
+
if patch:
|
|
544
|
+
return patch
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
"action": "respond",
|
|
548
|
+
"message": f"Project tests failed, but no safe automatic patch was inferred: {command_summary}",
|
|
549
|
+
"reason": "Stop because the failing output did not map to a safe patch.",
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
def _mock_batch_patch_action(self, request: str, linked_tools: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
553
|
+
edits = extract_batch_patch_requests(request)
|
|
554
|
+
if len(edits) < 2:
|
|
555
|
+
return None
|
|
556
|
+
prior = find_tool_trace(linked_tools, "batch_patch", str(edits[0]["path"]))
|
|
557
|
+
if prior:
|
|
558
|
+
summary = prior.get("output_summary") or "batch patch was applied"
|
|
559
|
+
return {
|
|
560
|
+
"action": "respond",
|
|
561
|
+
"message": f"Batch patch result: {summary}",
|
|
562
|
+
"reason": "The requested batch patch is already recorded in task state.",
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
"action": "batch_patch",
|
|
566
|
+
"edits": edits,
|
|
567
|
+
"reason": "Apply the requested multi-file patch batch before responding.",
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
def _mock_patch_file_action(self, request: str, linked_tools: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
571
|
+
patch = extract_patch_request(request)
|
|
572
|
+
if not patch:
|
|
573
|
+
return None
|
|
574
|
+
path = str(patch["path"])
|
|
575
|
+
prior = find_tool_trace(linked_tools, "patch_file", path)
|
|
576
|
+
read_trace = find_tool_trace(linked_tools, "read_file", path)
|
|
577
|
+
write_trace = find_tool_trace(linked_tools, "write_file", path)
|
|
578
|
+
if prior:
|
|
579
|
+
summary = prior.get("output_summary") or "patch was applied"
|
|
580
|
+
if prior.get("blocked"):
|
|
581
|
+
return {
|
|
582
|
+
"action": "respond",
|
|
583
|
+
"message": f"Patched {path}: {summary}",
|
|
584
|
+
"reason": "Stop because the requested patch was blocked.",
|
|
585
|
+
}
|
|
586
|
+
if not prior.get("ok", False):
|
|
587
|
+
if not read_trace:
|
|
588
|
+
return {
|
|
589
|
+
"action": "read_file",
|
|
590
|
+
"path": path,
|
|
591
|
+
"max_chars": 4000,
|
|
592
|
+
"reason": "Inspect the file after the patch failure so we can recover safely.",
|
|
593
|
+
}
|
|
594
|
+
if not patch.get("start_anchor"):
|
|
595
|
+
current_text = read_content(read_trace.get("output_summary", ""))
|
|
596
|
+
old = str(patch.get("old", ""))
|
|
597
|
+
if current_text.count(old) > 1 and not patch.get("replace_all"):
|
|
598
|
+
retry_patch = dict(patch)
|
|
599
|
+
retry_patch["replace_all"] = True
|
|
600
|
+
retry_patch["occurrence"] = None
|
|
601
|
+
return patch_action_from_request(
|
|
602
|
+
retry_patch,
|
|
603
|
+
reason="Retry the requested patch by replacing all inspected matches.",
|
|
604
|
+
)
|
|
605
|
+
if patch.get("start_anchor"):
|
|
606
|
+
rewritten = rewrite_anchor_block_from_summary(
|
|
607
|
+
read_trace.get("output_summary", ""),
|
|
608
|
+
str(patch.get("start_anchor", "")),
|
|
609
|
+
str(patch.get("end_anchor", "")),
|
|
610
|
+
str(patch["new"]),
|
|
611
|
+
include_anchors=bool(patch.get("include_anchors", False)),
|
|
612
|
+
)
|
|
613
|
+
else:
|
|
614
|
+
rewritten = rewrite_text_from_summary(
|
|
615
|
+
read_trace.get("output_summary", ""),
|
|
616
|
+
str(patch.get("old", "")),
|
|
617
|
+
str(patch["new"]),
|
|
618
|
+
)
|
|
619
|
+
if rewritten is None:
|
|
620
|
+
return {
|
|
621
|
+
"action": "respond",
|
|
622
|
+
"message": f"Patched {path}: {summary}",
|
|
623
|
+
"reason": "Stop because the file contents do not support a safe rewrite recovery.",
|
|
624
|
+
}
|
|
625
|
+
if not write_trace:
|
|
626
|
+
return {
|
|
627
|
+
"action": "write_file",
|
|
628
|
+
"path": path,
|
|
629
|
+
"text": rewritten,
|
|
630
|
+
"reason": "Recover from the failed patch by rewriting the file from inspected contents.",
|
|
631
|
+
}
|
|
632
|
+
write_summary = write_trace.get("output_summary") or "write completed"
|
|
633
|
+
return {
|
|
634
|
+
"action": "respond",
|
|
635
|
+
"message": f"Recovered {path} with a rewrite: {write_summary}",
|
|
636
|
+
"reason": "The requested patch was recovered via write_file.",
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
"action": "respond",
|
|
640
|
+
"message": f"Patched {path}: {summary}",
|
|
641
|
+
"reason": "The requested patch is already recorded in task state.",
|
|
642
|
+
}
|
|
643
|
+
return patch_action_from_request(patch, reason="Need to apply the requested patch before responding.")
|
|
644
|
+
|
|
645
|
+
def _mock_write_file_action(self, request: str, linked_tools: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
646
|
+
write = extract_write_instruction(request)
|
|
647
|
+
if not write:
|
|
648
|
+
return None
|
|
649
|
+
path, text = write
|
|
650
|
+
prior = find_tool_trace(linked_tools, "write_file", path)
|
|
651
|
+
if prior:
|
|
652
|
+
summary = prior.get("output_summary") or "file was written"
|
|
653
|
+
return {
|
|
654
|
+
"action": "respond",
|
|
655
|
+
"message": f"Wrote {path}: {summary}",
|
|
656
|
+
"reason": "The requested file write is already recorded in task state.",
|
|
657
|
+
}
|
|
658
|
+
return {
|
|
659
|
+
"action": "write_file",
|
|
660
|
+
"path": path,
|
|
661
|
+
"text": text,
|
|
662
|
+
"reason": "Need to create or overwrite the requested file before responding.",
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
class ChattyMockProvider(MockProvider):
|
|
667
|
+
name = "mock-chatty"
|
|
668
|
+
|
|
669
|
+
def run(self, packet: dict[str, Any]) -> ProviderResponse:
|
|
670
|
+
response = super().run(packet)
|
|
671
|
+
mode = str(packet.get("agent", {}).get("mode", ""))
|
|
672
|
+
if not mode.startswith("tool_planning_v"):
|
|
673
|
+
return response
|
|
674
|
+
text = f"Here is the next action:\n```json\n{response.text}\n```"
|
|
675
|
+
return ProviderResponse(
|
|
676
|
+
text=text,
|
|
677
|
+
input_tokens=response.input_tokens,
|
|
678
|
+
output_tokens=estimate_tokens(text),
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
class OpenAICompatibleProvider:
|
|
683
|
+
name = "openai"
|
|
684
|
+
|
|
685
|
+
def __init__(
|
|
686
|
+
self,
|
|
687
|
+
model: str | None = None,
|
|
688
|
+
base_url: str | None = None,
|
|
689
|
+
api_key: str | None = None,
|
|
690
|
+
timeout_seconds: int = 120,
|
|
691
|
+
):
|
|
692
|
+
self.model = model or env_value("CONTEXT_KERNEL_OPENAI_MODEL") or "gpt-5.5"
|
|
693
|
+
self.base_url = normalize_openai_base_url(base_url or env_value("CONTEXT_KERNEL_OPENAI_BASE_URL") or "")
|
|
694
|
+
self.api_key = api_key or env_value("CONTEXT_KERNEL_OPENAI_API_KEY")
|
|
695
|
+
self.timeout_seconds = timeout_seconds
|
|
696
|
+
if not self.base_url:
|
|
697
|
+
raise ValueError("Missing CONTEXT_KERNEL_OPENAI_BASE_URL for OpenAI-compatible provider.")
|
|
698
|
+
if not self.api_key:
|
|
699
|
+
raise ValueError("Missing CONTEXT_KERNEL_OPENAI_API_KEY for OpenAI-compatible provider.")
|
|
700
|
+
|
|
701
|
+
def run(self, packet: dict[str, Any]) -> ProviderResponse:
|
|
702
|
+
payload = {
|
|
703
|
+
"model": self.model,
|
|
704
|
+
"messages": build_messages(packet),
|
|
705
|
+
}
|
|
706
|
+
response = self._post_json("/chat/completions", payload)
|
|
707
|
+
text = extract_text(response)
|
|
708
|
+
usage = response.get("usage", {})
|
|
709
|
+
input_tokens = usage.get("prompt_tokens") or usage.get("input_tokens") or estimate_tokens(packet)
|
|
710
|
+
output_tokens = usage.get("completion_tokens") or usage.get("output_tokens") or estimate_tokens(text)
|
|
711
|
+
return ProviderResponse(
|
|
712
|
+
text=text,
|
|
713
|
+
input_tokens=int(input_tokens),
|
|
714
|
+
output_tokens=int(output_tokens),
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
def list_models(self) -> list[str]:
|
|
718
|
+
payload = self._get_json("/models")
|
|
719
|
+
models = payload.get("data", payload if isinstance(payload, list) else [])
|
|
720
|
+
names: list[str] = []
|
|
721
|
+
for item in models:
|
|
722
|
+
if isinstance(item, dict):
|
|
723
|
+
model_id = item.get("id") or item.get("name")
|
|
724
|
+
if model_id:
|
|
725
|
+
names.append(str(model_id))
|
|
726
|
+
else:
|
|
727
|
+
names.append(str(item))
|
|
728
|
+
return sorted(names)
|
|
729
|
+
|
|
730
|
+
def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
731
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
732
|
+
request = urllib.request.Request(
|
|
733
|
+
self.base_url + path,
|
|
734
|
+
data=data,
|
|
735
|
+
headers={
|
|
736
|
+
"Authorization": "Bearer " + self.api_key,
|
|
737
|
+
"Content-Type": "application/json",
|
|
738
|
+
},
|
|
739
|
+
method="POST",
|
|
740
|
+
)
|
|
741
|
+
return self._open_json(request)
|
|
742
|
+
|
|
743
|
+
def _get_json(self, path: str) -> dict[str, Any]:
|
|
744
|
+
request = urllib.request.Request(
|
|
745
|
+
self.base_url + path,
|
|
746
|
+
headers={"Authorization": "Bearer " + self.api_key},
|
|
747
|
+
method="GET",
|
|
748
|
+
)
|
|
749
|
+
return self._open_json(request)
|
|
750
|
+
|
|
751
|
+
def _open_json(self, request: urllib.request.Request) -> dict[str, Any]:
|
|
752
|
+
try:
|
|
753
|
+
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
|
|
754
|
+
body = response.read().decode("utf-8")
|
|
755
|
+
try:
|
|
756
|
+
return json.loads(body)
|
|
757
|
+
except json.JSONDecodeError as exc:
|
|
758
|
+
preview = body[:500].replace("\n", " ")
|
|
759
|
+
raise RuntimeError(f"Provider returned invalid JSON: {preview}") from exc
|
|
760
|
+
except urllib.error.HTTPError as exc:
|
|
761
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
762
|
+
raise RuntimeError(f"Provider HTTP {exc.code}: {body}") from exc
|
|
763
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
764
|
+
raise RuntimeError(f"Provider network error: {exc}") from exc
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def build_messages(packet: dict[str, Any]) -> list[dict[str, str]]:
|
|
768
|
+
context_json = json.dumps(packet, ensure_ascii=False, indent=2, sort_keys=True)
|
|
769
|
+
system = (
|
|
770
|
+
"You are running inside Context Kernel. Use the provided context packet only. "
|
|
771
|
+
"Prefer concise, task-focused answers and mention missing context when relevant."
|
|
772
|
+
)
|
|
773
|
+
if packet.get("agent", {}).get("response_contract"):
|
|
774
|
+
system += " Follow the agent response contract exactly and return only the requested JSON object."
|
|
775
|
+
return [
|
|
776
|
+
{
|
|
777
|
+
"role": "system",
|
|
778
|
+
"content": system,
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
"role": "user",
|
|
782
|
+
"content": "Context packet:\n```json\n" + context_json + "\n```",
|
|
783
|
+
},
|
|
784
|
+
]
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def extract_text(response: dict[str, Any]) -> str:
|
|
788
|
+
choices = response.get("choices") or []
|
|
789
|
+
if not choices:
|
|
790
|
+
return ""
|
|
791
|
+
choice = choices[0]
|
|
792
|
+
message = choice.get("message") if isinstance(choice, dict) else None
|
|
793
|
+
if isinstance(message, dict):
|
|
794
|
+
content = message.get("content", "")
|
|
795
|
+
if isinstance(content, str):
|
|
796
|
+
return content
|
|
797
|
+
if isinstance(content, list):
|
|
798
|
+
parts: list[str] = []
|
|
799
|
+
for item in content:
|
|
800
|
+
if isinstance(item, dict):
|
|
801
|
+
parts.append(str(item.get("text") or item.get("content") or ""))
|
|
802
|
+
else:
|
|
803
|
+
parts.append(str(item))
|
|
804
|
+
return "".join(parts)
|
|
805
|
+
return str(choice.get("text", "")) if isinstance(choice, dict) else ""
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def get_provider(name: str, model: str | None = None, base_url: str | None = None) -> ModelProvider:
|
|
809
|
+
if name == "mock":
|
|
810
|
+
return MockProvider()
|
|
811
|
+
if name == "mock-chatty":
|
|
812
|
+
return ChattyMockProvider()
|
|
813
|
+
if name == "openai":
|
|
814
|
+
return OpenAICompatibleProvider(model=model, base_url=base_url)
|
|
815
|
+
raise ValueError(f"Unsupported provider for MVP: {name}")
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def list_provider_models(name: str, base_url: str | None = None) -> list[str]:
|
|
819
|
+
if name == "openai":
|
|
820
|
+
return OpenAICompatibleProvider(base_url=base_url).list_models()
|
|
821
|
+
if name == "mock":
|
|
822
|
+
return ["mock"]
|
|
823
|
+
if name == "mock-chatty":
|
|
824
|
+
return ["mock-chatty"]
|
|
825
|
+
raise ValueError(f"Unsupported provider for model listing: {name}")
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def env_value(name: str) -> str | None:
|
|
829
|
+
value = os.environ.get(name)
|
|
830
|
+
if value:
|
|
831
|
+
return value
|
|
832
|
+
env_file = project_env_values()
|
|
833
|
+
return env_file.get(name)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def project_env_values() -> dict[str, str]:
|
|
837
|
+
for directory in [Path.cwd(), *Path.cwd().parents]:
|
|
838
|
+
path = directory / ".env"
|
|
839
|
+
if path.exists():
|
|
840
|
+
return parse_env_file(path)
|
|
841
|
+
project_root = os.environ.get("CONTEXT_KERNEL_PROJECT_ROOT")
|
|
842
|
+
if project_root:
|
|
843
|
+
path = Path(project_root) / ".env"
|
|
844
|
+
if path.exists():
|
|
845
|
+
return parse_env_file(path)
|
|
846
|
+
return {}
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def parse_env_file(path: Path) -> dict[str, str]:
|
|
850
|
+
values: dict[str, str] = {}
|
|
851
|
+
for raw_line in path.read_text(encoding="utf-8-sig").splitlines():
|
|
852
|
+
line = raw_line.strip()
|
|
853
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
854
|
+
continue
|
|
855
|
+
key, value = line.split("=", 1)
|
|
856
|
+
value = value.strip().strip('"').strip("'")
|
|
857
|
+
values[key.strip().lstrip("\ufeff")] = value
|
|
858
|
+
return values
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def normalize_openai_base_url(base_url: str) -> str:
|
|
862
|
+
normalized = base_url.rstrip("/")
|
|
863
|
+
if not normalized:
|
|
864
|
+
return ""
|
|
865
|
+
if normalized.endswith("/v1"):
|
|
866
|
+
return normalized
|
|
867
|
+
return normalized + "/v1"
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def find_tool_trace(
|
|
871
|
+
traces: list[dict[str, Any]],
|
|
872
|
+
tool_name: str,
|
|
873
|
+
subject_fragment: str,
|
|
874
|
+
) -> dict[str, Any] | None:
|
|
875
|
+
normalized_fragment = normalize_subject(subject_fragment)
|
|
876
|
+
for trace in reversed(traces):
|
|
877
|
+
if trace.get("tool") != tool_name:
|
|
878
|
+
continue
|
|
879
|
+
if normalized_fragment in normalize_subject(str(trace.get("subject", ""))):
|
|
880
|
+
return trace
|
|
881
|
+
return None
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def find_command_trace(traces: list[dict[str, Any]], command: str) -> dict[str, Any] | None:
|
|
885
|
+
matches = find_command_traces(traces, command)
|
|
886
|
+
return matches[-1] if matches else None
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def find_command_traces(traces: list[dict[str, Any]], command: str) -> list[dict[str, Any]]:
|
|
890
|
+
normalized_command = " ".join(command.split()).casefold()
|
|
891
|
+
matches: list[dict[str, Any]] = []
|
|
892
|
+
for trace in reversed(traces):
|
|
893
|
+
if trace.get("tool") != "run_command":
|
|
894
|
+
continue
|
|
895
|
+
subject = " ".join(str(trace.get("subject", "")).split()).casefold()
|
|
896
|
+
summary = " ".join(str(trace.get("output_summary", "")).split()).casefold()
|
|
897
|
+
if normalized_command and (normalized_command in subject or normalized_command in summary):
|
|
898
|
+
matches.append(trace)
|
|
899
|
+
return list(reversed(matches))
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def normalize_subject(text: str) -> str:
|
|
903
|
+
return text.replace("\\", "/").casefold()
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def extract_anchor_patch_instruction(request: str) -> tuple[str, str, str, str, bool] | None:
|
|
907
|
+
patterns = [
|
|
908
|
+
r"(?is)\bpatch\s+([^\s]+)\s+between\s+([\"'])(.*?)\2\s+and\s+([\"'])(.*?)\4\s+with\s+([\"'])(.*?)\6",
|
|
909
|
+
r"(?is)\bpatch\s+([^\s]+)\s+replace\s+block\s+between\s+([\"'])(.*?)\2\s+and\s+([\"'])(.*?)\4\s+with\s+([\"'])(.*?)\6",
|
|
910
|
+
]
|
|
911
|
+
for pattern in patterns:
|
|
912
|
+
match = re.search(pattern, request)
|
|
913
|
+
if not match:
|
|
914
|
+
continue
|
|
915
|
+
path, _, start_anchor, _, end_anchor, _, new = match.groups()
|
|
916
|
+
include_anchors = request_prefers_include_anchors(request)
|
|
917
|
+
return path.strip(), start_anchor, end_anchor, new, include_anchors
|
|
918
|
+
return None
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def extract_patch_instruction(request: str) -> tuple[str, str, str] | None:
|
|
922
|
+
match = re.search(r"(?is)\bpatch\s+([^\s]+)\s+replace(?:\s+all)?\s+([\"'])(.*?)\2\s+with\s+([\"'])(.*?)\4", request)
|
|
923
|
+
if not match:
|
|
924
|
+
return None
|
|
925
|
+
path, _, old, _, new = match.groups()
|
|
926
|
+
return path.strip(), old, new
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def extract_patch_request(request: str) -> dict[str, Any] | None:
|
|
930
|
+
anchor_patch = extract_anchor_patch_instruction(request)
|
|
931
|
+
if anchor_patch:
|
|
932
|
+
path, start_anchor, end_anchor, new, include_anchors = anchor_patch
|
|
933
|
+
return {
|
|
934
|
+
"path": path,
|
|
935
|
+
"new": new,
|
|
936
|
+
"start_anchor": start_anchor,
|
|
937
|
+
"end_anchor": end_anchor,
|
|
938
|
+
"include_anchors": include_anchors,
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
patch = extract_patch_instruction(request)
|
|
942
|
+
if not patch:
|
|
943
|
+
return None
|
|
944
|
+
path, old, new = patch
|
|
945
|
+
return {
|
|
946
|
+
"path": path,
|
|
947
|
+
"old": old,
|
|
948
|
+
"new": new,
|
|
949
|
+
"replace_all": request_prefers_replace_all(request),
|
|
950
|
+
"occurrence": None,
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def extract_batch_patch_requests(request: str) -> list[dict[str, Any]]:
|
|
955
|
+
clauses = [clause.strip() for clause in re.split(r";|\n+", request) if clause.strip()]
|
|
956
|
+
patches: list[dict[str, Any]] = []
|
|
957
|
+
for clause in clauses:
|
|
958
|
+
patch = extract_patch_request(clause)
|
|
959
|
+
if patch:
|
|
960
|
+
patches.append(patch_edit_from_request(patch))
|
|
961
|
+
return patches if len(patches) >= 2 else []
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def patch_edit_from_request(patch: dict[str, Any]) -> dict[str, Any]:
|
|
965
|
+
action = patch_action_from_request(patch, reason="")
|
|
966
|
+
action.pop("action", None)
|
|
967
|
+
action.pop("reason", None)
|
|
968
|
+
return action
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def extract_write_instruction(request: str) -> tuple[str, str] | None:
|
|
972
|
+
match = re.search(r"(?is)\b(?:write|create)\s+([^\s]+)\s+with\s+([\"'])(.*?)\2", request)
|
|
973
|
+
if not match:
|
|
974
|
+
return None
|
|
975
|
+
path, _, text = match.groups()
|
|
976
|
+
return path.strip(), text
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def extract_requested_command(request: str) -> str | None:
|
|
980
|
+
patterns = [
|
|
981
|
+
r"(?is)\brun\s+command\s+(.+)$",
|
|
982
|
+
r"(?is)\brun\s+`([^`]+)`",
|
|
983
|
+
r"(?is)\bverify\s+with\s+command\s+(.+)$",
|
|
984
|
+
]
|
|
985
|
+
for pattern in patterns:
|
|
986
|
+
match = re.search(pattern, request)
|
|
987
|
+
if not match:
|
|
988
|
+
continue
|
|
989
|
+
command = trim_command_tail(match.group(1).strip())
|
|
990
|
+
if command:
|
|
991
|
+
return command
|
|
992
|
+
return None
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def resolve_requested_command(request: str, project_commands: dict[str, str] | None = None) -> str | None:
|
|
996
|
+
explicit = extract_requested_command(request)
|
|
997
|
+
if explicit:
|
|
998
|
+
return explicit
|
|
999
|
+
return project_profile_command(request, project_commands or {})
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def project_profile_command(request: str, project_commands: dict[str, str]) -> str | None:
|
|
1003
|
+
if not isinstance(project_commands, dict) or not project_commands:
|
|
1004
|
+
return None
|
|
1005
|
+
lower = request.casefold()
|
|
1006
|
+
preferences = [
|
|
1007
|
+
("test", ["run tests", "run the tests", "test suite", "tests", "test", "verify", "verification"]),
|
|
1008
|
+
("lint", ["lint", "style check", "static check"]),
|
|
1009
|
+
("build", ["build", "compile"]),
|
|
1010
|
+
("install", ["install dependencies", "install", "setup"]),
|
|
1011
|
+
]
|
|
1012
|
+
for name, markers in preferences:
|
|
1013
|
+
command = project_commands.get(name)
|
|
1014
|
+
if isinstance(command, str) and command.strip() and any(marker in lower for marker in markers):
|
|
1015
|
+
return command.strip()
|
|
1016
|
+
return None
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def asks_to_fix_tests(request: str) -> bool:
|
|
1020
|
+
lower = request.casefold()
|
|
1021
|
+
return any(marker in lower for marker in ["fix failing test", "fix the failing test", "fix tests", "fix the tests"])
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def failure_path_from_summary(summary: str) -> str | None:
|
|
1025
|
+
paths = failure_paths_from_summary(summary, limit=1)
|
|
1026
|
+
return paths[0] if paths else None
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def failure_paths_from_summary(summary: str, *, limit: int = 5) -> list[str]:
|
|
1030
|
+
text = str(summary)
|
|
1031
|
+
candidates = re.findall(r"File\s+\"([^\"]+\.py)\",\s+line\s+\d+", text)
|
|
1032
|
+
candidates.extend(re.findall(r"((?:[A-Za-z]:)?[^\s:]+\.py):\d+", text))
|
|
1033
|
+
paths: list[str] = []
|
|
1034
|
+
seen: set[str] = set()
|
|
1035
|
+
for candidate in reversed(candidates):
|
|
1036
|
+
normalized = candidate.strip().strip('"').strip("'").replace("\\", "/")
|
|
1037
|
+
if "=" in normalized:
|
|
1038
|
+
normalized = normalized.rsplit("=", 1)[-1]
|
|
1039
|
+
if any(part in normalized for part in ["/.venv/", "/site-packages/", "/.akernel/"]):
|
|
1040
|
+
continue
|
|
1041
|
+
normalized = normalized.lstrip("./")
|
|
1042
|
+
if normalized in seen:
|
|
1043
|
+
continue
|
|
1044
|
+
seen.add(normalized)
|
|
1045
|
+
paths.append(normalized)
|
|
1046
|
+
if len(paths) >= limit:
|
|
1047
|
+
break
|
|
1048
|
+
return paths
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def infer_batch_patch_from_failures(read_traces: list[dict[str, Any]], failure_summary: str) -> dict[str, Any] | None:
|
|
1052
|
+
edits = []
|
|
1053
|
+
seen_paths: set[str] = set()
|
|
1054
|
+
for trace in read_traces:
|
|
1055
|
+
patch = infer_patch_from_failure(trace, failure_summary)
|
|
1056
|
+
if not patch or patch.get("action") != "patch_file":
|
|
1057
|
+
continue
|
|
1058
|
+
path = str(patch.get("path", ""))
|
|
1059
|
+
if not path or path in seen_paths:
|
|
1060
|
+
continue
|
|
1061
|
+
seen_paths.add(path)
|
|
1062
|
+
edits.append(
|
|
1063
|
+
{
|
|
1064
|
+
key: patch[key]
|
|
1065
|
+
for key in ["path", "old", "new", "replace_all", "occurrence", "start_anchor", "end_anchor", "include_anchors"]
|
|
1066
|
+
if key in patch and patch[key] not in {None, ""}
|
|
1067
|
+
}
|
|
1068
|
+
)
|
|
1069
|
+
if len(edits) < 2:
|
|
1070
|
+
return None
|
|
1071
|
+
return {
|
|
1072
|
+
"action": "batch_patch",
|
|
1073
|
+
"edits": edits,
|
|
1074
|
+
"reason": "Apply the inferred multi-file fix as one rollback-safe batch patch.",
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def infer_patch_from_failure(read_trace: dict[str, Any], failure_summary: str) -> dict[str, Any] | None:
|
|
1079
|
+
path = str(read_trace.get("subject") or read_trace.get("path") or "")
|
|
1080
|
+
if not path:
|
|
1081
|
+
path = str(read_trace.get("output", {}).get("path", ""))
|
|
1082
|
+
summary = read_trace.get("output_summary", "")
|
|
1083
|
+
content = read_content(summary)
|
|
1084
|
+
expected = expected_number_from_failure(failure_summary)
|
|
1085
|
+
actual = actual_number_from_failure(failure_summary)
|
|
1086
|
+
|
|
1087
|
+
if expected is not None and actual is not None and f"return {actual}" in content:
|
|
1088
|
+
return {
|
|
1089
|
+
"action": "patch_file",
|
|
1090
|
+
"path": path,
|
|
1091
|
+
"old": f"return {actual}",
|
|
1092
|
+
"new": f"return {expected}",
|
|
1093
|
+
"reason": "Patch the implementation value suggested by the failing assertion.",
|
|
1094
|
+
}
|
|
1095
|
+
if expected is not None and "raise ValueError" in content:
|
|
1096
|
+
line = first_matching_statement(content, "raise ValueError")
|
|
1097
|
+
if line:
|
|
1098
|
+
return {
|
|
1099
|
+
"action": "patch_file",
|
|
1100
|
+
"path": path,
|
|
1101
|
+
"old": line,
|
|
1102
|
+
"new": f"return {expected}",
|
|
1103
|
+
"reason": "Replace the failing exception with the value expected by the test.",
|
|
1104
|
+
}
|
|
1105
|
+
if "return False" in content and re.search(r"(?i)\btrue\b", failure_summary):
|
|
1106
|
+
return {
|
|
1107
|
+
"action": "patch_file",
|
|
1108
|
+
"path": path,
|
|
1109
|
+
"old": "return False",
|
|
1110
|
+
"new": "return True",
|
|
1111
|
+
"reason": "Patch the boolean return value suggested by the failing assertion.",
|
|
1112
|
+
}
|
|
1113
|
+
return None
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def expected_number_from_failure(text: str) -> str | None:
|
|
1117
|
+
match = re.search(r"assert\s+(-?\d+)\s*==\s*(-?\d+)", str(text))
|
|
1118
|
+
if match:
|
|
1119
|
+
return match.group(2)
|
|
1120
|
+
match = re.search(r"expected\s+(-?\d+)", str(text), flags=re.IGNORECASE)
|
|
1121
|
+
return match.group(1) if match else None
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def actual_number_from_failure(text: str) -> str | None:
|
|
1125
|
+
match = re.search(r"assert\s+(-?\d+)\s*==\s*(-?\d+)", str(text))
|
|
1126
|
+
if match:
|
|
1127
|
+
return match.group(1)
|
|
1128
|
+
return None
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def first_matching_statement(content: str, prefix: str) -> str | None:
|
|
1132
|
+
for part in re.split(r"\s{2,}|\n", content):
|
|
1133
|
+
stripped = part.strip()
|
|
1134
|
+
if stripped.startswith(prefix):
|
|
1135
|
+
return stripped
|
|
1136
|
+
return None
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def read_content(summary: str) -> str:
|
|
1140
|
+
text = str(summary)
|
|
1141
|
+
suffix = " (truncated)"
|
|
1142
|
+
if text.endswith(suffix):
|
|
1143
|
+
text = text[: -len(suffix)]
|
|
1144
|
+
return text
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def rewrite_text_from_summary(summary: str, old: str, new: str) -> str | None:
|
|
1148
|
+
content = read_content(summary)
|
|
1149
|
+
if old not in content:
|
|
1150
|
+
return None
|
|
1151
|
+
return content.replace(old, new)
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def rewrite_anchor_block_from_summary(
|
|
1155
|
+
summary: str,
|
|
1156
|
+
start_anchor: str,
|
|
1157
|
+
end_anchor: str,
|
|
1158
|
+
new: str,
|
|
1159
|
+
*,
|
|
1160
|
+
include_anchors: bool,
|
|
1161
|
+
) -> str | None:
|
|
1162
|
+
content = read_content(summary)
|
|
1163
|
+
start_index = content.find(start_anchor)
|
|
1164
|
+
if start_index < 0:
|
|
1165
|
+
return None
|
|
1166
|
+
end_index = content.find(end_anchor, start_index + len(start_anchor))
|
|
1167
|
+
if end_index < 0:
|
|
1168
|
+
return None
|
|
1169
|
+
|
|
1170
|
+
replace_start = start_index if include_anchors else start_index + len(start_anchor)
|
|
1171
|
+
replace_end = end_index + len(end_anchor) if include_anchors else end_index
|
|
1172
|
+
original = content[replace_start:replace_end]
|
|
1173
|
+
replacement = normalize_anchor_replacement(new, original)
|
|
1174
|
+
return content[:replace_start] + replacement + content[replace_end:]
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def rewrite_to_include_text(current_text: str, desired_text: str) -> str:
|
|
1178
|
+
if current_text:
|
|
1179
|
+
if current_text.endswith("\n"):
|
|
1180
|
+
return current_text + desired_text
|
|
1181
|
+
return current_text + "\n" + desired_text
|
|
1182
|
+
return desired_text
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def trim_command_tail(command: str) -> str:
|
|
1186
|
+
text = command.strip().strip("`")
|
|
1187
|
+
lower = text.casefold()
|
|
1188
|
+
for marker in [
|
|
1189
|
+
" and tell me",
|
|
1190
|
+
" and summarize",
|
|
1191
|
+
" and report",
|
|
1192
|
+
" then tell me",
|
|
1193
|
+
" then summarize",
|
|
1194
|
+
" then report",
|
|
1195
|
+
]:
|
|
1196
|
+
index = lower.find(marker)
|
|
1197
|
+
if index > 0:
|
|
1198
|
+
text = text[:index].rstrip()
|
|
1199
|
+
break
|
|
1200
|
+
return text.rstrip(" .!?;:")
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def request_prefers_replace_all(request: str) -> bool:
|
|
1204
|
+
return bool(re.search(r"(?is)\breplace\s+all\s+[\"']", request))
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def request_prefers_include_anchors(request: str) -> bool:
|
|
1208
|
+
return "including anchors" in request.casefold()
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def run_command_or_policy_response(command: str, allowed_roots: set[str], *, reason: str) -> dict[str, Any]:
|
|
1212
|
+
if command_allowed(command, allowed_roots):
|
|
1213
|
+
return {
|
|
1214
|
+
"action": "run_command",
|
|
1215
|
+
"command": command,
|
|
1216
|
+
"timeout_seconds": 30,
|
|
1217
|
+
"reason": reason,
|
|
1218
|
+
}
|
|
1219
|
+
rendered_roots = ", ".join(sorted(allowed_roots)) if allowed_roots else "none"
|
|
1220
|
+
return {
|
|
1221
|
+
"action": "respond",
|
|
1222
|
+
"message": f"Command `{command}` is outside the workspace allowlist. Allowed roots: {rendered_roots}.",
|
|
1223
|
+
"reason": "Stop because the requested command root is not allowed by runtime.command_policy.",
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def command_allowed(command: str, allowed_roots: set[str]) -> bool:
|
|
1228
|
+
if not allowed_roots:
|
|
1229
|
+
return True
|
|
1230
|
+
return bool(command_root_candidates(command).intersection(allowed_roots))
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def patch_action_from_request(patch: dict[str, Any], *, reason: str) -> dict[str, Any]:
|
|
1234
|
+
action = {
|
|
1235
|
+
"action": "patch_file",
|
|
1236
|
+
"path": str(patch["path"]),
|
|
1237
|
+
"new": str(patch["new"]),
|
|
1238
|
+
"reason": reason,
|
|
1239
|
+
}
|
|
1240
|
+
if patch.get("start_anchor"):
|
|
1241
|
+
action["start_anchor"] = str(patch["start_anchor"])
|
|
1242
|
+
action["end_anchor"] = str(patch["end_anchor"])
|
|
1243
|
+
action["include_anchors"] = bool(patch.get("include_anchors", False))
|
|
1244
|
+
return action
|
|
1245
|
+
|
|
1246
|
+
action["old"] = str(patch.get("old", ""))
|
|
1247
|
+
action["replace_all"] = bool(patch.get("replace_all", False))
|
|
1248
|
+
if patch.get("occurrence") is not None:
|
|
1249
|
+
action["occurrence"] = int(patch["occurrence"])
|
|
1250
|
+
return action
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def normalize_anchor_replacement(new: str, original: str) -> str:
|
|
1254
|
+
replacement = new
|
|
1255
|
+
if original.startswith("\r\n") and not replacement.startswith(("\r", "\n")):
|
|
1256
|
+
replacement = "\r\n" + replacement
|
|
1257
|
+
elif original.startswith("\n") and not replacement.startswith(("\r", "\n")):
|
|
1258
|
+
replacement = "\n" + replacement
|
|
1259
|
+
|
|
1260
|
+
if original.endswith("\r\n") and not replacement.endswith(("\r", "\n")):
|
|
1261
|
+
replacement = replacement + "\r\n"
|
|
1262
|
+
elif original.endswith("\n") and not replacement.endswith(("\r", "\n")):
|
|
1263
|
+
replacement = replacement + "\n"
|
|
1264
|
+
return replacement
|