clouds-coder 2026.3.17__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
@@ -93,7 +93,30 @@ TRUNCATION_CONTINUATION_ECHO_CHARS = 12000
93
93
  TRUNCATION_OVERLAP_SCAN_CHARS = 420
94
94
  TRUNCATION_PAIR_SCAN_CHARS = 120_000
95
95
  TRUNCATION_LIVE_BUFFER_MAX_CHARS = 32000
96
- 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
97
120
  MAX_CONTEXT_ARCHIVE_SEGMENTS = 1200
98
121
  MODEL_OUTPUT_RETRY_TIMES = 3
99
122
  ARBITER_TRIGGER_MIN_CONTENT_CHARS = 50
@@ -166,21 +189,24 @@ EXECUTION_MODE_CHOICES = (
166
189
  EXECUTION_MODE_SYNC,
167
190
  )
168
191
  AGENT_ROLES = ("explorer", "developer", "reviewer")
169
- AGENT_BUBBLE_ROLES = AGENT_ROLES + ("manager",)
192
+ AGENT_BUBBLE_ROLES = AGENT_ROLES + ("manager", "planner")
170
193
  AGENT_ROLE_LABELS = {
171
194
  "explorer": "Explorer",
172
195
  "developer": "Developer",
173
196
  "reviewer": "Reviewer",
174
197
  "manager": "Manager",
198
+ "planner": "Planner",
175
199
  }
176
200
  AGENT_ROLE_BUBBLE_COLORS = {
177
201
  "explorer": "#ff5fa2",
178
202
  "developer": "#22b455",
179
203
  "reviewer": "#dca61b",
180
204
  "manager": "#7c3aed",
205
+ "planner": "#e8533f",
181
206
  }
182
207
  BLACKBOARD_STATUSES = (
183
208
  "INITIALIZING",
209
+ "PLANNING",
184
210
  "RESEARCHING",
185
211
  "CODING",
186
212
  "TESTING",
@@ -254,6 +280,91 @@ SKILL_PROMPT_MAX_ITEMS = 40
254
280
  SKILL_PROMPT_MAX_CHARS = 2600
255
281
  SKILL_RUNTIME_CACHE_MAX_ENTRIES = 48
256
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
257
368
  HTML_FRONTEND_REQUEST_KEYWORDS = (
258
369
  "html",
259
370
  "web page",
@@ -7276,6 +7387,11 @@ class SessionState:
7276
7387
  self.runtime_reclassify_goal = ""
7277
7388
  self.runtime_reclassify_required = False
7278
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"
7279
7395
  self.rounds_without_todo = 0
7280
7396
  self.last_todo_reminder_ts = 0.0
7281
7397
  self.todo_reminder_count = 0
@@ -7283,6 +7399,12 @@ class SessionState:
7283
7399
  self.todo_last_issue = ""
7284
7400
  self.last_toolcall_overflow_hint_ts = 0.0
7285
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] = []
7286
7408
  self.tool_retry_counts: dict[str, int] = {}
7287
7409
  self.last_auto_title_ts = 0.0
7288
7410
  self.live_thinking_text = ""
@@ -7337,6 +7459,9 @@ class SessionState:
7337
7459
  self.context_archives: list[dict] = []
7338
7460
  self.last_compact_reason = ""
7339
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}
7340
7465
  self.created_at = now_ts()
7341
7466
  self.updated_at = now_ts()
7342
7467
  self.shutdown_requests: dict[str, dict] = {}
@@ -7477,6 +7602,21 @@ class SessionState:
7477
7602
  used_rel=used_rel,
7478
7603
  )
7479
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)
7480
7620
  return items
7481
7621
 
7482
7622
  def _latest_user_message_ts(self) -> float:
@@ -8190,8 +8330,25 @@ class SessionState:
8190
8330
  }
8191
8331
  )
8192
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
8193
8345
  self.last_compact_reason = str(raw.get("last_compact_reason", self.last_compact_reason) or "")
8194
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)
8195
8352
  pending_inputs = raw.get("pending_user_inputs", [])
8196
8353
  if isinstance(pending_inputs, list):
8197
8354
  clean_pending: list[dict] = []
@@ -8286,6 +8443,12 @@ class SessionState:
8286
8443
  self.runtime_goal_reset_pending = bool(
8287
8444
  raw.get("runtime_goal_reset_pending", self.runtime_goal_reset_pending)
8288
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 "")
8289
8452
  self.active_agent_role = str(raw.get("active_agent_role", self.active_agent_role) or "").strip().lower()
8290
8453
  raw_contexts = raw.get("contexts", {})
8291
8454
  if isinstance(raw_contexts, dict):
@@ -8400,8 +8563,13 @@ class SessionState:
8400
8563
  "last_truncation_ts": self.last_truncation_ts,
8401
8564
  "truncation_rescue_task_ids": self.truncation_rescue_task_ids[:12],
8402
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 {},
8403
8567
  "last_compact_reason": self.last_compact_reason,
8404
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),
8405
8573
  "pending_user_inputs": self.pending_user_inputs[-40:],
8406
8574
  "run_generation": int(self.run_generation),
8407
8575
  "agent_round_index": int(self.agent_round_index),
@@ -8433,6 +8601,10 @@ class SessionState:
8433
8601
  "runtime_reclassify_goal": trim(str(self.runtime_reclassify_goal or ""), 4000),
8434
8602
  "runtime_reclassify_required": bool(self.runtime_reclassify_required),
8435
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 ""),
8436
8608
  "active_agent_role": str(self.active_agent_role or ""),
8437
8609
  "contexts": {role: list(self.contexts.get(role, []))[-400:] for role in AGENT_ROLES},
8438
8610
  "manager_context": list(self.manager_context)[-400:],
@@ -8580,6 +8752,7 @@ class SessionState:
8580
8752
  self.runtime_reclassify_goal = ""
8581
8753
  self.runtime_reclassify_required = False
8582
8754
  self.runtime_goal_reset_pending = False
8755
+ self.runtime_plan_mode_needed = False
8583
8756
  return removed_hints
8584
8757
 
8585
8758
  def _event_payload_with_agent_role(self, kind: str, data: dict | None) -> dict:
@@ -8919,6 +9092,10 @@ class SessionState:
8919
9092
  "If execution stalls, load_skill('execution-degradation-recovery'). "
8920
9093
  )
8921
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"
8922
9099
  return (
8923
9100
  f"You are a coding agent. Workspace: {self.files_root}. "
8924
9101
  f"Task level={runtime_level}, mode={runtime_mode}, "
@@ -8928,6 +9105,7 @@ class SessionState:
8928
9105
  "Use tools to inspect, edit, and execute. "
8929
9106
  "Call finish_current_task when done. "
8930
9107
  f"{skill_hint}"
9108
+ f"{plan_steps_block}"
8931
9109
  f"{html_block}"
8932
9110
  f"{research_block}"
8933
9111
  f"{model_language_instruction(self.ui_language)}\n\n"
@@ -8936,7 +9114,28 @@ class SessionState:
8936
9114
  )
8937
9115
 
8938
9116
  def _estimate_tokens(self) -> int:
8939
- 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
8940
9139
 
8941
9140
  def _context_budget_metrics(self, token_estimate: int | None = None) -> dict:
8942
9141
  limit = max(1, int(self.context_token_upper_bound or 0))
@@ -8952,8 +9151,143 @@ class SessionState:
8952
9151
  "used_percent": used_pct,
8953
9152
  }
8954
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
+
8955
9278
  def _apply_auto_compact_if_needed(self, reason: str = "auto") -> bool:
8956
- 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
8957
9291
  metrics = self._context_budget_metrics()
8958
9292
  used = int(metrics.get("used", 0) or 0)
8959
9293
  limit = max(1, int(metrics.get("limit", 0) or 0))
@@ -9757,10 +10091,10 @@ class SessionState:
9757
10091
  total += len(str(row.get("content", ""))) // 4
9758
10092
  return total
9759
10093
 
9760
- 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]:
9761
10095
  if not self.messages:
9762
10096
  return []
9763
- budget = max(2000, int(token_budget))
10097
+ budget = max(1000, int(token_budget))
9764
10098
  selected: list[dict] = []
9765
10099
  total = 0
9766
10100
  for row in reversed(self.messages):
@@ -9810,6 +10144,60 @@ class SessionState:
9810
10144
  self.context_archives = self.context_archives[-MAX_CONTEXT_ARCHIVE_SEGMENTS:]
9811
10145
  return seg
9812
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
+
9813
10201
  def _summarize_compact_rows(self, rows: list[dict]) -> str:
9814
10202
  if not rows:
9815
10203
  return "(no archived rows)"
@@ -9882,17 +10270,145 @@ class SessionState:
9882
10270
  lines.append(f"Current focus: {focus}")
9883
10271
  lines.append(f"Open tasks ({len(task_open)}):")
9884
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} 以来无实质进展,需要换策略。")
9885
10282
  return "\n".join(lines)
9886
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
+
9887
10396
  def _auto_compact(self, reason: str):
9888
10397
  context_before = self._context_budget_metrics()
10398
+ tier = self._context_compression_tier(context_before)
9889
10399
  transcript_dir = self.root / "transcripts"
9890
10400
  transcript_dir.mkdir(parents=True, exist_ok=True)
9891
10401
  transcript_path = transcript_dir / f"transcript_{int(now_ts())}.jsonl"
9892
10402
  with transcript_path.open("w", encoding="utf-8") as handle:
9893
10403
  for msg in self.messages:
9894
10404
  handle.write(json_dumps(msg) + "\n")
9895
- 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)))
9896
10412
  tail = self._select_compact_tail(tail_budget)
9897
10413
  if len(tail) >= len(self.messages):
9898
10414
  tail = self._select_compact_tail(max(2200, int(tail_budget * 0.55)), min_count=4, max_count=20)
@@ -9907,35 +10423,59 @@ class SessionState:
9907
10423
  if seg_id
9908
10424
  else "If details are missing, call context_recall with recent_segments=2."
9909
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 ""
9910
10432
  compact_note = (
9911
10433
  "<compact-resume>\n"
9912
10434
  f"reason: {reason}\n"
10435
+ f"tier: {tier}\n"
9913
10436
  f"transcript: {transcript_path}\n"
9914
10437
  f"archive_segment: {seg_id or 'none'} messages={seg_msg_count} path={seg_path or '-'}\n"
10438
+ f"{state_handoff}\n"
9915
10439
  f"{self._open_work_brief()}\n"
10440
+ f"{failure_section}"
9916
10441
  f"summary:\n{summary}\n"
9917
10442
  f"{continuation}\n"
9918
10443
  "Continue exploring from current pending work; do not restart from scratch.\n"
9919
10444
  "</compact-resume>"
9920
10445
  )
9921
10446
  tail.append({"role": "user", "content": compact_note, "ts": now_ts()})
9922
- 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)))
9923
10454
  while len(tail) > 5 and self._estimate_messages_tokens(tail) > target_tokens:
9924
10455
  tail.pop(0)
9925
10456
  if self._estimate_messages_tokens(tail) > target_tokens:
9926
10457
  for msg in tail:
9927
10458
  if msg.get("role") == "tool":
9928
- msg["content"] = trim(msg.get("content", ""), 1800)
10459
+ msg["content"] = trim(msg.get("content", ""), 1800 if tier < 2 else 600)
9929
10460
  while len(tail) > 2 and self._estimate_messages_tokens(tail) > target_tokens:
9930
10461
  tail.pop(0)
9931
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")
9932
10471
  self.last_compact_reason = str(reason or "")
9933
10472
  self.last_compact_ts = now_ts()
9934
10473
  self._emit(
9935
10474
  "compact",
9936
10475
  {
9937
- "summary": f"context compacted ({reason}) archives={len(self.context_archives)}",
10476
+ "summary": f"context compacted ({reason}) tier={tier} archives={len(self.context_archives)}",
9938
10477
  "reason": str(reason or ""),
10478
+ "tier": tier,
9939
10479
  "archive_segment": seg_id,
9940
10480
  "archived_messages": seg_msg_count,
9941
10481
  "context_limit_before": int(context_before.get("limit", 0)),
@@ -9945,6 +10485,50 @@ class SessionState:
9945
10485
  },
9946
10486
  )
9947
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
+
9948
10532
  def _git_status_map(self, cwd: Path) -> dict[str, str]:
9949
10533
  try:
9950
10534
  check = subprocess.run(
@@ -11303,7 +11887,7 @@ class SessionState:
11303
11887
  active_rows.append(trim(content, 140))
11304
11888
  if len(active_rows) >= 3:
11305
11889
  break
11306
- return {
11890
+ snapshot = {
11307
11891
  "goal": self._latest_user_goal_text(),
11308
11892
  "todo_open_count": int(open_count),
11309
11893
  "todo_in_progress_count": int(in_progress_count),
@@ -11312,6 +11896,20 @@ class SessionState:
11312
11896
  "assistant_thinking": trim(str(thinking_text or "").strip(), 700),
11313
11897
  "agent_round_index": int(self.agent_round_index),
11314
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
11315
11913
 
11316
11914
  def _build_arbiter_client(self) -> OllamaClient:
11317
11915
  client = OllamaClient(self.ollama.base_url, self.ollama.model)
@@ -11412,16 +12010,41 @@ class SessionState:
11412
12010
 
11413
12011
  def _mark_all_done_silently(self, reason: str = "") -> dict:
11414
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
+ }
11415
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")
11416
12038
  updated = int(result.get("updated", 0) or 0)
11417
12039
  total = int(result.get("total", 0) or 0)
12040
+ pending_steps = len(saved_plan_steps)
11418
12041
  self._prune_runtime_retry_hints()
11419
12042
  self._emit(
11420
12043
  "status",
11421
12044
  {
11422
12045
  "summary": (
11423
- "arbiter marked task completed; "
11424
- 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 "")
11425
12048
  )
11426
12049
  },
11427
12050
  )
@@ -12501,13 +13124,61 @@ class SessionState:
12501
13124
  def _run_read(self, path: str, limit: int | None = None) -> str:
12502
13125
  try:
12503
13126
  rel = self._normalize_tool_path_text(path)
12504
- 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()
12505
13137
  if limit and limit < len(lines):
12506
13138
  lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
12507
13139
  return trim("\n".join(lines))
12508
13140
  except Exception as exc:
12509
13141
  return f"Error: {type(exc).__name__}: {exc}"
12510
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
+
12511
13182
  def _is_html_file_rel(self, path: str) -> bool:
12512
13183
  low = str(path or "").strip().lower()
12513
13184
  return low.endswith(".html") or low.endswith(".htm")
@@ -13313,6 +13984,15 @@ class SessionState:
13313
13984
  bb = board if isinstance(board, dict) else self._ensure_blackboard()
13314
13985
  if bool(self.runtime_goal_reset_pending):
13315
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)}"
13316
13996
  # Project todo gate: coding tasks must pass compile + test
13317
13997
  profile = self._ensure_blackboard_task_profile(bb)
13318
13998
  task_type = str(profile.get("task_type", "general") or "general")
@@ -13371,8 +14051,16 @@ class SessionState:
13371
14051
 
13372
14052
  def _manager_progress_state(self, board: dict | None = None) -> str:
13373
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
+ )
13374
14062
  can_finish, _ = self._can_auto_finish_from_approval(bb)
13375
- if can_finish:
14063
+ if can_finish and not has_pending_plan_steps:
13376
14064
  return "done"
13377
14065
  profile = self._ensure_blackboard_task_profile(bb)
13378
14066
  task_type = str(profile.get("task_type", "") or "")
@@ -14053,6 +14741,8 @@ class SessionState:
14053
14741
  bb["watchdog"] = wd
14054
14742
  self.blackboard = bb
14055
14743
  self._blackboard_touch()
14744
+ # Failure ledger: record stall event
14745
+ self._ledger_record_stall(trigger_reason, "unresolved")
14056
14746
  if int(wd["trigger_count"]) >= 2:
14057
14747
  self._watchdog_escalate_to_single_developer(bb, reason=trigger_reason)
14058
14748
  else:
@@ -14200,6 +14890,16 @@ class SessionState:
14200
14890
  "project_todos": [],
14201
14891
  "watchdog": self._new_watchdog_state(),
14202
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": [],
14203
14903
  }
14204
14904
 
14205
14905
  def _normalize_blackboard(self, raw: object) -> dict:
@@ -14371,8 +15071,324 @@ class SessionState:
14371
15071
  board["decomposition_queue"] = self._normalize_decomposition_queue_state(
14372
15072
  src.get("decomposition_queue", {})
14373
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 []
14374
15092
  return board
14375
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
+
14376
15392
  def _ensure_blackboard(self) -> dict:
14377
15393
  if not isinstance(self.blackboard, dict) or not self.blackboard:
14378
15394
  self.blackboard = self._new_blackboard(self._latest_user_goal_text())
@@ -14729,6 +15745,31 @@ class SessionState:
14729
15745
  bb = board if isinstance(board, dict) else self._ensure_blackboard()
14730
15746
  if bb.get("project_todos"):
14731
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
14732
15773
  raw = self._generate_project_todos_from_profile(bb)
14733
15774
  bb["project_todos"] = [
14734
15775
  {
@@ -14806,6 +15847,23 @@ class SessionState:
14806
15847
  todo.update(status="completed", completed_at=float(now_ts()), evidence="测试通过")
14807
15848
  elif cat == "review" and feedback_pass:
14808
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"
14809
15867
 
14810
15868
  if not any(t.get("status") == "in_progress" for t in todos):
14811
15869
  for t in todos:
@@ -14816,6 +15874,104 @@ class SessionState:
14816
15874
  bb["project_todos"] = todos
14817
15875
  self.blackboard = bb
14818
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
+
14819
15975
  def _todo_project_rows_from_blackboard(self, board: dict | None = None) -> list[dict]:
14820
15976
  bb = board if isinstance(board, dict) else self._ensure_blackboard()
14821
15977
  todos = bb.get("project_todos", [])
@@ -14881,6 +16037,42 @@ class SessionState:
14881
16037
  self._blackboard_touch()
14882
16038
  self._sync_todos_from_blackboard(reason="approved", board=board)
14883
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
+
14884
16076
  def _auto_summary_on_finish(self) -> str:
14885
16077
  """Generate concise summary from blackboard state when run ends."""
14886
16078
  bb = self._ensure_blackboard()
@@ -15035,7 +16227,9 @@ class SessionState:
15035
16227
 
15036
16228
  proj_todos = board.get("project_todos", [])
15037
16229
  if proj_todos:
15038
- 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}")
15039
16233
  for pt in proj_todos:
15040
16234
  s = pt.get("status", "pending")
15041
16235
  c = trim(str(pt.get("content", "") or ""), 200)
@@ -15046,8 +16240,9 @@ class SessionState:
15046
16240
  mark = "[>]"
15047
16241
  else:
15048
16242
  mark = "[ ]"
16243
+ prefix = f"Step {int(pt.get('plan_step_index', 0) or 0) + 1}: " if has_plan_steps else ""
15049
16244
  suffix = f" — {ev}" if ev else ""
15050
- lines.append(f"- {mark} {c}{suffix}")
16245
+ lines.append(f"- {mark} {prefix}{c}{suffix}")
15051
16246
 
15052
16247
  return "\n".join(lines)
15053
16248
 
@@ -15072,6 +16267,10 @@ class SessionState:
15072
16267
  "requires_user_confirmation": {"type": "boolean"},
15073
16268
  "is_mandatory": {"type": "boolean"},
15074
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
+ },
15075
16274
  },
15076
16275
  ["target", "instruction"],
15077
16276
  )
@@ -15100,6 +16299,15 @@ class SessionState:
15100
16299
  "participants": {"type": "array", "items": {"type": "string", "enum": list(AGENT_ROLES)}},
15101
16300
  "assigned_expert": {"type": "string", "enum": list(AGENT_ROLES)},
15102
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
+ },
15103
16311
  },
15104
16312
  ["level", "judgement", "inherit_previous_state"],
15105
16313
  )
@@ -15373,6 +16581,11 @@ class SessionState:
15373
16581
  level = 3
15374
16582
  if level not in TASK_LEVEL_CHOICES:
15375
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
15376
16589
  policy = dict(TASK_LEVEL_POLICIES.get(level, TASK_LEVEL_POLICIES[3]))
15377
16590
  policy_mode = str(policy.get("execution_mode", EXECUTION_MODE_SYNC))
15378
16591
  config_mode = normalize_execution_mode(self.execution_mode, default="")
@@ -15505,7 +16718,9 @@ class SessionState:
15505
16718
  objective = trim(str(row.get("direct_objective", "") or "").strip(), 800)
15506
16719
  if not objective:
15507
16720
  objective = fallback_objective
16721
+ _prev_level_val = int(getattr(self, '_prev_applied_task_level', 0) or 0)
15508
16722
  self.runtime_task_level = int(level)
16723
+ self._prev_applied_task_level = int(level)
15509
16724
  self.runtime_execution_mode = mode
15510
16725
  self.runtime_assigned_expert = assigned
15511
16726
  self.runtime_participants = list(participants)
@@ -15519,6 +16734,32 @@ class SessionState:
15519
16734
  self.runtime_direct_objective = objective
15520
16735
  self.runtime_reclassify_goal = trim(str(goal_text or "").strip(), 4000)
15521
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)
15522
16763
  board = self._ensure_blackboard()
15523
16764
  profile = self._ensure_blackboard_task_profile(board)
15524
16765
  profile["task_level"] = int(level)
@@ -15568,6 +16809,13 @@ class SessionState:
15568
16809
  )
15569
16810
  },
15570
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
+ })
15571
16819
 
15572
16820
  def _manager_classify_task_level(
15573
16821
  self,
@@ -15599,6 +16847,15 @@ class SessionState:
15599
16847
  "IMPORTANT: User has configured Single-agent mode. "
15600
16848
  "Prefer level 1-2 for simple tasks. Only escalate to level 3+ if truly complex.\n"
15601
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
+ )
15602
16859
  with self.lock:
15603
16860
  self.current_phase = "manager:classify:model-call"
15604
16861
  self.current_tool_name = ""
@@ -15644,6 +16901,61 @@ class SessionState:
15644
16901
  row["low_confidence_reason"] = "manager classifier returned no valid tool call"
15645
16902
  return row
15646
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
+
15647
16959
  def _refresh_runtime_task_policy(
15648
16960
  self,
15649
16961
  *,
@@ -15655,6 +16967,26 @@ class SessionState:
15655
16967
  goal = trim(str(goal_text or self.runtime_reclassify_goal or self._latest_user_goal_text() or "").strip(), 4000)
15656
16968
  if not goal:
15657
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)
15658
16990
  need = bool(
15659
16991
  force
15660
16992
  or self.runtime_reclassify_required
@@ -15681,11 +17013,94 @@ class SessionState:
15681
17013
  self._apply_runtime_task_decision(goal, decision)
15682
17014
  return dict(decision or {})
15683
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
+
15684
17082
  def _project_todo_hint_for_manager(self) -> str:
15685
17083
  bb = self._ensure_blackboard()
15686
17084
  todos = bb.get("project_todos", [])
15687
17085
  if not todos:
15688
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
+ # 原有逻辑
15689
17104
  pending = [t for t in todos if t.get("status") != "completed"]
15690
17105
  if not pending:
15691
17106
  return "All project tasks completed. Route to finish. "
@@ -15713,6 +17128,35 @@ class SessionState:
15713
17128
  "Explorer should only be used for specific file/API lookups, not broad analysis. "
15714
17129
  )
15715
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
+ )
15716
17160
  return (
15717
17161
  "You are Manager in a multi-agent coding system. "
15718
17162
  "Read blackboard, delegate one short timeslice via route_to_next_agent. "
@@ -15721,10 +17165,13 @@ class SessionState:
15721
17165
  "Role capabilities: "
15722
17166
  "Explorer=read-only (bash/read_file/search/blackboard, NO write_file/edit_file); "
15723
17167
  "Developer=all tools (write_file/edit_file/bash/read_file/etc); "
15724
- "Reviewer=read+verify (bash/read_file/finish_task, NO write_file/edit_file). "
15725
- "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. '}"
15726
17170
  f"{coding_hint}"
15727
17171
  f"{project_todo_hint}"
17172
+ f"{plan_context}"
17173
+ f"{phase_hint}"
17174
+ f"{failure_hint}"
15728
17175
  f"Level={level}, mode={mode}, progress={progress}, "
15729
17176
  f"budget={'unlimited' if int(budget) <= 0 else int(budget)}, "
15730
17177
  f"objective={trim(str(profile.get('direct_objective','') or ''), 220)}. "
@@ -15891,7 +17338,25 @@ class SessionState:
15891
17338
  cycles = int(board.get("manager_cycles", 0) or 0)
15892
17339
  summary_attempts = int(board.get("manager_summary_attempts", 0) or 0)
15893
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
+ ]
15894
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
+ }
15895
17360
  self._emit("status", {"summary": "Max cycles reached; forcing finish."})
15896
17361
  return {
15897
17362
  "target": "finish",
@@ -16167,12 +17632,43 @@ class SessionState:
16167
17632
  return row
16168
17633
  target = str(row.get("target", "") or "").strip().lower()
16169
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
16170
17666
  if task_type_low in ("simple_code", "engineering") and target == "explorer":
16171
- board = self._ensure_blackboard()
17667
+ board = bb_for_routes
16172
17668
  progress = self._manager_progress_state(board)
16173
17669
  if progress in ("initializing", "in_progress"):
16174
17670
  explorer_count = sum(
16175
- 1 for x in self.manager_routes[-8:]
17671
+ 1 for x in merged_routes[-8:]
16176
17672
  if str(x.get("target", "") or "").strip().lower() == "explorer"
16177
17673
  )
16178
17674
  if explorer_count >= 2:
@@ -16186,9 +17682,9 @@ class SessionState:
16186
17682
  return row
16187
17683
  if target not in AGENT_ROLES:
16188
17684
  return row
16189
- 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:]]
16190
17686
  if len(recent) >= 2 and recent[-1] == target and recent[-2] == target:
16191
- board = self._ensure_blackboard()
17687
+ board = bb_for_routes
16192
17688
  low_reason = str(row.get("reason", "") or "").strip().lower()
16193
17689
  if "summary" in low_reason and len(board.get("code_artifacts", {}) or {}) > 0:
16194
17690
  row["target"] = "finish"
@@ -16211,7 +17707,7 @@ class SessionState:
16211
17707
  row["source"] = "anti-stall"
16212
17708
  return row
16213
17709
  if len(recent) == 4 and recent[0] == recent[2] and recent[1] == recent[3] and recent[0] != recent[1]:
16214
- board = self._ensure_blackboard()
17710
+ board = bb_for_routes
16215
17711
  if len(board.get("code_artifacts", {}) or {}) > 0:
16216
17712
  row["target"] = "finish"
16217
17713
  row["instruction"] = "Oscillation detected with existing outputs; finish now."
@@ -16402,7 +17898,17 @@ class SessionState:
16402
17898
  target = assigned_expert
16403
17899
  else:
16404
17900
  if self._manager_has_error_log(board):
16405
- 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"
16406
17912
  elif len(board.get("code_artifacts", {}) or {}) > 0:
16407
17913
  target = "reviewer"
16408
17914
  elif len(board.get("research_notes", []) or []) > 0:
@@ -16582,6 +18088,7 @@ class SessionState:
16582
18088
  "requires_user_confirmation": bool(args.get("requires_user_confirmation", False)),
16583
18089
  "is_mandatory": _to_bool_like(args.get("is_mandatory", False), default=False),
16584
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),
16585
18092
  "round_budget": args.get("round_budget", 0),
16586
18093
  "reason": trim(str(text or "").strip(), 600),
16587
18094
  "source": "tool",
@@ -16878,6 +18385,29 @@ class SessionState:
16878
18385
  active_profile = self._ensure_blackboard_task_profile(board)
16879
18386
  target = str(route.get("target", "") or "").strip().lower()
16880
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}"
16881
18411
  try:
16882
18412
  task_level = int(route.get("task_level", active_profile.get("task_level", self.runtime_task_level or 3)) or 3)
16883
18413
  except Exception:
@@ -16945,8 +18475,27 @@ class SessionState:
16945
18475
  "round_budget": int(round_budget),
16946
18476
  "remaining_rounds": int(remaining_rounds),
16947
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
+ )
16948
18484
  self.manager_routes.append(route_row)
16949
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)
16950
18499
  profile = self._ensure_blackboard_task_profile(board)
16951
18500
  profile["task_level"] = int(task_level)
16952
18501
  profile["execution_mode"] = execution_mode
@@ -17109,7 +18658,23 @@ class SessionState:
17109
18658
  role_capability_note = {
17110
18659
  "explorer": "YOUR TOOLS: read-only (bash/read_file/search/blackboard). You CANNOT write_file or edit_file.",
17111
18660
  "developer": "YOUR TOOLS: all tools available (write_file/edit_file/bash/read_file/etc).",
17112
- "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
+ ),
17113
18678
  }.get(role_key, "")
17114
18679
  if role_key == "explorer":
17115
18680
  tool_examples = "bash/read_file/read_from_blackboard"
@@ -17140,6 +18705,16 @@ class SessionState:
17140
18705
  "use ask_colleague immediately with explicit intent and concise payload; "
17141
18706
  "do not wait for another manager cycle."
17142
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
+ )
17143
18718
  board_md = self._blackboard_read_state_markdown(max_items=5)
17144
18719
  payload = (
17145
18720
  "<manager-delegate>\n"
@@ -17152,6 +18727,7 @@ class SessionState:
17152
18727
  f"{executor_note}\n"
17153
18728
  f"{collaboration_note}\n"
17154
18729
  f"{role_capability_note}\n"
18730
+ f"{error_section}"
17155
18731
  "</manager-delegate>\n"
17156
18732
  "<blackboard-state>\n"
17157
18733
  f"{trim(board_md, 6000)}\n"
@@ -17680,10 +19256,13 @@ class SessionState:
17680
19256
  row["agent_role"] = role_key
17681
19257
  if "ts" not in row:
17682
19258
  row["ts"] = now_ts()
17683
- # Write to unified agent_messages
19259
+ # Write to unified agent_messages with tier-aware trim
17684
19260
  self.agent_messages.append(row)
17685
- if len(self.agent_messages) > 1200:
17686
- 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:]
17687
19266
  if mirror_to_global:
17688
19267
  mirror = dict(row)
17689
19268
  if "tool_calls" in mirror and isinstance(mirror.get("tool_calls"), list):
@@ -17711,10 +19290,14 @@ class SessionState:
17711
19290
  if "ts" not in row:
17712
19291
  row["ts"] = now_ts()
17713
19292
  self.manager_context.append(row)
17714
- 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:]
17715
19297
  self.agent_messages.append(row)
17716
- if len(self.agent_messages) > 1200:
17717
- 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:]
17718
19301
 
17719
19302
  def _agent_display_name(self, role: str) -> str:
17720
19303
  return AGENT_ROLE_LABELS.get(self._sanitize_agent_role(role), str(role or "").strip().title() or "Agent")
@@ -17869,12 +19452,41 @@ class SessionState:
17869
19452
  "Use blackboard for shared state, ask_colleague for inter-agent communication. "
17870
19453
  "Keep outputs concise and action-oriented. "
17871
19454
  "Use load_skill for detailed guidance (multi-agent-guide, code-review-checklist, finish-protocol). "
19455
+ f"{_detect_os_shell_instruction()} "
17872
19456
  f"{model_language_instruction(self.ui_language)} "
17873
19457
  )
17874
19458
  if role_key == "explorer":
17875
19459
  return base + "Role: analyze goals, inspect codebase, produce research notes. Prefer read/search. "
17876
19460
  if role_key == "reviewer":
17877
- 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
+ )
17878
19490
  return base + "Role: implement code changes, execute tools, record progress to blackboard. "
17879
19491
 
17880
19492
  def _seed_multi_agent_contexts_if_needed(self, user_text: str = ""):
@@ -18686,6 +20298,20 @@ class SessionState:
18686
20298
  )
18687
20299
  return out
18688
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)
18689
20315
  return self.todo.update(args["items"])
18690
20316
  if name == "TodoWriteRescue":
18691
20317
  return self._todo_write_rescue(args)
@@ -18726,6 +20352,9 @@ class SessionState:
18726
20352
  todo_mark = self.todo.complete_all_open(summary)
18727
20353
  else:
18728
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")
18729
20358
  updated = int(todo_mark.get("updated", 0) or 0)
18730
20359
  if updated > 0:
18731
20360
  self._emit(
@@ -18838,6 +20467,25 @@ class SessionState:
18838
20467
  return "Error: ask_colleague.intent is required"
18839
20468
  if not content:
18840
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
+ )
18841
20489
  env = self._agent_send_bus(from_role, to_role, intent, content)
18842
20490
  return (
18843
20491
  f"agent_bus sent ({env.get('from')} -> {env.get('to')}, "
@@ -19049,6 +20697,8 @@ class SessionState:
19049
20697
  "</live-user-adjustment>"
19050
20698
  )
19051
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)
19052
20702
  self.runtime_reclassify_goal = trim(content, 4000)
19053
20703
  self.runtime_reclassify_required = True
19054
20704
  self.runtime_goal_reset_pending = True
@@ -19082,6 +20732,104 @@ class SessionState:
19082
20732
  )
19083
20733
  return len(injected)
19084
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
+
19085
20833
  def submit_user_message(self, content: str):
19086
20834
  start_worker = False
19087
20835
  dropped_stale_inputs = 0
@@ -19092,12 +20840,37 @@ class SessionState:
19092
20840
  else:
19093
20841
  if self.pending_user_inputs:
19094
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
+ )
19095
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
19096
20852
  self.run_generation = int(self.run_generation) + 1
19097
20853
  clean_goal = trim(str(content or "").strip(), 4000)
19098
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)
19099
20868
  self.runtime_reclassify_goal = clean_goal
19100
- 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
19101
20874
  self.runtime_goal_reset_pending = True
19102
20875
  self.running = True
19103
20876
  self.current_phase = "starting"
@@ -19380,6 +21153,25 @@ class SessionState:
19380
21153
  }
19381
21154
  # Atomic blackboard sync: update shared state immediately after each tool result.
19382
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
+ )
19383
21175
  item["bb_applied"] = True
19384
21176
  tool_results.append(item)
19385
21177
  if name in {"finish_task", "finish_current_task", "mark_done"} and (not str(output).startswith("Error:")):
@@ -19568,6 +21360,9 @@ class SessionState:
19568
21360
  self._emit("status", {"summary": "run interrupted"})
19569
21361
  break
19570
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()
19571
21366
  with self.lock:
19572
21367
  self.agent_round_index = int(self.agent_round_index) + 1
19573
21368
  self.current_phase = "manager:dispatch"
@@ -19910,6 +21705,450 @@ class SessionState:
19910
21705
  self._mark_all_done_silently(f"budget exhausted: {summary}")
19911
21706
  self._emit("status", {"summary": f"Budget exhausted ({self.max_agent_rounds} rounds). {trim(summary, 300)}"})
19912
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
+
19913
22152
  def _agent_worker(self):
19914
22153
  single_role = "developer"
19915
22154
  try:
@@ -19960,6 +22199,10 @@ class SessionState:
19960
22199
  {"summary": "level-5 requires user confirmation before next actions"},
19961
22200
  )
19962
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
19963
22206
  if self._is_multi_agent_mode():
19964
22207
  self._seed_multi_agent_contexts_if_needed(self.runtime_reclassify_goal or "")
19965
22208
  self._emit(
@@ -20044,6 +22287,10 @@ class SessionState:
20044
22287
  )
20045
22288
  break
20046
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()
20047
22294
  notifs = self.bg.drain()
20048
22295
  if notifs:
20049
22296
  text = "\n".join(f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs)
@@ -20783,6 +23030,25 @@ class SessionState:
20783
23030
  "ok": not str(output).startswith("Error:"),
20784
23031
  }
20785
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
+ )
20786
23052
  self._emit("tool_result", {"name": name, "result": trim(output, 500), "summary": f"tool done: {name}"})
20787
23053
  if int(tool_error_streaks.get(tool_key, 0) or 0) >= HARD_BREAK_TOOL_ERROR_THRESHOLD:
20788
23054
  stop_due_to_hard_break = True
@@ -20823,6 +23089,8 @@ class SessionState:
20823
23089
  state_changed=bool(single_watchdog_after_fp != single_watchdog_before_fp),
20824
23090
  pinned_selection=pinned_selection,
20825
23091
  )
23092
+ # Single-agent plan step tracking: sync todos and auto-advance
23093
+ self._single_agent_plan_step_check(single_round_tool_results)
20826
23094
  if stop_due_to_hard_break:
20827
23095
  note = (
20828
23096
  "Execution paused after repeated tool/recovery failures. "
@@ -21345,6 +23613,7 @@ class SessionState:
21345
23613
  "context_token_upper_bound": int(self.context_token_upper_bound),
21346
23614
  "context_token_limit_config": int(self.max_context_token_limit),
21347
23615
  "context_token_limit_locked": bool(self.context_limit_locked),
23616
+ "plan_mode_preference": str(self.plan_mode_user_preference or "auto"),
21348
23617
  "context_tokens_estimate": int(ctx.get("used", 0)),
21349
23618
  "context_left_tokens": int(ctx.get("left", 0)),
21350
23619
  "context_left_percent": round(float(ctx.get("left_percent", 0.0)), 2),
@@ -22367,6 +24636,7 @@ window.MathJax={
22367
24636
  <button id="sendBtn">Send</button>
22368
24637
  <button id="interruptBtn" class="subtle">Interrupt</button>
22369
24638
  <button id="compactBtn" class="subtle">Compact</button>
24639
+ <button id="planModeBtn" class="subtle" title="Toggle Plan Mode">Plan: Auto</button>
22370
24640
  <button id="refreshBtn" class="subtle">Refresh</button>
22371
24641
  <div class="export-dropdown" style="position:relative;display:inline-block">
22372
24642
  <button id="exportMenuBtn" class="subtle">Export ▾</button>
@@ -22524,11 +24794,13 @@ main{display:grid;grid-template-columns:minmax(220px,260px) minmax(520px,920px)
22524
24794
  .msg.assistant.agent-developer,.msg.system.agent-developer{background:#e9fbea;border:1px solid #bde6c4}
22525
24795
  .msg.assistant.agent-reviewer,.msg.system.agent-reviewer{background:#fff6de;border:1px solid #f1d89a}
22526
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}
22527
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}
22528
24799
  .msg-agent-badge.explorer{background:#ffe6f0;border-color:#ffbdd5;color:#9b275e}
22529
24800
  .msg-agent-badge.developer{background:#e6f8e8;border-color:#b8e2c0;color:#1c6a33}
22530
24801
  .msg-agent-badge.reviewer{background:#fff3d4;border-color:#efd692;color:#8a6213}
22531
24802
  .msg-agent-badge.manager{background:#efe4ff;border-color:#d2b6ff;color:#6e36b8}
24803
+ .msg-agent-badge.planner{background:#fde8e4;border-color:#f5b8ad;color:#c0392b}
22532
24804
  .manager-delegate-card{border:1px solid #d7bbff;background:#f8f3ff;border-radius:10px;padding:10px}
22533
24805
  .manager-delegate-head{font-weight:800;font-size:.86rem;color:#5a2fa6}
22534
24806
  .manager-delegate-route{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:4px 0 8px}
@@ -23490,6 +25762,8 @@ self.onmessage=(ev)=>{
23490
25762
  slot.classList.remove('md-async-slot');
23491
25763
  const msg=slot.closest('.msg');
23492
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}}}
23493
25767
  const req=String(msg.getAttribute('data-math-request')||'').trim();
23494
25768
  if(req)_mathTypeset(msg,`chat:${req}`);
23495
25769
  }else{
@@ -24169,7 +26443,7 @@ function _chatVirtCollectRows(){
24169
26443
  const label=String(S.snap?.live_run_notice_label||'model call').trim()||'model call';
24170
26444
  const startedAt=Number(S.snap?.live_run_notice_started_at||0);
24171
26445
  const snapElapsed=Number(S.snap?.live_run_notice_elapsed||0);
24172
- const elapsed=startedAt>0?Math.max(snapElapsed,(Date.now()/1000)-startedAt):snapElapsed;
26446
+ const elapsed=startedAt>0?(Date.now()/1000)-startedAt:snapElapsed;
24173
26447
  rows.push({
24174
26448
  role:'assistant',
24175
26449
  type:'live_run_notice',
@@ -24178,12 +26452,12 @@ function _chatVirtCollectRows(){
24178
26452
  elapsed:elapsed,
24179
26453
  startedAt:startedAt,
24180
26454
  agent_role:activeAgentRole||undefined,
24181
- _vk:`run:${activeAgentRole}:${label}:${Math.round(startedAt*10)}`
26455
+ _vk:`run:${activeAgentRole}:${label}`
24182
26456
  });
24183
26457
  }
24184
26458
  return rows;
24185
26459
  }
24186
- 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))}
24187
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')||'')}}}
24188
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]}}}
24189
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}
@@ -24209,8 +26483,8 @@ function _chatVirtReleaseNode(node){
24209
26483
  CHAT_VIRT.poolSize=Number(CHAT_VIRT.poolSize||0)+1;
24210
26484
  }
24211
26485
  function _chatVirtReleaseRendered(root){if(!root)return;for(const node of root.querySelectorAll('.msg[data-vk]')){_chatVirtReleaseNode(node)}}
24212
- function _chatVirtAgentRoleKey(raw){const role=String(raw||'').trim().toLowerCase();return(role==='explorer'||role==='developer'||role==='reviewer'||role==='manager')?role:''}
24213
- 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''}
24214
26488
  function _stripLeadingAgentTitle(raw,agentRole){
24215
26489
  let txt=String(raw||'').replace(/^\\uFEFF/,'').trimStart();
24216
26490
  const role=_chatVirtAgentRoleKey(agentRole);
@@ -24764,6 +27038,7 @@ function renderFileExplorer(){const host=E('fileExplorer');if(!host)return;const
24764
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}}
24765
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'}`;
24766
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)}
24767
27042
  _renderBridgeSyncFromSnapshot(S.snap||{});
24768
27043
  setPanelHtml('todos',renderTodoBoard(S.snap?.todos||[]));
24769
27044
  setPanelHtml('tasks',renderTaskBoard(S.snap?.tasks||[]));
@@ -25034,9 +27309,10 @@ async function sendMessage(){showError('');const t=E('prompt').value.trim();if(!
25034
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})}}
25035
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})}}
25036
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))}}
25037
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})}
25038
27314
  function bindClick(id,fn){const el=E(id);if(el)el.onclick=fn}
25039
- 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})})})
25040
27316
  """
25041
27317
 
25042
27318
  APP_TS = """type SessionSummary={id:string;title:string;running:boolean;updated_at:number;message_count:number};
@@ -27357,6 +29633,20 @@ class Handler(BaseHTTPRequestHandler):
27357
29633
  return self._send_json({"error": "session not found"}, status=404)
27358
29634
  sess.interrupt()
27359
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})
27360
29650
  return self._send_json({"error": "not found"}, status=404)
27361
29651
 
27362
29652
  def do_PATCH(self):