clouds-coder 2026.3.16__tar.gz → 2026.3.20__tar.gz

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.
@@ -44,7 +44,7 @@ SCRIPT_DIR = Path(__file__).resolve().parent
44
44
  CODES_ROOT = WORKDIR / "Codes"
45
45
  LLM_CONFIG_PATH = WORKDIR / "LLM.config.json"
46
46
  MAX_TOOL_OUTPUT = 50_000
47
- TOKEN_THRESHOLD = 100_000
47
+ TOKEN_THRESHOLD = 1_000_000
48
48
  IDLE_TIMEOUT = 60
49
49
  POLL_INTERVAL = 5
50
50
  SSE_HEARTBEAT_SECONDS = 15
@@ -75,6 +75,8 @@ AGENT_MAX_OUTPUT_TOKENS = 16384
75
75
  OLLAMA_THINKING_TOOL_BUFFER = 4096
76
76
  WATCHDOG_INTENT_NO_TOOL_THRESHOLD = 2
77
77
  WATCHDOG_REPEAT_NO_TOOL_THRESHOLD = 2
78
+ WATCHDOG_INTENT_NO_TOOL_THRESHOLD_SINGLE = 4
79
+ WATCHDOG_REPEAT_NO_TOOL_THRESHOLD_SINGLE = 4
78
80
  WATCHDOG_STATE_STALL_THRESHOLD = 6
79
81
  WATCHDOG_CONTEXT_STALL_THRESHOLD = 2
80
82
  WATCHDOG_REPEAT_SIMILARITY_THRESHOLD = 0.85
@@ -91,7 +93,30 @@ TRUNCATION_CONTINUATION_ECHO_CHARS = 12000
91
93
  TRUNCATION_OVERLAP_SCAN_CHARS = 420
92
94
  TRUNCATION_PAIR_SCAN_CHARS = 120_000
93
95
  TRUNCATION_LIVE_BUFFER_MAX_CHARS = 32000
94
- MIN_CONTEXT_TOKEN_LIMIT = 18_000
96
+ MIN_CONTEXT_TOKEN_LIMIT = 4_000
97
+ # Tiered compression thresholds (fraction of ctx_left / limit)
98
+ COMPACT_TIER1_PCT = 0.40 # >40% left: normal; 20-40%: tier 1 light
99
+ COMPACT_TIER2_PCT = 0.20 # 10-20%: tier 2 medium
100
+ COMPACT_TIER3_PCT = 0.10 # <10%: tier 3 heavy
101
+ # Absolute minimums — prevent percentage instability at low ctx_left
102
+ COMPACT_TIER1_ABS = 3000
103
+ COMPACT_TIER2_ABS = 1500
104
+ # File buffer
105
+ FILE_BUFFER_CONTENT_THRESHOLD = 2000 # chars: content larger than this gets offloaded
106
+ FILE_BUFFER_MAX_FILES = 500
107
+ # Agent context limits per tier (messages count)
108
+ AGENT_MSG_LIMIT_TIER0 = 800
109
+ AGENT_MSG_LIMIT_TIER1 = 400
110
+ AGENT_MSG_LIMIT_TIER2 = 200
111
+ AGENT_MSG_LIMIT_TIER3 = 80
112
+ AGENT_CTX_LIMIT_TIER0 = 400
113
+ AGENT_CTX_LIMIT_TIER1 = 200
114
+ AGENT_CTX_LIMIT_TIER2 = 100
115
+ AGENT_CTX_LIMIT_TIER3 = 40
116
+ MANAGER_CTX_LIMIT_TIER0 = 400
117
+ MANAGER_CTX_LIMIT_TIER1 = 200
118
+ MANAGER_CTX_LIMIT_TIER2 = 100
119
+ MANAGER_CTX_LIMIT_TIER3 = 40
95
120
  MAX_CONTEXT_ARCHIVE_SEGMENTS = 1200
96
121
  MODEL_OUTPUT_RETRY_TIMES = 3
97
122
  ARBITER_TRIGGER_MIN_CONTENT_CHARS = 50
@@ -164,21 +189,24 @@ EXECUTION_MODE_CHOICES = (
164
189
  EXECUTION_MODE_SYNC,
165
190
  )
166
191
  AGENT_ROLES = ("explorer", "developer", "reviewer")
167
- AGENT_BUBBLE_ROLES = AGENT_ROLES + ("manager",)
192
+ AGENT_BUBBLE_ROLES = AGENT_ROLES + ("manager", "planner")
168
193
  AGENT_ROLE_LABELS = {
169
194
  "explorer": "Explorer",
170
195
  "developer": "Developer",
171
196
  "reviewer": "Reviewer",
172
197
  "manager": "Manager",
198
+ "planner": "Planner",
173
199
  }
174
200
  AGENT_ROLE_BUBBLE_COLORS = {
175
201
  "explorer": "#ff5fa2",
176
202
  "developer": "#22b455",
177
203
  "reviewer": "#dca61b",
178
204
  "manager": "#7c3aed",
205
+ "planner": "#e8533f",
179
206
  }
180
207
  BLACKBOARD_STATUSES = (
181
208
  "INITIALIZING",
209
+ "PLANNING",
182
210
  "RESEARCHING",
183
211
  "CODING",
184
212
  "TESTING",
@@ -252,6 +280,91 @@ SKILL_PROMPT_MAX_ITEMS = 40
252
280
  SKILL_PROMPT_MAX_CHARS = 2600
253
281
  SKILL_RUNTIME_CACHE_MAX_ENTRIES = 48
254
282
  SKILL_RUNTIME_CACHE_MAX_BYTES = 2_000_000
283
+ PLAN_MODE_ENABLED_LEVELS = {3, 4, 5}
284
+ PLAN_MODE_FORCED_LEVELS = {4, 5}
285
+ PLAN_MODE_USER_CHOICES = ("auto", "on", "off")
286
+ # Task phase definitions for stage-aware delegation
287
+ TASK_PHASES = ("research", "design", "implement", "test", "review", "deploy")
288
+ TASK_PHASE_ROUTING = {
289
+ "research": "explorer",
290
+ "design": "explorer",
291
+ "implement": "developer",
292
+ "test": "developer",
293
+ "review": "reviewer",
294
+ "deploy": "developer",
295
+ }
296
+ # Complexity keywords for semantic detection
297
+ COMPLEXITY_KEYWORDS = (
298
+ "简单", "复杂", "难", "容易", "快速", "详细", "深入",
299
+ "l1", "l2", "l3", "l4", "l5",
300
+ "simple", "complex", "easy", "hard", "difficult",
301
+ "thorough", "quick", "fast", "lightweight", "heavy",
302
+ )
303
+ PLAN_MODE_EXPLORER_MAX_ROUNDS = 8
304
+ # Reviewer debug mode
305
+ REVIEWER_DEBUG_MODE_MAX_ROUNDS = 6
306
+ REVIEWER_DEBUG_TOOL_ALLOWLIST = {
307
+ "bash", "read_file", "write_file", "edit_file",
308
+ "read_from_blackboard", "write_to_blackboard",
309
+ "finish_task", "finish_current_task",
310
+ }
311
+ EXPLORER_STALL_THRESHOLD = 3 # consecutive same-target delegations before forced switch
312
+ PLAN_MODE_MANAGER_SYNTHESIS_MAX_TOKENS = 4096
313
+ PLAN_MODE_MAX_OPTIONS = 3
314
+ PLAN_MODE_RESEARCH_TOOL_ALLOWLIST = {
315
+ "bash", "read_file", "context_recall", "task_get", "task_list",
316
+ "check_background", "read_from_blackboard", "write_to_blackboard",
317
+ "list_skills", "load_skill", "compress",
318
+ }
319
+ FAILURE_LEDGER_MAX_FIXES = 20
320
+ FAILURE_LEDGER_MAX_COMPILE_ERRORS = 15
321
+ FAILURE_LEDGER_MAX_DELEGATIONS = 10
322
+ FAILURE_LEDGER_MAX_STALLS = 8
323
+ FAILURE_LEDGER_MAX_TOOL_FPS = 30
324
+ FAILURE_LEDGER_MAX_ERRORS = 25
325
+ # Error category definitions: (name, {cmd_keywords, output_patterns, success_negatives, label})
326
+ # Priority order matters — first match wins. test before runtime because pytest cmd contains 'python'.
327
+ ERROR_CATEGORY_DEFS: list[tuple[str, dict]] = [
328
+ ("test", {
329
+ "cmd_keywords": ["pytest", "unittest", "nose", "jest", "mocha", "vitest", "cargo test", "go test", "phpunit", "rspec", "mix test"],
330
+ "output_patterns": ["FAILED", "ERRORS", "AssertionError", "assert ", "expected ", "failures=", "test failed", "FAIL:"],
331
+ "success_negatives": ["failed", "error", "FAILED"],
332
+ "label": "测试错误",
333
+ }),
334
+ ("lint", {
335
+ "cmd_keywords": ["mypy", "pylint", "flake8", "ruff", "eslint", "tsc ", "typescript", "clippy", "golint", "rubocop", "shellcheck"],
336
+ "output_patterns": ["error:", "warning:", "violation", "rule ", "type error", "incompatible type", "unused variable"],
337
+ "success_negatives": ["error:", "violation", "type error"],
338
+ "label": "Lint/类型错误",
339
+ }),
340
+ ("compilation", {
341
+ "cmd_keywords": ["make", "gcc", "gfortran", "g++", "clang", "cmake", "cargo build", "javac", "rustc", "msbuild", "dotnet build", "go build"],
342
+ "output_patterns": ["error:", "fatal error", "syntax error", "compile error", "undefined reference", "linker error"],
343
+ "success_negatives": ["error:", "fatal error", "syntax error"],
344
+ "label": "编译错误",
345
+ }),
346
+ ("build_package", {
347
+ "cmd_keywords": ["npm run build", "yarn build", "webpack", "vite build", "pip install", "poetry install", "bundle install", "gradle", "mvn ", "maven"],
348
+ "output_patterns": ["error:", "ERR!", "build failed", "ModuleNotFoundError", "cannot find module", "dependency", "resolution failed"],
349
+ "success_negatives": ["error:", "ERR!", "build failed"],
350
+ "label": "构建/包错误",
351
+ }),
352
+ ("deploy_infra", {
353
+ "cmd_keywords": ["docker", "kubectl", "terraform", "ansible", "cdk ", "pulumi", "serverless", "sam deploy", "helm"],
354
+ "output_patterns": ["error:", "failed", "denied", "timeout", "unreachable", "ImagePullBackOff", "CrashLoopBackOff"],
355
+ "success_negatives": ["error:", "failed", "denied"],
356
+ "label": "部署/基础设施错误",
357
+ }),
358
+ ("runtime", {
359
+ "cmd_keywords": ["python", "node ", "ruby ", "java ", "go run", "cargo run", "php ", "perl ", "bash ", "sh ", "./"],
360
+ "output_patterns": ["Traceback", "Exception", "Error:", "panic:", "segfault", "SIGSEGV", "core dumped", "stack trace", "at Object.", "RuntimeError"],
361
+ "success_negatives": ["Traceback", "Exception", "Error:", "panic:", "segfault"],
362
+ "label": "运行时错误",
363
+ }),
364
+ ]
365
+ CHECKPOINT_MAX_COUNT = 3
366
+ CHECKPOINT_INTERVAL_ROUNDS = 6
367
+ PERSISTED_ROUTES_MAX = 8
255
368
  HTML_FRONTEND_REQUEST_KEYWORDS = (
256
369
  "html",
257
370
  "web page",
@@ -2354,7 +2467,7 @@ def try_read_text(path: Path, max_bytes: int = 400_000) -> str | None:
2354
2467
  except Exception:
2355
2468
  return None
2356
2469
 
2357
- def make_unified_diff(path: str, old_text: str, new_text: str, max_lines: int = 400) -> str:
2470
+ def make_unified_diff(path: str, old_text: str, new_text: str, max_lines: int = 400) -> tuple[str, int, int]:
2358
2471
  old_lines = old_text.splitlines()
2359
2472
  new_lines = new_text.splitlines()
2360
2473
  diff = list(
@@ -2366,9 +2479,12 @@ def make_unified_diff(path: str, old_text: str, new_text: str, max_lines: int =
2366
2479
  lineterm="",
2367
2480
  )
2368
2481
  )
2369
- if len(diff) > max_lines:
2482
+ added = sum(1 for ln in diff if ln.startswith("+") and not ln.startswith("+++"))
2483
+ deleted = sum(1 for ln in diff if ln.startswith("-") and not ln.startswith("---"))
2484
+ if max_lines and len(diff) > max_lines:
2370
2485
  diff = diff[:max_lines] + [f"... diff truncated, total lines={len(diff)}"]
2371
- return "\n".join(diff) if diff else f"@@ no textual diff for {path}"
2486
+ text = "\n".join(diff) if diff else f"@@ no textual diff for {path}"
2487
+ return text, added, deleted
2372
2488
 
2373
2489
  def _skip_row(text: str) -> dict:
2374
2490
  msg = str(text or "").strip() or "⋮"
@@ -7226,6 +7342,7 @@ class SessionState:
7226
7342
  self.multimodal_capability_cache: dict[str, dict] = {}
7227
7343
  self.failed_selections: list[str] = []
7228
7344
  self.todo = TodoManager()
7345
+ self.single_advance_prompt_enhance = False
7229
7346
  self.skills = SkillStore(skills_root)
7230
7347
  self.skill_load_cache: dict[str, dict] = {}
7231
7348
  self.skills_last_refresh_ts = 0.0
@@ -7270,6 +7387,11 @@ class SessionState:
7270
7387
  self.runtime_reclassify_goal = ""
7271
7388
  self.runtime_reclassify_required = False
7272
7389
  self.runtime_goal_reset_pending = False
7390
+ self.runtime_plan_mode_needed = False
7391
+ self.runtime_plan_approved = False
7392
+ self.runtime_plan_proposal = {}
7393
+ self.runtime_plan_choice = ""
7394
+ self.plan_mode_user_preference = "auto" # "auto"|"on"|"off"
7273
7395
  self.rounds_without_todo = 0
7274
7396
  self.last_todo_reminder_ts = 0.0
7275
7397
  self.todo_reminder_count = 0
@@ -7277,6 +7399,12 @@ class SessionState:
7277
7399
  self.todo_last_issue = ""
7278
7400
  self.last_toolcall_overflow_hint_ts = 0.0
7279
7401
  self.last_reviewer_gate_warn_ts = 0.0
7402
+ self.reviewer_debug_mode = False
7403
+ self.reviewer_debug_rounds = 0
7404
+ self.reviewer_debug_context = ""
7405
+ self.explorer_consecutive_delegations = 0
7406
+ self.last_explorer_instruction_hash = ""
7407
+ self._pending_media_inputs: list[dict] = []
7280
7408
  self.tool_retry_counts: dict[str, int] = {}
7281
7409
  self.last_auto_title_ts = 0.0
7282
7410
  self.live_thinking_text = ""
@@ -7331,6 +7459,9 @@ class SessionState:
7331
7459
  self.context_archives: list[dict] = []
7332
7460
  self.last_compact_reason = ""
7333
7461
  self.last_compact_ts = 0.0
7462
+ self.file_buffer_dir = self.root / "file_buffer"
7463
+ self.file_buffer_dir.mkdir(parents=True, exist_ok=True)
7464
+ self.file_buffer_index: dict[str, dict] = {} # ref_id -> {path, chars, summary}
7334
7465
  self.created_at = now_ts()
7335
7466
  self.updated_at = now_ts()
7336
7467
  self.shutdown_requests: dict[str, dict] = {}
@@ -7471,6 +7602,21 @@ class SessionState:
7471
7602
  used_rel=used_rel,
7472
7603
  )
7473
7604
  items.extend(extra)
7605
+ # Merge pending media inputs from read_file (multimodal file reads)
7606
+ with self.lock:
7607
+ pending = list(getattr(self, "_pending_media_inputs", []))
7608
+ self._pending_media_inputs = []
7609
+ for media in pending:
7610
+ kind = str(media.get("type", "") or "").strip().lower()
7611
+ if kind not in kind_allow:
7612
+ continue
7613
+ rel = str(media.get("workspace_path", "") or "")
7614
+ if rel in used_rel:
7615
+ continue
7616
+ if len(items) >= max_items:
7617
+ break
7618
+ items.append(media)
7619
+ used_rel.add(rel)
7474
7620
  return items
7475
7621
 
7476
7622
  def _latest_user_message_ts(self) -> float:
@@ -8184,8 +8330,25 @@ class SessionState:
8184
8330
  }
8185
8331
  )
8186
8332
  self.context_archives = clean_archives[-MAX_CONTEXT_ARCHIVE_SEGMENTS:]
8333
+ # Restore file_buffer_index
8334
+ raw_fbi = raw.get("file_buffer_index", {})
8335
+ if isinstance(raw_fbi, dict):
8336
+ clean_fbi: dict[str, dict] = {}
8337
+ for ref_id, entry in raw_fbi.items():
8338
+ if isinstance(entry, dict) and entry.get("path"):
8339
+ clean_fbi[str(ref_id)] = {
8340
+ "path": str(entry.get("path", "")),
8341
+ "chars": int(entry.get("chars", 0) or 0),
8342
+ "summary": trim(str(entry.get("summary", "") or ""), 200),
8343
+ }
8344
+ self.file_buffer_index = clean_fbi
8187
8345
  self.last_compact_reason = str(raw.get("last_compact_reason", self.last_compact_reason) or "")
8188
8346
  self.last_compact_ts = float(raw.get("last_compact_ts", self.last_compact_ts) or 0.0)
8347
+ pref = str(raw.get("plan_mode_user_preference", "auto") or "auto").lower()
8348
+ self.plan_mode_user_preference = pref if pref in PLAN_MODE_USER_CHOICES else "auto"
8349
+ self.reviewer_debug_mode = bool(raw.get("reviewer_debug_mode", False))
8350
+ self.reviewer_debug_rounds = int(raw.get("reviewer_debug_rounds", 0) or 0)
8351
+ self.reviewer_debug_context = trim(str(raw.get("reviewer_debug_context", "") or ""), 1000)
8189
8352
  pending_inputs = raw.get("pending_user_inputs", [])
8190
8353
  if isinstance(pending_inputs, list):
8191
8354
  clean_pending: list[dict] = []
@@ -8280,6 +8443,12 @@ class SessionState:
8280
8443
  self.runtime_goal_reset_pending = bool(
8281
8444
  raw.get("runtime_goal_reset_pending", self.runtime_goal_reset_pending)
8282
8445
  )
8446
+ # Restore plan mode state
8447
+ self.runtime_plan_mode_needed = bool(raw.get("runtime_plan_mode_needed", False))
8448
+ self.runtime_plan_approved = bool(raw.get("runtime_plan_approved", False))
8449
+ raw_proposal = raw.get("runtime_plan_proposal", {})
8450
+ self.runtime_plan_proposal = dict(raw_proposal) if isinstance(raw_proposal, dict) else {}
8451
+ self.runtime_plan_choice = str(raw.get("runtime_plan_choice", "") or "")
8283
8452
  self.active_agent_role = str(raw.get("active_agent_role", self.active_agent_role) or "").strip().lower()
8284
8453
  raw_contexts = raw.get("contexts", {})
8285
8454
  if isinstance(raw_contexts, dict):
@@ -8394,8 +8563,13 @@ class SessionState:
8394
8563
  "last_truncation_ts": self.last_truncation_ts,
8395
8564
  "truncation_rescue_task_ids": self.truncation_rescue_task_ids[:12],
8396
8565
  "context_archives": self.context_archives[-MAX_CONTEXT_ARCHIVE_SEGMENTS:],
8566
+ "file_buffer_index": dict(self.file_buffer_index) if self.file_buffer_index else {},
8397
8567
  "last_compact_reason": self.last_compact_reason,
8398
8568
  "last_compact_ts": self.last_compact_ts,
8569
+ "plan_mode_user_preference": str(self.plan_mode_user_preference or "auto"),
8570
+ "reviewer_debug_mode": bool(self.reviewer_debug_mode),
8571
+ "reviewer_debug_rounds": int(self.reviewer_debug_rounds or 0),
8572
+ "reviewer_debug_context": trim(str(self.reviewer_debug_context or ""), 1000),
8399
8573
  "pending_user_inputs": self.pending_user_inputs[-40:],
8400
8574
  "run_generation": int(self.run_generation),
8401
8575
  "agent_round_index": int(self.agent_round_index),
@@ -8427,6 +8601,10 @@ class SessionState:
8427
8601
  "runtime_reclassify_goal": trim(str(self.runtime_reclassify_goal or ""), 4000),
8428
8602
  "runtime_reclassify_required": bool(self.runtime_reclassify_required),
8429
8603
  "runtime_goal_reset_pending": bool(self.runtime_goal_reset_pending),
8604
+ "runtime_plan_mode_needed": bool(self.runtime_plan_mode_needed),
8605
+ "runtime_plan_approved": bool(self.runtime_plan_approved),
8606
+ "runtime_plan_proposal": dict(self.runtime_plan_proposal) if self.runtime_plan_proposal else {},
8607
+ "runtime_plan_choice": str(self.runtime_plan_choice or ""),
8430
8608
  "active_agent_role": str(self.active_agent_role or ""),
8431
8609
  "contexts": {role: list(self.contexts.get(role, []))[-400:] for role in AGENT_ROLES},
8432
8610
  "manager_context": list(self.manager_context)[-400:],
@@ -8574,6 +8752,7 @@ class SessionState:
8574
8752
  self.runtime_reclassify_goal = ""
8575
8753
  self.runtime_reclassify_required = False
8576
8754
  self.runtime_goal_reset_pending = False
8755
+ self.runtime_plan_mode_needed = False
8577
8756
  return removed_hints
8578
8757
 
8579
8758
  def _event_payload_with_agent_role(self, kind: str, data: dict | None) -> dict:
@@ -8900,6 +9079,23 @@ class SessionState:
8900
9079
  budget = int(self.runtime_round_budget or 0)
8901
9080
  html_block = f"{html_hint}\n\n" if html_hint else ""
8902
9081
  research_block = f"{research_hint}\n\n" if research_hint else ""
9082
+ _is_single_no_enhance = (
9083
+ runtime_mode == EXECUTION_MODE_SINGLE
9084
+ and not self.single_advance_prompt_enhance
9085
+ )
9086
+ skill_hint = (
9087
+ "Use load_skill for workspace-paths and tool-best-practices if needed. "
9088
+ if _is_single_no_enhance
9089
+ else (
9090
+ "Use load_skill for guidance on specific topics "
9091
+ "(workspace-paths, task-management, tool-best-practices, context-management). "
9092
+ "If execution stalls, load_skill('execution-degradation-recovery'). "
9093
+ )
9094
+ )
9095
+ plan_steps_block = ""
9096
+ plan_ctx = self._plan_steps_context_for_manager()
9097
+ if plan_ctx:
9098
+ plan_steps_block = f"{plan_ctx}\n"
8903
9099
  return (
8904
9100
  f"You are a coding agent. Workspace: {self.files_root}. "
8905
9101
  f"Task level={runtime_level}, mode={runtime_mode}, "
@@ -8908,9 +9104,8 @@ class SessionState:
8908
9104
  f"{_detect_os_shell_instruction()} "
8909
9105
  "Use tools to inspect, edit, and execute. "
8910
9106
  "Call finish_current_task when done. "
8911
- "Use load_skill for guidance on specific topics "
8912
- "(workspace-paths, task-management, tool-best-practices, context-management). "
8913
- "If execution stalls, load_skill('execution-degradation-recovery'). "
9107
+ f"{skill_hint}"
9108
+ f"{plan_steps_block}"
8914
9109
  f"{html_block}"
8915
9110
  f"{research_block}"
8916
9111
  f"{model_language_instruction(self.ui_language)}\n\n"
@@ -8919,7 +9114,28 @@ class SessionState:
8919
9114
  )
8920
9115
 
8921
9116
  def _estimate_tokens(self) -> int:
8922
- return len(json_dumps(self.messages)) // 4
9117
+ # Core: messages (global or agent context depending on mode)
9118
+ if self._is_multi_agent_mode():
9119
+ # In multi-agent mode, the actual context sent to the model is per-role
9120
+ # agent_messages filtered by active role. Use the largest agent context
9121
+ # as the representative cost, since that's the bottleneck.
9122
+ agent_max = 0
9123
+ for role in (self.runtime_participants or AGENT_ROLES):
9124
+ ctx = self._agent_context(role)
9125
+ if ctx:
9126
+ cost = len(json_dumps(ctx)) // 4
9127
+ if cost > agent_max:
9128
+ agent_max = cost
9129
+ msg_tokens = max(agent_max, len(json_dumps(self.messages)) // 4)
9130
+ else:
9131
+ msg_tokens = len(json_dumps(self.messages)) // 4
9132
+ # Overhead: system prompt + tools (always present in API calls)
9133
+ try:
9134
+ sys_tokens = len(self._system_prompt()) // 4
9135
+ except Exception:
9136
+ sys_tokens = 300
9137
+ tools_tokens = len(json_dumps(TOOLS)) // 4 if TOOLS else 0
9138
+ return msg_tokens + sys_tokens + tools_tokens
8923
9139
 
8924
9140
  def _context_budget_metrics(self, token_estimate: int | None = None) -> dict:
8925
9141
  limit = max(1, int(self.context_token_upper_bound or 0))
@@ -8935,8 +9151,143 @@ class SessionState:
8935
9151
  "used_percent": used_pct,
8936
9152
  }
8937
9153
 
9154
+ def _context_compression_tier(self, metrics: dict | None = None) -> int:
9155
+ """Return compression tier 0-3 based on context budget.
9156
+ Tier 0: >40% left (normal)
9157
+ Tier 1: 20-40% left (light — aggressive microcompact, trim outputs)
9158
+ Tier 2: 10-20% left (medium — compact agent contexts, file buffer)
9159
+ Tier 3: <10% left (heavy — deep compact everything)
9160
+ """
9161
+ m = metrics or self._context_budget_metrics()
9162
+ left = int(m.get("left", 0))
9163
+ left_pct = float(m.get("left_percent", 100.0)) / 100.0
9164
+ if left < COMPACT_TIER2_ABS:
9165
+ return 3
9166
+ if left < COMPACT_TIER1_ABS:
9167
+ return max(2, 3 if left_pct < COMPACT_TIER3_PCT else 2)
9168
+ if left_pct < COMPACT_TIER3_PCT:
9169
+ return 3
9170
+ if left_pct < COMPACT_TIER2_PCT:
9171
+ return 2
9172
+ if left_pct < COMPACT_TIER1_PCT:
9173
+ return 1
9174
+ return 0
9175
+
9176
+ def _tier_agent_context_limits(self, tier: int) -> dict:
9177
+ """Return message count limits for agent_messages, contexts, manager_context by tier."""
9178
+ t = min(max(tier, 0), 3)
9179
+ return {
9180
+ "agent_messages": [AGENT_MSG_LIMIT_TIER0, AGENT_MSG_LIMIT_TIER1, AGENT_MSG_LIMIT_TIER2, AGENT_MSG_LIMIT_TIER3][t],
9181
+ "context_per_role": [AGENT_CTX_LIMIT_TIER0, AGENT_CTX_LIMIT_TIER1, AGENT_CTX_LIMIT_TIER2, AGENT_CTX_LIMIT_TIER3][t],
9182
+ "manager_context": [MANAGER_CTX_LIMIT_TIER0, MANAGER_CTX_LIMIT_TIER1, MANAGER_CTX_LIMIT_TIER2, MANAGER_CTX_LIMIT_TIER3][t],
9183
+ }
9184
+
9185
+ def _compact_agent_contexts(self, tier: int):
9186
+ """Compress agent_messages, manager_context, and per-role contexts based on tier."""
9187
+ limits = self._tier_agent_context_limits(tier)
9188
+ am_limit = limits["agent_messages"]
9189
+ ctx_limit = limits["context_per_role"]
9190
+ mgr_limit = limits["manager_context"]
9191
+ # 1. Microcompact agent messages (tier >= 1)
9192
+ if tier >= 1:
9193
+ keep = max(1, 3 - tier) # tier1=2, tier2=1, tier3=0(but min 1)
9194
+ self._microcompact_agent_messages(self.agent_messages, keep_recent=keep)
9195
+ # 2. Trim agent_messages to limit
9196
+ if len(self.agent_messages) > am_limit:
9197
+ self.agent_messages = self.agent_messages[-am_limit:]
9198
+ # 3. Trim manager_context
9199
+ if len(self.manager_context) > mgr_limit:
9200
+ self.manager_context = self.manager_context[-mgr_limit:]
9201
+ # 4. Trim per-role contexts
9202
+ for role in AGENT_ROLES:
9203
+ ctx = self.contexts.get(role, [])
9204
+ if len(ctx) > ctx_limit:
9205
+ self.contexts[role] = ctx[-ctx_limit:]
9206
+ # 5. Tier 2+: offload large content in agent messages to file buffer
9207
+ if tier >= 2:
9208
+ self._offload_agent_message_content(self.agent_messages)
9209
+ self._offload_agent_message_content(self.manager_context)
9210
+ # 6. Tier 3: aggressive content trimming
9211
+ if tier >= 3:
9212
+ for msg in self.agent_messages:
9213
+ c = msg.get("content", "")
9214
+ if isinstance(c, str) and len(c) > 600:
9215
+ msg["content"] = trim(c, 600)
9216
+ for msg in self.manager_context:
9217
+ c = msg.get("content", "")
9218
+ if isinstance(c, str) and len(c) > 600:
9219
+ msg["content"] = trim(c, 600)
9220
+ # 7. Plan mode context compression — tiered by ctx_left
9221
+ self._compact_plan_context(tier)
9222
+
9223
+ def _compact_plan_context(self, tier: int):
9224
+ """Compress plan mode context based on tier.
9225
+ Tier 0: keep everything
9226
+ Tier 1: trim plan findings to last 3
9227
+ Tier 2: drop plan findings, keep only plan steps + user choice
9228
+ Tier 3: minimal — only plan steps and chosen option title
9229
+ """
9230
+ bb = self._ensure_blackboard()
9231
+ plan = bb.get("plan")
9232
+ if not isinstance(plan, dict):
9233
+ return
9234
+ if tier <= 0:
9235
+ return
9236
+ if tier == 1:
9237
+ # Keep last 3 findings
9238
+ findings = plan.get("findings", [])
9239
+ if isinstance(findings, list) and len(findings) > 3:
9240
+ plan["findings"] = findings[-3:]
9241
+ elif tier == 2:
9242
+ # Drop findings entirely, keep steps + proposal summary
9243
+ plan.pop("findings", None)
9244
+ proposal = plan.get("proposal", {})
9245
+ if isinstance(proposal, dict):
9246
+ # Trim option details — keep only chosen option
9247
+ chosen_id = str(plan.get("chosen", "") or "")
9248
+ options = proposal.get("options", [])
9249
+ if isinstance(options, list) and chosen_id:
9250
+ proposal["options"] = [
9251
+ o for o in options
9252
+ if isinstance(o, dict) and o.get("id") == chosen_id
9253
+ ]
9254
+ # Trim chosen option — drop pros/cons/risk
9255
+ for o in proposal.get("options", []):
9256
+ o.pop("pros", None)
9257
+ o.pop("cons", None)
9258
+ o.pop("risk", None)
9259
+ elif tier >= 3:
9260
+ # Minimal: only phase, chosen, steps
9261
+ phase = plan.get("phase", "")
9262
+ chosen = plan.get("chosen", "")
9263
+ steps = plan.get("steps", [])
9264
+ bb["plan"] = {"phase": phase, "chosen": chosen, "steps": steps}
9265
+ self.blackboard = bb
9266
+ # Also trim plan proposal in runtime state
9267
+ if tier >= 2 and self.runtime_plan_proposal:
9268
+ chosen_id = str(self.runtime_plan_choice or "")
9269
+ if chosen_id:
9270
+ options = self.runtime_plan_proposal.get("options", [])
9271
+ if isinstance(options, list):
9272
+ self.runtime_plan_proposal["options"] = [
9273
+ o for o in options if isinstance(o, dict) and o.get("id") == chosen_id
9274
+ ]
9275
+ if tier >= 3:
9276
+ self.runtime_plan_proposal = {}
9277
+
8938
9278
  def _apply_auto_compact_if_needed(self, reason: str = "auto") -> bool:
8939
- self._microcompact()
9279
+ metrics = self._context_budget_metrics()
9280
+ tier = self._context_compression_tier(metrics)
9281
+ # Tier 1+: apply progressively aggressive microcompact
9282
+ if tier >= 1:
9283
+ keep = max(1, 3 - tier)
9284
+ self._microcompact(keep_recent=keep)
9285
+ else:
9286
+ self._microcompact()
9287
+ # Tier 2+: compact agent contexts proactively
9288
+ if tier >= 2:
9289
+ self._compact_agent_contexts(tier)
9290
+ # Re-check after tier-based compression
8940
9291
  metrics = self._context_budget_metrics()
8941
9292
  used = int(metrics.get("used", 0) or 0)
8942
9293
  limit = max(1, int(metrics.get("limit", 0) or 0))
@@ -9740,10 +10091,10 @@ class SessionState:
9740
10091
  total += len(str(row.get("content", ""))) // 4
9741
10092
  return total
9742
10093
 
9743
- def _select_compact_tail(self, token_budget: int, min_count: int = 8, max_count: int = 48) -> list[dict]:
10094
+ def _select_compact_tail(self, token_budget: int, min_count: int = 4, max_count: int = 48) -> list[dict]:
9744
10095
  if not self.messages:
9745
10096
  return []
9746
- budget = max(2000, int(token_budget))
10097
+ budget = max(1000, int(token_budget))
9747
10098
  selected: list[dict] = []
9748
10099
  total = 0
9749
10100
  for row in reversed(self.messages):
@@ -9793,6 +10144,60 @@ class SessionState:
9793
10144
  self.context_archives = self.context_archives[-MAX_CONTEXT_ARCHIVE_SEGMENTS:]
9794
10145
  return seg
9795
10146
 
10147
+ # --- File buffer methods (修改 7) ---
10148
+
10149
+ def _offload_to_file_buffer(self, content: str, label: str = "") -> str:
10150
+ """Write large content to disk, return compact reference placeholder."""
10151
+ if len(content) < FILE_BUFFER_CONTENT_THRESHOLD:
10152
+ return content
10153
+ ref_id = f"fb_{int(now_ts())}_{uuid.uuid4().hex[:6]}"
10154
+ fp = self.file_buffer_dir / f"{ref_id}.txt"
10155
+ fp.write_text(content, encoding="utf-8")
10156
+ summary = trim(content, 120).replace("\n", " ")
10157
+ self.file_buffer_index[ref_id] = {
10158
+ "path": fp.relative_to(self.root).as_posix(),
10159
+ "chars": len(content),
10160
+ "summary": summary,
10161
+ }
10162
+ # Prune old entries if over limit
10163
+ if len(self.file_buffer_index) > FILE_BUFFER_MAX_FILES:
10164
+ oldest_keys = sorted(self.file_buffer_index.keys())[:len(self.file_buffer_index) - FILE_BUFFER_MAX_FILES]
10165
+ for k in oldest_keys:
10166
+ old_path = self.root / self.file_buffer_index[k].get("path", "")
10167
+ if old_path.exists():
10168
+ try:
10169
+ old_path.unlink()
10170
+ except Exception:
10171
+ pass
10172
+ del self.file_buffer_index[k]
10173
+ return f"[file_buffer:{ref_id} chars={len(content)} summary={summary}]"
10174
+
10175
+ def _load_from_file_buffer(self, ref_id: str) -> str:
10176
+ """Load content from file buffer by reference ID."""
10177
+ entry = self.file_buffer_index.get(ref_id)
10178
+ if not entry:
10179
+ return f"[file_buffer:{ref_id} not found]"
10180
+ fp = self.root / entry.get("path", "")
10181
+ if not fp.exists():
10182
+ return f"[file_buffer:{ref_id} file missing]"
10183
+ try:
10184
+ return fp.read_text(encoding="utf-8")
10185
+ except Exception as exc:
10186
+ return f"[file_buffer:{ref_id} read error: {exc}]"
10187
+
10188
+ def _offload_agent_message_content(self, messages: list[dict]):
10189
+ """Scan messages and offload large string content to file buffer."""
10190
+ for msg in messages:
10191
+ content = msg.get("content", "")
10192
+ if isinstance(content, str) and len(content) >= FILE_BUFFER_CONTENT_THRESHOLD:
10193
+ msg["content"] = self._offload_to_file_buffer(content, label=msg.get("role", ""))
10194
+ elif isinstance(content, list):
10195
+ for block in content:
10196
+ if isinstance(block, dict):
10197
+ bc = block.get("content", "")
10198
+ if isinstance(bc, str) and len(bc) >= FILE_BUFFER_CONTENT_THRESHOLD:
10199
+ block["content"] = self._offload_to_file_buffer(bc, label=block.get("type", ""))
10200
+
9796
10201
  def _summarize_compact_rows(self, rows: list[dict]) -> str:
9797
10202
  if not rows:
9798
10203
  return "(no archived rows)"
@@ -9865,17 +10270,145 @@ class SessionState:
9865
10270
  lines.append(f"Current focus: {focus}")
9866
10271
  lines.append(f"Open tasks ({len(task_open)}):")
9867
10272
  lines.extend(task_open[:6] if task_open else ["- none"])
10273
+ # Checkpoint drift detection
10274
+ bb = self._ensure_blackboard()
10275
+ cps = bb.get("checkpoints", [])
10276
+ if isinstance(cps, list) and len(cps) >= 2:
10277
+ latest_cp = cps[-1] if isinstance(cps[-1], dict) else {}
10278
+ prev_cp = cps[-2] if isinstance(cps[-2], dict) else {}
10279
+ if self._checkpoint_detect_drift(latest_cp, prev_cp):
10280
+ cp_round = int(latest_cp.get("round", 0) or 0)
10281
+ lines.append(f"⚠️ 自 round {cp_round} 以来无实质进展,需要换策略。")
9868
10282
  return "\n".join(lines)
9869
10283
 
10284
+ def _failure_aware_brief(self) -> str:
10285
+ bb = self._ensure_blackboard()
10286
+ fl = bb.get("failure_ledger")
10287
+ if not isinstance(fl, dict):
10288
+ return ""
10289
+ lines: list[str] = []
10290
+ # Unresolved errors (unified) — grouped by category
10291
+ all_errors = fl.get("errors", [])
10292
+ unresolved = [e for e in all_errors if isinstance(e, dict) and int(e.get("count", 0) or 0) > 0]
10293
+ # Fallback: if errors is empty but compilation_errors has data, use that
10294
+ if not unresolved:
10295
+ comp_errors = fl.get("compilation_errors", [])
10296
+ unresolved = [e for e in comp_errors if isinstance(e, dict) and int(e.get("count", 0) or 0) > 0]
10297
+ for e in unresolved:
10298
+ if "category" not in e:
10299
+ e["category"] = "compilation"
10300
+ if unresolved:
10301
+ lines.append("## ⚠️ 已知问题(勿重复尝试)")
10302
+ by_cat: dict[str, list] = {}
10303
+ for e in unresolved:
10304
+ cat = str(e.get("category", "unknown") or "unknown")
10305
+ by_cat.setdefault(cat, []).append(e)
10306
+ cat_labels = dict(ERROR_CATEGORY_DEFS)
10307
+ for cat, errs in by_cat.items():
10308
+ label = cat_labels.get(cat, {}).get("label", cat) if cat in cat_labels else cat
10309
+ lines.append(f"{label} (未解决):")
10310
+ for e in errs[:8]:
10311
+ f = str(e.get("file", "") or "")
10312
+ msg = str(e.get("error_msg", "") or "")
10313
+ cnt = int(e.get("count", 1) or 1)
10314
+ lines.append(f"- {f}: {trim(msg, 120)} (出现 {cnt} 次)")
10315
+ # Failed fix attempts
10316
+ fixes = fl.get("attempted_fixes", [])
10317
+ if fixes:
10318
+ lines.append("已尝试修复 (失败):")
10319
+ for fx in fixes[-6:]:
10320
+ if not isinstance(fx, dict):
10321
+ continue
10322
+ fix_desc = str(fx.get("fix", "") or "")
10323
+ result = str(fx.get("result", "") or "")
10324
+ lines.append(f"- {trim(fix_desc, 100)} → {trim(result, 60)}")
10325
+ # Repeated delegations
10326
+ delegations = fl.get("repeated_delegations", [])
10327
+ repeated = [d for d in delegations if isinstance(d, dict) and int(d.get("count", 0) or 0) >= 2]
10328
+ if repeated:
10329
+ lines.append("重复委派 (需换策略):")
10330
+ for d in repeated[:5]:
10331
+ tgt = str(d.get("target", "") or "")
10332
+ cnt = int(d.get("count", 1) or 1)
10333
+ preview = str(d.get("instruction_preview", "") or "")
10334
+ lines.append(f"- {tgt} 收到相同指令 {cnt} 次: {trim(preview, 80)}")
10335
+ # Stall events
10336
+ stalls = fl.get("stall_events", [])
10337
+ if stalls:
10338
+ lines.append(f"停滞事件: {len(stalls)} 次 (最近: {str(stalls[-1].get('reason', '') if isinstance(stalls[-1], dict) else '')})")
10339
+ if not lines:
10340
+ return ""
10341
+ return trim("\n".join(lines), 800)
10342
+
10343
+ def _create_checkpoint_snapshot(self) -> dict:
10344
+ bb = self._ensure_blackboard()
10345
+ fl = bb.get("failure_ledger", {})
10346
+ if not isinstance(fl, dict):
10347
+ fl = {}
10348
+ comp_errors = fl.get("errors", []) or fl.get("compilation_errors", [])
10349
+ active_errors = len([e for e in comp_errors if isinstance(e, dict) and int(e.get("count", 0) or 0) > 0])
10350
+ artifacts = bb.get("code_artifacts", {})
10351
+ artifacts_keys = sorted(artifacts.keys()) if isinstance(artifacts, dict) else []
10352
+ files_hash = hashlib.sha1(str(artifacts_keys).encode("utf-8")).hexdigest()[:12]
10353
+ try:
10354
+ todo_snap = json_dumps(self.todo.snapshot()) if hasattr(self, "todo") else ""
10355
+ except Exception:
10356
+ todo_snap = ""
10357
+ todos_hash = hashlib.sha1(todo_snap.encode("utf-8")).hexdigest()[:12]
10358
+ stall_events = fl.get("stall_events", [])
10359
+ delegations = fl.get("repeated_delegations", [])
10360
+ return {
10361
+ "round": int(getattr(self, "agent_round_index", 0) or 0),
10362
+ "ts": float(now_ts()),
10363
+ "active_errors_count": active_errors,
10364
+ "files_modified_hash": files_hash,
10365
+ "todos_hash": todos_hash,
10366
+ "stall_count": len(stall_events) if isinstance(stall_events, list) else 0,
10367
+ "delegation_count": sum(
10368
+ int(d.get("count", 0) or 0) for d in delegations if isinstance(d, dict)
10369
+ ) if isinstance(delegations, list) else 0,
10370
+ }
10371
+
10372
+ def _checkpoint_detect_drift(self, current: dict, previous: dict) -> bool:
10373
+ if not isinstance(current, dict) or not isinstance(previous, dict):
10374
+ return False
10375
+ errors_not_reduced = int(current.get("active_errors_count", 0) or 0) >= int(previous.get("active_errors_count", 0) or 0)
10376
+ files_unchanged = str(current.get("files_modified_hash", "")) == str(previous.get("files_modified_hash", ""))
10377
+ stalls_increased = int(current.get("stall_count", 0) or 0) > int(previous.get("stall_count", 0) or 0)
10378
+ return errors_not_reduced and files_unchanged and stalls_increased
10379
+
10380
+ def _maybe_create_checkpoint(self):
10381
+ bb = self._ensure_blackboard()
10382
+ cps = bb.get("checkpoints", [])
10383
+ if not isinstance(cps, list):
10384
+ cps = []
10385
+ current_round = int(getattr(self, "agent_round_index", 0) or 0)
10386
+ if cps:
10387
+ last_cp = cps[-1] if isinstance(cps[-1], dict) else {}
10388
+ last_round = int(last_cp.get("round", 0) or 0)
10389
+ if current_round - last_round < CHECKPOINT_INTERVAL_ROUNDS:
10390
+ return
10391
+ snapshot = self._create_checkpoint_snapshot()
10392
+ cps.append(snapshot)
10393
+ bb["checkpoints"] = cps[-CHECKPOINT_MAX_COUNT:]
10394
+ self.blackboard = bb
10395
+
9870
10396
  def _auto_compact(self, reason: str):
9871
10397
  context_before = self._context_budget_metrics()
10398
+ tier = self._context_compression_tier(context_before)
9872
10399
  transcript_dir = self.root / "transcripts"
9873
10400
  transcript_dir.mkdir(parents=True, exist_ok=True)
9874
10401
  transcript_path = transcript_dir / f"transcript_{int(now_ts())}.jsonl"
9875
10402
  with transcript_path.open("w", encoding="utf-8") as handle:
9876
10403
  for msg in self.messages:
9877
10404
  handle.write(json_dumps(msg) + "\n")
9878
- tail_budget = max(4500, min(16_000, int(self.context_token_upper_bound * 0.35)))
10405
+ # Adjust tail budget based on tier — lower tier = smaller tail
10406
+ if tier >= 3:
10407
+ tail_budget = max(2000, min(6000, int(self.context_token_upper_bound * 0.20)))
10408
+ elif tier >= 2:
10409
+ tail_budget = max(3000, min(10_000, int(self.context_token_upper_bound * 0.28)))
10410
+ else:
10411
+ tail_budget = max(4500, min(16_000, int(self.context_token_upper_bound * 0.35)))
9879
10412
  tail = self._select_compact_tail(tail_budget)
9880
10413
  if len(tail) >= len(self.messages):
9881
10414
  tail = self._select_compact_tail(max(2200, int(tail_budget * 0.55)), min_count=4, max_count=20)
@@ -9890,35 +10423,59 @@ class SessionState:
9890
10423
  if seg_id
9891
10424
  else "If details are missing, call context_recall with recent_segments=2."
9892
10425
  )
10426
+ # Create checkpoint before compaction
10427
+ self._maybe_create_checkpoint()
10428
+ # Build state handoff for lossless continuity
10429
+ state_handoff = self._build_state_handoff()
10430
+ failure_brief = self._failure_aware_brief()
10431
+ failure_section = f"{failure_brief}\n" if failure_brief else ""
9893
10432
  compact_note = (
9894
10433
  "<compact-resume>\n"
9895
10434
  f"reason: {reason}\n"
10435
+ f"tier: {tier}\n"
9896
10436
  f"transcript: {transcript_path}\n"
9897
10437
  f"archive_segment: {seg_id or 'none'} messages={seg_msg_count} path={seg_path or '-'}\n"
10438
+ f"{state_handoff}\n"
9898
10439
  f"{self._open_work_brief()}\n"
10440
+ f"{failure_section}"
9899
10441
  f"summary:\n{summary}\n"
9900
10442
  f"{continuation}\n"
9901
10443
  "Continue exploring from current pending work; do not restart from scratch.\n"
9902
10444
  "</compact-resume>"
9903
10445
  )
9904
10446
  tail.append({"role": "user", "content": compact_note, "ts": now_ts()})
9905
- target_tokens = max(4000, min(20_000, int(self.context_token_upper_bound * 0.55)))
10447
+ # Adjust target tokens based on tier
10448
+ if tier >= 3:
10449
+ target_tokens = max(2000, min(8000, int(self.context_token_upper_bound * 0.35)))
10450
+ elif tier >= 2:
10451
+ target_tokens = max(3000, min(12_000, int(self.context_token_upper_bound * 0.45)))
10452
+ else:
10453
+ target_tokens = max(4000, min(20_000, int(self.context_token_upper_bound * 0.55)))
9906
10454
  while len(tail) > 5 and self._estimate_messages_tokens(tail) > target_tokens:
9907
10455
  tail.pop(0)
9908
10456
  if self._estimate_messages_tokens(tail) > target_tokens:
9909
10457
  for msg in tail:
9910
10458
  if msg.get("role") == "tool":
9911
- msg["content"] = trim(msg.get("content", ""), 1800)
10459
+ msg["content"] = trim(msg.get("content", ""), 1800 if tier < 2 else 600)
9912
10460
  while len(tail) > 2 and self._estimate_messages_tokens(tail) > target_tokens:
9913
10461
  tail.pop(0)
9914
10462
  self.messages = tail
10463
+ # Compact agent contexts in sync (critical fix for re-wall prevention)
10464
+ self._compact_agent_contexts(max(tier, 1))
10465
+ # Tier 2+: offload remaining large content in messages to file buffer
10466
+ if tier >= 2:
10467
+ for msg in self.messages:
10468
+ c = msg.get("content", "")
10469
+ if isinstance(c, str) and len(c) >= FILE_BUFFER_CONTENT_THRESHOLD * 2:
10470
+ msg["content"] = self._offload_to_file_buffer(c, label="compact-msg")
9915
10471
  self.last_compact_reason = str(reason or "")
9916
10472
  self.last_compact_ts = now_ts()
9917
10473
  self._emit(
9918
10474
  "compact",
9919
10475
  {
9920
- "summary": f"context compacted ({reason}) archives={len(self.context_archives)}",
10476
+ "summary": f"context compacted ({reason}) tier={tier} archives={len(self.context_archives)}",
9921
10477
  "reason": str(reason or ""),
10478
+ "tier": tier,
9922
10479
  "archive_segment": seg_id,
9923
10480
  "archived_messages": seg_msg_count,
9924
10481
  "context_limit_before": int(context_before.get("limit", 0)),
@@ -9928,6 +10485,50 @@ class SessionState:
9928
10485
  },
9929
10486
  )
9930
10487
 
10488
+ def _build_state_handoff(self) -> str:
10489
+ """Build a lossless state handoff string for compact-resume note.
10490
+ Captures: goal, progress, current node state, active agent, round info."""
10491
+ bb = self._ensure_blackboard()
10492
+ parts: list[str] = ["<STATE_HANDOFF>"]
10493
+ # 1. Original goal
10494
+ goal = trim(str(bb.get("original_goal", "") or ""), 500)
10495
+ if goal:
10496
+ parts.append(f"GOAL: {goal}")
10497
+ # 2. Current progress / manager judgement
10498
+ judgement = bb.get("manager_judgement", {})
10499
+ if isinstance(judgement, dict):
10500
+ progress = str(judgement.get("progress", "") or "")
10501
+ remaining = judgement.get("remaining_rounds", "")
10502
+ if progress:
10503
+ parts.append(f"PROGRESS: {progress}")
10504
+ if remaining != "":
10505
+ parts.append(f"REMAINING_ROUNDS: {remaining}")
10506
+ # 3. Active agent and phase
10507
+ active_role = str(getattr(self, "runtime_active_role", "") or "")
10508
+ phase = str(getattr(self, "current_phase", "") or "")
10509
+ round_idx = int(getattr(self, "agent_round_index", 0) or 0)
10510
+ parts.append(f"ROUND: {round_idx}, ACTIVE_AGENT: {active_role or 'none'}, PHASE: {phase or 'idle'}")
10511
+ # 4. Task profile
10512
+ tp = bb.get("task_profile", {})
10513
+ if isinstance(tp, dict):
10514
+ task_type = str(tp.get("task_type", "") or "")
10515
+ complexity = str(tp.get("complexity", "") or "")
10516
+ if task_type:
10517
+ parts.append(f"TASK_TYPE: {task_type}, COMPLEXITY: {complexity}")
10518
+ # 5. Last delegate instruction (if multi-agent)
10519
+ last_del = bb.get("last_delegate", {})
10520
+ if isinstance(last_del, dict) and last_del.get("instruction"):
10521
+ parts.append(f"LAST_DELEGATE: {trim(str(last_del.get('instruction', '')), 200)}")
10522
+ # 6. Key blackboard entries (code_artifacts keys, review_feedback)
10523
+ artifacts = bb.get("code_artifacts", {})
10524
+ if isinstance(artifacts, dict) and artifacts:
10525
+ parts.append(f"CODE_ARTIFACTS: {', '.join(sorted(artifacts.keys())[:10])}")
10526
+ review = bb.get("review_feedback", "")
10527
+ if review:
10528
+ parts.append(f"REVIEW_FEEDBACK: {trim(str(review), 200)}")
10529
+ parts.append("</STATE_HANDOFF>")
10530
+ return "\n".join(parts)
10531
+
9931
10532
  def _git_status_map(self, cwd: Path) -> dict[str, str]:
9932
10533
  try:
9933
10534
  check = subprocess.run(
@@ -11286,7 +11887,7 @@ class SessionState:
11286
11887
  active_rows.append(trim(content, 140))
11287
11888
  if len(active_rows) >= 3:
11288
11889
  break
11289
- return {
11890
+ snapshot = {
11290
11891
  "goal": self._latest_user_goal_text(),
11291
11892
  "todo_open_count": int(open_count),
11292
11893
  "todo_in_progress_count": int(in_progress_count),
@@ -11295,6 +11896,20 @@ class SessionState:
11295
11896
  "assistant_thinking": trim(str(thinking_text or "").strip(), 700),
11296
11897
  "agent_round_index": int(self.agent_round_index),
11297
11898
  }
11899
+ # Inject plan step progress so arbiter knows overall completion state
11900
+ bb = self._ensure_blackboard()
11901
+ plan_todos = bb.get("project_todos", [])
11902
+ plan_steps = [t for t in plan_todos if t.get("category") == "plan_step"]
11903
+ if plan_steps:
11904
+ completed = len([t for t in plan_steps if t.get("status") == "completed"])
11905
+ total = len(plan_steps)
11906
+ snapshot["plan_progress"] = f"{completed}/{total} steps completed"
11907
+ if completed < total:
11908
+ snapshot["plan_warning"] = (
11909
+ f"IMPORTANT: Only {completed}/{total} plan steps are completed. "
11910
+ "Do NOT classify as TASK_COMPLETED unless ALL steps are done."
11911
+ )
11912
+ return snapshot
11298
11913
 
11299
11914
  def _build_arbiter_client(self) -> OllamaClient:
11300
11915
  client = OllamaClient(self.ollama.base_url, self.ollama.model)
@@ -11395,16 +12010,41 @@ class SessionState:
11395
12010
 
11396
12011
  def _mark_all_done_silently(self, reason: str = "") -> dict:
11397
12012
  summary = trim(str(reason or "").strip(), 160) or "arbiter: task completed"
12013
+ # Protect plan_step todos — they must only be completed by _advance_plan_step
12014
+ bb = self._ensure_blackboard()
12015
+ plan_todos = bb.get("project_todos", [])
12016
+ saved_plan_steps: dict[str, dict] = {}
12017
+ for t in plan_todos:
12018
+ if t.get("category") == "plan_step" and t.get("status") != "completed":
12019
+ saved_plan_steps[str(t.get("id", ""))] = {
12020
+ "status": t.get("status", "pending"),
12021
+ "completed_at": t.get("completed_at"),
12022
+ "completed_by": t.get("completed_by", ""),
12023
+ "evidence": t.get("evidence", ""),
12024
+ }
11398
12025
  result = self.todo.complete_all_open(summary)
12026
+ # Restore plan_step todos to their original state
12027
+ if saved_plan_steps:
12028
+ bb = self._ensure_blackboard()
12029
+ for t in bb.get("project_todos", []):
12030
+ tid = str(t.get("id", ""))
12031
+ if tid in saved_plan_steps:
12032
+ t["status"] = saved_plan_steps[tid]["status"]
12033
+ t["completed_at"] = saved_plan_steps[tid]["completed_at"]
12034
+ t["completed_by"] = saved_plan_steps[tid]["completed_by"]
12035
+ t["evidence"] = saved_plan_steps[tid]["evidence"]
12036
+ self.blackboard = bb
12037
+ self._sync_todos_from_blackboard(reason="plan-step-restore")
11399
12038
  updated = int(result.get("updated", 0) or 0)
11400
12039
  total = int(result.get("total", 0) or 0)
12040
+ pending_steps = len(saved_plan_steps)
11401
12041
  self._prune_runtime_retry_hints()
11402
12042
  self._emit(
11403
12043
  "status",
11404
12044
  {
11405
12045
  "summary": (
11406
- "arbiter marked task completed; "
11407
- f"todos completed={updated}/{total}"
12046
+ f"arbiter marked task completed; todos completed={updated}/{total}"
12047
+ + (f"; {pending_steps} plan steps protected (still pending)" if pending_steps else "")
11408
12048
  )
11409
12049
  },
11410
12050
  )
@@ -12484,13 +13124,61 @@ class SessionState:
12484
13124
  def _run_read(self, path: str, limit: int | None = None) -> str:
12485
13125
  try:
12486
13126
  rel = self._normalize_tool_path_text(path)
12487
- lines = self._session_path(rel).read_text(encoding="utf-8").splitlines()
13127
+ fp = self._session_path(rel)
13128
+ # Multimodal: detect image/audio/video files and handle natively
13129
+ ext = fp.suffix.lower() if fp.suffix else ""
13130
+ if ext in IMAGE_EXTS:
13131
+ return self._run_read_media(fp, rel, "image")
13132
+ if ext in AUDIO_EXTS:
13133
+ return self._run_read_media(fp, rel, "audio")
13134
+ if ext in VIDEO_EXTS:
13135
+ return self._run_read_media(fp, rel, "video")
13136
+ lines = fp.read_text(encoding="utf-8").splitlines()
12488
13137
  if limit and limit < len(lines):
12489
13138
  lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
12490
13139
  return trim("\n".join(lines))
12491
13140
  except Exception as exc:
12492
13141
  return f"Error: {type(exc).__name__}: {exc}"
12493
13142
 
13143
+ def _run_read_media(self, fp: Path, rel: str, media_type: str) -> str:
13144
+ """Read a media file and return description or inject into multimodal context."""
13145
+ if not fp.exists():
13146
+ return f"Error: file not found: {rel}"
13147
+ caps = self._capabilities_from_profile()
13148
+ cap_key = f"input_{media_type}"
13149
+ file_size = fp.stat().st_size
13150
+ size_kb = file_size / 1024
13151
+ if bool(caps.get(cap_key, False)) and file_size < 20 * 1024 * 1024:
13152
+ # Model supports this media type — encode and inject as multimodal input
13153
+ try:
13154
+ b64 = base64.b64encode(fp.read_bytes()).decode("ascii")
13155
+ mime = guess_mime_from_name(fp.name)
13156
+ media_item = {
13157
+ "type": media_type,
13158
+ "data_b64": b64,
13159
+ "mime": mime,
13160
+ "name": fp.name,
13161
+ "workspace_path": str(rel),
13162
+ }
13163
+ # Store for injection into next API call
13164
+ with self.lock:
13165
+ self._pending_media_inputs.append(media_item)
13166
+ return (
13167
+ f"[{media_type} file loaded: {rel} ({size_kb:.1f} KB, {mime})] "
13168
+ f"The {media_type} has been attached to the conversation for native {media_type} analysis. "
13169
+ f"Describe or analyze it directly."
13170
+ )
13171
+ except Exception as exc:
13172
+ return f"Error reading {media_type} file: {exc}"
13173
+ else:
13174
+ # Model doesn't support this media type — return metadata only
13175
+ reason = "model does not support" if not caps.get(cap_key) else "file too large"
13176
+ return (
13177
+ f"[{media_type} file: {rel} ({size_kb:.1f} KB)] "
13178
+ f"Note: {reason} native {media_type} input. "
13179
+ f"File exists at {fp}. Use bash tools to process it if needed."
13180
+ )
13181
+
12494
13182
  def _is_html_file_rel(self, path: str) -> bool:
12495
13183
  low = str(path or "").strip().lower()
12496
13184
  return low.endswith(".html") or low.endswith(".htm")
@@ -13296,6 +13984,15 @@ class SessionState:
13296
13984
  bb = board if isinstance(board, dict) else self._ensure_blackboard()
13297
13985
  if bool(self.runtime_goal_reset_pending):
13298
13986
  return False, "goal-reset-pending"
13987
+ # Plan step gate: if plan_step todos exist, ALL must be completed
13988
+ plan_step_todos = [
13989
+ t for t in bb.get("project_todos", [])
13990
+ if t.get("category") == "plan_step"
13991
+ ]
13992
+ if plan_step_todos:
13993
+ pending = [t for t in plan_step_todos if t.get("status") != "completed"]
13994
+ if pending:
13995
+ return False, f"plan-steps-incomplete:{len(pending)}/{len(plan_step_todos)}"
13299
13996
  # Project todo gate: coding tasks must pass compile + test
13300
13997
  profile = self._ensure_blackboard_task_profile(bb)
13301
13998
  task_type = str(profile.get("task_type", "general") or "general")
@@ -13354,8 +14051,16 @@ class SessionState:
13354
14051
 
13355
14052
  def _manager_progress_state(self, board: dict | None = None) -> str:
13356
14053
  bb = board if isinstance(board, dict) else self._ensure_blackboard()
14054
+ # Hard guard: pending plan steps → never "done"
14055
+ plan_step_todos = [
14056
+ t for t in bb.get("project_todos", [])
14057
+ if t.get("category") == "plan_step"
14058
+ ]
14059
+ has_pending_plan_steps = plan_step_todos and any(
14060
+ t.get("status") != "completed" for t in plan_step_todos
14061
+ )
13357
14062
  can_finish, _ = self._can_auto_finish_from_approval(bb)
13358
- if can_finish:
14063
+ if can_finish and not has_pending_plan_steps:
13359
14064
  return "done"
13360
14065
  profile = self._ensure_blackboard_task_profile(bb)
13361
14066
  task_type = str(profile.get("task_type", "") or "")
@@ -14005,10 +14710,13 @@ class SessionState:
14005
14710
  bb = self._ensure_blackboard()
14006
14711
  dq = self._normalize_decomposition_queue_state(bb.get("decomposition_queue", {}))
14007
14712
  trigger_reason = ""
14713
+ _is_single = self._effective_execution_mode() == EXECUTION_MODE_SINGLE
14714
+ _intent_th = WATCHDOG_INTENT_NO_TOOL_THRESHOLD_SINGLE if _is_single else WATCHDOG_INTENT_NO_TOOL_THRESHOLD
14715
+ _repeat_th = WATCHDOG_REPEAT_NO_TOOL_THRESHOLD_SINGLE if _is_single else WATCHDOG_REPEAT_NO_TOOL_THRESHOLD
14008
14716
  if not bool(dq.get("active", False)):
14009
- if int(wd.get("intent_no_tool_streak", 0) or 0) >= int(WATCHDOG_INTENT_NO_TOOL_THRESHOLD):
14717
+ if int(wd.get("intent_no_tool_streak", 0) or 0) >= int(_intent_th):
14010
14718
  trigger_reason = "intent-without-tool-call"
14011
- elif int(wd.get("repeat_no_tool_streak", 0) or 0) >= int(WATCHDOG_REPEAT_NO_TOOL_THRESHOLD):
14719
+ elif int(wd.get("repeat_no_tool_streak", 0) or 0) >= int(_repeat_th):
14012
14720
  trigger_reason = "repeated-no-tool-reply"
14013
14721
  elif (
14014
14722
  self._watchdog_context_near_limit()
@@ -14033,6 +14741,8 @@ class SessionState:
14033
14741
  bb["watchdog"] = wd
14034
14742
  self.blackboard = bb
14035
14743
  self._blackboard_touch()
14744
+ # Failure ledger: record stall event
14745
+ self._ledger_record_stall(trigger_reason, "unresolved")
14036
14746
  if int(wd["trigger_count"]) >= 2:
14037
14747
  self._watchdog_escalate_to_single_developer(bb, reason=trigger_reason)
14038
14748
  else:
@@ -14180,6 +14890,16 @@ class SessionState:
14180
14890
  "project_todos": [],
14181
14891
  "watchdog": self._new_watchdog_state(),
14182
14892
  "decomposition_queue": self._new_decomposition_queue_state(),
14893
+ "failure_ledger": {
14894
+ "attempted_fixes": [],
14895
+ "compilation_errors": [],
14896
+ "errors": [],
14897
+ "repeated_delegations": [],
14898
+ "stall_events": [],
14899
+ "tool_call_fingerprints": [],
14900
+ },
14901
+ "checkpoints": [],
14902
+ "persisted_manager_routes": [],
14183
14903
  }
14184
14904
 
14185
14905
  def _normalize_blackboard(self, raw: object) -> dict:
@@ -14351,8 +15071,324 @@ class SessionState:
14351
15071
  board["decomposition_queue"] = self._normalize_decomposition_queue_state(
14352
15072
  src.get("decomposition_queue", {})
14353
15073
  )
15074
+ # Preserve plan-mode data through normalization
15075
+ raw_plan = src.get("plan")
15076
+ if isinstance(raw_plan, dict):
15077
+ board["plan"] = raw_plan
15078
+ # Preserve failure_ledger, checkpoints, persisted_manager_routes
15079
+ raw_fl = src.get("failure_ledger")
15080
+ board["failure_ledger"] = self._normalize_failure_ledger(raw_fl) if isinstance(raw_fl, dict) else {
15081
+ "attempted_fixes": [],
15082
+ "compilation_errors": [],
15083
+ "errors": [],
15084
+ "repeated_delegations": [],
15085
+ "stall_events": [],
15086
+ "tool_call_fingerprints": [],
15087
+ }
15088
+ raw_cp = src.get("checkpoints")
15089
+ board["checkpoints"] = list(raw_cp)[-CHECKPOINT_MAX_COUNT:] if isinstance(raw_cp, list) else []
15090
+ raw_pmr = src.get("persisted_manager_routes")
15091
+ board["persisted_manager_routes"] = list(raw_pmr)[-PERSISTED_ROUTES_MAX:] if isinstance(raw_pmr, list) else []
14354
15092
  return board
14355
15093
 
15094
+ def _normalize_failure_ledger(self, raw: dict) -> dict:
15095
+ fl: dict = {
15096
+ "attempted_fixes": [],
15097
+ "compilation_errors": [],
15098
+ "errors": [],
15099
+ "repeated_delegations": [],
15100
+ "stall_events": [],
15101
+ "tool_call_fingerprints": [],
15102
+ }
15103
+ if not isinstance(raw, dict):
15104
+ return fl
15105
+ def _safe_list(key: str, max_len: int) -> list[dict]:
15106
+ items = raw.get(key)
15107
+ if not isinstance(items, list):
15108
+ return []
15109
+ out: list[dict] = []
15110
+ for item in items[-max_len:]:
15111
+ if isinstance(item, dict):
15112
+ out.append(item)
15113
+ return out
15114
+ fl["attempted_fixes"] = _safe_list("attempted_fixes", FAILURE_LEDGER_MAX_FIXES)
15115
+ fl["compilation_errors"] = _safe_list("compilation_errors", FAILURE_LEDGER_MAX_COMPILE_ERRORS)
15116
+ fl["errors"] = _safe_list("errors", FAILURE_LEDGER_MAX_ERRORS)
15117
+ fl["repeated_delegations"] = _safe_list("repeated_delegations", FAILURE_LEDGER_MAX_DELEGATIONS)
15118
+ fl["stall_events"] = _safe_list("stall_events", FAILURE_LEDGER_MAX_STALLS)
15119
+ fl["tool_call_fingerprints"] = _safe_list("tool_call_fingerprints", FAILURE_LEDGER_MAX_TOOL_FPS)
15120
+ return fl
15121
+
15122
+ def _ledger_record_fix_attempt(self, file: str, error: str, fix: str, result: str, actor: str):
15123
+ bb = self._ensure_blackboard()
15124
+ fl = bb.get("failure_ledger")
15125
+ if not isinstance(fl, dict):
15126
+ return
15127
+ fixes = fl.get("attempted_fixes", [])
15128
+ fixes.append({
15129
+ "file": trim(str(file or ""), 260),
15130
+ "error": trim(str(error or ""), 400),
15131
+ "fix": trim(str(fix or ""), 400),
15132
+ "result": trim(str(result or ""), 200),
15133
+ "actor": trim(str(actor or ""), 40),
15134
+ "round": int(getattr(self, "agent_round_index", 0) or 0),
15135
+ "ts": float(now_ts()),
15136
+ })
15137
+ fl["attempted_fixes"] = fixes[-FAILURE_LEDGER_MAX_FIXES:]
15138
+ bb["failure_ledger"] = fl
15139
+ self.blackboard = bb
15140
+
15141
+ # --- Unified error detection helpers (修改 3) ---
15142
+
15143
+ def _detect_error_category(self, cmd_str: str) -> str | None:
15144
+ """Match a command string against ERROR_CATEGORY_DEFS, return first matching category name."""
15145
+ cmd_lower = str(cmd_str or "").lower()
15146
+ for cat_name, cat_def in ERROR_CATEGORY_DEFS:
15147
+ if any(kw in cmd_lower for kw in cat_def.get("cmd_keywords", [])):
15148
+ return cat_name
15149
+ return None
15150
+
15151
+ def _check_output_has_errors(self, output: str, category: str) -> bool:
15152
+ """Check if output contains error patterns for the given category."""
15153
+ out_lower = str(output or "").lower()
15154
+ cat_def = dict(ERROR_CATEGORY_DEFS).get(category, {})
15155
+ return any(pat.lower() in out_lower for pat in cat_def.get("output_patterns", []))
15156
+
15157
+ def _extract_error_lines(self, output: str, category: str, max_lines: int = 3) -> list[tuple[str, str]]:
15158
+ """Scan output lines for error patterns, return [(file_hint, error_line), ...]."""
15159
+ cat_def = dict(ERROR_CATEGORY_DEFS).get(category, {})
15160
+ patterns = [p.lower() for p in cat_def.get("output_patterns", [])]
15161
+ results: list[tuple[str, str]] = []
15162
+ for ln in str(output or "").split("\n"):
15163
+ if any(pat in ln.lower() for pat in patterns):
15164
+ file_hint = ""
15165
+ parts = ln.split(":")
15166
+ if len(parts) >= 2 and ("." in parts[0] or "/" in parts[0]):
15167
+ file_hint = parts[0].strip()
15168
+ results.append((file_hint, ln.strip()))
15169
+ if len(results) >= max_lines:
15170
+ break
15171
+ return results
15172
+
15173
+ # --- Unified error record / clear / sync (修改 4) ---
15174
+
15175
+ def _ledger_record_error(self, category: str, file: str, error_msg: str):
15176
+ """Record an error into fl['errors'] with category field. Dedup by fingerprint."""
15177
+ bb = self._ensure_blackboard()
15178
+ fl = bb.get("failure_ledger")
15179
+ if not isinstance(fl, dict):
15180
+ return
15181
+ errors = fl.get("errors", [])
15182
+ fp = hashlib.sha1((str(category or "") + str(file or "") + str(error_msg or "")).encode("utf-8")).hexdigest()[:12]
15183
+ for entry in errors:
15184
+ if entry.get("fingerprint") == fp:
15185
+ entry["count"] = int(entry.get("count", 1) or 1) + 1
15186
+ entry["last_round"] = int(getattr(self, "agent_round_index", 0) or 0)
15187
+ fl["errors"] = errors
15188
+ bb["failure_ledger"] = fl
15189
+ self.blackboard = bb
15190
+ self._sync_compilation_errors_compat()
15191
+ return
15192
+ errors.append({
15193
+ "fingerprint": fp,
15194
+ "category": str(category or ""),
15195
+ "file": trim(str(file or ""), 260),
15196
+ "error_msg": trim(str(error_msg or ""), 400),
15197
+ "count": 1,
15198
+ "first_round": int(getattr(self, "agent_round_index", 0) or 0),
15199
+ "last_round": int(getattr(self, "agent_round_index", 0) or 0),
15200
+ })
15201
+ fl["errors"] = errors[-FAILURE_LEDGER_MAX_ERRORS:]
15202
+ bb["failure_ledger"] = fl
15203
+ self.blackboard = bb
15204
+ self._sync_compilation_errors_compat()
15205
+
15206
+ def _ledger_clear_errors(self, category: str | None = None):
15207
+ """Clear errors from fl['errors']. If category given, only that category; else all."""
15208
+ bb = self._ensure_blackboard()
15209
+ fl = bb.get("failure_ledger")
15210
+ if not isinstance(fl, dict):
15211
+ return
15212
+ if category is None:
15213
+ fl["errors"] = []
15214
+ fl["attempted_fixes"] = []
15215
+ else:
15216
+ fl["errors"] = [e for e in fl.get("errors", []) if e.get("category") != category]
15217
+ # Clear attempted_fixes that match files in the cleared category
15218
+ fl["attempted_fixes"] = [
15219
+ fx for fx in fl.get("attempted_fixes", [])
15220
+ if not (isinstance(fx, dict) and fx.get("category") == category)
15221
+ ]
15222
+ bb["failure_ledger"] = fl
15223
+ self.blackboard = bb
15224
+ self._sync_compilation_errors_compat()
15225
+
15226
+ def _sync_compilation_errors_compat(self):
15227
+ """Sync fl['compilation_errors'] from fl['errors'] where category=='compilation' for backward compat."""
15228
+ bb = self._ensure_blackboard()
15229
+ fl = bb.get("failure_ledger")
15230
+ if not isinstance(fl, dict):
15231
+ return
15232
+ fl["compilation_errors"] = [
15233
+ e for e in fl.get("errors", [])
15234
+ if isinstance(e, dict) and e.get("category") == "compilation"
15235
+ ]
15236
+ bb["failure_ledger"] = fl
15237
+ self.blackboard = bb
15238
+
15239
+ # --- Thin wrappers for backward compatibility (修改 4d, 4e) ---
15240
+
15241
+ def _ledger_record_compile_error(self, file: str, error_msg: str):
15242
+ self._ledger_record_error("compilation", file, error_msg)
15243
+
15244
+ def _ledger_clear_compile_errors(self):
15245
+ self._ledger_clear_errors("compilation")
15246
+
15247
+ # --- Unified tool result error processing (修改 5) ---
15248
+
15249
+ def _process_tool_result_errors(self, name: str, args: dict, output: str, ok: bool, role_key: str):
15250
+ """Detect and record errors from bash tool results, or clear on success."""
15251
+ if name != "bash" or not isinstance(args, dict):
15252
+ return
15253
+ cmd_str = str(args.get("command", "") or "").lower()
15254
+ category = self._detect_error_category(cmd_str)
15255
+ if not category:
15256
+ return
15257
+ if not ok:
15258
+ error_lines = self._extract_error_lines(str(output or ""), category)
15259
+ for file_hint, err_line in error_lines:
15260
+ self._ledger_record_error(category, file_hint or cmd_str[:80], trim(err_line, 300))
15261
+ else:
15262
+ out_lower = str(output or "").lower()
15263
+ cat_def = dict(ERROR_CATEGORY_DEFS).get(category, {})
15264
+ negatives = cat_def.get("success_negatives", [])
15265
+ if not any(neg.lower() in out_lower for neg in negatives):
15266
+ self._ledger_clear_errors(category)
15267
+ # Auto-deactivate reviewer debug mode when errors are resolved
15268
+ if bool(self.reviewer_debug_mode) and not self._manager_has_error_log():
15269
+ self._deactivate_reviewer_debug_mode("errors resolved")
15270
+
15271
+ # --- Generalized error context (修改 6) ---
15272
+
15273
+ def _recent_error_context(self, max_chars: int = 800) -> str:
15274
+ """Build context string from all error categories in fl['errors']."""
15275
+ bb = self._ensure_blackboard()
15276
+ lines: list[str] = []
15277
+ fl = bb.get("failure_ledger", {})
15278
+ if isinstance(fl, dict):
15279
+ all_errors = fl.get("errors", [])
15280
+ unresolved = [e for e in all_errors if isinstance(e, dict) and int(e.get("count", 0) or 0) > 0]
15281
+ if unresolved:
15282
+ # Group by category
15283
+ by_cat: dict[str, list] = {}
15284
+ for e in unresolved:
15285
+ cat = str(e.get("category", "unknown") or "unknown")
15286
+ by_cat.setdefault(cat, []).append(e)
15287
+ cat_labels = dict(ERROR_CATEGORY_DEFS)
15288
+ for cat, errs in by_cat.items():
15289
+ label = cat_labels.get(cat, {}).get("label", cat.upper()) if cat in cat_labels else cat.upper()
15290
+ lines.append(f"UNRESOLVED {label.upper()}:")
15291
+ for e in errs[:5]:
15292
+ f = str(e.get("file", "") or "")
15293
+ msg = str(e.get("error_msg", "") or "")
15294
+ cnt = int(e.get("count", 1) or 1)
15295
+ lines.append(f" [{f}] {trim(msg, 200)} (seen {cnt}x)")
15296
+ fixes = fl.get("attempted_fixes", [])
15297
+ if fixes:
15298
+ lines.append("PREVIOUSLY ATTEMPTED FIXES (all failed):")
15299
+ for fx in fixes[-3:]:
15300
+ if isinstance(fx, dict):
15301
+ lines.append(f" - {trim(str(fx.get('fix', '') or ''), 120)} -> {trim(str(fx.get('result', '') or ''), 60)}")
15302
+ logs = bb.get("execution_logs", []) if isinstance(bb.get("execution_logs"), list) else []
15303
+ _all_kw = ("error:", "fatal error", "syntax error", "compile error", "build failed",
15304
+ "traceback", "exception", "failed", "panic:", "FAIL:", "AssertionError")
15305
+ for entry in reversed(logs[-6:]):
15306
+ if not isinstance(entry, dict):
15307
+ continue
15308
+ txt = str(entry.get("content", "") or "")
15309
+ if any(kw in txt.lower() for kw in _all_kw):
15310
+ lines.append(f"LAST ERROR OUTPUT:\n{trim(txt, 400)}")
15311
+ break
15312
+ if not lines:
15313
+ return ""
15314
+ return trim("\n".join(lines), max_chars)
15315
+
15316
+ def _recent_compile_error_context(self, max_chars: int = 800) -> str:
15317
+ return self._recent_error_context(max_chars=max_chars)
15318
+
15319
+ def _ledger_record_delegation(self, target: str, instruction: str):
15320
+ bb = self._ensure_blackboard()
15321
+ fl = bb.get("failure_ledger")
15322
+ if not isinstance(fl, dict):
15323
+ return
15324
+ delegations = fl.get("repeated_delegations", [])
15325
+ fp = hashlib.sha1(str(instruction or "").encode("utf-8")).hexdigest()[:12]
15326
+ for entry in delegations:
15327
+ if entry.get("instruction_hash") == fp and entry.get("target") == target:
15328
+ entry["count"] = int(entry.get("count", 1) or 1) + 1
15329
+ entry["last_round"] = int(getattr(self, "agent_round_index", 0) or 0)
15330
+ fl["repeated_delegations"] = delegations
15331
+ bb["failure_ledger"] = fl
15332
+ self.blackboard = bb
15333
+ return
15334
+ delegations.append({
15335
+ "target": trim(str(target or ""), 40),
15336
+ "instruction_hash": fp,
15337
+ "instruction_preview": trim(str(instruction or ""), 200),
15338
+ "count": 1,
15339
+ "first_round": int(getattr(self, "agent_round_index", 0) or 0),
15340
+ "last_round": int(getattr(self, "agent_round_index", 0) or 0),
15341
+ })
15342
+ fl["repeated_delegations"] = delegations[-FAILURE_LEDGER_MAX_DELEGATIONS:]
15343
+ bb["failure_ledger"] = fl
15344
+ self.blackboard = bb
15345
+
15346
+ def _ledger_record_stall(self, reason: str, resolution: str):
15347
+ bb = self._ensure_blackboard()
15348
+ fl = bb.get("failure_ledger")
15349
+ if not isinstance(fl, dict):
15350
+ return
15351
+ stalls = fl.get("stall_events", [])
15352
+ stalls.append({
15353
+ "reason": trim(str(reason or ""), 200),
15354
+ "resolution": trim(str(resolution or ""), 200),
15355
+ "round": int(getattr(self, "agent_round_index", 0) or 0),
15356
+ "ts": float(now_ts()),
15357
+ })
15358
+ fl["stall_events"] = stalls[-FAILURE_LEDGER_MAX_STALLS:]
15359
+ bb["failure_ledger"] = fl
15360
+ self.blackboard = bb
15361
+
15362
+ def _ledger_record_tool_call(self, tool_name: str, args: dict):
15363
+ bb = self._ensure_blackboard()
15364
+ fl = bb.get("failure_ledger")
15365
+ if not isinstance(fl, dict):
15366
+ return
15367
+ fps = fl.get("tool_call_fingerprints", [])
15368
+ try:
15369
+ args_str = json_dumps(args) if isinstance(args, dict) else str(args or "")
15370
+ except Exception:
15371
+ args_str = str(args or "")
15372
+ fp = hashlib.sha1((str(tool_name or "") + args_str).encode("utf-8")).hexdigest()[:16]
15373
+ for entry in fps:
15374
+ if entry.get("fingerprint") == fp:
15375
+ entry["count"] = int(entry.get("count", 1) or 1) + 1
15376
+ entry["last_round"] = int(getattr(self, "agent_round_index", 0) or 0)
15377
+ fl["tool_call_fingerprints"] = fps
15378
+ bb["failure_ledger"] = fl
15379
+ self.blackboard = bb
15380
+ return
15381
+ fps.append({
15382
+ "fingerprint": fp,
15383
+ "tool_name": trim(str(tool_name or ""), 60),
15384
+ "count": 1,
15385
+ "first_round": int(getattr(self, "agent_round_index", 0) or 0),
15386
+ "last_round": int(getattr(self, "agent_round_index", 0) or 0),
15387
+ })
15388
+ fl["tool_call_fingerprints"] = fps[-FAILURE_LEDGER_MAX_TOOL_FPS:]
15389
+ bb["failure_ledger"] = fl
15390
+ self.blackboard = bb
15391
+
14356
15392
  def _ensure_blackboard(self) -> dict:
14357
15393
  if not isinstance(self.blackboard, dict) or not self.blackboard:
14358
15394
  self.blackboard = self._new_blackboard(self._latest_user_goal_text())
@@ -14709,6 +15745,31 @@ class SessionState:
14709
15745
  bb = board if isinstance(board, dict) else self._ensure_blackboard()
14710
15746
  if bb.get("project_todos"):
14711
15747
  return
15748
+ # 如果有已审批的 plan steps,优先用 plan steps 生成 todos
15749
+ plan = bb.get("plan", {})
15750
+ if isinstance(plan, dict) and plan.get("phase") == "executing" and plan.get("steps"):
15751
+ steps = plan["steps"]
15752
+ plan_todos = [
15753
+ {
15754
+ "id": f"pt:{i:03d}",
15755
+ "content": trim(str(s or "").strip(), 400),
15756
+ "status": "in_progress" if i == 0 else "pending",
15757
+ "category": "plan_step",
15758
+ "plan_step_index": i,
15759
+ "created_at": float(now_ts()),
15760
+ "completed_at": None,
15761
+ "completed_by": "",
15762
+ "evidence": "",
15763
+ }
15764
+ for i, s in enumerate(steps) if str(s or "").strip()
15765
+ ][:10]
15766
+ if plan_todos:
15767
+ bb["project_todos"] = plan_todos
15768
+ bb["plan_step_cursor"] = 0
15769
+ bb["plan_step_total"] = len(plan_todos)
15770
+ self.blackboard = bb
15771
+ self._blackboard_touch()
15772
+ return
14712
15773
  raw = self._generate_project_todos_from_profile(bb)
14713
15774
  bb["project_todos"] = [
14714
15775
  {
@@ -14786,6 +15847,23 @@ class SessionState:
14786
15847
  todo.update(status="completed", completed_at=float(now_ts()), evidence="测试通过")
14787
15848
  elif cat == "review" and feedback_pass:
14788
15849
  todo.update(status="completed", completed_at=float(now_ts()), evidence="审查通过")
15850
+ elif cat == "plan_step":
15851
+ # Plan steps 不自动完成,由 _advance_plan_step 显式推进
15852
+ # 但如果当前步骤之前的所有步骤都完成了,标记当前步骤为 in_progress
15853
+ if todo.get("status") == "pending":
15854
+ step_idx = int(todo.get("plan_step_index", 0) or 0)
15855
+ all_prior_done = all(
15856
+ t.get("status") == "completed"
15857
+ for t in todos
15858
+ if t.get("category") == "plan_step"
15859
+ and int(t.get("plan_step_index", 0) or 0) < step_idx
15860
+ )
15861
+ if all_prior_done and not any(
15862
+ t.get("status") == "in_progress"
15863
+ for t in todos
15864
+ if t.get("category") == "plan_step"
15865
+ ):
15866
+ todo["status"] = "in_progress"
14789
15867
 
14790
15868
  if not any(t.get("status") == "in_progress" for t in todos):
14791
15869
  for t in todos:
@@ -14796,6 +15874,104 @@ class SessionState:
14796
15874
  bb["project_todos"] = todos
14797
15875
  self.blackboard = bb
14798
15876
 
15877
+ def _advance_plan_step(self, evidence: str = "", actor: str = "developer"):
15878
+ bb = self._ensure_blackboard()
15879
+ todos = bb.get("project_todos", [])
15880
+ if not todos:
15881
+ return False
15882
+ current = None
15883
+ for t in todos:
15884
+ if t.get("category") == "plan_step" and t.get("status") == "in_progress":
15885
+ current = t
15886
+ break
15887
+ if not current:
15888
+ return False
15889
+ current["status"] = "completed"
15890
+ current["completed_at"] = float(now_ts())
15891
+ current["completed_by"] = actor
15892
+ current["evidence"] = trim(str(evidence or "").strip(), 200) or "step completed"
15893
+ # 推进 cursor,激活下一步
15894
+ cursor = int(bb.get("plan_step_cursor", 0) or 0)
15895
+ bb["plan_step_cursor"] = cursor + 1
15896
+ next_step = None
15897
+ for t in todos:
15898
+ if t.get("category") == "plan_step" and t.get("status") == "pending":
15899
+ next_step = t
15900
+ break
15901
+ if next_step:
15902
+ next_step["status"] = "in_progress"
15903
+ step_idx = int(next_step.get("plan_step_index", 0) or 0) + 1
15904
+ total = int(bb.get("plan_step_total", len(todos)) or len(todos))
15905
+ self._emit("status", {
15906
+ "summary": f"📋 Plan step {step_idx}/{total}: {trim(str(next_step.get('content', '') or ''), 120)}"
15907
+ })
15908
+ self.blackboard = bb
15909
+ self._blackboard_touch()
15910
+ # Immediately sync todos so UI reflects plan step advancement
15911
+ self._sync_todos_from_blackboard(reason=f"plan-step-advanced:{cursor + 1}", board=bb)
15912
+ return True
15913
+
15914
+ def _single_agent_plan_step_check(self, tool_results: list[dict]):
15915
+ """In single-agent mode, check if current plan step should be advanced based on tool results."""
15916
+ bb = self._ensure_blackboard()
15917
+ todos = bb.get("project_todos", [])
15918
+ plan_steps = [t for t in todos if t.get("category") == "plan_step"]
15919
+ if not plan_steps:
15920
+ # No plan steps — just sync todos
15921
+ self._sync_todos_from_blackboard(reason="single-agent-round")
15922
+ return
15923
+ current = None
15924
+ for t in plan_steps:
15925
+ if t.get("status") == "in_progress":
15926
+ current = t
15927
+ break
15928
+ if not current:
15929
+ self._sync_todos_from_blackboard(reason="single-agent-round")
15930
+ return
15931
+ # Heuristic: check if tool results indicate step completion
15932
+ # - write_file/edit_file calls suggest implementation progress
15933
+ # - successful bash calls suggest testing/verification
15934
+ step_content = str(current.get("content", "") or "").lower()
15935
+ phase = self._plan_step_phase_hint(step_content)
15936
+ wrote_files = any(
15937
+ str(r.get("name", "")) in ("write_file", "edit_file") and r.get("ok", False)
15938
+ for r in tool_results
15939
+ )
15940
+ ran_bash_ok = any(
15941
+ str(r.get("name", "")) == "bash" and r.get("ok", False)
15942
+ for r in tool_results
15943
+ )
15944
+ # Auto-advance conditions based on phase:
15945
+ should_advance = False
15946
+ if phase in ("research", "design") and wrote_files:
15947
+ # Research/design phase completed when files are produced
15948
+ should_advance = True
15949
+ elif phase == "implement" and wrote_files and ran_bash_ok:
15950
+ # Implementation completed when files written and bash succeeds
15951
+ should_advance = True
15952
+ elif phase in ("test", "review") and ran_bash_ok and not any(
15953
+ not r.get("ok", False) for r in tool_results if str(r.get("name", "")) == "bash"
15954
+ ):
15955
+ # Test/review completed when all bash calls succeed
15956
+ should_advance = True
15957
+ # Also check if the agent explicitly mentioned step completion
15958
+ if not should_advance:
15959
+ # Check last assistant message for step completion signals
15960
+ last_text = ""
15961
+ for msg in reversed(self.messages[-3:]):
15962
+ if msg.get("role") == "assistant":
15963
+ last_text = str(msg.get("content", "") or "").lower()
15964
+ break
15965
+ step_done_signals = ("step completed", "步骤完成", "step done", "完成了", "已完成",
15966
+ "next step", "下一步", "proceed to step", "进入下一")
15967
+ if any(sig in last_text for sig in step_done_signals):
15968
+ should_advance = True
15969
+ if should_advance:
15970
+ evidence = f"single-agent auto-advance: phase={phase}, wrote={wrote_files}, bash_ok={ran_bash_ok}"
15971
+ self._advance_plan_step(evidence=evidence, actor="single")
15972
+ else:
15973
+ self._sync_todos_from_blackboard(reason="single-agent-round")
15974
+
14799
15975
  def _todo_project_rows_from_blackboard(self, board: dict | None = None) -> list[dict]:
14800
15976
  bb = board if isinstance(board, dict) else self._ensure_blackboard()
14801
15977
  todos = bb.get("project_todos", [])
@@ -14861,6 +16037,42 @@ class SessionState:
14861
16037
  self._blackboard_touch()
14862
16038
  self._sync_todos_from_blackboard(reason="approved", board=board)
14863
16039
 
16040
+ # --- Reviewer Debug Mode ---
16041
+
16042
+ def _activate_reviewer_debug_mode(self, error_context: str = ""):
16043
+ """Activate reviewer debug mode — reviewer gets write access to fix bugs."""
16044
+ if bool(self.reviewer_debug_mode):
16045
+ return # already active
16046
+ self.reviewer_debug_mode = True
16047
+ self.reviewer_debug_rounds = 0
16048
+ self.reviewer_debug_context = trim(str(error_context or ""), 1000)
16049
+ self._emit("status", {
16050
+ "summary": "reviewer debug mode ACTIVATED — reviewer can now edit files to fix bugs"
16051
+ })
16052
+
16053
+ def _deactivate_reviewer_debug_mode(self, reason: str = ""):
16054
+ """Deactivate reviewer debug mode — reviewer returns to read-only."""
16055
+ if not bool(self.reviewer_debug_mode):
16056
+ return
16057
+ self.reviewer_debug_mode = False
16058
+ rounds_used = int(self.reviewer_debug_rounds)
16059
+ self.reviewer_debug_rounds = 0
16060
+ self.reviewer_debug_context = ""
16061
+ self._emit("status", {
16062
+ "summary": f"reviewer debug mode DEACTIVATED ({reason or 'resolved'}; {rounds_used} rounds used)"
16063
+ })
16064
+
16065
+ def _check_reviewer_debug_mode_timeout(self) -> bool:
16066
+ """Check if debug mode should be deactivated due to round limit.
16067
+ Returns True if debug mode was deactivated (should switch to developer)."""
16068
+ if not bool(self.reviewer_debug_mode):
16069
+ return False
16070
+ self.reviewer_debug_rounds = int(self.reviewer_debug_rounds or 0) + 1
16071
+ if self.reviewer_debug_rounds > REVIEWER_DEBUG_MODE_MAX_ROUNDS:
16072
+ self._deactivate_reviewer_debug_mode("round limit exceeded")
16073
+ return True
16074
+ return False
16075
+
14864
16076
  def _auto_summary_on_finish(self) -> str:
14865
16077
  """Generate concise summary from blackboard state when run ends."""
14866
16078
  bb = self._ensure_blackboard()
@@ -15015,7 +16227,9 @@ class SessionState:
15015
16227
 
15016
16228
  proj_todos = board.get("project_todos", [])
15017
16229
  if proj_todos:
15018
- lines.append("\n### Project Tasks")
16230
+ has_plan_steps = any(pt.get("category") == "plan_step" for pt in proj_todos)
16231
+ title = "Plan Steps" if has_plan_steps else "Project Tasks"
16232
+ lines.append(f"\n### {title}")
15019
16233
  for pt in proj_todos:
15020
16234
  s = pt.get("status", "pending")
15021
16235
  c = trim(str(pt.get("content", "") or ""), 200)
@@ -15026,8 +16240,9 @@ class SessionState:
15026
16240
  mark = "[>]"
15027
16241
  else:
15028
16242
  mark = "[ ]"
16243
+ prefix = f"Step {int(pt.get('plan_step_index', 0) or 0) + 1}: " if has_plan_steps else ""
15029
16244
  suffix = f" — {ev}" if ev else ""
15030
- lines.append(f"- {mark} {c}{suffix}")
16245
+ lines.append(f"- {mark} {prefix}{c}{suffix}")
15031
16246
 
15032
16247
  return "\n".join(lines)
15033
16248
 
@@ -15052,6 +16267,10 @@ class SessionState:
15052
16267
  "requires_user_confirmation": {"type": "boolean"},
15053
16268
  "is_mandatory": {"type": "boolean"},
15054
16269
  "executor_mode": {"type": "boolean"},
16270
+ "advance_plan_step": {
16271
+ "type": "boolean",
16272
+ "description": "Set true if the current plan step is completed and should advance to next step.",
16273
+ },
15055
16274
  },
15056
16275
  ["target", "instruction"],
15057
16276
  )
@@ -15080,6 +16299,15 @@ class SessionState:
15080
16299
  "participants": {"type": "array", "items": {"type": "string", "enum": list(AGENT_ROLES)}},
15081
16300
  "assigned_expert": {"type": "string", "enum": list(AGENT_ROLES)},
15082
16301
  "requires_user_confirmation": {"type": "boolean"},
16302
+ "requires_plan": {
16303
+ "type": "boolean",
16304
+ "description": (
16305
+ "Whether this task needs a plan-mode research phase before execution. "
16306
+ "True for tasks involving: multi-file refactoring, architecture changes, "
16307
+ "migration, new feature with unclear scope, system-level changes. "
16308
+ "False for simple Q&A, single-file edits, direct bug fixes."
16309
+ ),
16310
+ },
15083
16311
  },
15084
16312
  ["level", "judgement", "inherit_previous_state"],
15085
16313
  )
@@ -15353,6 +16581,11 @@ class SessionState:
15353
16581
  level = 3
15354
16582
  if level not in TASK_LEVEL_CHOICES:
15355
16583
  level = 3
16584
+ # Complexity inheritance: if user didn't mention complexity, inherit previous level
16585
+ previous_level = int(getattr(self, "runtime_task_level", 0) or 0)
16586
+ if previous_level > 0 and not self._user_mentions_complexity(str(goal_text or "")):
16587
+ if bool(inherit_previous_state) or self._is_plan_choice_response(str(goal_text or "")):
16588
+ level = previous_level
15356
16589
  policy = dict(TASK_LEVEL_POLICIES.get(level, TASK_LEVEL_POLICIES[3]))
15357
16590
  policy_mode = str(policy.get("execution_mode", EXECUTION_MODE_SYNC))
15358
16591
  config_mode = normalize_execution_mode(self.execution_mode, default="")
@@ -15485,7 +16718,9 @@ class SessionState:
15485
16718
  objective = trim(str(row.get("direct_objective", "") or "").strip(), 800)
15486
16719
  if not objective:
15487
16720
  objective = fallback_objective
16721
+ _prev_level_val = int(getattr(self, '_prev_applied_task_level', 0) or 0)
15488
16722
  self.runtime_task_level = int(level)
16723
+ self._prev_applied_task_level = int(level)
15489
16724
  self.runtime_execution_mode = mode
15490
16725
  self.runtime_assigned_expert = assigned
15491
16726
  self.runtime_participants = list(participants)
@@ -15499,6 +16734,32 @@ class SessionState:
15499
16734
  self.runtime_direct_objective = objective
15500
16735
  self.runtime_reclassify_goal = trim(str(goal_text or "").strip(), 4000)
15501
16736
  self.runtime_reclassify_required = False
16737
+ # Plan mode 判定(用户偏好 > 规则兜底 > LLM 语义)
16738
+ raw_requires_plan = bool(decision.get("requires_plan", False))
16739
+ user_pref = str(self.plan_mode_user_preference or "auto").lower()
16740
+ if user_pref == "off":
16741
+ requires_plan = False
16742
+ elif user_pref == "on":
16743
+ requires_plan = True
16744
+ else: # auto — 保持原逻辑
16745
+ if level in PLAN_MODE_FORCED_LEVELS:
16746
+ requires_plan = True
16747
+ elif level in PLAN_MODE_ENABLED_LEVELS:
16748
+ requires_plan = raw_requires_plan
16749
+ else:
16750
+ requires_plan = False
16751
+ if self.runtime_plan_approved:
16752
+ requires_plan = False
16753
+ self.runtime_plan_mode_needed = requires_plan
16754
+ # Check if user is replying with a plan choice
16755
+ if self.runtime_plan_proposal and not self.runtime_plan_approved:
16756
+ choice_text = str(goal_text or "").strip()
16757
+ choice = self._parse_plan_choice(choice_text, self.runtime_plan_proposal)
16758
+ if choice:
16759
+ self.runtime_plan_choice = choice
16760
+ self.runtime_plan_approved = True
16761
+ self.runtime_plan_mode_needed = False
16762
+ self._inject_plan_into_context(choice)
15502
16763
  board = self._ensure_blackboard()
15503
16764
  profile = self._ensure_blackboard_task_profile(board)
15504
16765
  profile["task_level"] = int(level)
@@ -15548,6 +16809,13 @@ class SessionState:
15548
16809
  )
15549
16810
  },
15550
16811
  )
16812
+ if _prev_level_val > 0 and _prev_level_val != int(level):
16813
+ self._emit("status", {
16814
+ "summary": (
16815
+ f"⚠️ 复杂度等级变更: L{_prev_level_val} → L{level} "
16816
+ f"(理由: {trim(judgement, 200)})"
16817
+ )
16818
+ })
15551
16819
 
15552
16820
  def _manager_classify_task_level(
15553
16821
  self,
@@ -15579,6 +16847,15 @@ class SessionState:
15579
16847
  "IMPORTANT: User has configured Single-agent mode. "
15580
16848
  "Prefer level 1-2 for simple tasks. Only escalate to level 3+ if truly complex.\n"
15581
16849
  )
16850
+ prev_level = int(self.runtime_task_level or 0)
16851
+ if prev_level > 0 and self._is_minor_followup_input(goal_text):
16852
+ prompt += (
16853
+ f"\nIMPORTANT: The previous task was classified as level {prev_level}. "
16854
+ f"This appears to be a follow-up input, NOT a new task. "
16855
+ f"Unless the user is clearly starting a completely different task, "
16856
+ f"you MUST inherit the previous level={prev_level} and set inherit_previous_state=true. "
16857
+ f"Do NOT downgrade the level for follow-up inputs.\n"
16858
+ )
15582
16859
  with self.lock:
15583
16860
  self.current_phase = "manager:classify:model-call"
15584
16861
  self.current_tool_name = ""
@@ -15624,6 +16901,61 @@ class SessionState:
15624
16901
  row["low_confidence_reason"] = "manager classifier returned no valid tool call"
15625
16902
  return row
15626
16903
 
16904
+ # ------------------------------------------------------------------
16905
+ # Continuation / follow-up detection (complexity inheritance)
16906
+ # ------------------------------------------------------------------
16907
+
16908
+ _CONTINUATION_PHRASES = frozenset({
16909
+ "继续", "continue", "go", "proceed", "go ahead", "ok", "好", "好的",
16910
+ "是", "是的", "确认", "执行", "开始", "start", "yes", "do it", "run it",
16911
+ "let's go", "carry on", "keep going", "接着做", "往下做", "下一步", "next",
16912
+ })
16913
+
16914
+ def _is_continuation_input(self, text: str) -> bool:
16915
+ """Return True if *text* is a short continuation phrase that should inherit complexity."""
16916
+ t = str(text or "").strip().lower().rstrip("!.。!")
16917
+ if not t or len(t) > 30:
16918
+ return False
16919
+ if "?" in t or "?" in t or "\n" in t:
16920
+ return False
16921
+ return t in self._CONTINUATION_PHRASES
16922
+
16923
+ _NEW_TASK_PREFIXES = (
16924
+ "实现", "创建", "写一个", "新建", "添加", "开发", "设计", "重构", "修改",
16925
+ "build", "implement", "create", "add", "write", "develop", "design",
16926
+ "refactor", "make", "generate", "set up", "setup",
16927
+ )
16928
+
16929
+ def _is_minor_followup_input(self, text: str) -> bool:
16930
+ """Return True if *text* looks like a lightweight follow-up, not a brand-new task."""
16931
+ t = str(text or "").strip()
16932
+ if not t or len(t) > 80:
16933
+ return False
16934
+ t_lower = t.lower()
16935
+ for prefix in self._NEW_TASK_PREFIXES:
16936
+ if t_lower.startswith(prefix):
16937
+ return False
16938
+ board = self._ensure_blackboard()
16939
+ progress = str(board.get("progress", "") or "").strip().lower()
16940
+ if progress in ("done", "completed"):
16941
+ return False
16942
+ return True
16943
+
16944
+ def _is_plan_choice_response(self, text: str) -> bool:
16945
+ """Check if user text is a response to a plan proposal."""
16946
+ bb = self._ensure_blackboard()
16947
+ plan = bb.get("plan", {})
16948
+ if not isinstance(plan, dict):
16949
+ return False
16950
+ if plan.get("phase") != "awaiting_choice":
16951
+ return False
16952
+ return True
16953
+
16954
+ def _user_mentions_complexity(self, text: str) -> bool:
16955
+ """Check if user text explicitly mentions complexity-related keywords."""
16956
+ t = str(text or "").strip().lower()
16957
+ return any(kw in t for kw in COMPLEXITY_KEYWORDS)
16958
+
15627
16959
  def _refresh_runtime_task_policy(
15628
16960
  self,
15629
16961
  *,
@@ -15635,6 +16967,26 @@ class SessionState:
15635
16967
  goal = trim(str(goal_text or self.runtime_reclassify_goal or self._latest_user_goal_text() or "").strip(), 4000)
15636
16968
  if not goal:
15637
16969
  return {}
16970
+ # --- Continuation input short-circuit: inherit previous complexity ---
16971
+ prev_level = int(self.runtime_task_level or 0)
16972
+ if prev_level > 0 and self._is_continuation_input(goal):
16973
+ decision = {
16974
+ "level": prev_level,
16975
+ "execution_mode": self._effective_execution_mode(),
16976
+ "participants": list(self.runtime_participants or []),
16977
+ "assigned_expert": str(self.runtime_assigned_expert or ""),
16978
+ "task_type": str(self.runtime_task_type or "general"),
16979
+ "complexity": str(self.runtime_task_complexity or "simple"),
16980
+ "scale_preference": str(self.runtime_scale_preference or "balanced"),
16981
+ "round_budget": int(self.runtime_round_budget or 0),
16982
+ "requires_user_confirmation": False,
16983
+ "inherit_previous_state": True,
16984
+ "judgement": "continuation input detected — inheriting previous complexity",
16985
+ "source": "continuation-inherit",
16986
+ "semantic_confidence": "high",
16987
+ }
16988
+ self._apply_runtime_task_decision(goal, decision)
16989
+ return dict(decision)
15638
16990
  need = bool(
15639
16991
  force
15640
16992
  or self.runtime_reclassify_required
@@ -15661,11 +17013,94 @@ class SessionState:
15661
17013
  self._apply_runtime_task_decision(goal, decision)
15662
17014
  return dict(decision or {})
15663
17015
 
17016
+ def _plan_steps_context_for_manager(self) -> str:
17017
+ bb = self._ensure_blackboard()
17018
+ todos = bb.get("project_todos", [])
17019
+ if not todos or not any(t.get("category") == "plan_step" for t in todos):
17020
+ return ""
17021
+ lines = ["APPROVED PLAN STEPS:"]
17022
+ for t in todos:
17023
+ if t.get("category") != "plan_step":
17024
+ continue
17025
+ idx = int(t.get("plan_step_index", 0) or 0) + 1
17026
+ status = t.get("status", "pending")
17027
+ mark = "✅" if status == "completed" else "👉" if status == "in_progress" else "⬜"
17028
+ phase_hint = self._plan_step_phase_hint(str(t.get("content", "") or ""))
17029
+ phase_tag = f" [{phase_hint}]" if phase_hint else ""
17030
+ lines.append(f" {mark} Step {idx}: {trim(str(t.get('content', '') or ''), 160)}{phase_tag}")
17031
+ lines.append("Execute steps IN ORDER. Do NOT skip ahead. Mark current step done before advancing. ")
17032
+ # Add current phase routing hint
17033
+ current_phase = self._infer_current_phase_from_blackboard()
17034
+ if current_phase:
17035
+ preferred = TASK_PHASE_ROUTING.get(current_phase, "developer")
17036
+ lines.append(
17037
+ f"CURRENT PHASE: {current_phase} → prefer routing to {preferred}. "
17038
+ f"Each phase has its own expertise — let {preferred} lead this phase independently. "
17039
+ "Do NOT carry over implementation patterns from previous phases. "
17040
+ )
17041
+ return "\n".join(lines) + "\n"
17042
+
17043
+ def _plan_step_phase_hint(self, step_content: str) -> str:
17044
+ """Infer the task phase from a plan step's content."""
17045
+ c = str(step_content or "").lower()
17046
+ if any(kw in c for kw in ("研究", "分析", "调研", "探索", "research", "analyze", "investigate", "explore", "inspect")):
17047
+ return "research"
17048
+ if any(kw in c for kw in ("设计", "架构", "规划", "design", "architect", "plan", "interface", "接口")):
17049
+ return "design"
17050
+ if any(kw in c for kw in ("实现", "编写", "创建", "开发", "implement", "write", "create", "build", "develop", "code")):
17051
+ return "implement"
17052
+ if any(kw in c for kw in ("测试", "验证", "检查", "test", "verify", "check", "validate", "compile")):
17053
+ return "test"
17054
+ if any(kw in c for kw in ("审查", "评审", "review", "audit", "inspect code")):
17055
+ return "review"
17056
+ if any(kw in c for kw in ("部署", "发布", "deploy", "release", "publish", "配置")):
17057
+ return "deploy"
17058
+ return ""
17059
+
17060
+ def _infer_current_phase_from_blackboard(self) -> str:
17061
+ """Infer the current task phase from blackboard state and active plan step."""
17062
+ bb = self._ensure_blackboard()
17063
+ for t in bb.get("project_todos", []):
17064
+ if t.get("category") == "plan_step" and t.get("status") == "in_progress":
17065
+ phase = self._plan_step_phase_hint(str(t.get("content", "") or ""))
17066
+ if phase:
17067
+ return phase
17068
+ # Fallback: infer from blackboard state
17069
+ code_count = len(bb.get("code_artifacts", {}) or {})
17070
+ research_count = len(bb.get("research_notes", []) or [])
17071
+ feedback = bb.get("review_feedback", []) if isinstance(bb.get("review_feedback"), list) else []
17072
+ if not research_count and not code_count:
17073
+ return "research"
17074
+ if research_count > 0 and not code_count:
17075
+ return "design"
17076
+ if code_count > 0 and not feedback:
17077
+ return "implement"
17078
+ if feedback:
17079
+ return "review"
17080
+ return ""
17081
+
15664
17082
  def _project_todo_hint_for_manager(self) -> str:
15665
17083
  bb = self._ensure_blackboard()
15666
17084
  todos = bb.get("project_todos", [])
15667
17085
  if not todos:
15668
17086
  return ""
17087
+ # plan step 模式
17088
+ has_plan_steps = any(t.get("category") == "plan_step" for t in todos)
17089
+ if has_plan_steps:
17090
+ completed = [t for t in todos if t.get("category") == "plan_step" and t.get("status") == "completed"]
17091
+ pending = [t for t in todos if t.get("category") == "plan_step" and t.get("status") != "completed"]
17092
+ if not pending:
17093
+ return f"All {len(completed)} plan steps completed. Route to finish. "
17094
+ cur = pending[0]
17095
+ step_idx = int(cur.get("plan_step_index", 0) or 0) + 1
17096
+ total = len(completed) + len(pending)
17097
+ return (
17098
+ f"⚠️ PLAN STEP {step_idx}/{total}: {trim(str(cur.get('content', '') or ''), 200)}. "
17099
+ f"({len(completed)} completed, {len(pending)} remaining) "
17100
+ f"DO NOT finish until all {total} steps are completed. "
17101
+ f"Focus on THIS step. When done, set advance_plan_step=true. "
17102
+ )
17103
+ # 原有逻辑
15669
17104
  pending = [t for t in todos if t.get("status") != "completed"]
15670
17105
  if not pending:
15671
17106
  return "All project tasks completed. Route to finish. "
@@ -15693,6 +17128,35 @@ class SessionState:
15693
17128
  "Explorer should only be used for specific file/API lookups, not broad analysis. "
15694
17129
  )
15695
17130
  project_todo_hint = self._project_todo_hint_for_manager()
17131
+ plan_context = self._plan_steps_context_for_manager()
17132
+ # If no plan steps, give manager autonomous planning ability
17133
+ if not plan_context:
17134
+ plan_context = (
17135
+ "No pre-approved plan. Analyze the goal, break it into logical phases, "
17136
+ "and delegate one phase at a time. Track progress via blackboard. "
17137
+ "Each phase should use the most appropriate agent for that phase's work. "
17138
+ )
17139
+ # Phase-aware independence hint
17140
+ current_phase = self._infer_current_phase_from_blackboard()
17141
+ phase_hint = ""
17142
+ if current_phase:
17143
+ phase_hint = (
17144
+ f"PHASE INDEPENDENCE: Current phase is '{current_phase}'. "
17145
+ "Each task phase has its own expertise and approach. "
17146
+ "Do NOT force implementation patterns from previous phases onto the current one. "
17147
+ "For research/analysis phases: let the agent think freely and use its domain knowledge. "
17148
+ "For implementation phases: focus on concrete code output. "
17149
+ "For test/review phases: focus on verification, not re-implementation. "
17150
+ )
17151
+ failure_brief = self._failure_aware_brief()
17152
+ failure_hint = ""
17153
+ if failure_brief:
17154
+ failure_hint = (
17155
+ "FAILURE CONTEXT (from ledger):\n"
17156
+ f"{trim(failure_brief, 600)}\n"
17157
+ "IMPORTANT: Previous fix attempts FAILED. You MUST change your approach — "
17158
+ "do NOT repeat the same instruction. Include the exact error output in your delegation. "
17159
+ )
15696
17160
  return (
15697
17161
  "You are Manager in a multi-agent coding system. "
15698
17162
  "Read blackboard, delegate one short timeslice via route_to_next_agent. "
@@ -15701,10 +17165,13 @@ class SessionState:
15701
17165
  "Role capabilities: "
15702
17166
  "Explorer=read-only (bash/read_file/search/blackboard, NO write_file/edit_file); "
15703
17167
  "Developer=all tools (write_file/edit_file/bash/read_file/etc); "
15704
- "Reviewer=read+verify (bash/read_file/finish_task, NO write_file/edit_file). "
15705
- "NEVER delegate file-writing tasks to Explorer or Reviewer. "
17168
+ f"{'Reviewer=DEBUG MODE (bash/read_file/write_file/edit_file/finish_task — can fix bugs directly). ' if bool(self.reviewer_debug_mode) else 'Reviewer=read+verify (bash/read_file/finish_task, NO write_file/edit_file). '}"
17169
+ f"{'NEVER delegate file-writing tasks to Explorer. Reviewer is in debug mode and can fix bugs. ' if bool(self.reviewer_debug_mode) else 'NEVER delegate file-writing tasks to Explorer or Reviewer. '}"
15706
17170
  f"{coding_hint}"
15707
17171
  f"{project_todo_hint}"
17172
+ f"{plan_context}"
17173
+ f"{phase_hint}"
17174
+ f"{failure_hint}"
15708
17175
  f"Level={level}, mode={mode}, progress={progress}, "
15709
17176
  f"budget={'unlimited' if int(budget) <= 0 else int(budget)}, "
15710
17177
  f"objective={trim(str(profile.get('direct_objective','') or ''), 220)}. "
@@ -15871,7 +17338,25 @@ class SessionState:
15871
17338
  cycles = int(board.get("manager_cycles", 0) or 0)
15872
17339
  summary_attempts = int(board.get("manager_summary_attempts", 0) or 0)
15873
17340
  max_budget = max(1, int(getattr(self, "max_agent_rounds", MAX_AGENT_ROUNDS) or MAX_AGENT_ROUNDS))
17341
+ # Hard guard: pending plan steps block finish — redirect to developer instead
17342
+ pending_plan_steps = [
17343
+ t for t in board.get("project_todos", [])
17344
+ if t.get("category") == "plan_step" and t.get("status") != "completed"
17345
+ ]
15874
17346
  if cycles >= max_budget:
17347
+ if pending_plan_steps:
17348
+ # Budget exhausted but plan steps remain — continue with current step instead of finishing
17349
+ cur = pending_plan_steps[0]
17350
+ self._emit("status", {
17351
+ "summary": f"Max cycles reached but {len(pending_plan_steps)} plan steps pending; continuing."
17352
+ })
17353
+ return {
17354
+ "target": "developer",
17355
+ "instruction": f"Continue with plan step: {trim(str(cur.get('content', '')), 300)}",
17356
+ "reason": f"budget-exhausted-but-plan-steps-pending:{len(pending_plan_steps)}",
17357
+ "source": "fallback",
17358
+ "advance_plan_step": False,
17359
+ }
15875
17360
  self._emit("status", {"summary": "Max cycles reached; forcing finish."})
15876
17361
  return {
15877
17362
  "target": "finish",
@@ -16147,12 +17632,43 @@ class SessionState:
16147
17632
  return row
16148
17633
  target = str(row.get("target", "") or "").strip().lower()
16149
17634
  task_type_low = str(row.get("task_type", "") or "").strip().lower()
17635
+ # 5a: Merge in-memory routes with persisted routes for detection
17636
+ bb_for_routes = self._ensure_blackboard()
17637
+ persisted_routes = bb_for_routes.get("persisted_manager_routes", [])
17638
+ if not isinstance(persisted_routes, list):
17639
+ persisted_routes = []
17640
+ merged_routes = list(persisted_routes) + list(self.manager_routes)
17641
+ # 5b: Delegation repetition detection from failure_ledger
17642
+ fl = bb_for_routes.get("failure_ledger", {})
17643
+ if isinstance(fl, dict):
17644
+ for deleg in (fl.get("repeated_delegations") or []):
17645
+ if (
17646
+ isinstance(deleg, dict)
17647
+ and str(deleg.get("target", "") or "").strip().lower() == target
17648
+ and int(deleg.get("count", 0) or 0) >= 3
17649
+ ):
17650
+ alt_targets = [r for r in ("reviewer", "developer", "explorer") if r != target]
17651
+ if len(bb_for_routes.get("code_artifacts", {}) or {}) > 0:
17652
+ row["target"] = "finish"
17653
+ row["instruction"] = (
17654
+ f"Anti-stall: delegation to '{target}' repeated {deleg.get('count')} times with same instruction. "
17655
+ "Forcing finish to break loop."
17656
+ )
17657
+ else:
17658
+ row["target"] = alt_targets[0] if alt_targets else "developer"
17659
+ row["instruction"] = (
17660
+ f"Anti-stall: delegation to '{target}' repeated {deleg.get('count')} times. "
17661
+ f"Switching to {row['target']} with fresh approach."
17662
+ )
17663
+ row["reason"] = f"{row.get('reason', '')}|anti-stall-repeated-delegation"
17664
+ row["source"] = "anti-stall"
17665
+ return row
16150
17666
  if task_type_low in ("simple_code", "engineering") and target == "explorer":
16151
- board = self._ensure_blackboard()
17667
+ board = bb_for_routes
16152
17668
  progress = self._manager_progress_state(board)
16153
17669
  if progress in ("initializing", "in_progress"):
16154
17670
  explorer_count = sum(
16155
- 1 for x in self.manager_routes[-8:]
17671
+ 1 for x in merged_routes[-8:]
16156
17672
  if str(x.get("target", "") or "").strip().lower() == "explorer"
16157
17673
  )
16158
17674
  if explorer_count >= 2:
@@ -16166,9 +17682,9 @@ class SessionState:
16166
17682
  return row
16167
17683
  if target not in AGENT_ROLES:
16168
17684
  return row
16169
- recent = [str(x.get("target", "") or "").strip().lower() for x in self.manager_routes[-4:]]
17685
+ recent = [str(x.get("target", "") or "").strip().lower() for x in merged_routes[-4:]]
16170
17686
  if len(recent) >= 2 and recent[-1] == target and recent[-2] == target:
16171
- board = self._ensure_blackboard()
17687
+ board = bb_for_routes
16172
17688
  low_reason = str(row.get("reason", "") or "").strip().lower()
16173
17689
  if "summary" in low_reason and len(board.get("code_artifacts", {}) or {}) > 0:
16174
17690
  row["target"] = "finish"
@@ -16191,7 +17707,7 @@ class SessionState:
16191
17707
  row["source"] = "anti-stall"
16192
17708
  return row
16193
17709
  if len(recent) == 4 and recent[0] == recent[2] and recent[1] == recent[3] and recent[0] != recent[1]:
16194
- board = self._ensure_blackboard()
17710
+ board = bb_for_routes
16195
17711
  if len(board.get("code_artifacts", {}) or {}) > 0:
16196
17712
  row["target"] = "finish"
16197
17713
  row["instruction"] = "Oscillation detected with existing outputs; finish now."
@@ -16382,7 +17898,17 @@ class SessionState:
16382
17898
  target = assigned_expert
16383
17899
  else:
16384
17900
  if self._manager_has_error_log(board):
16385
- target = "explorer"
17901
+ # Error exists: activate reviewer debug mode and route to reviewer
17902
+ error_logs = board.get("execution_logs", []) if isinstance(board.get("execution_logs"), list) else []
17903
+ error_summary = ""
17904
+ for log in reversed(error_logs[-5:]):
17905
+ if isinstance(log, dict) and str(log.get("category", "")).lower() in ("compilation", "runtime", "test", "lint", "build"):
17906
+ error_summary = trim(str(log.get("error", log.get("output", "")) or ""), 500)
17907
+ break
17908
+ if not error_summary:
17909
+ error_summary = "Error detected in execution logs. Check blackboard for details."
17910
+ self._activate_reviewer_debug_mode(error_summary)
17911
+ target = "reviewer"
16386
17912
  elif len(board.get("code_artifacts", {}) or {}) > 0:
16387
17913
  target = "reviewer"
16388
17914
  elif len(board.get("research_notes", []) or []) > 0:
@@ -16562,6 +18088,7 @@ class SessionState:
16562
18088
  "requires_user_confirmation": bool(args.get("requires_user_confirmation", False)),
16563
18089
  "is_mandatory": _to_bool_like(args.get("is_mandatory", False), default=False),
16564
18090
  "executor_mode": _to_bool_like(args.get("executor_mode", False), default=False),
18091
+ "advance_plan_step": _to_bool_like(args.get("advance_plan_step", False), default=False),
16565
18092
  "round_budget": args.get("round_budget", 0),
16566
18093
  "reason": trim(str(text or "").strip(), 600),
16567
18094
  "source": "tool",
@@ -16858,6 +18385,29 @@ class SessionState:
16858
18385
  active_profile = self._ensure_blackboard_task_profile(board)
16859
18386
  target = str(route.get("target", "") or "").strip().lower()
16860
18387
  instruction = trim(str(route.get("instruction", "") or "").strip(), 1200)
18388
+ # Anti-stall: detect explorer consecutive delegation loop
18389
+ if target == "explorer":
18390
+ inst_hash = str(hash(instruction[:200]))
18391
+ if inst_hash == self.last_explorer_instruction_hash:
18392
+ self.explorer_consecutive_delegations += 1
18393
+ else:
18394
+ self.explorer_consecutive_delegations = 1
18395
+ self.last_explorer_instruction_hash = inst_hash
18396
+ if self.explorer_consecutive_delegations >= EXPLORER_STALL_THRESHOLD:
18397
+ self._emit("status", {
18398
+ "summary": f"explorer stall detected ({self.explorer_consecutive_delegations} consecutive); switching to developer"
18399
+ })
18400
+ target = "developer"
18401
+ instruction = f"Explorer is stalled. Take over: {instruction}"
18402
+ self.explorer_consecutive_delegations = 0
18403
+ else:
18404
+ self.explorer_consecutive_delegations = 0
18405
+ self.last_explorer_instruction_hash = ""
18406
+ # Reviewer debug mode timeout check
18407
+ if target == "reviewer" and bool(self.reviewer_debug_mode):
18408
+ if self._check_reviewer_debug_mode_timeout():
18409
+ target = "developer"
18410
+ instruction = f"Reviewer debug mode timed out. Developer take over: {instruction}"
16861
18411
  try:
16862
18412
  task_level = int(route.get("task_level", active_profile.get("task_level", self.runtime_task_level or 3)) or 3)
16863
18413
  except Exception:
@@ -16925,8 +18475,27 @@ class SessionState:
16925
18475
  "round_budget": int(round_budget),
16926
18476
  "remaining_rounds": int(remaining_rounds),
16927
18477
  }
18478
+ # advance_plan_step: 当前 plan step 完成,推进到下一步
18479
+ if _to_bool_like(route.get("advance_plan_step", False), default=False):
18480
+ self._advance_plan_step(
18481
+ evidence=trim(str(route.get("instruction", "") or ""), 200),
18482
+ actor=str(route.get("target", "developer") or "developer"),
18483
+ )
16928
18484
  self.manager_routes.append(route_row)
16929
18485
  self.manager_routes = self.manager_routes[-240:]
18486
+ # Failure ledger: persist route and record delegation
18487
+ _pmr = board.get("persisted_manager_routes", [])
18488
+ if not isinstance(_pmr, list):
18489
+ _pmr = []
18490
+ _pmr.append({
18491
+ "target": target,
18492
+ "instruction_hash": hashlib.sha1(str(instruction or "").encode("utf-8")).hexdigest()[:12],
18493
+ "round": int(getattr(self, "agent_round_index", 0) or 0),
18494
+ "ts": float(now_ts()),
18495
+ })
18496
+ board["persisted_manager_routes"] = _pmr[-PERSISTED_ROUTES_MAX:]
18497
+ self.blackboard = board
18498
+ self._ledger_record_delegation(target, instruction)
16930
18499
  profile = self._ensure_blackboard_task_profile(board)
16931
18500
  profile["task_level"] = int(task_level)
16932
18501
  profile["execution_mode"] = execution_mode
@@ -17089,7 +18658,23 @@ class SessionState:
17089
18658
  role_capability_note = {
17090
18659
  "explorer": "YOUR TOOLS: read-only (bash/read_file/search/blackboard). You CANNOT write_file or edit_file.",
17091
18660
  "developer": "YOUR TOOLS: all tools available (write_file/edit_file/bash/read_file/etc).",
17092
- "reviewer": "YOUR TOOLS: read+verify (bash/read_file/finish_task). You CANNOT write_file or edit_file.",
18661
+ "reviewer": (
18662
+ "YOUR TOOLS: read+verify+DEBUG (bash/read_file/write_file/edit_file/finish_task). "
18663
+ "You CAN directly edit code to fix bugs when in debug mode. "
18664
+ "DEBUG METHODOLOGY: 1) read_file to see the exact problematic line, "
18665
+ "2) analyze the error output character-by-character, "
18666
+ "3) edit_file to fix the root cause directly, "
18667
+ "4) bash to verify the fix compiles/runs, "
18668
+ "5) finish_task when the error is resolved. "
18669
+ ) if bool(self.reviewer_debug_mode) else (
18670
+ "YOUR TOOLS: read+verify (bash/read_file/finish_task). You CANNOT write_file or edit_file. "
18671
+ "When you find errors: 1) read_file to see the exact problematic line, "
18672
+ "2) analyze the error output character-by-character, "
18673
+ "3) send ask_colleague(to='developer', intent='fix_request') with STRUCTURED content: "
18674
+ "ERROR: <exact error output>, FILE: <path>, LINE: <number>, "
18675
+ "ROOT_CAUSE: <analysis>, CATEGORY: <compilation|runtime|test|lint|build|deploy>, "
18676
+ "FIX: change '<old>' to '<new>' OR <action to take>. "
18677
+ ),
17093
18678
  }.get(role_key, "")
17094
18679
  if role_key == "explorer":
17095
18680
  tool_examples = "bash/read_file/read_from_blackboard"
@@ -17120,6 +18705,16 @@ class SessionState:
17120
18705
  "use ask_colleague immediately with explicit intent and concise payload; "
17121
18706
  "do not wait for another manager cycle."
17122
18707
  )
18708
+ compile_error_ctx = self._recent_compile_error_context(max_chars=800)
18709
+ error_section = ""
18710
+ if compile_error_ctx:
18711
+ error_section = (
18712
+ "<compile-error-context>\n"
18713
+ f"{compile_error_ctx}\n"
18714
+ "CRITICAL: Read the EXACT error message above. Fix the SPECIFIC syntax/character issue. "
18715
+ "Do NOT repeat previous failed fixes.\n"
18716
+ "</compile-error-context>\n"
18717
+ )
17123
18718
  board_md = self._blackboard_read_state_markdown(max_items=5)
17124
18719
  payload = (
17125
18720
  "<manager-delegate>\n"
@@ -17132,6 +18727,7 @@ class SessionState:
17132
18727
  f"{executor_note}\n"
17133
18728
  f"{collaboration_note}\n"
17134
18729
  f"{role_capability_note}\n"
18730
+ f"{error_section}"
17135
18731
  "</manager-delegate>\n"
17136
18732
  "<blackboard-state>\n"
17137
18733
  f"{trim(board_md, 6000)}\n"
@@ -17660,10 +19256,13 @@ class SessionState:
17660
19256
  row["agent_role"] = role_key
17661
19257
  if "ts" not in row:
17662
19258
  row["ts"] = now_ts()
17663
- # Write to unified agent_messages
19259
+ # Write to unified agent_messages with tier-aware trim
17664
19260
  self.agent_messages.append(row)
17665
- if len(self.agent_messages) > 1200:
17666
- self.agent_messages = self.agent_messages[-800:]
19261
+ tier = self._context_compression_tier()
19262
+ am_limit = self._tier_agent_context_limits(tier)["agent_messages"]
19263
+ overflow = int(am_limit * 1.5) # trigger trim at 1.5x limit
19264
+ if len(self.agent_messages) > overflow:
19265
+ self.agent_messages = self.agent_messages[-am_limit:]
17667
19266
  if mirror_to_global:
17668
19267
  mirror = dict(row)
17669
19268
  if "tool_calls" in mirror and isinstance(mirror.get("tool_calls"), list):
@@ -17691,10 +19290,14 @@ class SessionState:
17691
19290
  if "ts" not in row:
17692
19291
  row["ts"] = now_ts()
17693
19292
  self.manager_context.append(row)
17694
- self.manager_context = self.manager_context[-400:]
19293
+ tier = self._context_compression_tier()
19294
+ mgr_limit = self._tier_agent_context_limits(tier)["manager_context"]
19295
+ if len(self.manager_context) > int(mgr_limit * 1.5):
19296
+ self.manager_context = self.manager_context[-mgr_limit:]
17695
19297
  self.agent_messages.append(row)
17696
- if len(self.agent_messages) > 1200:
17697
- self.agent_messages = self.agent_messages[-800:]
19298
+ am_limit = self._tier_agent_context_limits(tier)["agent_messages"]
19299
+ if len(self.agent_messages) > int(am_limit * 1.5):
19300
+ self.agent_messages = self.agent_messages[-am_limit:]
17698
19301
 
17699
19302
  def _agent_display_name(self, role: str) -> str:
17700
19303
  return AGENT_ROLE_LABELS.get(self._sanitize_agent_role(role), str(role or "").strip().title() or "Agent")
@@ -17849,12 +19452,41 @@ class SessionState:
17849
19452
  "Use blackboard for shared state, ask_colleague for inter-agent communication. "
17850
19453
  "Keep outputs concise and action-oriented. "
17851
19454
  "Use load_skill for detailed guidance (multi-agent-guide, code-review-checklist, finish-protocol). "
19455
+ f"{_detect_os_shell_instruction()} "
17852
19456
  f"{model_language_instruction(self.ui_language)} "
17853
19457
  )
17854
19458
  if role_key == "explorer":
17855
19459
  return base + "Role: analyze goals, inspect codebase, produce research notes. Prefer read/search. "
17856
19460
  if role_key == "reviewer":
17857
- return base + "Role: verify output, run checks, issue pass/fix decisions. Write review_feedback to blackboard. "
19461
+ if bool(self.reviewer_debug_mode):
19462
+ debug_ctx = trim(str(self.reviewer_debug_context or ""), 500)
19463
+ return base + (
19464
+ "Role: DEBUG MODE ACTIVE — independent bug fix process. "
19465
+ "You have FULL write access: write_file, edit_file, bash, read_file, finish_task. "
19466
+ "You CAN and SHOULD directly edit code to fix bugs. "
19467
+ "Be methodical and calm: "
19468
+ "1) Read the error context carefully. "
19469
+ "2) Locate the root cause in the source file. "
19470
+ "3) Fix it with edit_file (precise, minimal changes). "
19471
+ "4) Verify the fix compiles/runs with bash. "
19472
+ "5) Call finish_task when the error is resolved. "
19473
+ f"DEBUG CONTEXT: {debug_ctx} "
19474
+ )
19475
+ return base + (
19476
+ "Role: verify output, run checks, issue pass/fix decisions. Write review_feedback to blackboard. "
19477
+ "DEBUG METHODOLOGY when any error occurs (compilation, runtime, test, lint, build, deploy): "
19478
+ "1) Read the EXACT error message — identify the file, line number, and error type. "
19479
+ "2) Read the source file at that exact line — examine what the error points to. "
19480
+ "3) Identify the ROOT CAUSE — match the error to a specific rule violation. "
19481
+ "For runtime errors: identify the traceback, exception type, and the triggering line. "
19482
+ "For test failures: identify which test failed, the assertion, expected vs actual. "
19483
+ "For lint/type errors: identify the rule violation and exact location. "
19484
+ "4) When sending fix_request via ask_colleague, you MUST include: "
19485
+ "the exact error output, the file and line number, the root cause analysis, "
19486
+ "the error category (compilation/runtime/test/lint/build/deploy), "
19487
+ "and the precise fix (what to change FROM and TO). "
19488
+ "NEVER send vague fix requests without verifying the actual cause. "
19489
+ )
17858
19490
  return base + "Role: implement code changes, execute tools, record progress to blackboard. "
17859
19491
 
17860
19492
  def _seed_multi_agent_contexts_if_needed(self, user_text: str = ""):
@@ -18582,11 +20214,9 @@ class SessionState:
18582
20214
  self._emit("status", {"summary": summary})
18583
20215
  out = f"{out}\n{summary}"
18584
20216
  after_text = try_read_text(fp, max_bytes=CODE_PREVIEW_STAGE_MAX_BYTES) or ""
18585
- diff = make_unified_diff(rel, before_text, after_text)
20217
+ diff, added, deleted = make_unified_diff(rel, before_text, after_text)
18586
20218
  numbered = make_numbered_diff(before_text, after_text)
18587
20219
  numbered_text = render_numbered_diff_text(numbered)
18588
- added = sum(1 for ln in diff.splitlines() if ln.startswith("+") and not ln.startswith("+++"))
18589
- deleted = sum(1 for ln in diff.splitlines() if ln.startswith("-") and not ln.startswith("---"))
18590
20220
  code_stage = self._record_code_preview_stage(
18591
20221
  rel_path=rel,
18592
20222
  before_text=before_text,
@@ -18635,11 +20265,9 @@ class SessionState:
18635
20265
  self._emit("status", {"summary": summary})
18636
20266
  out = f"{out}\n{summary}"
18637
20267
  after_text = try_read_text(fp, max_bytes=CODE_PREVIEW_STAGE_MAX_BYTES) or ""
18638
- diff = make_unified_diff(rel, before_text, after_text)
20268
+ diff, added, deleted = make_unified_diff(rel, before_text, after_text)
18639
20269
  numbered = make_numbered_diff(before_text, after_text)
18640
20270
  numbered_text = render_numbered_diff_text(numbered)
18641
- added = sum(1 for ln in diff.splitlines() if ln.startswith("+") and not ln.startswith("+++"))
18642
- deleted = sum(1 for ln in diff.splitlines() if ln.startswith("-") and not ln.startswith("---"))
18643
20271
  code_stage = self._record_code_preview_stage(
18644
20272
  rel_path=rel,
18645
20273
  before_text=before_text,
@@ -18670,6 +20298,20 @@ class SessionState:
18670
20298
  )
18671
20299
  return out
18672
20300
  if name == "TodoWrite":
20301
+ # Protect plan_step todos: worker TodoWrite creates sub-items, not replacements
20302
+ bb = self._ensure_blackboard()
20303
+ has_plan_steps = any(
20304
+ t.get("category") == "plan_step"
20305
+ for t in bb.get("project_todos", [])
20306
+ )
20307
+ if has_plan_steps:
20308
+ # Tag worker items as sub-tasks so _sync_todos_from_blackboard won't conflict
20309
+ items = args.get("items", [])
20310
+ if isinstance(items, list):
20311
+ for item in items:
20312
+ if isinstance(item, dict) and not item.get("key", "").startswith("bb:"):
20313
+ item["owner"] = str(role_key or "developer")
20314
+ return self.todo.update(items)
18673
20315
  return self.todo.update(args["items"])
18674
20316
  if name == "TodoWriteRescue":
18675
20317
  return self._todo_write_rescue(args)
@@ -18710,6 +20352,9 @@ class SessionState:
18710
20352
  todo_mark = self.todo.complete_all_open(summary)
18711
20353
  else:
18712
20354
  todo_mark = self.todo.complete_active(summary)
20355
+ # Deactivate reviewer debug mode on finish
20356
+ if bool(self.reviewer_debug_mode) and role_key == "reviewer":
20357
+ self._deactivate_reviewer_debug_mode("reviewer finished")
18713
20358
  updated = int(todo_mark.get("updated", 0) or 0)
18714
20359
  if updated > 0:
18715
20360
  self._emit(
@@ -18822,6 +20467,25 @@ class SessionState:
18822
20467
  return "Error: ask_colleague.intent is required"
18823
20468
  if not content:
18824
20469
  return "Error: ask_colleague.content is required"
20470
+ if intent == "fix_request":
20471
+ required_markers = ["ERROR:", "ROOT_CAUSE:", "FIX:"]
20472
+ soft_markers = ["FILE:", "LINE:", "CATEGORY:"]
20473
+ missing = [m for m in required_markers if m not in content.upper()]
20474
+ missing_soft = [m for m in soft_markers if m not in content.upper()]
20475
+ if missing or missing_soft:
20476
+ hint_parts = []
20477
+ if missing:
20478
+ hint_parts.append(f"Missing required: {', '.join(missing)}")
20479
+ if missing_soft:
20480
+ hint_parts.append(f"Recommended: {', '.join(missing_soft)}")
20481
+ content = (
20482
+ f"{content}\n\n"
20483
+ "[SYSTEM NOTE: fix_request should include structured fields: "
20484
+ "ERROR: <exact error>, FILE: <path>, LINE: <number>, "
20485
+ "ROOT_CAUSE: <analysis>, CATEGORY: <compilation|runtime|test|lint|build|deploy>, "
20486
+ "FIX: <specific change>. "
20487
+ f"{'; '.join(hint_parts)}]"
20488
+ )
18825
20489
  env = self._agent_send_bus(from_role, to_role, intent, content)
18826
20490
  return (
18827
20491
  f"agent_bus sent ({env.get('from')} -> {env.get('to')}, "
@@ -19033,6 +20697,8 @@ class SessionState:
19033
20697
  "</live-user-adjustment>"
19034
20698
  )
19035
20699
  self.messages.append({"role": "user", "content": payload, "ts": now_ts()})
20700
+ # Merge user feedback with plan direction
20701
+ self._merge_user_feedback_with_plan(content)
19036
20702
  self.runtime_reclassify_goal = trim(content, 4000)
19037
20703
  self.runtime_reclassify_required = True
19038
20704
  self.runtime_goal_reset_pending = True
@@ -19066,6 +20732,104 @@ class SessionState:
19066
20732
  )
19067
20733
  return len(injected)
19068
20734
 
20735
+ def _merge_user_feedback_with_plan(self, user_text: str):
20736
+ """When user provides feedback during execution, inject plan-aware merge note into manager context."""
20737
+ bb = self._ensure_blackboard()
20738
+ plan = bb.get("plan", {})
20739
+ if not isinstance(plan, dict):
20740
+ return
20741
+ current_step = None
20742
+ for t in bb.get("project_todos", []):
20743
+ if t.get("category") == "plan_step" and t.get("status") == "in_progress":
20744
+ current_step = t
20745
+ break
20746
+ step_desc = trim(str(current_step.get("content", "") if current_step else "none"), 200)
20747
+ is_plan_executing = plan.get("phase") == "executing"
20748
+ if is_plan_executing:
20749
+ merge_note = (
20750
+ f"<user-feedback-merge>\n"
20751
+ f"User provided new input during plan execution: {trim(user_text, 500)}\n"
20752
+ f"Current plan step: {step_desc}\n"
20753
+ f"Re-evaluate: Does this feedback change the current step's approach? "
20754
+ f"If yes, adjust delegation accordingly. If it's a new requirement, "
20755
+ f"integrate it into the current or next step. Do NOT restart from scratch.\n"
20756
+ f"</user-feedback-merge>"
20757
+ )
20758
+ else:
20759
+ merge_note = (
20760
+ f"<user-feedback-merge>\n"
20761
+ f"User provided new input: {trim(user_text, 500)}\n"
20762
+ f"Integrate this feedback with current work direction. "
20763
+ f"Adjust approach if needed but maintain progress.\n"
20764
+ f"</user-feedback-merge>"
20765
+ )
20766
+ if self._is_multi_agent_mode():
20767
+ self._append_manager_context({
20768
+ "role": "system",
20769
+ "content": merge_note,
20770
+ "ts": now_ts(),
20771
+ "agent_role": "manager",
20772
+ })
20773
+
20774
+ def _is_restart_scenario(self) -> bool:
20775
+ """Check if current state is a restart after finished/aborted task."""
20776
+ bb = self._ensure_blackboard()
20777
+ status = str(bb.get("status", "") or "").upper()
20778
+ approval = bb.get("approval", {}) if isinstance(bb.get("approval"), dict) else {}
20779
+ return status in ("COMPLETED", "ABORTED") or bool(approval.get("approved", False))
20780
+
20781
+ def _fuse_restart_intent(self, user_text: str):
20782
+ """Fuse user intent with plan/context intent on restart.
20783
+ Priority: 1. user intent, 2. plan intent, 3. context intent."""
20784
+ bb = self._ensure_blackboard()
20785
+ plan = bb.get("plan", {}) if isinstance(bb.get("plan"), dict) else {}
20786
+ original_goal = trim(str(bb.get("original_goal", "") or ""), 500)
20787
+ plan_steps = plan.get("steps", []) if isinstance(plan.get("steps"), list) else []
20788
+ # Detect pure continuation
20789
+ _CONTINUE_PHRASES = {
20790
+ "继续", "continue", "go on", "接着", "接续", "keep going", "proceed",
20791
+ "继续执行", "接着做", "go ahead", "next", "下一步",
20792
+ }
20793
+ is_continuation = str(user_text or "").strip().lower().rstrip("!.。!") in _CONTINUE_PHRASES
20794
+ if is_continuation and plan_steps:
20795
+ # Pure continuation — inherit plan intent fully
20796
+ pending = [s for i, s in enumerate(plan_steps) if i >= int(bb.get("plan_step_cursor", 0) or 0)]
20797
+ if pending:
20798
+ fused = (
20799
+ f"<intent-fusion type='continuation'>\n"
20800
+ f"User said: {trim(user_text, 100)}\n"
20801
+ f"Continuing approved plan. Original goal: {original_goal}\n"
20802
+ f"Remaining steps: {', '.join(trim(str(s), 80) for s in pending[:5])}\n"
20803
+ f"Resume from where we left off. Do NOT restart.\n"
20804
+ f"</intent-fusion>"
20805
+ )
20806
+ self.messages.append({"role": "user", "content": fused, "ts": now_ts()})
20807
+ # Don't reset blackboard — continue
20808
+ self.runtime_goal_reset_pending = False
20809
+ return
20810
+ if is_continuation and original_goal:
20811
+ # Continuation without plan — inherit context intent
20812
+ fused = (
20813
+ f"<intent-fusion type='context-continuation'>\n"
20814
+ f"User said: {trim(user_text, 100)}\n"
20815
+ f"Continue previous work. Original goal: {original_goal}\n"
20816
+ f"Resume from current state. Do NOT restart.\n"
20817
+ f"</intent-fusion>"
20818
+ )
20819
+ self.messages.append({"role": "user", "content": fused, "ts": now_ts()})
20820
+ self.runtime_goal_reset_pending = False
20821
+ return
20822
+ # New instruction — fuse with context but user intent takes priority
20823
+ if original_goal:
20824
+ fused = (
20825
+ f"<intent-fusion type='new-with-context'>\n"
20826
+ f"New user instruction: {trim(user_text, 500)}\n"
20827
+ f"Previous context: {original_goal}\n"
20828
+ f"User's new instruction takes priority. Use previous context for background only.\n"
20829
+ f"</intent-fusion>"
20830
+ )
20831
+ self.messages.append({"role": "user", "content": fused, "ts": now_ts()})
20832
+
19069
20833
  def submit_user_message(self, content: str):
19070
20834
  start_worker = False
19071
20835
  dropped_stale_inputs = 0
@@ -19076,12 +20840,37 @@ class SessionState:
19076
20840
  else:
19077
20841
  if self.pending_user_inputs:
19078
20842
  dropped_stale_inputs = len(self.pending_user_inputs)
20843
+ # Preserve plan state if awaiting user choice
20844
+ _awaiting_plan_choice = bool(
20845
+ self.runtime_plan_proposal
20846
+ and not self.runtime_plan_approved
20847
+ )
19079
20848
  removed_runtime_hints = self._reset_runtime_state_locked(purge_runtime_hints=True)
20849
+ if _awaiting_plan_choice:
20850
+ # Restore plan proposal so choice can be parsed
20851
+ self.runtime_plan_mode_needed = True
19080
20852
  self.run_generation = int(self.run_generation) + 1
19081
20853
  clean_goal = trim(str(content or "").strip(), 4000)
19082
20854
  self.messages.append({"role": "user", "content": content, "ts": now_ts()})
20855
+ # Parse plan choice immediately after clean_goal is available
20856
+ if _awaiting_plan_choice:
20857
+ choice = self._parse_plan_choice(clean_goal, self.runtime_plan_proposal)
20858
+ if choice:
20859
+ self.runtime_plan_choice = choice
20860
+ self.runtime_plan_approved = True
20861
+ self.runtime_plan_mode_needed = False
20862
+ self._inject_plan_into_context(choice)
20863
+ self.runtime_reclassify_required = False
20864
+ self.runtime_goal_reset_pending = False
20865
+ # Restart intent fusion: merge user/plan/context intents
20866
+ if self._is_restart_scenario():
20867
+ self._fuse_restart_intent(clean_goal)
19083
20868
  self.runtime_reclassify_goal = clean_goal
19084
- self.runtime_reclassify_required = True
20869
+ # Skip reclassification for plan choice responses — preserve complexity
20870
+ if _awaiting_plan_choice or self._is_plan_choice_response(clean_goal):
20871
+ self.runtime_reclassify_required = False
20872
+ else:
20873
+ self.runtime_reclassify_required = True
19085
20874
  self.runtime_goal_reset_pending = True
19086
20875
  self.running = True
19087
20876
  self.current_phase = "starting"
@@ -19265,7 +21054,7 @@ class SessionState:
19265
21054
  for tc in tool_calls
19266
21055
  ]
19267
21056
  self._append_agent_context_message(role_key, assistant, mirror_to_global=True)
19268
- if text.strip() or thinking_text:
21057
+ if (text.strip() or thinking_text) and not tool_calls:
19269
21058
  emit_text = text if text.strip() else "[thinking-only output]"
19270
21059
  self._emit_agent_message(role_key, emit_text, summary=f"{self._agent_display_name(role_key)} response")
19271
21060
  if not tool_calls:
@@ -19364,6 +21153,25 @@ class SessionState:
19364
21153
  }
19365
21154
  # Atomic blackboard sync: update shared state immediately after each tool result.
19366
21155
  self._blackboard_update_from_tool_result(role_key, item)
21156
+ # Failure ledger: record tool call and detect errors (unified)
21157
+ self._ledger_record_tool_call(name, args if isinstance(args, dict) else {})
21158
+ self._process_tool_result_errors(name, args if isinstance(args, dict) else {}, output, item["ok"], role_key)
21159
+ # Failure ledger: record edit_file as potential fix attempt if errors exist
21160
+ if name in ("edit_file", "write_file") and isinstance(args, dict):
21161
+ fl = self._ensure_blackboard().get("failure_ledger", {})
21162
+ all_errors = fl.get("errors", []) if isinstance(fl, dict) else []
21163
+ edit_path = str(args.get("path", "") or "").strip()
21164
+ if all_errors and edit_path:
21165
+ matching = [e for e in all_errors if edit_path.endswith(str(e.get("file", "") or "")) or str(e.get("file", "") or "").endswith(edit_path)]
21166
+ if matching:
21167
+ fix_desc = trim(str(args.get("new_text", "") or args.get("content", "") or ""), 300)
21168
+ self._ledger_record_fix_attempt(
21169
+ edit_path,
21170
+ trim(str(matching[-1].get("error_msg", "") or ""), 300),
21171
+ fix_desc,
21172
+ "pending",
21173
+ role_key,
21174
+ )
19367
21175
  item["bb_applied"] = True
19368
21176
  tool_results.append(item)
19369
21177
  if name in {"finish_task", "finish_current_task", "mark_done"} and (not str(output).startswith("Error:")):
@@ -19552,6 +21360,9 @@ class SessionState:
19552
21360
  self._emit("status", {"summary": "run interrupted"})
19553
21361
  break
19554
21362
  self._apply_auto_compact_if_needed("auto:multi-sync")
21363
+ # Periodic checkpoint in multi-agent sync loop
21364
+ if rounds_used % CHECKPOINT_INTERVAL_ROUNDS == 0:
21365
+ self._maybe_create_checkpoint()
19555
21366
  with self.lock:
19556
21367
  self.agent_round_index = int(self.agent_round_index) + 1
19557
21368
  self.current_phase = "manager:dispatch"
@@ -19894,6 +21705,450 @@ class SessionState:
19894
21705
  self._mark_all_done_silently(f"budget exhausted: {summary}")
19895
21706
  self._emit("status", {"summary": f"Budget exhausted ({self.max_agent_rounds} rounds). {trim(summary, 300)}"})
19896
21707
 
21708
+ # ── Plan Mode Methods ──
21709
+
21710
+ def _plan_mode_worker(self, pinned_selection: str):
21711
+ """Plan mode: explorer 调研 → manager 综合 → emit 方案 → 等待用户选择"""
21712
+ # Phase 1: Explorer 调研
21713
+ self._emit("status", {"summary": "plan-mode: research phase started"})
21714
+ bb = self._ensure_blackboard()
21715
+ bb["status"] = "PLANNING"
21716
+ bb["plan"] = {"phase": "research", "findings": []}
21717
+ self.blackboard = bb
21718
+
21719
+ research_prompt = self._plan_mode_research_prompt()
21720
+ self._seed_plan_mode_explorer_context(research_prompt)
21721
+
21722
+ for r in range(PLAN_MODE_EXPLORER_MAX_ROUNDS):
21723
+ if self.cancel_requested:
21724
+ return
21725
+ step = self._plan_mode_explorer_turn(pinned_selection, round_idx=r)
21726
+ if step.get("status") in ("no-tools", "skip", "interrupted"):
21727
+ break
21728
+ self._plan_mode_update_findings(step)
21729
+
21730
+ # Phase 2: Manager 综合分析
21731
+ self._emit("status", {"summary": "plan-mode: synthesizing proposals"})
21732
+ bb = self._ensure_blackboard()
21733
+ if not isinstance(bb.get("plan"), dict):
21734
+ bb["plan"] = {"phase": "synthesis", "findings": []}
21735
+ bb["plan"]["phase"] = "synthesis"
21736
+ self.blackboard = bb
21737
+
21738
+ proposal = self._plan_mode_synthesize_proposal(pinned_selection)
21739
+ if not proposal or not proposal.get("options"):
21740
+ self._emit("status", {"summary": "plan-mode: synthesis failed, falling back to direct execution"})
21741
+ self.runtime_plan_mode_needed = False
21742
+ self.runtime_plan_approved = True
21743
+ return
21744
+
21745
+ # Phase 3: Emit 方案到前端
21746
+ self.runtime_plan_proposal = proposal
21747
+ bb = self._ensure_blackboard()
21748
+ if not isinstance(bb.get("plan"), dict):
21749
+ bb["plan"] = {"phase": "awaiting_choice", "findings": []}
21750
+ bb["plan"]["phase"] = "awaiting_choice"
21751
+ bb["plan"]["proposal"] = proposal
21752
+ self.blackboard = bb
21753
+
21754
+ plan_text = self._format_plan_proposal_markdown(proposal)
21755
+ self.messages.append({
21756
+ "role": "assistant",
21757
+ "content": plan_text,
21758
+ "ts": now_ts(),
21759
+ "agent_role": "planner",
21760
+ })
21761
+ self._emit("message", {
21762
+ "role": "assistant",
21763
+ "text": trim(plan_text, int(ASSISTANT_MESSAGE_EVENT_MAX_CHARS)),
21764
+ "summary": "plan-mode proposal",
21765
+ "agent_role": "planner",
21766
+ })
21767
+ self._emit("status", {"summary": "plan-mode: awaiting user choice"})
21768
+
21769
+ def _plan_mode_research_prompt(self) -> str:
21770
+ goal = trim(str(self.runtime_reclassify_goal or self._latest_user_goal_text() or ""), 4000)
21771
+ lang_note = model_language_instruction(self.ui_language)
21772
+ os_note = _detect_os_shell_instruction()
21773
+ return (
21774
+ f"You are in plan-mode research phase. Your task is to analyze the following request "
21775
+ f"WITHOUT making any modifications.\n\n"
21776
+ f"## User Request\n{goal}\n\n"
21777
+ f"## Instructions\n"
21778
+ f"1. Analyze the impact scope — which files, modules, and systems are affected\n"
21779
+ f"2. Identify key files that need to be read and understood\n"
21780
+ f"3. Assess risks and potential side effects\n"
21781
+ f"4. Note any ambiguities or decisions that need user input\n"
21782
+ f"5. DO NOT write, edit, or create any files. Read-only analysis only.\n"
21783
+ f"6. Write your findings to the blackboard under 'plan.findings'.\n\n"
21784
+ f"Workspace: {self.files_root}\n"
21785
+ f"{os_note}\n"
21786
+ f"{lang_note}"
21787
+ )
21788
+
21789
+ def _seed_plan_mode_explorer_context(self, research_prompt: str):
21790
+ os_note = _detect_os_shell_instruction()
21791
+ self._append_agent_context_message("explorer", {
21792
+ "role": "system",
21793
+ "content": (
21794
+ "You are Explorer in plan-mode (read-only research). "
21795
+ "Analyze the codebase to understand the task scope. "
21796
+ "Do NOT modify any files. Use read_file, bash (read-only commands), "
21797
+ "and blackboard tools only. "
21798
+ f"{os_note} "
21799
+ f"{model_language_instruction(self.ui_language)}"
21800
+ ),
21801
+ "ts": now_ts(),
21802
+ "agent_role": "explorer",
21803
+ }, mirror_to_global=False)
21804
+ self._append_agent_context_message("explorer", {
21805
+ "role": "user",
21806
+ "content": research_prompt,
21807
+ "ts": now_ts(),
21808
+ "agent_role": "explorer",
21809
+ }, mirror_to_global=False)
21810
+
21811
+ def _plan_mode_explorer_turn(self, pinned_selection: str, round_idx: int) -> dict:
21812
+ ctx = self._agent_context("explorer")
21813
+ if not ctx:
21814
+ return {"status": "skip", "reason": "empty-context"}
21815
+ # Build filtered tool list (read-only allowlist)
21816
+ filtered_tools = []
21817
+ for tool in TOOLS:
21818
+ fn = tool.get("function", {}) if isinstance(tool, dict) else {}
21819
+ name = str(fn.get("name", "")).strip()
21820
+ if name in PLAN_MODE_RESEARCH_TOOL_ALLOWLIST:
21821
+ filtered_tools.append(tool)
21822
+ if not filtered_tools:
21823
+ filtered_tools = self._tools_for_agent("explorer")
21824
+ with self.lock:
21825
+ self.current_phase = f"plan-mode:explorer:round-{round_idx}"
21826
+ self.current_tool_name = ""
21827
+ self.active_agent_role = "explorer"
21828
+ response = self._chat_with_same_model_retry(
21829
+ ctx,
21830
+ tools=filtered_tools,
21831
+ system=(
21832
+ "You are Explorer in plan-mode research. Read-only analysis. "
21833
+ "Do NOT create, write, or edit files. "
21834
+ f"Workspace: {self.files_root}. "
21835
+ f"{_detect_os_shell_instruction()} "
21836
+ f"{model_language_instruction(self.ui_language)}"
21837
+ ),
21838
+ max_tokens=self.max_output_tokens,
21839
+ think=False,
21840
+ stream_thinking=False,
21841
+ on_thinking_chunk=self._append_live_thinking,
21842
+ pinned_selection=pinned_selection,
21843
+ context_label=f"plan-mode explorer round {round_idx}",
21844
+ retries=MODEL_OUTPUT_RETRY_TIMES,
21845
+ )
21846
+ text = str(response.get("content") or "")
21847
+ tool_calls = response.get("tool_calls", [])
21848
+ text, _ = self._sanitize_assistant_text_for_runtime(text, tool_calls)
21849
+ assistant = {"role": "assistant", "content": text, "ts": now_ts(), "agent_role": "explorer"}
21850
+ if tool_calls:
21851
+ assistant["tool_calls"] = [
21852
+ {
21853
+ "id": tc["id"],
21854
+ "type": "function",
21855
+ "function": {
21856
+ "name": tc["function"]["name"],
21857
+ "arguments": json_dumps(tc["function"]["arguments"]),
21858
+ },
21859
+ }
21860
+ for tc in tool_calls
21861
+ ]
21862
+ self._append_agent_context_message("explorer", assistant, mirror_to_global=False)
21863
+ # B4: Emit planner research bubble for user visibility
21864
+ if text.strip():
21865
+ self._emit("message", {
21866
+ "role": "assistant",
21867
+ "agent_role": "planner",
21868
+ "text": trim(text, int(ASSISTANT_MESSAGE_EVENT_MAX_CHARS)),
21869
+ "summary": f"plan-mode: research round {round_idx + 1}",
21870
+ })
21871
+ if not tool_calls:
21872
+ return {"status": "no-tools", "text": text}
21873
+ # Execute tool calls (read-only)
21874
+ for tc in tool_calls:
21875
+ if self.cancel_requested:
21876
+ return {"status": "interrupted"}
21877
+ fn_name = tc["function"]["name"]
21878
+ fn_args = tc["function"]["arguments"]
21879
+ # Block write operations in bash
21880
+ if fn_name == "bash":
21881
+ cmd = str(fn_args.get("command", "") if isinstance(fn_args, dict) else "")
21882
+ cmd_lower = cmd.lower().strip()
21883
+ write_indicators = ("rm ", "mv ", "cp ", ">", ">>", "tee ", "dd ", "write_file", "edit_file", "mkdir ", "touch ")
21884
+ if any(cmd_lower.startswith(w) or f" {w}" in cmd_lower for w in write_indicators):
21885
+ result_content = "[plan-mode] Write operations are blocked during research phase."
21886
+ self._append_agent_context_message("explorer", {
21887
+ "role": "tool",
21888
+ "tool_call_id": tc["id"],
21889
+ "content": result_content,
21890
+ "ts": now_ts(),
21891
+ "agent_role": "explorer",
21892
+ }, mirror_to_global=False)
21893
+ continue
21894
+ try:
21895
+ raw_output = self._dispatch_tool(fn_name, fn_args, agent_role="explorer")
21896
+ except Exception as exc:
21897
+ raw_output = f"Error: {exc}"
21898
+ result_content = str(raw_output or "")
21899
+ self._append_agent_context_message("explorer", {
21900
+ "role": "tool",
21901
+ "tool_call_id": tc["id"],
21902
+ "content": trim(result_content, 8000),
21903
+ "ts": now_ts(),
21904
+ "agent_role": "explorer",
21905
+ }, mirror_to_global=False)
21906
+ # B5: Emit tool result as planner bubble
21907
+ self._emit("tool_result", {
21908
+ "name": fn_name,
21909
+ "agent_role": "planner",
21910
+ "result": trim(result_content, 500),
21911
+ "summary": f"plan-mode research: {fn_name}",
21912
+ })
21913
+ return {"status": "ok", "tool_count": len(tool_calls), "text": text}
21914
+
21915
+ def _plan_mode_update_findings(self, step: dict):
21916
+ bb = self._ensure_blackboard()
21917
+ plan = bb.get("plan", {})
21918
+ if not isinstance(plan, dict):
21919
+ plan = {"phase": "research", "findings": []}
21920
+ findings = plan.get("findings", [])
21921
+ if not isinstance(findings, list):
21922
+ findings = []
21923
+ text = trim(str(step.get("text", "") or ""), 2000)
21924
+ if text:
21925
+ findings.append({"round": len(findings), "content": text, "ts": now_ts()})
21926
+ findings = findings[-20:]
21927
+ plan["findings"] = findings
21928
+ bb["plan"] = plan
21929
+ self.blackboard = bb
21930
+ # B6: Emit findings status as planner
21931
+ if text:
21932
+ self._emit("status", {
21933
+ "summary": f"plan-mode: finding #{len(findings)} collected",
21934
+ "agent_role": "planner",
21935
+ })
21936
+
21937
+ def _plan_mode_synthesize_proposal(self, pinned_selection: str) -> dict:
21938
+ bb = self._ensure_blackboard()
21939
+ plan_data = bb.get("plan", {})
21940
+ findings = plan_data.get("findings", []) if isinstance(plan_data, dict) else []
21941
+ findings_text = "\n\n".join(
21942
+ f"### Finding {i+1}\n{f.get('content', '')}"
21943
+ for i, f in enumerate(findings) if isinstance(f, dict)
21944
+ )
21945
+ goal = trim(str(self.runtime_reclassify_goal or self._latest_user_goal_text() or ""), 4000)
21946
+ synthesis_prompt = (
21947
+ f"Based on the research findings below, generate a structured plan proposal "
21948
+ f"with up to {PLAN_MODE_MAX_OPTIONS} options for the user to choose from.\n\n"
21949
+ f"## User Request\n{goal}\n\n"
21950
+ f"## Research Findings\n{trim(findings_text, 6000)}\n\n"
21951
+ f"## Instructions\n"
21952
+ f"Call the submit_plan_proposal tool with:\n"
21953
+ f"- context: brief background analysis\n"
21954
+ f"- options: array of 1-{PLAN_MODE_MAX_OPTIONS} options, each with id (A/B/C), title, summary, steps, pros, cons, risk\n"
21955
+ f"- recommended: id of the recommended option\n\n"
21956
+ f"Make options meaningfully different (e.g. different approaches, scope levels, or trade-offs).\n"
21957
+ f"{model_language_instruction(self.ui_language)}"
21958
+ )
21959
+ synthesis_ctx = [
21960
+ {"role": "system", "content": "You are a technical architect synthesizing research into actionable plans.", "ts": now_ts()},
21961
+ {"role": "user", "content": synthesis_prompt, "ts": now_ts()},
21962
+ ]
21963
+ response = self._chat_with_same_model_retry(
21964
+ synthesis_ctx,
21965
+ tools=self._plan_mode_synthesis_tools(),
21966
+ system="Generate a structured plan proposal. Use the submit_plan_proposal tool.",
21967
+ max_tokens=PLAN_MODE_MANAGER_SYNTHESIS_MAX_TOKENS,
21968
+ think=False,
21969
+ stream_thinking=False,
21970
+ on_thinking_chunk=self._append_live_thinking,
21971
+ pinned_selection=pinned_selection,
21972
+ context_label="plan-mode synthesis",
21973
+ retries=MODEL_OUTPUT_RETRY_TIMES,
21974
+ )
21975
+ tool_calls = response.get("tool_calls", [])
21976
+ for tc in tool_calls:
21977
+ if tc.get("function", {}).get("name") == "submit_plan_proposal":
21978
+ args = tc["function"].get("arguments", {})
21979
+ if isinstance(args, dict) and args.get("options"):
21980
+ return dict(args)
21981
+ return {}
21982
+
21983
+ def _plan_mode_synthesis_tools(self) -> list:
21984
+ return [tool_def(
21985
+ "submit_plan_proposal",
21986
+ "Submit a structured plan proposal with multiple options for user review.",
21987
+ {
21988
+ "context": {"type": "string", "description": "Background analysis of the task"},
21989
+ "options": {
21990
+ "type": "array",
21991
+ "items": {
21992
+ "type": "object",
21993
+ "properties": {
21994
+ "id": {"type": "string"},
21995
+ "title": {"type": "string"},
21996
+ "summary": {"type": "string"},
21997
+ "steps": {"type": "array", "items": {"type": "string"}},
21998
+ "pros": {"type": "string"},
21999
+ "cons": {"type": "string"},
22000
+ "risk": {"type": "string", "enum": ["low", "medium", "high"]},
22001
+ },
22002
+ "required": ["id", "title", "summary", "steps"],
22003
+ },
22004
+ },
22005
+ "recommended": {"type": "string", "description": "ID of recommended option"},
22006
+ },
22007
+ ["context", "options", "recommended"],
22008
+ )]
22009
+
22010
+ def _format_plan_proposal_markdown(self, proposal: dict) -> str:
22011
+ lines = ["## 📋 执行方案\n"]
22012
+ context = str(proposal.get("context", "") or "").strip()
22013
+ if context:
22014
+ lines.append(f"### 背景分析\n{context}\n")
22015
+ recommended = str(proposal.get("recommended", "") or "").strip()
22016
+ options = proposal.get("options", [])
22017
+ if not isinstance(options, list):
22018
+ options = []
22019
+ for opt in options[:PLAN_MODE_MAX_OPTIONS]:
22020
+ if not isinstance(opt, dict):
22021
+ continue
22022
+ opt_id = str(opt.get("id", "") or "").strip()
22023
+ title = str(opt.get("title", "") or "").strip()
22024
+ is_recommended = opt_id == recommended
22025
+ header = f"### 方案 {opt_id}: {title}"
22026
+ if is_recommended:
22027
+ header += " ⭐推荐"
22028
+ lines.append(header)
22029
+ summary = str(opt.get("summary", "") or "").strip()
22030
+ if summary:
22031
+ lines.append(summary)
22032
+ steps = opt.get("steps", [])
22033
+ if isinstance(steps, list) and steps:
22034
+ lines.append("\n**步骤:**")
22035
+ for i, s in enumerate(steps):
22036
+ lines.append(f"{i+1}. {s}")
22037
+ pros = str(opt.get("pros", "") or "").strip()
22038
+ if pros:
22039
+ lines.append(f"\n**优势:** {pros}")
22040
+ cons = str(opt.get("cons", "") or "").strip()
22041
+ if cons:
22042
+ lines.append(f"**劣势:** {cons}")
22043
+ risk = str(opt.get("risk", "") or "").strip()
22044
+ if risk:
22045
+ lines.append(f"**风险:** {risk}")
22046
+ lines.append("")
22047
+ lines.append("---")
22048
+ lines.append('请回复选择(如"方案A"、"A"、"选1"),或输入修改意见。')
22049
+ return "\n".join(lines)
22050
+
22051
+ def _parse_plan_choice(self, text: str, proposal: dict) -> str:
22052
+ if not text or not proposal:
22053
+ return ""
22054
+ low = text.strip().lower()
22055
+ options = proposal.get("options", [])
22056
+ if not isinstance(options, list) or not options:
22057
+ return ""
22058
+ option_ids = [str(o.get("id", "")).strip() for o in options if isinstance(o, dict)]
22059
+ # Direct ID match: "A", "B", "C"
22060
+ if low.upper() in option_ids:
22061
+ return low.upper()
22062
+ # "方案A", "方案 A", "option A"
22063
+ import re
22064
+ m = re.search(r'(?:方案|option|选项)\s*([a-zA-Z0-9])', low, re.IGNORECASE)
22065
+ if m:
22066
+ candidate = m.group(1).upper()
22067
+ if candidate in option_ids:
22068
+ return candidate
22069
+ # "选1", "第1个", "第一个"
22070
+ num_map = {"一": "1", "二": "2", "三": "3", "1": "1", "2": "2", "3": "3"}
22071
+ m2 = re.search(r'(?:选|第)\s*([一二三1-3])', low)
22072
+ if m2:
22073
+ idx_str = num_map.get(m2.group(1), "")
22074
+ if idx_str:
22075
+ idx = int(idx_str) - 1
22076
+ if 0 <= idx < len(option_ids):
22077
+ return option_ids[idx]
22078
+ # "继续"/"确认"/"推荐" → pick recommended
22079
+ recommended = str(proposal.get("recommended", "") or "").strip()
22080
+ confirm_tokens = ("继续", "确认", "推荐", "推荐方案", "go", "proceed", "continue", "yes", "ok")
22081
+ if any(tok in low for tok in confirm_tokens) and recommended:
22082
+ return recommended
22083
+ return ""
22084
+
22085
+ def _inject_plan_into_context(self, choice_id: str):
22086
+ chosen = next(
22087
+ (o for o in self.runtime_plan_proposal.get("options", [])
22088
+ if isinstance(o, dict) and o.get("id") == choice_id),
22089
+ None,
22090
+ )
22091
+ if not chosen:
22092
+ return
22093
+ steps_text = "\n".join(f"{i+1}. {s}" for i, s in enumerate(chosen.get("steps", [])))
22094
+ plan_msg = (
22095
+ f"[approved-plan] Execute the following plan:\n"
22096
+ f"## {chosen.get('title', '')}\n"
22097
+ f"{chosen.get('summary', '')}\n\n"
22098
+ f"### Steps:\n{steps_text}\n\n"
22099
+ f"Follow these steps. Use tools to implement each step concretely."
22100
+ )
22101
+ self.messages.append({
22102
+ "role": "system",
22103
+ "content": plan_msg,
22104
+ "ts": now_ts(),
22105
+ })
22106
+ if self._is_multi_agent_mode():
22107
+ for role in ("explorer", "developer"):
22108
+ self._append_agent_context_message(role, {
22109
+ "role": "system",
22110
+ "content": plan_msg,
22111
+ "ts": now_ts(),
22112
+ "agent_role": role,
22113
+ }, mirror_to_global=False)
22114
+ bb = self._ensure_blackboard()
22115
+ bb["plan"] = {"phase": "executing", "chosen": choice_id, "steps": chosen.get("steps", [])}
22116
+ self.blackboard = bb
22117
+ self._blackboard_history("manager", f"plan approved: option {choice_id} — {chosen.get('title', '')}")
22118
+ # Auto-create todos from plan steps → write into bb["project_todos"]
22119
+ steps = chosen.get("steps", [])
22120
+ if steps and isinstance(steps, list):
22121
+ plan_todos = []
22122
+ for i, step in enumerate(steps):
22123
+ step_text = trim(str(step or "").strip(), 400)
22124
+ if not step_text:
22125
+ continue
22126
+ plan_todos.append({
22127
+ "id": f"pt:{i:03d}",
22128
+ "content": step_text,
22129
+ "status": "in_progress" if i == 0 else "pending",
22130
+ "category": "plan_step",
22131
+ "plan_step_index": i,
22132
+ "created_at": float(now_ts()),
22133
+ "completed_at": None,
22134
+ "completed_by": "",
22135
+ "evidence": "",
22136
+ })
22137
+ if plan_todos:
22138
+ bb["project_todos"] = plan_todos[:10]
22139
+ bb["plan_step_cursor"] = 0
22140
+ bb["plan_step_total"] = len(plan_todos)
22141
+ self.blackboard = bb
22142
+ self._blackboard_touch()
22143
+ # 同步到 UI todo
22144
+ try:
22145
+ self.todo.update([
22146
+ {"content": t["content"], "status": t["status"], "owner": "developer"}
22147
+ for t in plan_todos[:10]
22148
+ ])
22149
+ except Exception:
22150
+ pass
22151
+
19897
22152
  def _agent_worker(self):
19898
22153
  single_role = "developer"
19899
22154
  try:
@@ -19944,6 +22199,10 @@ class SessionState:
19944
22199
  {"summary": "level-5 requires user confirmation before next actions"},
19945
22200
  )
19946
22201
  return
22202
+ # ── Plan Mode 检查 ──
22203
+ if bool(self.runtime_plan_mode_needed) and not bool(self.runtime_plan_approved):
22204
+ self._plan_mode_worker(pinned_selection=pinned_selection)
22205
+ return
19947
22206
  if self._is_multi_agent_mode():
19948
22207
  self._seed_multi_agent_contexts_if_needed(self.runtime_reclassify_goal or "")
19949
22208
  self._emit(
@@ -20028,6 +22287,10 @@ class SessionState:
20028
22287
  )
20029
22288
  break
20030
22289
  self._apply_auto_compact_if_needed("auto")
22290
+ # Periodic checkpoint in single-agent loop
22291
+ _sa_round = int(getattr(self, "agent_round_index", 0) or 0)
22292
+ if _sa_round > 0 and _sa_round % CHECKPOINT_INTERVAL_ROUNDS == 0:
22293
+ self._maybe_create_checkpoint()
20031
22294
  notifs = self.bg.drain()
20032
22295
  if notifs:
20033
22296
  text = "\n".join(f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs)
@@ -20251,7 +22514,7 @@ class SessionState:
20251
22514
  for tc in tool_calls
20252
22515
  ]
20253
22516
  self.messages.append(assistant)
20254
- if text.strip() or thinking_text:
22517
+ if (text.strip() or thinking_text) and not tool_calls:
20255
22518
  emit_text = text if text.strip() else "[thinking-only output]"
20256
22519
  emit_summary = "assistant message" if text.strip() else "assistant thinking-only message"
20257
22520
  self._emit(
@@ -20409,6 +22672,8 @@ class SessionState:
20409
22672
  fault_counter = 0
20410
22673
  last_fault_reason = ""
20411
22674
  self._prune_runtime_retry_hints()
22675
+ if self.todo.has_open_items():
22676
+ self._mark_all_done_silently("single-mode endpoint exit")
20412
22677
  self._emit(
20413
22678
  "status",
20414
22679
  {
@@ -20426,6 +22691,8 @@ class SessionState:
20426
22691
  fault_counter = 0
20427
22692
  last_fault_reason = ""
20428
22693
  self._prune_runtime_retry_hints()
22694
+ if self.todo.has_open_items():
22695
+ self._mark_all_done_silently("single-mode conclusive/substantial exit")
20429
22696
  self._emit(
20430
22697
  "status",
20431
22698
  {
@@ -20763,6 +23030,25 @@ class SessionState:
20763
23030
  "ok": not str(output).startswith("Error:"),
20764
23031
  }
20765
23032
  )
23033
+ # Failure ledger: record tool call and detect errors (single-agent, unified)
23034
+ self._ledger_record_tool_call(name, args if isinstance(args, dict) else {})
23035
+ _sa_ok = not str(output or "").startswith("Error")
23036
+ self._process_tool_result_errors(name, args if isinstance(args, dict) else {}, output, _sa_ok, "single")
23037
+ if name in ("edit_file", "write_file") and isinstance(args, dict):
23038
+ _fl = self._ensure_blackboard().get("failure_ledger", {})
23039
+ _all_errors = _fl.get("errors", []) if isinstance(_fl, dict) else []
23040
+ _edit_path = str(args.get("path", "") or "").strip()
23041
+ if _all_errors and _edit_path:
23042
+ _matching = [e for e in _all_errors if _edit_path.endswith(str(e.get("file", "") or "")) or str(e.get("file", "") or "").endswith(_edit_path)]
23043
+ if _matching:
23044
+ _fix_desc = trim(str(args.get("new_text", "") or args.get("content", "") or ""), 300)
23045
+ self._ledger_record_fix_attempt(
23046
+ _edit_path,
23047
+ trim(str(_matching[-1].get("error_msg", "") or ""), 300),
23048
+ _fix_desc,
23049
+ "pending",
23050
+ "single",
23051
+ )
20766
23052
  self._emit("tool_result", {"name": name, "result": trim(output, 500), "summary": f"tool done: {name}"})
20767
23053
  if int(tool_error_streaks.get(tool_key, 0) or 0) >= HARD_BREAK_TOOL_ERROR_THRESHOLD:
20768
23054
  stop_due_to_hard_break = True
@@ -20803,6 +23089,8 @@ class SessionState:
20803
23089
  state_changed=bool(single_watchdog_after_fp != single_watchdog_before_fp),
20804
23090
  pinned_selection=pinned_selection,
20805
23091
  )
23092
+ # Single-agent plan step tracking: sync todos and auto-advance
23093
+ self._single_agent_plan_step_check(single_round_tool_results)
20806
23094
  if stop_due_to_hard_break:
20807
23095
  note = (
20808
23096
  "Execution paused after repeated tool/recovery failures. "
@@ -21014,6 +23302,10 @@ class SessionState:
21014
23302
  except Exception as exc:
21015
23303
  self._emit("error", {"summary": f"agent error: {exc}", "trace": traceback.format_exc()})
21016
23304
  finally:
23305
+ if self.todo.has_open_items() and not self.cancel_requested:
23306
+ _last = self._latest_agent_assistant_text(single_role) or ""
23307
+ if self._looks_like_conclusive_reply(_last):
23308
+ self._mark_all_done_silently(f"single-mode conclusive exit by {single_role}")
21017
23309
  dropped_pending_inputs = 0
21018
23310
  removed_runtime_hints = 0
21019
23311
  with self.lock:
@@ -21321,6 +23613,7 @@ class SessionState:
21321
23613
  "context_token_upper_bound": int(self.context_token_upper_bound),
21322
23614
  "context_token_limit_config": int(self.max_context_token_limit),
21323
23615
  "context_token_limit_locked": bool(self.context_limit_locked),
23616
+ "plan_mode_preference": str(self.plan_mode_user_preference or "auto"),
21324
23617
  "context_tokens_estimate": int(ctx.get("used", 0)),
21325
23618
  "context_left_tokens": int(ctx.get("left", 0)),
21326
23619
  "context_left_percent": round(float(ctx.get("left_percent", 0.0)), 2),
@@ -21526,6 +23819,7 @@ class SessionManager:
21526
23819
  self.arbiter_max_tokens = max(24, min(256, int(arbiter_max_tokens or ARBITER_DEFAULT_MAX_TOKENS)))
21527
23820
  self.arbiter_temperature = max(0.0, min(1.0, float(arbiter_temperature if arbiter_temperature is not None else ARBITER_DEFAULT_TEMPERATURE)))
21528
23821
  self.execution_mode = normalize_execution_mode(execution_mode, default=EXECUTION_MODE_SYNC)
23822
+ self.single_advance_prompt_enhance = False
21529
23823
  env_ok, env_tags, _ = probe_ollama_environment(ollama_base)
21530
23824
  self.ollama_env_available = bool(env_ok)
21531
23825
  self.ollama_env_tags: list[str] = list(env_tags)
@@ -21792,6 +24086,7 @@ class SessionManager:
21792
24086
  min(1.0, float(self.arbiter_temperature if self.arbiter_temperature is not None else ARBITER_DEFAULT_TEMPERATURE)),
21793
24087
  )
21794
24088
  sess.execution_mode = normalize_execution_mode(self.execution_mode, default=EXECUTION_MODE_SYNC)
24089
+ sess.single_advance_prompt_enhance = bool(self.single_advance_prompt_enhance)
21795
24090
  sess._apply_active_profile()
21796
24091
  sess.updated_at = now_ts()
21797
24092
  sess._persist()
@@ -22341,6 +24636,7 @@ window.MathJax={
22341
24636
  <button id="sendBtn">Send</button>
22342
24637
  <button id="interruptBtn" class="subtle">Interrupt</button>
22343
24638
  <button id="compactBtn" class="subtle">Compact</button>
24639
+ <button id="planModeBtn" class="subtle" title="Toggle Plan Mode">Plan: Auto</button>
22344
24640
  <button id="refreshBtn" class="subtle">Refresh</button>
22345
24641
  <div class="export-dropdown" style="position:relative;display:inline-block">
22346
24642
  <button id="exportMenuBtn" class="subtle">Export ▾</button>
@@ -22498,11 +24794,13 @@ main{display:grid;grid-template-columns:minmax(220px,260px) minmax(520px,920px)
22498
24794
  .msg.assistant.agent-developer,.msg.system.agent-developer{background:#e9fbea;border:1px solid #bde6c4}
22499
24795
  .msg.assistant.agent-reviewer,.msg.system.agent-reviewer{background:#fff6de;border:1px solid #f1d89a}
22500
24796
  .msg.assistant.agent-manager,.msg.system.agent-manager{background:#f3e9ff;border:1px solid #d7bbff}
24797
+ .msg.assistant.agent-planner,.msg.system.agent-planner{background:#fde8e4;border:1px solid #f5b8ad}
22501
24798
  .msg-agent-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:999px;font-size:.68rem;font-weight:700;letter-spacing:.02em;margin:0 0 6px 0;border:1px solid #d6deea;background:#f5f8fd;color:#31465f}
22502
24799
  .msg-agent-badge.explorer{background:#ffe6f0;border-color:#ffbdd5;color:#9b275e}
22503
24800
  .msg-agent-badge.developer{background:#e6f8e8;border-color:#b8e2c0;color:#1c6a33}
22504
24801
  .msg-agent-badge.reviewer{background:#fff3d4;border-color:#efd692;color:#8a6213}
22505
24802
  .msg-agent-badge.manager{background:#efe4ff;border-color:#d2b6ff;color:#6e36b8}
24803
+ .msg-agent-badge.planner{background:#fde8e4;border-color:#f5b8ad;color:#c0392b}
22506
24804
  .manager-delegate-card{border:1px solid #d7bbff;background:#f8f3ff;border-radius:10px;padding:10px}
22507
24805
  .manager-delegate-head{font-weight:800;font-size:.86rem;color:#5a2fa6}
22508
24806
  .manager-delegate-route{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:4px 0 8px}
@@ -22889,6 +25187,7 @@ function _deltaScheduleRender(flags={}){
22889
25187
  if(S.deltaRenderRaf)return;
22890
25188
  S.deltaRenderRaf=requestAnimationFrame(()=>{
22891
25189
  S.deltaRenderRaf=0;
25190
+ if(S.refreshInFlight)return;
22892
25191
  const needChat=!!S.deltaRenderChat;
22893
25192
  const needBoards=!!S.deltaRenderBoards;
22894
25193
  const needSessions=!!S.deltaRenderSessions;
@@ -23048,7 +25347,7 @@ function onRuntimeEvent(evt){
23048
25347
  const typ=String(evt.type||'');
23049
25348
  if(typ==='render_frame'){_renderBridgeEnqueue(evt.data||{});return{handled:true,needsSnapshot:false}}
23050
25349
  if(typ==='render_bridge'){const d=evt.data||{};const summary=String(d?.summary||'').trim();if(summary){_renderBridgeShow();_renderBridgeUpdateMeta(summary,true);_renderBridgeHideLater(30000)}return{handled:true,needsSnapshot:false}}
23051
- if(typ==='compact'){scheduleCompactRefreshBurst(COMPACT_AUTO_REFRESH_COUNT);const reason=parseCompactReason(evt.data||{});if(reason==='auto'||reason.startsWith('truncation-rescue')){const pct=Number(evt.data?.context_left_percent_before);const left=Number(evt.data?.context_left_before);const limit=Number(evt.data?.context_limit_before);const pctTxt=Number.isFinite(pct)?pct.toFixed(1):'-';const leftTxt=Number.isFinite(left)&&Number.isFinite(limit)?`${left}/${limit}`:'-';showCompactToast(`${t('compact_auto')}:${pctTxt}% left (${leftTxt}) · delta-sync`)}_deltaAppendActivity(typ,evt.data||{},Number(evt?.ts||Date.now()/1000));_deltaScheduleRender({boards:true,sessions:true});return{handled:true,needsSnapshot:false}}
25350
+ if(typ==='compact'){scheduleCompactRefreshBurst(COMPACT_AUTO_REFRESH_COUNT);const reason=parseCompactReason(evt.data||{});if(reason==='auto'||reason.startsWith('truncation-rescue')){const pct=Number(evt.data?.context_left_percent_before);const left=Number(evt.data?.context_left_before);const limit=Number(evt.data?.context_limit_before);const pctTxt=Number.isFinite(pct)?pct.toFixed(1):'-';const leftTxt=Number.isFinite(left)&&Number.isFinite(limit)?`${left}/${limit}`:'-';showCompactToast(`${t('compact_auto')}:${pctTxt}% left (${leftTxt}) · delta-sync`)}_deltaAppendActivity(typ,evt.data||{},Number(evt?.ts||Date.now()/1000));return{handled:true,needsSnapshot:false}}
23052
25351
  return _deltaApplyRuntimeEvent(evt);
23053
25352
  }
23054
25353
  function _deltaStartWatchdog(){
@@ -23463,6 +25762,8 @@ self.onmessage=(ev)=>{
23463
25762
  slot.classList.remove('md-async-slot');
23464
25763
  const msg=slot.closest('.msg');
23465
25764
  if(msg){
25765
+ const vk=String(msg.getAttribute('data-vk')||'');
25766
+ if(vk){const newH=Math.max(48,Math.ceil(msg.getBoundingClientRect().height||msg.offsetHeight||0));if(newH>0){const oldH=Number(CHAT_VIRT.heights[vk]||0);if(!oldH||Math.abs(oldH-newH)>=3){CHAT_VIRT.heights[vk]=newH;CHAT_VIRT.heightVersion=Number(CHAT_VIRT.heightVersion||0)+1}}}
23466
25767
  const req=String(msg.getAttribute('data-math-request')||'').trim();
23467
25768
  if(req)_mathTypeset(msg,`chat:${req}`);
23468
25769
  }else{
@@ -24142,7 +26443,7 @@ function _chatVirtCollectRows(){
24142
26443
  const label=String(S.snap?.live_run_notice_label||'model call').trim()||'model call';
24143
26444
  const startedAt=Number(S.snap?.live_run_notice_started_at||0);
24144
26445
  const snapElapsed=Number(S.snap?.live_run_notice_elapsed||0);
24145
- const elapsed=startedAt>0?Math.max(snapElapsed,(Date.now()/1000)-startedAt):snapElapsed;
26446
+ const elapsed=startedAt>0?(Date.now()/1000)-startedAt:snapElapsed;
24146
26447
  rows.push({
24147
26448
  role:'assistant',
24148
26449
  type:'live_run_notice',
@@ -24151,12 +26452,12 @@ function _chatVirtCollectRows(){
24151
26452
  elapsed:elapsed,
24152
26453
  startedAt:startedAt,
24153
26454
  agent_role:activeAgentRole||undefined,
24154
- _vk:`run:${activeAgentRole}:${label}:${Math.round(startedAt*10)}`
26455
+ _vk:`run:${activeAgentRole}:${label}`
24155
26456
  });
24156
26457
  }
24157
26458
  return rows;
24158
26459
  }
24159
- function _chatVirtEstimatedHeight(row){const key=String(row?._vk||'');const known=Number(CHAT_VIRT.heights[key]||0);if(known>0)return known;const txtLen=String(row?.text||'').length;const thinkLen=String(row?.thinking||'').length;const base=Math.max(72,Math.min(560,80+Math.ceil((txtLen+thinkLen)/90)*20));return Math.max(base,Number(CHAT_VIRT.avgHeight||140))}
26460
+ function _chatVirtEstimatedHeight(row){const key=String(row?._vk||'');const known=Number(CHAT_VIRT.heights[key]||0);if(known>0)return known;const txtLen=String(row?.text||'').length;const thinkLen=String(row?.thinking||'').length;const totalLen=txtLen+thinkLen;const base=totalLen>1200?Math.max(400,Math.min(3200,120+Math.ceil(totalLen/60)*18)):Math.max(72,Math.min(560,80+Math.ceil(totalLen/90)*20));return Math.max(base,Number(CHAT_VIRT.avgHeight||140))}
24160
26461
  function _chatVirtBindPreviewButtons(root){if(!root)return;for(const btn of root.querySelectorAll('.msg-preview-btn')){btn.onclick=(ev)=>{ev.preventDefault();ev.stopPropagation();openPreviewTab(btn.getAttribute('data-preview-path')||'')}}}
24161
26462
  function _chatVirtPruneHeightCache(rows){const keys=Object.keys(CHAT_VIRT.heights||{});if(keys.length<=CHAT_VIRT.maxCacheKeys)return;const keep=new Set(rows.map(x=>String(x?._vk||'')));for(const k of keys){if(!keep.has(k)){delete CHAT_VIRT.heights[k]}}}
24162
26463
  function _chatVirtPoolForKind(kind){const key=String(kind||'text');let pool=CHAT_VIRT.poolByKind[key];if(!Array.isArray(pool)){pool=[];CHAT_VIRT.poolByKind[key]=pool}return pool}
@@ -24182,8 +26483,8 @@ function _chatVirtReleaseNode(node){
24182
26483
  CHAT_VIRT.poolSize=Number(CHAT_VIRT.poolSize||0)+1;
24183
26484
  }
24184
26485
  function _chatVirtReleaseRendered(root){if(!root)return;for(const node of root.querySelectorAll('.msg[data-vk]')){_chatVirtReleaseNode(node)}}
24185
- function _chatVirtAgentRoleKey(raw){const role=String(raw||'').trim().toLowerCase();return(role==='explorer'||role==='developer'||role==='reviewer'||role==='manager')?role:''}
24186
- function _chatVirtAgentRoleLabel(role){if(role==='explorer')return'Explorer';if(role==='developer')return'Developer';if(role==='reviewer')return'Reviewer';if(role==='manager')return'Manager';return''}
26486
+ function _chatVirtAgentRoleKey(raw){const role=String(raw||'').trim().toLowerCase();return(role==='explorer'||role==='developer'||role==='reviewer'||role==='manager'||role==='planner')?role:''}
26487
+ function _chatVirtAgentRoleLabel(role){if(role==='explorer')return'Explorer';if(role==='developer')return'Developer';if(role==='reviewer')return'Reviewer';if(role==='manager')return'Manager';if(role==='planner')return'Planner';return''}
24187
26488
  function _stripLeadingAgentTitle(raw,agentRole){
24188
26489
  let txt=String(raw||'').replace(/^\\uFEFF/,'').trimStart();
24189
26490
  const role=_chatVirtAgentRoleKey(agentRole);
@@ -24737,6 +27038,7 @@ function renderFileExplorer(){const host=E('fileExplorer');if(!host)return;const
24737
27038
  async function refreshFileExplorer(force=false){const sid=String(S.activeId||'').trim();if(!sid)return;const st=ensureFileExplorerState(sid);if(!st)return;const now=Date.now();if(st.inflight)return;if(!force&&st.tree&&(now-Number(st.fetchedAt||0)<1400))return;st.inflight=true;const btn=E('refreshFilesBtn');if(btn)btn.disabled=true;renderFileExplorer();try{const payload=await api(_fePath(sid));if(String(S.activeId||'')!==sid)return;st.tree=(payload&&typeof payload==='object'&&payload.tree&&typeof payload.tree==='object')?payload.tree:null;st.root=String(payload?.root||S.snap?.session_files_root||'');st.nodeCount=Number(payload?.node_count||0);st.truncated=!!payload?.truncated;st.maxNodes=Number(payload?.max_nodes||0);st.fetchedAt=Date.now();renderFileExplorer()}catch(err){if(String(S.activeId||'')===sid){const host=E('fileExplorer');if(host)host.innerHTML=`<div class=\"fe-empty mono\">${esc(err?.message||String(err))}</div>`}}finally{st.inflight=false;if(btn)btn.disabled=false}}
24738
27039
  function renderBoards(){const uiState=S.staticMode?(S.frozen?'static':'live'):'live';E('status').textContent=`session=${S.snap?.id||'-'} | model=${S.snap?.model||'-'} | thinking=${S.snap?.thinking?'on':'off'} | thinking_stream=${S.snap?.thinking_stream?'on':'off'} | mode=${S.snap?.execution_mode||S.config?.execution_mode||'sync'} | active_agent=${S.snap?.agent_active_role||'-'} | bb=${S.snap?.blackboard?.status||'-'} | task=${S.snap?.blackboard?.task_profile?.task_type||'-'} | complexity=${S.snap?.blackboard?.task_profile?.complexity||'-'} | judgement=${S.snap?.blackboard?.manager_judgement?.progress||'-'} | budget=${S.snap?.blackboard?.task_profile?.round_budget??'-'} | remaining=${S.snap?.blackboard?.manager_judgement?.remaining_rounds??'-'} | bb_cycles=${S.snap?.blackboard?.manager_cycles??'-'} | round_limit=${S.snap?.max_agent_rounds||'-'} | round=${S.snap?.agent_round_index??'-'} | phase=${S.snap?.agent_phase||'idle'} | queued_inputs=${S.snap?.queued_user_inputs_count??0} | run_timeout=${S.snap?.max_run_seconds??'-'}s | ctx_used=${S.snap?.context_tokens_estimate??'-'} | ctx_limit=${S.snap?.context_token_upper_bound||'-'} | ctx_mode=${S.snap?.context_token_limit_locked?'manual-lock':'adaptive'} | ctx_left=${formatContextLeft(S.snap)} | truncation=${S.snap?.truncation_count||0} | trunc_retry=${S.snap?.live_truncation_attempts||0} | trunc_tokens~=${S.snap?.live_truncation_tokens||0} | archive=${S.snap?.compact_segments_count||0} | last_compact=${S.snap?.last_compact_reason||'-'} | ollama=${S.snap?.ollama_base_url||'-'} | files=${S.snap?.session_files_root||'-'} | ui_mode=${uiState} | ${S.snap?.running?'running':'idle'}`;
24739
27040
  renderCtxLive(S.snap);
27041
+ const _pmBtn=E('planModeBtn');if(_pmBtn){const _pm=S.snap?.plan_mode_preference||'auto';_pmBtn.textContent='Plan: '+_pm.charAt(0).toUpperCase()+_pm.slice(1)}
24740
27042
  _renderBridgeSyncFromSnapshot(S.snap||{});
24741
27043
  setPanelHtml('todos',renderTodoBoard(S.snap?.todos||[]));
24742
27044
  setPanelHtml('tasks',renderTaskBoard(S.snap?.tasks||[]));
@@ -24909,23 +27211,21 @@ async function refreshSnapshot(opt={}){
24909
27211
  const chatEl=E('chat');
24910
27212
  const scrolling=_chatVirtIsUserScrolling(chatEl);
24911
27213
  const feedSig=feedSignature(S.snap);
24912
- if(forceFull||feedSig!==S.lastFeedSig){
24913
- S.lastFeedSig=feedSig;
24914
- if(scrolling&&chatEl){
24915
- _chatVirtDebounceWhileScrolling(chatEl,'_virtScrollSyncTimer',()=>renderChat('snapshot'));
24916
- }else{
24917
- if(chatEl)_chatVirtCancelDebounce(chatEl,'_virtScrollSyncTimer');
24918
- renderChat();
24919
- }
24920
- }
24921
27214
  const boardSig=boardsSignature(S.snap);
24922
- if(forceFull||boardSig!==S.lastBoardsSig){
24923
- S.lastBoardsSig=boardSig;
27215
+ const needChat=forceFull||feedSig!==S.lastFeedSig;
27216
+ const needBoards=forceFull||boardSig!==S.lastBoardsSig;
27217
+ if(needChat)S.lastFeedSig=feedSig;
27218
+ if(needBoards)S.lastBoardsSig=boardSig;
27219
+ if(needChat||needBoards){
27220
+ const doRender=()=>{
27221
+ if(needChat)renderChat('snapshot');
27222
+ if(needBoards)renderBoards();
27223
+ };
24924
27224
  if(scrolling&&chatEl){
24925
- _chatVirtDebounceWhileScrolling(chatEl,'_virtBoardsSyncTimer',()=>renderBoards(),CHAT_SCROLL_SYNC_DEBOUNCE_MS+20);
27225
+ _chatVirtDebounceWhileScrolling(chatEl,'_virtScrollSyncTimer',doRender);
24926
27226
  }else{
24927
- if(chatEl)_chatVirtCancelDebounce(chatEl,'_virtBoardsSyncTimer');
24928
- renderBoards();
27227
+ if(chatEl){_chatVirtCancelDebounce(chatEl,'_virtScrollSyncTimer');_chatVirtCancelDebounce(chatEl,'_virtBoardsSyncTimer');}
27228
+ doRender();
24929
27229
  }
24930
27230
  }
24931
27231
  renderActivePreview(false);
@@ -25009,9 +27309,10 @@ async function sendMessage(){showError('');const t=E('prompt').value.trim();if(!
25009
27309
  async function interruptRun(){if(!S.activeId)return;if(S.staticMode&&S.frozen)resumeAutoUpdates();await api('/api/sessions/'+S.activeId+'/interrupt',{method:'POST'});S.lastDeltaTs=Date.now();if(!S.es||S.es.readyState===2){scheduleSnapshot({forceFull:false,delayMs:140,allowWhenFrozen:true})}}
25010
27310
  async function compactNow(){if(!S.activeId)return;if(S.staticMode&&S.frozen)resumeAutoUpdates();await api('/api/sessions/'+S.activeId+'/compact',{method:'POST'});S.lastDeltaTs=Date.now();scheduleCompactRefreshBurst(COMPACT_AUTO_REFRESH_COUNT);if(!S.es||S.es.readyState===2){scheduleSnapshot({forceFull:false,delayMs:180,allowWhenFrozen:true})}}
25011
27311
  async function clearStaleTodos(){if(!S.activeId){showError(t('select_session_first'));return}if(S.staticMode&&S.frozen)resumeAutoUpdates();await api('/api/sessions/'+S.activeId+'/todos/clear-stale',{method:'POST'});S.lastDeltaTs=Date.now();if(!S.es||S.es.readyState===2){scheduleSnapshot({forceFull:false,delayMs:160,allowWhenFrozen:true})}}
27312
+ async function togglePlanMode(){if(!S.activeId)return;const states=['auto','on','off'];const current=S.snap?.plan_mode_preference||'auto';const next=states[(states.indexOf(current)+1)%states.length];try{await api('/api/sessions/'+S.activeId+'/config/plan-mode',{method:'POST',body:JSON.stringify({preference:next})});if(S.snap)S.snap.plan_mode_preference=next;const btn=E('planModeBtn');if(btn)btn.textContent='Plan: '+next.charAt(0).toUpperCase()+next.slice(1)}catch(err){showError(err.message||String(err))}}
25012
27313
  async function refreshAll(forceProbe=false){if(S.staticMode&&S.frozen){S.frozen=false;applyStaticUiClass()}S.config=await api('/api/config');renderLanguageControls();applyMainI18n();S.skills=await api('/api/skills');S.tools=await api('/api/tools');S.providers=await api('/api/skills/providers');S.protocols=await api('/api/skills/protocols');renderSkillsEntryLink();await refreshSessions();const mc=await loadModelCatalog(forceProbe);if(!applyModelCatalog(mc)){renderModelControls()}if(S.activeId)await refreshSnapshot({forceFull:true,allowWhenFrozen:true})}
25013
27314
  function bindClick(id,fn){const el=E(id);if(el)el.onclick=fn}
25014
- window.addEventListener('DOMContentLoaded',async()=>{for(const id of ['chat','sessionList','todos','tasks','activity','commands','diffs','fileExplorer','catalog']){const el=E(id);if(el){if(id==='chat'){continue}if(id==='sessionList'||id==='todos'||id==='tasks'){S.follow[id]=false;const mark=(lockMs=PANEL_SCROLL_ACTIVE_MS)=>{const now=Date.now();el._panelUserScrollTs=now;el._panelUserScrollLockTs=Math.max(Number(el._panelUserScrollLockTs||0),now+Math.max(PANEL_SCROLL_ACTIVE_MS,Number(lockMs)||PANEL_SCROLL_ACTIVE_MS))};el.addEventListener('wheel',()=>mark(PANEL_SCROLL_ACTIVE_MS+260),{passive:true});el.addEventListener('touchstart',()=>mark(PANEL_SCROLL_ACTIVE_MS+520),{passive:true});el.addEventListener('touchmove',()=>mark(PANEL_SCROLL_ACTIVE_MS+520),{passive:true});el.addEventListener('mousedown',()=>mark(PANEL_SCROLL_ACTIVE_MS+180),{passive:true});el.addEventListener('scroll',()=>mark(PANEL_SCROLL_ACTIVE_MS),{passive:true});continue}el.addEventListener('scroll',()=>{S.follow[id]=nearBottom(el)})}}const drop=E('uploadDrop');const fileInput=E('uploadInput');if(drop&&fileInput){drop.onclick=()=>fileInput.click();fileInput.onchange=()=>uploadFiles(fileInput.files).then(()=>{fileInput.value=''}).catch(err=>showError(err.message));for(const evt of ['dragenter','dragover']){drop.addEventListener(evt,e=>{e.preventDefault();drop.classList.add('dragover')})}for(const evt of ['dragleave','dragend']){drop.addEventListener(evt,e=>{e.preventDefault();drop.classList.remove('dragover')})}drop.addEventListener('drop',e=>{e.preventDefault();drop.classList.remove('dragover');uploadFiles(e.dataTransfer?.files||[]).catch(err=>showError(err.message))})}const configInput=E('configInput');if(configInput){configInput.onchange=()=>uploadLlmConfigFile(configInput.files&&configInput.files[0]).then(()=>{configInput.value=''}).catch(err=>showError(err.message||String(err)))}bindClick('newSessionBtn',createSession);bindClick('renameSessionBtn',renameSession);bindClick('deleteSessionBtn',deleteSession);bindClick('applyModelBtn',applyModel);bindClick('importConfigBtn',importDefaultConfig);bindClick('sendBtn',sendMessage);bindClick('interruptBtn',interruptRun);bindClick('compactBtn',compactNow);bindClick('clearStaleTodosBtn',clearStaleTodos);bindClick('refreshFilesBtn',()=>refreshFileExplorer(true));bindClick('refreshBtn',()=>refreshAll(true));bindClick('previewReloadBtn',()=>renderActivePreview(true));bindClick('previewCopyBtn',()=>copyPreviewCode());const exportMenuBtn=E('exportMenuBtn');const exportMenu=E('exportMenu');if(exportMenuBtn&&exportMenu){exportMenuBtn.addEventListener('click',e=>{e.stopPropagation();exportMenu.style.display=exportMenu.style.display==='none'?'block':'none'});document.addEventListener('click',()=>{exportMenu.style.display='none'});exportMenu.addEventListener('click',e=>{e.stopPropagation()});for(const a of exportMenu.querySelectorAll('.export-item')){a.addEventListener('click',()=>{exportMenu.style.display='none'})}}const langSel=E('langSelect');if(langSel){langSel.onchange=()=>setLanguage(langSel.value).catch(err=>showError(err.message||String(err)))}const promptEl=E('prompt');if(promptEl){promptEl.addEventListener('keydown',e=>{if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){e.preventDefault();sendMessage()}})}applyStaticUiClass();applyMainI18n();_bindPreviewCopyGuard();try{await refreshAll(false);if(!S.sessions.length)await createSession()}catch(err){showError(err.message||String(err))}_deltaStartWatchdog();scheduleSessionPoll(false);document.addEventListener('visibilitychange',()=>{const next=document.visibilityState||'visible';if(next===S.lastVisibilityState)return;S.lastVisibilityState=next;if(next==='hidden'){if(S.deltaWatchdogTimer){clearTimeout(S.deltaWatchdogTimer);S.deltaWatchdogTimer=null}if(S.sessionPollTimer){clearTimeout(S.sessionPollTimer);S.sessionPollTimer=null}if(S.staticMode)freezeAutoUpdates();return}if(S.staticMode&&S.frozen)resumeAutoUpdates();_deltaStartWatchdog();scheduleSessionPoll(true);scheduleSnapshot({forceFull:false,delayMs:40,allowWhenFrozen:true})})})
27315
+ window.addEventListener('DOMContentLoaded',async()=>{for(const id of ['chat','sessionList','todos','tasks','activity','commands','diffs','fileExplorer','catalog']){const el=E(id);if(el){if(id==='chat'){continue}if(id==='sessionList'||id==='todos'||id==='tasks'){S.follow[id]=false;const mark=(lockMs=PANEL_SCROLL_ACTIVE_MS)=>{const now=Date.now();el._panelUserScrollTs=now;el._panelUserScrollLockTs=Math.max(Number(el._panelUserScrollLockTs||0),now+Math.max(PANEL_SCROLL_ACTIVE_MS,Number(lockMs)||PANEL_SCROLL_ACTIVE_MS))};el.addEventListener('wheel',()=>mark(PANEL_SCROLL_ACTIVE_MS+260),{passive:true});el.addEventListener('touchstart',()=>mark(PANEL_SCROLL_ACTIVE_MS+520),{passive:true});el.addEventListener('touchmove',()=>mark(PANEL_SCROLL_ACTIVE_MS+520),{passive:true});el.addEventListener('mousedown',()=>mark(PANEL_SCROLL_ACTIVE_MS+180),{passive:true});el.addEventListener('scroll',()=>mark(PANEL_SCROLL_ACTIVE_MS),{passive:true});continue}el.addEventListener('scroll',()=>{S.follow[id]=nearBottom(el)})}}const drop=E('uploadDrop');const fileInput=E('uploadInput');if(drop&&fileInput){drop.onclick=()=>fileInput.click();fileInput.onchange=()=>uploadFiles(fileInput.files).then(()=>{fileInput.value=''}).catch(err=>showError(err.message));for(const evt of ['dragenter','dragover']){drop.addEventListener(evt,e=>{e.preventDefault();drop.classList.add('dragover')})}for(const evt of ['dragleave','dragend']){drop.addEventListener(evt,e=>{e.preventDefault();drop.classList.remove('dragover')})}drop.addEventListener('drop',e=>{e.preventDefault();drop.classList.remove('dragover');uploadFiles(e.dataTransfer?.files||[]).catch(err=>showError(err.message))})}const configInput=E('configInput');if(configInput){configInput.onchange=()=>uploadLlmConfigFile(configInput.files&&configInput.files[0]).then(()=>{configInput.value=''}).catch(err=>showError(err.message||String(err)))}bindClick('newSessionBtn',createSession);bindClick('renameSessionBtn',renameSession);bindClick('deleteSessionBtn',deleteSession);bindClick('applyModelBtn',applyModel);bindClick('importConfigBtn',importDefaultConfig);bindClick('sendBtn',sendMessage);bindClick('interruptBtn',interruptRun);bindClick('compactBtn',compactNow);bindClick('clearStaleTodosBtn',clearStaleTodos);bindClick('planModeBtn',togglePlanMode);bindClick('refreshFilesBtn',()=>refreshFileExplorer(true));bindClick('refreshBtn',()=>refreshAll(true));bindClick('previewReloadBtn',()=>renderActivePreview(true));bindClick('previewCopyBtn',()=>copyPreviewCode());const exportMenuBtn=E('exportMenuBtn');const exportMenu=E('exportMenu');if(exportMenuBtn&&exportMenu){exportMenuBtn.addEventListener('click',e=>{e.stopPropagation();exportMenu.style.display=exportMenu.style.display==='none'?'block':'none'});document.addEventListener('click',()=>{exportMenu.style.display='none'});exportMenu.addEventListener('click',e=>{e.stopPropagation()});for(const a of exportMenu.querySelectorAll('.export-item')){a.addEventListener('click',()=>{exportMenu.style.display='none'})}}const langSel=E('langSelect');if(langSel){langSel.onchange=()=>setLanguage(langSel.value).catch(err=>showError(err.message||String(err)))}const promptEl=E('prompt');if(promptEl){promptEl.addEventListener('keydown',e=>{if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){e.preventDefault();sendMessage()}})}applyStaticUiClass();applyMainI18n();_bindPreviewCopyGuard();try{await refreshAll(false);if(!S.sessions.length)await createSession()}catch(err){showError(err.message||String(err))}_deltaStartWatchdog();scheduleSessionPoll(false);document.addEventListener('visibilitychange',()=>{const next=document.visibilityState||'visible';if(next===S.lastVisibilityState)return;S.lastVisibilityState=next;if(next==='hidden'){if(S.deltaWatchdogTimer){clearTimeout(S.deltaWatchdogTimer);S.deltaWatchdogTimer=null}if(S.sessionPollTimer){clearTimeout(S.sessionPollTimer);S.sessionPollTimer=null}if(S.staticMode)freezeAutoUpdates();return}if(S.staticMode&&S.frozen)resumeAutoUpdates();_deltaStartWatchdog();scheduleSessionPoll(true);scheduleSnapshot({forceFull:false,delayMs:40,allowWhenFrozen:true})})})
25015
27316
  """
25016
27317
 
25017
27318
  APP_TS = """type SessionSummary={id:string;title:string;running:boolean;updated_at:number;message_count:number};
@@ -26027,6 +28328,8 @@ class AppContext:
26027
28328
  cfg_max_output_tokens = cfg.get("max_output_tokens")
26028
28329
  if cfg_max_output_tokens is not None:
26029
28330
  self.max_output_tokens = max(256, int(cfg_max_output_tokens))
28331
+ if "single_advance_prompt_enhance" in cfg:
28332
+ self.single_advance_prompt_enhance = bool(cfg["single_advance_prompt_enhance"])
26030
28333
 
26031
28334
  def normalized_profiles() -> tuple[dict[str, dict], str]:
26032
28335
  rows: dict[str, dict] = {}
@@ -27330,6 +29633,20 @@ class Handler(BaseHTTPRequestHandler):
27330
29633
  return self._send_json({"error": "session not found"}, status=404)
27331
29634
  sess.interrupt()
27332
29635
  return self._send_json({"ok": True})
29636
+ m = re.match(r"^/api/sessions/([^/]+)/config/plan-mode$", path)
29637
+ if m:
29638
+ sess = mgr.get(m.group(1))
29639
+ if not sess:
29640
+ return self._send_json({"error": "session not found"}, status=404)
29641
+ try:
29642
+ body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0) or 0)))
29643
+ except Exception:
29644
+ body = {}
29645
+ pref = str(body.get("preference", "auto") or "auto").strip().lower()
29646
+ if pref not in PLAN_MODE_USER_CHOICES:
29647
+ pref = "auto"
29648
+ sess.plan_mode_user_preference = pref
29649
+ return self._send_json({"plan_mode": pref})
27333
29650
  return self._send_json({"error": "not found"}, status=404)
27334
29651
 
27335
29652
  def do_PATCH(self):