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
context_kernel/loop.py ADDED
@@ -0,0 +1,1617 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import re
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from .memory import MemoryStore
10
+ from .models import utc_now
11
+ from .planner import ExecutionPlanner
12
+ from .providers import env_value, extract_anchor_patch_instruction, extract_patch_instruction, extract_write_instruction
13
+ from .runner import AgentRunner
14
+ from .skills import extract_json_object
15
+ from .storage import Workspace
16
+ from .tasks import TaskStore
17
+ from .tools import MAX_CAPTURE_CHARS, ToolExecutor
18
+
19
+
20
+ TOOL_ACTIONS = {"read_file", "write_file", "patch_file", "batch_patch", "run_command"}
21
+ ALLOWED_ACTIONS = TOOL_ACTIONS | {"respond"}
22
+ DEFAULT_PRIMARY_MODEL = "gpt-5.5"
23
+ DEFAULT_AUXILIARY_MODEL = "gpt-5.3-codex"
24
+ MODEL_ROUTING_MODES = {"auto", "primary", "auxiliary"}
25
+ AUX_REVIEW_MODES = {"auto", "off", "always"}
26
+
27
+
28
+ class AgentLoop:
29
+ def __init__(self, workspace: Workspace):
30
+ self.workspace = workspace
31
+ self.tasks = TaskStore(workspace)
32
+ self.tools = ToolExecutor(workspace)
33
+
34
+ def run(
35
+ self,
36
+ request: str,
37
+ *,
38
+ provider_name: str,
39
+ budget: int | None,
40
+ profile: str = "balanced",
41
+ model: str | None = None,
42
+ aux_model: str | None = None,
43
+ model_routing: str = "auto",
44
+ aux_review: str = "auto",
45
+ base_url: str | None = None,
46
+ task_id: str | None = None,
47
+ max_steps: int = 5,
48
+ remember: bool = True,
49
+ allow_over_budget: bool = False,
50
+ expect_json: bool = False,
51
+ ) -> dict[str, Any]:
52
+ if max_steps < 1:
53
+ raise ValueError("max_steps must be at least 1")
54
+ if model_routing not in MODEL_ROUTING_MODES:
55
+ raise ValueError(f"model_routing must be one of: {', '.join(sorted(MODEL_ROUTING_MODES))}")
56
+ if aux_review not in AUX_REVIEW_MODES:
57
+ raise ValueError(f"aux_review must be one of: {', '.join(sorted(AUX_REVIEW_MODES))}")
58
+
59
+ task = self._task_for_request(request, task_id)
60
+ report = {
61
+ "id": uuid4().hex[:12],
62
+ "created_at": utc_now(),
63
+ "request": request,
64
+ "task_id": task["id"],
65
+ "status": "running",
66
+ "max_steps": max_steps,
67
+ "steps": [],
68
+ "final_response": None,
69
+ "model_routing": {
70
+ "mode": model_routing,
71
+ "primary_model": resolve_role_model(provider_name, model, aux_model, "primary"),
72
+ "auxiliary_model": resolve_role_model(provider_name, model, aux_model, "auxiliary"),
73
+ "aux_review": aux_review,
74
+ },
75
+ "diagnostic": None,
76
+ "state": {"enabled": False, "candidate_count": 0, "written_count": 0, "records": []},
77
+ "totals": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
78
+ }
79
+
80
+ for index in range(1, max_steps + 1):
81
+ step = self._run_step(
82
+ request,
83
+ task["id"],
84
+ index=index,
85
+ max_steps=max_steps,
86
+ prior_steps=report["steps"],
87
+ provider_name=provider_name,
88
+ budget=budget,
89
+ profile=profile,
90
+ model=model,
91
+ aux_model=aux_model,
92
+ model_routing=model_routing,
93
+ aux_review=aux_review,
94
+ base_url=base_url,
95
+ allow_over_budget=allow_over_budget,
96
+ expect_json=expect_json,
97
+ )
98
+ report["steps"].append(step)
99
+ add_tokens(report["totals"], step.get("tokens", {}))
100
+ add_tokens(report["totals"], step.get("aux_review", {}).get("tokens", {}))
101
+ if step.get("final_response") is not None:
102
+ report["final_response"] = step["final_response"]
103
+
104
+ if not step["continue"]:
105
+ report["status"] = step["status"]
106
+ report["diagnostic"] = step.get("diagnostic")
107
+ self.tasks.step(task["id"], step["stop_reason"], kind="agent_stop")
108
+ break
109
+ else:
110
+ report["status"] = "stopped"
111
+ self.tasks.step(task["id"], f"Agent loop stopped after {max_steps} step(s).", kind="agent_stop")
112
+
113
+ if remember:
114
+ report["state"] = write_agent_run_memory(self.workspace, self.tasks, report)
115
+ report["completed_at"] = utc_now()
116
+ self.workspace.agent_runs_dir.mkdir(parents=True, exist_ok=True)
117
+ Workspace.write_json(self.workspace.agent_runs_dir / f"{report['id']}.json", compact_saved_agent_report(report))
118
+ return report
119
+
120
+ def _task_for_request(self, request: str, task_id: str | None) -> dict[str, Any]:
121
+ if task_id:
122
+ task = self.tasks.get(task_id)
123
+ if task.get("status") == "completed":
124
+ raise ValueError(f"Task is completed and cannot receive agent loop steps: {task_id}")
125
+ return task
126
+ return self.tasks.start(request, goal=request)
127
+
128
+ def _run_step(
129
+ self,
130
+ request: str,
131
+ task_id: str,
132
+ *,
133
+ index: int,
134
+ max_steps: int,
135
+ prior_steps: list[dict[str, Any]],
136
+ provider_name: str,
137
+ budget: int | None,
138
+ profile: str,
139
+ model: str | None,
140
+ aux_model: str | None,
141
+ model_routing: str,
142
+ aux_review: str,
143
+ base_url: str | None,
144
+ allow_over_budget: bool,
145
+ expect_json: bool,
146
+ ) -> dict[str, Any]:
147
+ plan = ExecutionPlanner(self.workspace).plan(
148
+ request,
149
+ budget,
150
+ profile,
151
+ task_id=task_id,
152
+ resume=True,
153
+ )
154
+ if plan["budget"]["over_budget"] and not allow_over_budget:
155
+ diagnostic = {
156
+ "category": "context_budget",
157
+ "message": "Context packet is over budget.",
158
+ "suggestion": "Use a leaner profile, increase --budget, run /compact, or pass --allow-over-budget when you intentionally want to continue.",
159
+ }
160
+ return {
161
+ "index": index,
162
+ "status": "blocked",
163
+ "continue": False,
164
+ "stop_reason": "Agent loop stopped: context packet is over budget.",
165
+ "reason": "context packet is over budget",
166
+ "diagnostic": diagnostic,
167
+ "plan": summarize_plan(plan),
168
+ "trace_id": None,
169
+ "tool_trace_id": None,
170
+ "action": None,
171
+ "tokens": {},
172
+ "verifier_ok": False,
173
+ }
174
+
175
+ selected_role, routing_reason = select_model_role(
176
+ model_routing=model_routing,
177
+ plan=plan,
178
+ step_index=index,
179
+ prior_steps=prior_steps,
180
+ profile=profile,
181
+ )
182
+ selected_model = resolve_role_model(provider_name, model, aux_model, selected_role)
183
+ review = run_auxiliary_review(
184
+ self.workspace,
185
+ request=request,
186
+ provider_name=provider_name,
187
+ plan=plan,
188
+ selected_role=selected_role,
189
+ selected_model=selected_model,
190
+ aux_model=aux_model,
191
+ base_url=base_url,
192
+ budget=budget,
193
+ profile=profile,
194
+ task_id=task_id,
195
+ allow_over_budget=allow_over_budget,
196
+ aux_review=aux_review,
197
+ routing_reason=routing_reason,
198
+ )
199
+
200
+ try:
201
+ trace = AgentRunner(self.workspace).run(
202
+ request,
203
+ provider_name=provider_name,
204
+ budget=budget,
205
+ profile=profile,
206
+ model=selected_model,
207
+ base_url=base_url,
208
+ allow_over_budget=allow_over_budget,
209
+ expect_json=True,
210
+ remember=False,
211
+ task_id=task_id,
212
+ resume=True,
213
+ packet_overrides=build_agent_packet(
214
+ request,
215
+ index,
216
+ max_steps,
217
+ expect_json=expect_json,
218
+ model_role=selected_role,
219
+ routing_reason=routing_reason,
220
+ ),
221
+ )
222
+ except Exception as exc:
223
+ diagnostic = diagnose_agent_exception(exc)
224
+ self.tasks.step(
225
+ task_id,
226
+ f"Agent step {index} failed before action execution: {diagnostic['message']}",
227
+ kind="agent_step",
228
+ )
229
+ return {
230
+ "index": index,
231
+ "status": "failed",
232
+ "continue": False,
233
+ "stop_reason": f"Agent loop stopped: {diagnostic['category']}.",
234
+ "reason": str(exc),
235
+ "diagnostic": diagnostic,
236
+ "plan": summarize_plan(plan),
237
+ "trace_id": None,
238
+ "model_role": selected_role,
239
+ "model": selected_model,
240
+ "routing_reason": routing_reason,
241
+ "aux_review": review,
242
+ "tool_trace_id": None,
243
+ "action": None,
244
+ "tokens": {},
245
+ "verifier_ok": False,
246
+ }
247
+ attach_trace_outputs(self.tasks, task_id, trace)
248
+ tokens = trace.get("response", {})
249
+
250
+ verifier_ok = bool(trace.get("verifier", {}).get("ok"))
251
+ contract_recovered = False
252
+ try:
253
+ action = parse_agent_action(trace["response"]["text"], expect_json=expect_json)
254
+ except (ValueError, KeyError, TypeError) as exc:
255
+ stop_reason = "provider returned an invalid action payload"
256
+ if not verifier_ok:
257
+ stop_reason = "provider response failed JSON action verification"
258
+ self.tasks.step(
259
+ task_id,
260
+ f"Agent step {index} needs review: invalid action payload ({compact(str(exc), limit=240)}).",
261
+ kind="agent_step",
262
+ refs={"run_traces": [trace["id"]]},
263
+ )
264
+ return {
265
+ "index": index,
266
+ "status": "needs_review",
267
+ "continue": False,
268
+ "stop_reason": f"Agent loop stopped: {stop_reason}.",
269
+ "reason": str(exc),
270
+ "diagnostic": {
271
+ "category": "provider_response",
272
+ "message": compact(str(exc), limit=240),
273
+ "suggestion": "Retry with the same task, or use a stricter/stronger model if the provider keeps returning malformed agent actions.",
274
+ },
275
+ "plan": summarize_plan(plan),
276
+ "trace_id": trace["id"],
277
+ "model_role": selected_role,
278
+ "model": selected_model,
279
+ "routing_reason": routing_reason,
280
+ "aux_review": review,
281
+ "tool_trace_id": None,
282
+ "action": None,
283
+ "tokens": {
284
+ "input_tokens": tokens.get("input_tokens", 0),
285
+ "output_tokens": tokens.get("output_tokens", 0),
286
+ "total_tokens": tokens.get("total_tokens", 0),
287
+ },
288
+ "verifier_ok": verifier_ok,
289
+ "contract_recovered": False,
290
+ }
291
+ if not verifier_ok:
292
+ contract_recovered = True
293
+ self.tasks.step(
294
+ task_id,
295
+ f"Agent step {index} recovered a valid action from non-strict provider JSON.",
296
+ kind="agent_step",
297
+ refs={"run_traces": [trace["id"]]},
298
+ )
299
+
300
+ repeated_action = repeated_agent_action(report_steps=prior_steps, action=action)
301
+ if repeated_action:
302
+ self.tasks.step(
303
+ task_id,
304
+ f"Agent step {index} stopped: repeated action detected for {action['action']}.",
305
+ kind="agent_step",
306
+ refs={"run_traces": [trace["id"]]},
307
+ )
308
+ return {
309
+ "index": index,
310
+ "status": "needs_review",
311
+ "continue": False,
312
+ "stop_reason": "Agent loop stopped: repeated identical action would likely cause a loop.",
313
+ "reason": "repeated identical action detected",
314
+ "diagnostic": {
315
+ "category": "loop_guard",
316
+ "message": "The provider returned the same action twice.",
317
+ "suggestion": "Inspect the saved agent run and linked traces, then retry with more specific instructions or a larger step budget.",
318
+ },
319
+ "plan": summarize_plan(plan),
320
+ "trace_id": trace["id"],
321
+ "model_role": selected_role,
322
+ "model": selected_model,
323
+ "routing_reason": routing_reason,
324
+ "aux_review": review,
325
+ "tool_trace_id": None,
326
+ "action": summarize_action(action),
327
+ "tokens": {
328
+ "input_tokens": tokens.get("input_tokens", 0),
329
+ "output_tokens": tokens.get("output_tokens", 0),
330
+ "total_tokens": tokens.get("total_tokens", 0),
331
+ },
332
+ "verifier_ok": verifier_ok,
333
+ "contract_recovered": contract_recovered,
334
+ }
335
+
336
+ if action["action"] == "respond":
337
+ response_text = render_agent_response(action)
338
+ self.tasks.step(
339
+ task_id,
340
+ f"Agent response: {compact(response_text, limit=400)}",
341
+ kind="agent_response",
342
+ refs={"run_traces": [trace["id"]]},
343
+ )
344
+ return {
345
+ "index": index,
346
+ "status": "responded",
347
+ "continue": False,
348
+ "stop_reason": "Agent loop stopped: a final response was produced.",
349
+ "plan": summarize_plan(plan),
350
+ "trace_id": trace["id"],
351
+ "model_role": selected_role,
352
+ "model": selected_model,
353
+ "routing_reason": routing_reason,
354
+ "aux_review": review,
355
+ "tool_trace_id": None,
356
+ "action": summarize_action(action),
357
+ "tokens": {
358
+ "input_tokens": tokens.get("input_tokens", 0),
359
+ "output_tokens": tokens.get("output_tokens", 0),
360
+ "total_tokens": tokens.get("total_tokens", 0),
361
+ },
362
+ "verifier_ok": verifier_ok,
363
+ "contract_recovered": contract_recovered,
364
+ "final_response": response_text,
365
+ }
366
+
367
+ tool_result = execute_agent_action(self.tools, action)
368
+ self.tasks.attach(task_id, "tool", tool_result["id"])
369
+ tool_summary = summarize_tool_result(tool_result)
370
+ self.tasks.step(
371
+ task_id,
372
+ f"Agent step {index} executed {action['action']}: {tool_summary}",
373
+ kind="agent_tool",
374
+ refs={"run_traces": [trace["id"]], "tool_traces": [tool_result["id"]]},
375
+ )
376
+ recovery_tools = auto_recovery_tools(self.tools, request, action, tool_result) if index < max_steps else []
377
+ if recovery_tools:
378
+ for recovery in recovery_tools:
379
+ self.tasks.attach(task_id, "tool", recovery["id"])
380
+ recovery_summary = "; ".join(
381
+ f"{item['tool']}:{summarize_tool_result(item)}"
382
+ for item in recovery_tools
383
+ )
384
+ self.tasks.step(
385
+ task_id,
386
+ f"Agent recovery prepared after {action['action']}: {recovery_summary}",
387
+ kind="agent_recovery",
388
+ refs={"tool_traces": [item["id"] for item in recovery_tools]},
389
+ )
390
+ can_continue = index < max_steps and (tool_result["ok"] or bool(recovery_tools))
391
+ if tool_result["blocked"]:
392
+ status = "blocked"
393
+ elif not tool_result["ok"]:
394
+ status = "recovery_prepared" if can_continue else "needs_review"
395
+ else:
396
+ status = "ok" if can_continue else "stopped"
397
+ diagnostic = diagnose_tool_result(tool_result) if not can_continue and not tool_result["ok"] else None
398
+ return {
399
+ "index": index,
400
+ "status": status,
401
+ "continue": can_continue,
402
+ "stop_reason": final_tool_stop_reason(tool_result, recovery_tools=recovery_tools, max_steps=max_steps) if not can_continue else "",
403
+ "diagnostic": diagnostic,
404
+ "plan": summarize_plan(plan),
405
+ "trace_id": trace["id"],
406
+ "model_role": selected_role,
407
+ "model": selected_model,
408
+ "routing_reason": routing_reason,
409
+ "aux_review": review,
410
+ "tool_trace_id": tool_result["id"],
411
+ "action": summarize_action(action),
412
+ "tool": {
413
+ "id": tool_result["id"],
414
+ "name": tool_result["tool"],
415
+ "ok": tool_result["ok"],
416
+ "blocked": tool_result["blocked"],
417
+ "error": tool_result.get("error"),
418
+ "summary": tool_summary,
419
+ },
420
+ "recovery_tools": [
421
+ {
422
+ "id": item["id"],
423
+ "name": item["tool"],
424
+ "ok": item["ok"],
425
+ "blocked": item["blocked"],
426
+ "summary": summarize_tool_result(item),
427
+ }
428
+ for item in recovery_tools
429
+ ],
430
+ "tokens": {
431
+ "input_tokens": tokens.get("input_tokens", 0),
432
+ "output_tokens": tokens.get("output_tokens", 0),
433
+ "total_tokens": tokens.get("total_tokens", 0),
434
+ },
435
+ "verifier_ok": verifier_ok,
436
+ "contract_recovered": contract_recovered,
437
+ }
438
+
439
+
440
+ def build_agent_packet(
441
+ request: str,
442
+ step_index: int,
443
+ max_steps: int,
444
+ *,
445
+ expect_json: bool = False,
446
+ model_role: str = "primary",
447
+ routing_reason: str = "",
448
+ ) -> dict[str, Any]:
449
+ respond_schema: dict[str, Any] = {
450
+ "action": "respond",
451
+ "message": "string",
452
+ "reason": "string optional",
453
+ }
454
+ if expect_json:
455
+ respond_schema["message"] = "string containing compact JSON text"
456
+ rules = [
457
+ "Return only valid JSON with no surrounding commentary.",
458
+ "Choose exactly one action.",
459
+ "Use at most one tool action in a step.",
460
+ "Use respond when enough information is already available.",
461
+ "Respect policy-gated tools; do not ask for destructive operations.",
462
+ "Before choosing run_command, check runtime.command_policy.allowed_roots and only use an allowed command root.",
463
+ "When the user asks to run tests, verify, build, lint, or install and runtime.project.commands contains the matching command, use that exact project command.",
464
+ "If the user's requested command root is outside runtime.command_policy.allowed_roots, respond with the restriction instead of retrying the blocked command.",
465
+ "Prefer reusing task brief summaries instead of repeating a completed tool action.",
466
+ "If a patch or verification step fails, check any recovery read summaries before deciding the next action.",
467
+ "When the user describes a block between markers, use patch_file with start_anchor and end_anchor instead of rewriting the whole file.",
468
+ "When the user asks for multiple file edits, prefer one batch_patch action with an edits array.",
469
+ ]
470
+ if is_patch_verify_request(request):
471
+ rules.append("When the request asks for a patch and a verification command, patch first, then run the command, then respond.")
472
+ if is_write_verify_request(request):
473
+ rules.append("When the request asks for a file write and a verification command, write the file first, then run the command, then respond.")
474
+ return {
475
+ "agent": {
476
+ "mode": "tool_planning_v8",
477
+ "step_index": step_index,
478
+ "max_steps": max_steps,
479
+ "model_role": model_role,
480
+ "routing_reason": routing_reason,
481
+ "available_tools": [
482
+ {
483
+ "name": "respond",
484
+ "description": "Return the final user-facing response and stop the loop.",
485
+ "schema": respond_schema,
486
+ },
487
+ {
488
+ "name": "read_file",
489
+ "description": "Read one workspace file through policy checks.",
490
+ "schema": {
491
+ "action": "read_file",
492
+ "path": "relative file path",
493
+ "max_chars": f"optional integer, <= {MAX_CAPTURE_CHARS}",
494
+ "reason": "string optional",
495
+ },
496
+ },
497
+ {
498
+ "name": "write_file",
499
+ "description": "Create or overwrite one workspace file through policy checks.",
500
+ "schema": {
501
+ "action": "write_file",
502
+ "path": "relative file path",
503
+ "text": "complete file contents",
504
+ "reason": "string optional",
505
+ },
506
+ },
507
+ {
508
+ "name": "patch_file",
509
+ "description": "Apply a structured text replacement to a workspace file.",
510
+ "schema": {
511
+ "action": "patch_file",
512
+ "path": "relative file path",
513
+ "new": "replacement text",
514
+ "old": "exact old text for text replacement mode",
515
+ "replace_all": "optional boolean; use true to replace every match of old",
516
+ "occurrence": "optional integer >= 1; replace only the nth match of old",
517
+ "start_anchor": "optional exact start marker for anchor block mode",
518
+ "end_anchor": "optional exact end marker for anchor block mode",
519
+ "include_anchors": "optional boolean; replace anchors together with the block body",
520
+ "reason": "string optional",
521
+ },
522
+ },
523
+ {
524
+ "name": "batch_patch",
525
+ "description": "Apply multiple structured patches as one batch tool step.",
526
+ "schema": {
527
+ "action": "batch_patch",
528
+ "edits": [
529
+ {
530
+ "path": "relative file path",
531
+ "new": "replacement text",
532
+ "old": "exact old text for text replacement mode",
533
+ "replace_all": "optional boolean",
534
+ "occurrence": "optional integer >= 1",
535
+ "start_anchor": "optional exact start marker for anchor block mode",
536
+ "end_anchor": "optional exact end marker for anchor block mode",
537
+ "include_anchors": "optional boolean",
538
+ }
539
+ ],
540
+ "reason": "string optional",
541
+ },
542
+ },
543
+ {
544
+ "name": "run_command",
545
+ "description": "Run one safe non-interactive command through policy checks.",
546
+ "schema": {
547
+ "action": "run_command",
548
+ "command": "command string",
549
+ "timeout_seconds": "optional integer between 1 and 300",
550
+ "reason": "string optional",
551
+ },
552
+ },
553
+ ],
554
+ "response_contract": {
555
+ "type": "json_object",
556
+ "rules": rules,
557
+ },
558
+ }
559
+ }
560
+
561
+
562
+ def parse_agent_action(text: str, *, expect_json: bool = False) -> dict[str, Any]:
563
+ action = normalize_agent_action_payload(extract_json_object(text))
564
+ action_name = str(action.get("action", "")).strip().lower()
565
+ if action_name not in ALLOWED_ACTIONS:
566
+ raise ValueError(f"Unsupported action: {action_name or '[missing]'}")
567
+ if action_name == "respond":
568
+ message = action.get("message")
569
+ if not isinstance(message, str) or not message.strip():
570
+ raise ValueError("respond action requires a non-empty string message")
571
+ if expect_json:
572
+ json.loads(message)
573
+ return {
574
+ "action": "respond",
575
+ "message": message.strip(),
576
+ "reason": compact(str(action.get("reason", "")), limit=240),
577
+ }
578
+ if action_name == "read_file":
579
+ path = require_non_empty_string(action, "path")
580
+ max_chars = clamp_int(action.get("max_chars", MAX_CAPTURE_CHARS), default=MAX_CAPTURE_CHARS, minimum=1, maximum=MAX_CAPTURE_CHARS)
581
+ return {
582
+ "action": "read_file",
583
+ "path": path,
584
+ "max_chars": max_chars,
585
+ "reason": compact(str(action.get("reason", "")), limit=240),
586
+ }
587
+ if action_name == "write_file":
588
+ path = require_non_empty_string(action, "path")
589
+ text = str(action.get("text", ""))
590
+ return {
591
+ "action": "write_file",
592
+ "path": path,
593
+ "text": text,
594
+ "reason": compact(str(action.get("reason", "")), limit=240),
595
+ }
596
+ if action_name == "patch_file":
597
+ path = require_non_empty_string(action, "path")
598
+ new = str(action.get("new", ""))
599
+ start_anchor = optional_non_empty_string(action, "start_anchor")
600
+ end_anchor = optional_non_empty_string(action, "end_anchor")
601
+ include_anchors = bool(action.get("include_anchors", False))
602
+ anchor_mode = start_anchor is not None or end_anchor is not None
603
+ if anchor_mode:
604
+ if not start_anchor or not end_anchor:
605
+ raise ValueError("patch_file anchor mode requires both start_anchor and end_anchor")
606
+ if action.get("old"):
607
+ raise ValueError("patch_file anchor mode cannot combine old with start/end anchors")
608
+ if action.get("replace_all") or action.get("occurrence") not in {None, ""}:
609
+ raise ValueError("patch_file anchor mode cannot combine replace_all or occurrence")
610
+ return {
611
+ "action": "patch_file",
612
+ "path": path,
613
+ "new": new,
614
+ "start_anchor": start_anchor,
615
+ "end_anchor": end_anchor,
616
+ "include_anchors": include_anchors,
617
+ "reason": compact(str(action.get("reason", "")), limit=240),
618
+ }
619
+
620
+ old = require_non_empty_string(action, "old")
621
+ replace_all = bool(action.get("replace_all", False))
622
+ occurrence = optional_int(action.get("occurrence"), minimum=1)
623
+ if replace_all and occurrence is not None:
624
+ raise ValueError("patch_file cannot combine replace_all with occurrence")
625
+ return {
626
+ "action": "patch_file",
627
+ "path": path,
628
+ "old": old,
629
+ "new": new,
630
+ "replace_all": replace_all,
631
+ "occurrence": occurrence,
632
+ "include_anchors": False,
633
+ "reason": compact(str(action.get("reason", "")), limit=240),
634
+ }
635
+ if action_name == "batch_patch":
636
+ edits = action.get("edits")
637
+ if not isinstance(edits, list) or not edits:
638
+ raise ValueError("batch_patch requires a non-empty edits array")
639
+ return {
640
+ "action": "batch_patch",
641
+ "edits": [parse_patch_edit(edit) for edit in edits],
642
+ "reason": compact(str(action.get("reason", "")), limit=240),
643
+ }
644
+ command = require_non_empty_string(action, "command")
645
+ timeout_seconds = clamp_int(action.get("timeout_seconds", 30), default=30, minimum=1, maximum=300)
646
+ return {
647
+ "action": "run_command",
648
+ "command": command,
649
+ "timeout_seconds": timeout_seconds,
650
+ "reason": compact(str(action.get("reason", "")), limit=240),
651
+ }
652
+
653
+
654
+ def normalize_agent_action_payload(payload: dict[str, Any]) -> dict[str, Any]:
655
+ """Accept common one-tool JSON shapes while preserving the canonical contract."""
656
+ action = unwrap_single_action_payload(payload)
657
+ action = unwrap_tool_call_payload(action)
658
+
659
+ if not isinstance(action, dict):
660
+ raise ValueError("Agent action payload must be a JSON object")
661
+
662
+ nested_args = first_dict(action, "arguments", "args", "input", "parameters")
663
+ action_name = (
664
+ action.get("action")
665
+ or action.get("tool")
666
+ or action.get("name")
667
+ or action.get("tool_name")
668
+ )
669
+ if isinstance(action_name, dict):
670
+ nested_args = nested_args or first_dict(action_name, "arguments", "args", "input", "parameters")
671
+ action_name = action_name.get("name") or action_name.get("action") or action_name.get("tool")
672
+
673
+ normalized: dict[str, Any] = {}
674
+ if nested_args:
675
+ normalized.update(nested_args)
676
+ normalized.update({key: value for key, value in action.items() if key not in {"arguments", "args", "input", "parameters"}})
677
+ if action_name is not None:
678
+ normalized["action"] = normalize_action_name(str(action_name))
679
+ return normalized
680
+
681
+
682
+ def unwrap_single_action_payload(payload: dict[str, Any]) -> Any:
683
+ for key in ["action", "tool", "tool_call"]:
684
+ value = payload.get(key)
685
+ if isinstance(value, dict):
686
+ return value
687
+ for key in ["actions", "steps"]:
688
+ value = payload.get(key)
689
+ if isinstance(value, list) and len(value) == 1:
690
+ return value[0]
691
+ return payload
692
+
693
+
694
+ def unwrap_tool_call_payload(payload: Any) -> Any:
695
+ if not isinstance(payload, dict):
696
+ return payload
697
+ tool_calls = payload.get("tool_calls")
698
+ if isinstance(tool_calls, list) and len(tool_calls) == 1:
699
+ return unwrap_tool_call_payload(tool_calls[0])
700
+ function = payload.get("function")
701
+ if isinstance(function, dict):
702
+ arguments = parse_arguments_object(function.get("arguments"))
703
+ result = dict(arguments)
704
+ result["action"] = function.get("name")
705
+ return result
706
+ return payload
707
+
708
+
709
+ def first_dict(data: dict[str, Any], *keys: str) -> dict[str, Any] | None:
710
+ for key in keys:
711
+ value = data.get(key)
712
+ if isinstance(value, dict):
713
+ return value
714
+ parsed = parse_arguments_object(value)
715
+ if parsed:
716
+ return parsed
717
+ return None
718
+
719
+
720
+ def parse_arguments_object(value: Any) -> dict[str, Any]:
721
+ if isinstance(value, dict):
722
+ return value
723
+ if not isinstance(value, str) or not value.strip():
724
+ return {}
725
+ try:
726
+ parsed = json.loads(value)
727
+ except json.JSONDecodeError:
728
+ return {}
729
+ return parsed if isinstance(parsed, dict) else {}
730
+
731
+
732
+ def normalize_action_name(name: str) -> str:
733
+ normalized = name.strip().lower().replace("-", "_").replace(" ", "_")
734
+ aliases = {
735
+ "read": "read_file",
736
+ "file_read": "read_file",
737
+ "write": "write_file",
738
+ "file_write": "write_file",
739
+ "patch": "patch_file",
740
+ "edit_file": "patch_file",
741
+ "batch_edit": "batch_patch",
742
+ "shell": "run_command",
743
+ "exec": "run_command",
744
+ "execute": "run_command",
745
+ "command": "run_command",
746
+ "final": "respond",
747
+ "final_answer": "respond",
748
+ "answer": "respond",
749
+ }
750
+ return aliases.get(normalized, normalized)
751
+
752
+
753
+ def parse_patch_edit(edit: Any) -> dict[str, Any]:
754
+ if not isinstance(edit, dict):
755
+ raise ValueError("batch_patch edits must be objects")
756
+ path = require_non_empty_string(edit, "path")
757
+ new = str(edit.get("new", ""))
758
+ start_anchor = optional_non_empty_string(edit, "start_anchor")
759
+ end_anchor = optional_non_empty_string(edit, "end_anchor")
760
+ include_anchors = bool(edit.get("include_anchors", False))
761
+ anchor_mode = start_anchor is not None or end_anchor is not None
762
+ if anchor_mode:
763
+ if not start_anchor or not end_anchor:
764
+ raise ValueError("batch_patch anchor edits require both start_anchor and end_anchor")
765
+ if edit.get("old"):
766
+ raise ValueError("batch_patch anchor edits cannot combine old with start/end anchors")
767
+ if edit.get("replace_all") or edit.get("occurrence") not in {None, ""}:
768
+ raise ValueError("batch_patch anchor edits cannot combine replace_all or occurrence")
769
+ return {
770
+ "path": path,
771
+ "new": new,
772
+ "start_anchor": start_anchor,
773
+ "end_anchor": end_anchor,
774
+ "include_anchors": include_anchors,
775
+ }
776
+
777
+ old = require_non_empty_string(edit, "old")
778
+ replace_all = bool(edit.get("replace_all", False))
779
+ occurrence = optional_int(edit.get("occurrence"), minimum=1)
780
+ if replace_all and occurrence is not None:
781
+ raise ValueError("batch_patch edits cannot combine replace_all with occurrence")
782
+ return {
783
+ "path": path,
784
+ "old": old,
785
+ "new": new,
786
+ "replace_all": replace_all,
787
+ "occurrence": occurrence,
788
+ "include_anchors": False,
789
+ }
790
+
791
+
792
+ def execute_agent_action(executor: ToolExecutor, action: dict[str, Any]) -> dict[str, Any]:
793
+ if action["action"] == "read_file":
794
+ return executor.read_file(action["path"], max_chars=action["max_chars"])
795
+ if action["action"] == "write_file":
796
+ return executor.write_file(action["path"], action["text"])
797
+ if action["action"] == "patch_file":
798
+ return executor.patch_file(
799
+ action["path"],
800
+ action.get("old", ""),
801
+ action["new"],
802
+ replace_all=bool(action.get("replace_all", False)),
803
+ occurrence=action.get("occurrence"),
804
+ start_anchor=action.get("start_anchor"),
805
+ end_anchor=action.get("end_anchor"),
806
+ include_anchors=bool(action.get("include_anchors", False)),
807
+ )
808
+ if action["action"] == "batch_patch":
809
+ return executor.batch_patch(action["edits"])
810
+ if action["action"] == "run_command":
811
+ return executor.run_command(action["command"], timeout_seconds=action["timeout_seconds"])
812
+ raise ValueError(f"Unsupported tool action: {action['action']}")
813
+
814
+
815
+ def attach_trace_outputs(tasks: TaskStore, task_id: str, trace: dict[str, Any]) -> None:
816
+ tasks.attach(task_id, "run", trace["id"])
817
+ for record in trace.get("state", {}).get("records", []):
818
+ tasks.attach(task_id, "memory", record["id"])
819
+
820
+
821
+ def auto_recovery_tools(
822
+ executor: ToolExecutor,
823
+ request: str,
824
+ action: dict[str, Any],
825
+ tool_result: dict[str, Any],
826
+ ) -> list[dict[str, Any]]:
827
+ if tool_result["blocked"] or tool_result["ok"]:
828
+ return []
829
+ if action["action"] == "patch_file":
830
+ return [executor.read_file(action["path"], max_chars=min(4000, MAX_CAPTURE_CHARS))]
831
+ if action["action"] == "run_command":
832
+ explicit_target = recovery_target_path(request)
833
+ targets = [explicit_target] if explicit_target else command_failure_target_paths(executor.workspace, tool_result, limit=3)
834
+ return [
835
+ executor.read_file(target, max_chars=min(4000, MAX_CAPTURE_CHARS))
836
+ for target in targets
837
+ if target
838
+ ]
839
+ return []
840
+
841
+
842
+ def recovery_target_path(request: str) -> str | None:
843
+ anchor_patch = extract_anchor_patch_instruction(request)
844
+ if anchor_patch:
845
+ return anchor_patch[0]
846
+ patch = extract_patch_instruction(request)
847
+ if patch:
848
+ return patch[0]
849
+ write = extract_write_instruction(request)
850
+ if write:
851
+ return write[0]
852
+ return None
853
+
854
+
855
+ def diagnose_agent_exception(exc: Exception) -> dict[str, str]:
856
+ message = compact(str(exc), limit=500) or exc.__class__.__name__
857
+ lower = message.casefold()
858
+ if "missing context_kernel_openai_base_url" in lower or "missing context_kernel_openai_api_key" in lower:
859
+ return {
860
+ "category": "provider_configuration",
861
+ "message": message,
862
+ "suggestion": "Run `akernel setup` in the project, then set API key, base URL, primary model, and auxiliary model.",
863
+ }
864
+ if "provider http 401" in lower or "provider http 403" in lower:
865
+ return {
866
+ "category": "provider_auth",
867
+ "message": message,
868
+ "suggestion": "Check `CONTEXT_KERNEL_OPENAI_API_KEY` in the project `.env` and confirm the endpoint accepts it.",
869
+ }
870
+ if "provider http 404" in lower:
871
+ return {
872
+ "category": "provider_endpoint",
873
+ "message": message,
874
+ "suggestion": "Check that the base URL includes `/v1` and that the selected model exists on this endpoint.",
875
+ }
876
+ if "provider http 429" in lower:
877
+ return {
878
+ "category": "provider_rate_limit",
879
+ "message": message,
880
+ "suggestion": "Wait and retry, or switch to a lower-cost auxiliary model for planning steps.",
881
+ }
882
+ if "provider http 5" in lower:
883
+ return {
884
+ "category": "provider_server",
885
+ "message": message,
886
+ "suggestion": "The provider returned a server error. Retry later or verify the endpoint health with `akernel models --provider openai`.",
887
+ }
888
+ if "provider network" in lower or "timed out" in lower or "connection" in lower:
889
+ return {
890
+ "category": "provider_network",
891
+ "message": message,
892
+ "suggestion": "Check VPN/proxy connectivity and verify the base URL with `akernel models --provider openai`.",
893
+ }
894
+ if "provider returned invalid json" in lower:
895
+ return {
896
+ "category": "provider_protocol",
897
+ "message": message,
898
+ "suggestion": "The endpoint did not return OpenAI-compatible JSON. Check the base URL and provider compatibility.",
899
+ }
900
+ return {
901
+ "category": "runtime_error",
902
+ "message": message,
903
+ "suggestion": "Inspect the saved run, then retry with `--provider mock` to separate runtime issues from provider issues.",
904
+ }
905
+
906
+
907
+ def diagnose_tool_result(tool_result: dict[str, Any]) -> dict[str, str]:
908
+ tool = str(tool_result.get("tool") or "tool")
909
+ summary = summarize_tool_result(tool_result)
910
+ if tool_result.get("blocked"):
911
+ return {
912
+ "category": "policy_block",
913
+ "message": summary,
914
+ "suggestion": "Use an allowed workspace path or command root, or update `.akernel/config.json` if the command is intentionally safe.",
915
+ }
916
+ if tool == "run_command":
917
+ return {
918
+ "category": "command_failed",
919
+ "message": summary,
920
+ "suggestion": "Inspect stdout/stderr in the linked tool trace, fix the underlying issue, then rerun the task.",
921
+ }
922
+ return {
923
+ "category": "tool_failed",
924
+ "message": summary,
925
+ "suggestion": "Inspect the linked tool trace and retry with a narrower file path or patch instruction.",
926
+ }
927
+
928
+
929
+ def command_failure_target_path(workspace: Workspace, tool_result: dict[str, Any]) -> str | None:
930
+ targets = command_failure_target_paths(workspace, tool_result, limit=1)
931
+ return targets[0] if targets else None
932
+
933
+
934
+ def command_failure_target_paths(workspace: Workspace, tool_result: dict[str, Any], *, limit: int = 3) -> list[str]:
935
+ output = tool_result.get("output", {})
936
+ text = "\n".join(
937
+ str(part)
938
+ for part in [output.get("stderr", ""), output.get("stdout", ""), tool_result.get("error", "")]
939
+ if part
940
+ )
941
+ if not text:
942
+ return []
943
+ candidates = python_failure_path_candidates(text)
944
+ targets: list[str] = []
945
+ seen: set[str] = set()
946
+ for candidate in reversed(candidates):
947
+ normalized = candidate.strip().strip('"').strip("'").replace("\\", "/")
948
+ if "=" in normalized:
949
+ normalized = normalized.rsplit("=", 1)[-1]
950
+ if any(part in normalized for part in ["/.venv/", "/site-packages/", "/.akernel/"]):
951
+ continue
952
+ path = Path(normalized)
953
+ if path.is_absolute():
954
+ try:
955
+ normalized = path.resolve().relative_to(workspace.root).as_posix()
956
+ except ValueError:
957
+ continue
958
+ else:
959
+ normalized = normalized.lstrip("./")
960
+ if normalized in seen:
961
+ continue
962
+ seen.add(normalized)
963
+ targets.append(normalized)
964
+ if len(targets) >= limit:
965
+ break
966
+ return targets
967
+
968
+
969
+ def python_failure_path_candidates(text: str) -> list[str]:
970
+ candidates = re.findall(r"File\s+\"([^\"]+\.py)\",\s+line\s+\d+", text)
971
+ candidates.extend(re.findall(r"((?:[A-Za-z]:)?[^\s:]+\.py):\d+", text))
972
+ return candidates
973
+
974
+
975
+ def write_agent_run_memory(workspace: Workspace, tasks: TaskStore, report: dict[str, Any]) -> dict[str, Any]:
976
+ memory = MemoryStore(workspace)
977
+ text = (
978
+ f"Agent run {report['id']} for task {report['task_id']} completed with status={report['status']}; "
979
+ f"steps={len(report.get('steps', []))}; total_tokens={report.get('totals', {}).get('total_tokens', 0)}; "
980
+ f"request='{compact(report.get('request', ''), limit=160)}'; "
981
+ f"outcome='{compact(report.get('final_response') or summarize_report_outcome(report), limit=240)}'."
982
+ )
983
+ record = memory.add(
984
+ "task_state",
985
+ text,
986
+ [
987
+ "auto",
988
+ "agent",
989
+ f"agent_run:{report['id']}",
990
+ f"status:{report['status']}",
991
+ ],
992
+ )
993
+ tasks.attach(report["task_id"], "memory", record.id)
994
+ return {
995
+ "enabled": True,
996
+ "candidate_count": 1,
997
+ "written_count": 1,
998
+ "records": [record.to_dict()],
999
+ }
1000
+
1001
+
1002
+ def summarize_plan(plan: dict[str, Any]) -> dict[str, Any]:
1003
+ return {
1004
+ "route": plan["route"],
1005
+ "budget": plan["budget"],
1006
+ "task": plan["task"],
1007
+ "selection": {
1008
+ "memory_count": len(plan["selection"]["memory"]),
1009
+ "skill_count": len(plan["selection"]["skills"]),
1010
+ },
1011
+ "warnings": plan["warnings"],
1012
+ }
1013
+
1014
+
1015
+ def select_model_role(
1016
+ *,
1017
+ model_routing: str,
1018
+ plan: dict[str, Any],
1019
+ step_index: int,
1020
+ prior_steps: list[dict[str, Any]],
1021
+ profile: str,
1022
+ ) -> tuple[str, str]:
1023
+ if model_routing in {"primary", "auxiliary"}:
1024
+ return model_routing, f"forced by --model-routing {model_routing}"
1025
+
1026
+ route = plan.get("route", {})
1027
+ complexity = route.get("complexity", "low")
1028
+ warnings = plan.get("warnings", [])
1029
+ serious_warnings = [warning for warning in warnings if is_primary_required_warning(str(warning))]
1030
+ if profile == "deep":
1031
+ return "primary", "deep profile keeps reasoning on the primary model"
1032
+ if complexity == "high":
1033
+ return "primary", "high-complexity route requires the primary model"
1034
+ if serious_warnings:
1035
+ return "primary", "policy or budget warnings require the primary model"
1036
+ if prior_steps:
1037
+ return "primary", "synthesis after tool/context steps stays on the primary model"
1038
+ if step_index == 1 and complexity in {"low", "medium"}:
1039
+ return "auxiliary", f"{complexity}-complexity first-step planning is delegated to the auxiliary model"
1040
+ return "primary", "default fallback to the primary model"
1041
+
1042
+
1043
+ def is_primary_required_warning(warning: str) -> bool:
1044
+ text = warning.casefold()
1045
+ return any(term in text for term in ["over budget", "policy", "blocked", "destructive", "unsafe", "do not execute"])
1046
+
1047
+
1048
+ def resolve_role_model(provider_name: str, model: str | None, aux_model: str | None, role: str) -> str | None:
1049
+ if provider_name != "openai":
1050
+ return aux_model if role == "auxiliary" else model
1051
+ if role == "auxiliary":
1052
+ return aux_model or env_value("CONTEXT_KERNEL_OPENAI_AUX_MODEL") or DEFAULT_AUXILIARY_MODEL
1053
+ return model or env_value("CONTEXT_KERNEL_OPENAI_MODEL") or DEFAULT_PRIMARY_MODEL
1054
+
1055
+
1056
+ def run_auxiliary_review(
1057
+ workspace: Workspace,
1058
+ *,
1059
+ request: str,
1060
+ provider_name: str,
1061
+ plan: dict[str, Any],
1062
+ selected_role: str,
1063
+ selected_model: str | None,
1064
+ aux_model: str | None,
1065
+ base_url: str | None,
1066
+ budget: int | None,
1067
+ profile: str,
1068
+ task_id: str,
1069
+ allow_over_budget: bool,
1070
+ aux_review: str,
1071
+ routing_reason: str,
1072
+ ) -> dict[str, Any]:
1073
+ resolved_aux = resolve_role_model(provider_name, selected_model, aux_model, "auxiliary")
1074
+ if aux_review == "off":
1075
+ return {"enabled": False, "reason": "disabled by --aux-review off"}
1076
+ if aux_review == "auto" and selected_role != "primary":
1077
+ return {"enabled": False, "reason": "auto review only runs before primary-model steps"}
1078
+ if not resolved_aux:
1079
+ return {"enabled": False, "reason": "no auxiliary model configured for this provider"}
1080
+
1081
+ try:
1082
+ trace = AgentRunner(workspace).run(
1083
+ request,
1084
+ provider_name=provider_name,
1085
+ budget=budget,
1086
+ profile=profile,
1087
+ model=resolved_aux,
1088
+ base_url=base_url,
1089
+ allow_over_budget=allow_over_budget,
1090
+ expect_json=True,
1091
+ remember=False,
1092
+ task_id=task_id,
1093
+ resume=True,
1094
+ packet_overrides=build_aux_review_packet(
1095
+ plan,
1096
+ selected_role=selected_role,
1097
+ selected_model=selected_model,
1098
+ aux_model=resolved_aux,
1099
+ routing_reason=routing_reason,
1100
+ ),
1101
+ )
1102
+ except Exception as exc:
1103
+ diagnostic = diagnose_agent_exception(exc)
1104
+ return {
1105
+ "enabled": True,
1106
+ "trace_id": None,
1107
+ "model": resolved_aux,
1108
+ "ok": False,
1109
+ "risk": "medium",
1110
+ "recommendation": "use_primary",
1111
+ "notes": [diagnostic["message"]],
1112
+ "tokens": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
1113
+ "verifier_ok": False,
1114
+ "diagnostic": diagnostic,
1115
+ }
1116
+ parsed = parse_aux_review(trace.get("response", {}).get("text", ""))
1117
+ tokens = trace.get("response", {})
1118
+ return {
1119
+ "enabled": True,
1120
+ "trace_id": trace["id"],
1121
+ "model": resolved_aux,
1122
+ "ok": parsed["ok"],
1123
+ "risk": parsed["risk"],
1124
+ "recommendation": parsed["recommendation"],
1125
+ "notes": parsed["notes"],
1126
+ "tokens": {
1127
+ "input_tokens": tokens.get("input_tokens", 0),
1128
+ "output_tokens": tokens.get("output_tokens", 0),
1129
+ "total_tokens": tokens.get("total_tokens", 0),
1130
+ },
1131
+ "verifier_ok": bool(trace.get("verifier", {}).get("ok")),
1132
+ }
1133
+
1134
+
1135
+ def build_aux_review_packet(
1136
+ plan: dict[str, Any],
1137
+ *,
1138
+ selected_role: str,
1139
+ selected_model: str | None,
1140
+ aux_model: str | None,
1141
+ routing_reason: str,
1142
+ ) -> dict[str, Any]:
1143
+ route = plan.get("route", {})
1144
+ return {
1145
+ "agent": {
1146
+ "mode": "aux_review_v1",
1147
+ "review": {
1148
+ "selected_role": selected_role,
1149
+ "selected_model": selected_model,
1150
+ "aux_model": aux_model,
1151
+ "routing_reason": routing_reason,
1152
+ "route_mode": route.get("mode"),
1153
+ "complexity": route.get("complexity"),
1154
+ "warnings": plan.get("warnings", []),
1155
+ },
1156
+ "response_contract": {
1157
+ "type": "json_object",
1158
+ "rules": [
1159
+ "Return only valid JSON.",
1160
+ "Schema: {\"ok\": boolean, \"risk\": \"low|medium|high\", \"recommendation\": \"continue|use_primary|reduce_context|stop\", \"notes\": [\"short note\"]}.",
1161
+ "Be conservative about policy or budget risk, but do not invent missing facts.",
1162
+ ],
1163
+ },
1164
+ }
1165
+ }
1166
+
1167
+
1168
+ def parse_aux_review(text: str) -> dict[str, Any]:
1169
+ try:
1170
+ data = json.loads(text)
1171
+ except json.JSONDecodeError:
1172
+ data = {}
1173
+ risk = str(data.get("risk", "medium")).strip().lower()
1174
+ if risk not in {"low", "medium", "high"}:
1175
+ risk = "medium"
1176
+ recommendation = str(data.get("recommendation", "continue")).strip().lower()
1177
+ if recommendation not in {"continue", "use_primary", "reduce_context", "stop"}:
1178
+ recommendation = "continue"
1179
+ notes = data.get("notes", [])
1180
+ if not isinstance(notes, list):
1181
+ notes = [str(notes)]
1182
+ return {
1183
+ "ok": bool(data.get("ok", True)),
1184
+ "risk": risk,
1185
+ "recommendation": recommendation,
1186
+ "notes": [compact(str(item), limit=160) for item in notes[:5]],
1187
+ }
1188
+
1189
+
1190
+ def compact_saved_agent_report(report: dict[str, Any]) -> dict[str, Any]:
1191
+ steps = report.get("steps", [])
1192
+ return {
1193
+ "id": report["id"],
1194
+ "created_at": report.get("created_at"),
1195
+ "completed_at": report.get("completed_at"),
1196
+ "request": compact(str(report.get("request", "")), limit=500),
1197
+ "task_id": report.get("task_id"),
1198
+ "status": report.get("status"),
1199
+ "max_steps": report.get("max_steps"),
1200
+ "model_routing": report.get("model_routing"),
1201
+ "diagnostic": compact_saved_diagnostic(report.get("diagnostic")),
1202
+ "steps": [compact_saved_step(step) for step in steps],
1203
+ "final_response": compact(str(report.get("final_response") or ""), limit=800) or None,
1204
+ "state": compact_saved_state(report.get("state", {})),
1205
+ "totals": compact_saved_tokens(report.get("totals", {})),
1206
+ "storage": {
1207
+ "detail_level": "compact_v1",
1208
+ "step_count": len(steps),
1209
+ "full_details_in": {
1210
+ "run_traces": unique_non_empty(collect_run_trace_ids(steps)),
1211
+ "tool_traces": unique_non_empty(collect_tool_trace_ids(steps)),
1212
+ },
1213
+ },
1214
+ }
1215
+
1216
+
1217
+ def compact_saved_step(step: dict[str, Any]) -> dict[str, Any]:
1218
+ saved = {
1219
+ "index": step.get("index"),
1220
+ "status": step.get("status"),
1221
+ "continue": bool(step.get("continue")),
1222
+ "trace_id": step.get("trace_id"),
1223
+ "tool_trace_id": step.get("tool_trace_id"),
1224
+ "model_role": step.get("model_role"),
1225
+ "model": step.get("model"),
1226
+ "routing_reason": compact(str(step.get("routing_reason", "")), limit=180) or None,
1227
+ "aux_review": compact_saved_aux_review(step.get("aux_review", {})),
1228
+ "action": step.get("action"),
1229
+ "tokens": compact_saved_tokens(step.get("tokens", {})),
1230
+ "verifier_ok": step.get("verifier_ok"),
1231
+ "contract_recovered": bool(step.get("contract_recovered")),
1232
+ }
1233
+ diagnostic = compact_saved_diagnostic(step.get("diagnostic"))
1234
+ if diagnostic:
1235
+ saved["diagnostic"] = diagnostic
1236
+ if step.get("stop_reason"):
1237
+ saved["stop_reason"] = compact(str(step.get("stop_reason", "")), limit=180)
1238
+ plan = step.get("plan")
1239
+ if isinstance(plan, dict):
1240
+ saved["plan"] = compact_saved_plan(plan)
1241
+ tool = step.get("tool")
1242
+ if isinstance(tool, dict) and tool:
1243
+ saved["tool"] = compact_saved_tool(tool)
1244
+ recovery_tools = step.get("recovery_tools")
1245
+ if isinstance(recovery_tools, list) and recovery_tools:
1246
+ saved["recovery_tools"] = [
1247
+ compact_saved_tool(item)
1248
+ for item in recovery_tools
1249
+ if isinstance(item, dict)
1250
+ ]
1251
+ if step.get("final_response"):
1252
+ saved["final_response"] = compact(str(step.get("final_response", "")), limit=320)
1253
+ return saved
1254
+
1255
+
1256
+ def compact_saved_diagnostic(diagnostic: Any) -> dict[str, str] | None:
1257
+ if not isinstance(diagnostic, dict) or not diagnostic:
1258
+ return None
1259
+ return {
1260
+ "category": compact(str(diagnostic.get("category", "")), limit=80),
1261
+ "message": compact(str(diagnostic.get("message", "")), limit=400),
1262
+ "suggestion": compact(str(diagnostic.get("suggestion", "")), limit=240),
1263
+ }
1264
+
1265
+
1266
+ def compact_saved_aux_review(review: dict[str, Any]) -> dict[str, Any]:
1267
+ if not isinstance(review, dict) or not review:
1268
+ return {"enabled": False}
1269
+ saved = {
1270
+ "enabled": bool(review.get("enabled")),
1271
+ "reason": compact(str(review.get("reason", "")), limit=180) or None,
1272
+ }
1273
+ if review.get("enabled"):
1274
+ saved.update(
1275
+ {
1276
+ "trace_id": review.get("trace_id"),
1277
+ "model": review.get("model"),
1278
+ "ok": bool(review.get("ok")),
1279
+ "risk": review.get("risk"),
1280
+ "recommendation": review.get("recommendation"),
1281
+ "notes": [compact(str(item), limit=160) for item in review.get("notes", [])[:5]],
1282
+ "tokens": compact_saved_tokens(review.get("tokens", {})),
1283
+ "verifier_ok": bool(review.get("verifier_ok")),
1284
+ }
1285
+ )
1286
+ diagnostic = compact_saved_diagnostic(review.get("diagnostic"))
1287
+ if diagnostic:
1288
+ saved["diagnostic"] = diagnostic
1289
+ return saved
1290
+
1291
+
1292
+ def compact_saved_plan(plan: dict[str, Any]) -> dict[str, Any]:
1293
+ route = plan.get("route", {})
1294
+ budget = plan.get("budget", {})
1295
+ task = plan.get("task", {})
1296
+ warnings = plan.get("warnings", [])
1297
+ saved = {
1298
+ "route": {
1299
+ "mode": route.get("mode"),
1300
+ "complexity": route.get("complexity"),
1301
+ },
1302
+ "budget": {
1303
+ "profile": budget.get("profile"),
1304
+ "total": budget.get("total"),
1305
+ "estimated_used": budget.get("estimated_used"),
1306
+ "estimated_remaining": budget.get("estimated_remaining"),
1307
+ "over_budget": budget.get("over_budget"),
1308
+ },
1309
+ "selection": {
1310
+ "memory_count": plan.get("selection", {}).get("memory_count"),
1311
+ "skill_count": plan.get("selection", {}).get("skill_count"),
1312
+ },
1313
+ "task": {
1314
+ "id": task.get("id"),
1315
+ "status": task.get("status"),
1316
+ "resume": task.get("resume"),
1317
+ "estimated_tokens": task.get("estimated_tokens"),
1318
+ },
1319
+ }
1320
+ if route.get("reason"):
1321
+ saved["route"]["reason"] = compact(str(route.get("reason", "")), limit=180)
1322
+ if isinstance(warnings, list) and warnings:
1323
+ saved["warnings"] = [compact(str(item), limit=140) for item in warnings[:3]]
1324
+ saved["warning_count"] = len(warnings)
1325
+ return saved
1326
+
1327
+
1328
+ def compact_saved_tool(tool: dict[str, Any]) -> dict[str, Any]:
1329
+ saved = {
1330
+ "id": tool.get("id"),
1331
+ "name": tool.get("name") or tool.get("tool"),
1332
+ "ok": tool.get("ok"),
1333
+ "blocked": tool.get("blocked"),
1334
+ }
1335
+ if tool.get("summary"):
1336
+ saved["summary"] = compact(str(tool.get("summary", "")), limit=240)
1337
+ if tool.get("error"):
1338
+ saved["error"] = compact(str(tool.get("error", "")), limit=240)
1339
+ return saved
1340
+
1341
+
1342
+ def compact_saved_state(state: dict[str, Any]) -> dict[str, Any]:
1343
+ records = state.get("records", [])
1344
+ saved = {
1345
+ "enabled": bool(state.get("enabled")),
1346
+ "candidate_count": state.get("candidate_count", 0),
1347
+ "written_count": state.get("written_count", 0),
1348
+ }
1349
+ if isinstance(records, list) and records:
1350
+ saved["record_count"] = len(records)
1351
+ saved["records"] = [
1352
+ compact_saved_memory_record(record)
1353
+ for record in records[:3]
1354
+ if isinstance(record, dict)
1355
+ ]
1356
+ return saved
1357
+
1358
+
1359
+ def compact_saved_memory_record(record: dict[str, Any]) -> dict[str, Any]:
1360
+ saved = {
1361
+ "id": record.get("id"),
1362
+ "kind": record.get("kind"),
1363
+ "created_at": record.get("created_at"),
1364
+ "tags": record.get("tags", []),
1365
+ }
1366
+ if record.get("text"):
1367
+ saved["text"] = compact(str(record.get("text", "")), limit=240)
1368
+ return saved
1369
+
1370
+
1371
+ def compact_saved_tokens(tokens: dict[str, Any]) -> dict[str, Any]:
1372
+ return {
1373
+ "input_tokens": int(tokens.get("input_tokens", 0) or 0),
1374
+ "output_tokens": int(tokens.get("output_tokens", 0) or 0),
1375
+ "total_tokens": int(tokens.get("total_tokens", 0) or 0),
1376
+ }
1377
+
1378
+
1379
+ def summarize_action(action: dict[str, Any] | None) -> dict[str, Any] | None:
1380
+ if not action:
1381
+ return None
1382
+ summary = {"action": action["action"]}
1383
+ for key in ["path", "command", "reason"]:
1384
+ if action.get(key):
1385
+ summary[key] = compact(str(action[key]), limit=240)
1386
+ if action["action"] == "write_file":
1387
+ summary["text"] = compact(str(action.get("text", "")), limit=120)
1388
+ if action["action"] == "patch_file":
1389
+ summary["new"] = compact(str(action.get("new", "")), limit=120)
1390
+ if action.get("start_anchor"):
1391
+ summary["start_anchor"] = compact(str(action.get("start_anchor", "")), limit=120)
1392
+ summary["end_anchor"] = compact(str(action.get("end_anchor", "")), limit=120)
1393
+ if action.get("include_anchors"):
1394
+ summary["include_anchors"] = True
1395
+ else:
1396
+ summary["old"] = compact(str(action.get("old", "")), limit=120)
1397
+ if action.get("replace_all"):
1398
+ summary["replace_all"] = True
1399
+ if action.get("occurrence") is not None:
1400
+ summary["occurrence"] = action["occurrence"]
1401
+ if action["action"] == "batch_patch":
1402
+ summary["edit_count"] = len(action.get("edits", []))
1403
+ summary["paths"] = [
1404
+ compact(str(edit.get("path", "")), limit=80)
1405
+ for edit in action.get("edits", [])[:5]
1406
+ ]
1407
+ if action["action"] == "respond":
1408
+ summary["message"] = compact(str(action.get("message", "")), limit=240)
1409
+ return summary
1410
+
1411
+
1412
+ def summarize_tool_result(result: dict[str, Any]) -> str:
1413
+ if result["blocked"]:
1414
+ return f"blocked by policy; subject={compact(str(result['policy'].get('subject', '')), limit=180)}"
1415
+ if result.get("error"):
1416
+ return compact(str(result["error"]), limit=240)
1417
+ output = result.get("output", {})
1418
+ if result["tool"] == "read_file":
1419
+ text = compact(str(output.get("content", "")), limit=240)
1420
+ return text or "file read completed"
1421
+ if result["tool"] == "write_file":
1422
+ return f"path={compact(str(output.get('path', '')), limit=160)}; written_chars={output.get('written_chars')}"
1423
+ if result["tool"] == "patch_file":
1424
+ return (
1425
+ f"path={compact(str(output.get('path', '')), limit=160)}; "
1426
+ f"mode={output.get('mode')}; replacement_count={output.get('replacement_count')}; "
1427
+ f"delta_chars={output.get('delta_chars')}"
1428
+ )
1429
+ if result["tool"] == "batch_patch":
1430
+ return (
1431
+ f"applied_count={output.get('applied_count')}; "
1432
+ f"rolled_back={output.get('rolled_back')}; "
1433
+ f"edits={len(output.get('results', []))}"
1434
+ )
1435
+ if result["tool"] == "run_command":
1436
+ stdout = compact(str(output.get("stdout", "")), limit=180)
1437
+ stderr = compact(str(output.get("stderr", "")), limit=120)
1438
+ exit_code = output.get("exit_code")
1439
+ if stdout:
1440
+ return f"exit_code={exit_code}; stdout={stdout}"
1441
+ if stderr:
1442
+ return f"exit_code={exit_code}; stderr={stderr}"
1443
+ return f"exit_code={exit_code}"
1444
+ return compact(str(output), limit=240)
1445
+
1446
+
1447
+ def render_agent_response(action: dict[str, Any]) -> str:
1448
+ return str(action.get("message", "")).strip()
1449
+
1450
+
1451
+ def summarize_report_outcome(report: dict[str, Any]) -> str:
1452
+ steps = report.get("steps", [])
1453
+ if not steps:
1454
+ return "no steps were recorded"
1455
+ last = steps[-1]
1456
+ action = (last.get("action") or {}).get("action")
1457
+ tool = last.get("tool", {})
1458
+ if action == "respond":
1459
+ return str((last.get("action") or {}).get("message") or "final response produced")
1460
+ if tool:
1461
+ summary = tool.get("summary") or tool.get("error") or "tool step completed"
1462
+ return f"{tool.get('name')} -> {summary}"
1463
+ return f"last_step_status={last.get('status')}"
1464
+
1465
+
1466
+ def final_tool_stop_reason(result: dict[str, Any], *, recovery_tools: list[dict[str, Any]], max_steps: int) -> str:
1467
+ if result["blocked"]:
1468
+ return "Agent loop stopped: the final tool action was blocked by policy."
1469
+ if not result["ok"]:
1470
+ if recovery_tools:
1471
+ return "Agent loop stopped: recovery context was prepared, but no loop step remained to use it."
1472
+ return "Agent loop stopped: the final tool action failed and needs review."
1473
+ return f"Agent loop stopped after {max_steps} step(s)."
1474
+
1475
+
1476
+ def is_patch_verify_request(request: str) -> bool:
1477
+ lower = request.casefold()
1478
+ has_patch = "patch " in lower
1479
+ has_command = "run command " in lower or " run `" in lower or "verify with command " in lower
1480
+ return has_patch and has_command
1481
+
1482
+
1483
+ def is_write_verify_request(request: str) -> bool:
1484
+ lower = request.casefold()
1485
+ has_write = "write " in lower or "create " in lower
1486
+ has_command = "run command " in lower or " run `" in lower or "verify with command " in lower
1487
+ return has_write and has_command
1488
+
1489
+
1490
+ def repeated_agent_action(report_steps: list[dict[str, Any]], action: dict[str, Any]) -> bool:
1491
+ if not report_steps:
1492
+ return False
1493
+ fingerprint = action_fingerprint(action)
1494
+ latest = report_steps[-1]
1495
+ latest_action = latest.get("action") or {}
1496
+ return bool(fingerprint) and action_fingerprint(latest_action) == fingerprint
1497
+
1498
+
1499
+ def action_fingerprint(action: dict[str, Any]) -> str:
1500
+ action_name = action.get("action")
1501
+ if action_name == "read_file":
1502
+ return f"read_file:{action.get('path', '')}"
1503
+ if action_name == "write_file":
1504
+ return f"write_file:{action.get('path', '')}:{compact(str(action.get('text', '')), limit=80)}"
1505
+ if action_name == "patch_file":
1506
+ if action.get("start_anchor"):
1507
+ return (
1508
+ f"patch_file:{action.get('path', '')}:"
1509
+ f"anchor={compact(str(action.get('start_anchor', '')), limit=40)}:"
1510
+ f"{compact(str(action.get('end_anchor', '')), limit=40)}:"
1511
+ f"include={bool(action.get('include_anchors', False))}:"
1512
+ f"{compact(str(action.get('new', '')), limit=60)}"
1513
+ )
1514
+ return (
1515
+ f"patch_file:{action.get('path', '')}:"
1516
+ f"{compact(str(action.get('old', '')), limit=60)}:"
1517
+ f"{compact(str(action.get('new', '')), limit=60)}:"
1518
+ f"all={bool(action.get('replace_all', False))}:"
1519
+ f"occ={action.get('occurrence')}"
1520
+ )
1521
+ if action_name == "batch_patch":
1522
+ pieces = []
1523
+ for edit in action.get("edits", [])[:8]:
1524
+ pieces.append(
1525
+ f"{edit.get('path', '')}:"
1526
+ f"{compact(str(edit.get('old') or edit.get('start_anchor') or ''), limit=30)}:"
1527
+ f"{compact(str(edit.get('new', '')), limit=30)}"
1528
+ )
1529
+ return "batch_patch:" + "|".join(pieces)
1530
+ if action_name == "run_command":
1531
+ return f"run_command:{action.get('command', '')}"
1532
+ return ""
1533
+
1534
+
1535
+ def add_tokens(total: dict[str, int], tokens: dict[str, int]) -> None:
1536
+ total["input_tokens"] += int(tokens.get("input_tokens", 0) or 0)
1537
+ total["output_tokens"] += int(tokens.get("output_tokens", 0) or 0)
1538
+ total["total_tokens"] += int(tokens.get("total_tokens", 0) or 0)
1539
+
1540
+
1541
+ def collect_tool_trace_ids(steps: list[dict[str, Any]]) -> list[str]:
1542
+ ids: list[str] = []
1543
+ for step in steps:
1544
+ if step.get("tool_trace_id"):
1545
+ ids.append(str(step.get("tool_trace_id")))
1546
+ for recovery in step.get("recovery_tools", []):
1547
+ if isinstance(recovery, dict) and recovery.get("id"):
1548
+ ids.append(str(recovery.get("id")))
1549
+ return ids
1550
+
1551
+
1552
+ def collect_run_trace_ids(steps: list[dict[str, Any]]) -> list[str]:
1553
+ ids: list[str] = []
1554
+ for step in steps:
1555
+ if step.get("trace_id"):
1556
+ ids.append(str(step.get("trace_id")))
1557
+ review = step.get("aux_review", {})
1558
+ if isinstance(review, dict) and review.get("trace_id"):
1559
+ ids.append(str(review.get("trace_id")))
1560
+ return ids
1561
+
1562
+
1563
+ def unique_non_empty(values: list[Any]) -> list[str]:
1564
+ ordered: list[str] = []
1565
+ seen: set[str] = set()
1566
+ for value in values:
1567
+ if value is None:
1568
+ continue
1569
+ text = str(value).strip()
1570
+ if not text or text in seen:
1571
+ continue
1572
+ seen.add(text)
1573
+ ordered.append(text)
1574
+ return ordered
1575
+
1576
+
1577
+ def require_non_empty_string(data: dict[str, Any], key: str) -> str:
1578
+ value = data.get(key)
1579
+ if not isinstance(value, str) or not value.strip():
1580
+ raise ValueError(f"{key} must be a non-empty string")
1581
+ return value.strip()
1582
+
1583
+
1584
+ def optional_non_empty_string(data: dict[str, Any], key: str) -> str | None:
1585
+ value = data.get(key)
1586
+ if value is None:
1587
+ return None
1588
+ if not isinstance(value, str) or not value.strip():
1589
+ raise ValueError(f"{key} must be a non-empty string when provided")
1590
+ return value.strip()
1591
+
1592
+
1593
+ def clamp_int(value: Any, *, default: int, minimum: int, maximum: int) -> int:
1594
+ try:
1595
+ parsed = int(value)
1596
+ except (TypeError, ValueError):
1597
+ return default
1598
+ return max(minimum, min(maximum, parsed))
1599
+
1600
+
1601
+ def optional_int(value: Any, *, minimum: int) -> int | None:
1602
+ if value is None or value == "":
1603
+ return None
1604
+ try:
1605
+ parsed = int(value)
1606
+ except (TypeError, ValueError) as exc:
1607
+ raise ValueError("value must be an integer") from exc
1608
+ if parsed < minimum:
1609
+ raise ValueError(f"value must be at least {minimum}")
1610
+ return parsed
1611
+
1612
+
1613
+ def compact(text: str, limit: int = 300) -> str:
1614
+ normalized = " ".join(str(text).split())
1615
+ if len(normalized) <= limit:
1616
+ return normalized
1617
+ return normalized[: limit - 3].rstrip() + "..."