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.
Files changed (40) hide show
  1. akernel_runtime-0.1.0.dist-info/METADATA +270 -0
  2. akernel_runtime-0.1.0.dist-info/RECORD +40 -0
  3. akernel_runtime-0.1.0.dist-info/WHEEL +5 -0
  4. akernel_runtime-0.1.0.dist-info/entry_points.txt +2 -0
  5. akernel_runtime-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. akernel_runtime-0.1.0.dist-info/licenses/NOTICE +4 -0
  7. akernel_runtime-0.1.0.dist-info/top_level.txt +1 -0
  8. context_kernel/__init__.py +4 -0
  9. context_kernel/__main__.py +5 -0
  10. context_kernel/agent_reports.py +188 -0
  11. context_kernel/benchmarks.py +493 -0
  12. context_kernel/budget.py +72 -0
  13. context_kernel/cli.py +2953 -0
  14. context_kernel/context.py +161 -0
  15. context_kernel/evals.py +347 -0
  16. context_kernel/global_memory.py +126 -0
  17. context_kernel/loop.py +1617 -0
  18. context_kernel/marketplace.py +194 -0
  19. context_kernel/marketplace_data/skills/context_budget.json +27 -0
  20. context_kernel/marketplace_data/skills/context_compaction.json +27 -0
  21. context_kernel/marketplace_data/skills/edit_file.json +27 -0
  22. context_kernel/marketplace_data/skills/index.json +66 -0
  23. context_kernel/marketplace_data/skills/long_task_planning.json +27 -0
  24. context_kernel/marketplace_data/skills/multi_file_bugfix.json +28 -0
  25. context_kernel/memory.py +515 -0
  26. context_kernel/models.py +144 -0
  27. context_kernel/planner.py +155 -0
  28. context_kernel/policy.py +271 -0
  29. context_kernel/project.py +317 -0
  30. context_kernel/providers.py +1264 -0
  31. context_kernel/report_costs.py +375 -0
  32. context_kernel/runner.py +78 -0
  33. context_kernel/skills.py +318 -0
  34. context_kernel/state_writer.py +108 -0
  35. context_kernel/storage.py +171 -0
  36. context_kernel/tasks.py +549 -0
  37. context_kernel/text.py +42 -0
  38. context_kernel/tokenizer.py +22 -0
  39. context_kernel/tools.py +544 -0
  40. 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