Justin-lee commited on
Commit
d44b9cb
·
verified ·
1 Parent(s): ecbfaff

P0: /init + tool budget reduction + error recovery + auto-verify + hooks

Browse files
Files changed (1) hide show
  1. codepilot_v4.py +285 -14
codepilot_v4.py CHANGED
@@ -404,6 +404,23 @@ class ProjectTools:
404
 
405
  TOOL_PATTERN = re.compile(r'<tool>\s*(\w+)\s*\n(.*?)</tool>', re.DOTALL)
406
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  def parse_tool_calls(text):
408
  calls = []
409
  for m in TOOL_PATTERN.finditer(text):
@@ -418,15 +435,227 @@ def parse_tool_calls(text):
418
  def execute_tool(tools, call):
419
  n, p = call["tool"], call["params"]
420
  try:
421
- if n == "read_file": return tools.read_file(p.get("path",""), int(p.get("offset",1)), int(p.get("limit",200)))
422
- elif n == "edit_file": return tools.edit_file(p.get("path",""), p.get("old_string",""), p.get("new_string",""))
423
- elif n == "write_file": return tools.write_file(p.get("path",""), p.get("content",""))
424
- elif n == "run_command": return tools.run_command(p.get("command",""), int(p.get("timeout",120)))
425
- elif n == "search_files": return tools.search_files(p.get("pattern",""), p.get("glob"))
426
- elif n == "list_files": return tools.list_files(p.get("pattern","*"), int(p.get("max_depth",3)))
427
- elif n == "git_status": return tools.git_context()
428
- else: return f"❌ 未知: {n}"
429
- except Exception as e: return f"❌ {e}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
 
431
 
432
  def build_system_prompt(tools, project_memory=""):
@@ -644,7 +873,10 @@ def run_agent_loop(args):
644
  else:
645
  messages = [{"role": "system", "content": system_prompt}]
646
 
647
- console.print("[dim]/duel on|off /memo /grind /ls /git /clear /status /train /quit[/]\n")
 
 
 
648
 
649
  while True:
650
  try: user_input = Prompt.ask("\n[bold green]🧑 You")
@@ -655,6 +887,17 @@ def run_agent_loop(args):
655
  # ---- 指令 ----
656
  if cmd in ("/quit", "/exit"): break
657
 
 
 
 
 
 
 
 
 
 
 
 
658
  elif cmd == "/duel on":
659
  if local_model_ref and cloud_model_ref:
660
  duel_mode = True; console.print("[yellow]⚔️ Duel 模式已開啟 — 每個問題自動雙模型比較[/]")
@@ -786,14 +1029,21 @@ def run_agent_loop(args):
786
  ctx.save_session(messages)
787
  continue
788
 
789
- # ---- 正常模式:單模型 + 工具循環 ----
790
  messages.append({"role": "user", "content": user_input})
791
  full_response = ""
 
792
 
793
  for rnd in range(10):
794
  with console.status(f"[bold cyan]{'思考中' if rnd == 0 else f'工具 round {rnd+1}'}..."):
795
- try: response = model.chat(messages)
796
- except Exception as e: console.print(f"[red]❌ {e}[/]"); break
 
 
 
 
 
 
797
 
798
  tool_calls = parse_tool_calls(response)
799
  text_parts = TOOL_PATTERN.sub("", response).strip()
@@ -807,7 +1057,22 @@ def run_agent_loop(args):
807
  results = []
808
  for call in tool_calls:
809
  console.print(f" [dim]🔧 {call['tool']}[/]")
810
- result = execute_tool(tools, call)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
811
  if call["tool"] == "edit_file" and "✅" in result:
812
  d = result.split("\n", 1)[1] if "\n" in result else ""
813
  if d: console.print(Syntax(d, "diff", theme="monokai"))
@@ -817,6 +1082,12 @@ def run_agent_loop(args):
817
  results.append(f"[{call['tool']}] {result}")
818
  messages.append({"role": "user", "content": "Tool results:\n" + "\n\n".join(results)})
819
 
 
 
 
 
 
 
820
  # 回饋
821
  console.print(f"\n[dim][green]y[/]=👍 [red]n[/]=👎 [yellow]e[/]=✏️ Enter=跳過[/]")
822
  fb = Prompt.ask(" ", choices=["y","n","e",""], default="", show_choices=False)
 
404
 
405
  TOOL_PATTERN = re.compile(r'<tool>\s*(\w+)\s*\n(.*?)</tool>', re.DOTALL)
406
 
407
+ # ============================================================
408
+ # P0-2: TOOL RESULT BUDGET REDUCTION(工具結果截斷)
409
+ # ============================================================
410
+ MAX_TOOL_RESULT_CHARS = 12000 # ~3000 tokens
411
+
412
+ def truncate_tool_result(result, max_chars=MAX_TOOL_RESULT_CHARS):
413
+ """Claude Code 的 Budget Reduction — 限制每個工具結果大小"""
414
+ if len(result) <= max_chars:
415
+ return result
416
+ head = max_chars * 2 // 3
417
+ tail = max_chars // 3
418
+ truncated_lines = len(result) - max_chars
419
+ return (result[:head]
420
+ + f"\n\n... ⚠️ Output truncated ({len(result):,} chars total, {truncated_lines:,} chars omitted) ...\n\n"
421
+ + result[-tail:])
422
+
423
+
424
  def parse_tool_calls(text):
425
  calls = []
426
  for m in TOOL_PATTERN.finditer(text):
 
435
  def execute_tool(tools, call):
436
  n, p = call["tool"], call["params"]
437
  try:
438
+ if n == "read_file": result = tools.read_file(p.get("path",""), int(p.get("offset",1)), int(p.get("limit",200)))
439
+ elif n == "edit_file": result = tools.edit_file(p.get("path",""), p.get("old_string",""), p.get("new_string",""))
440
+ elif n == "write_file": result = tools.write_file(p.get("path",""), p.get("content",""))
441
+ elif n == "run_command": result = tools.run_command(p.get("command",""), int(p.get("timeout",120)))
442
+ elif n == "search_files": result = tools.search_files(p.get("pattern",""), p.get("glob"))
443
+ elif n == "list_files": result = tools.list_files(p.get("pattern","*"), int(p.get("max_depth",3)))
444
+ elif n == "git_status": result = tools.git_context()
445
+ else: result = f"❌ 未知: {n}"
446
+ except Exception as e: result = f"❌ {e}"
447
+ return truncate_tool_result(result) # P0-2: 自動截斷
448
+
449
+
450
+ # ============================================================
451
+ # P0-1: /init 自動產生 CODEPILOT.md
452
+ # ============================================================
453
+ def cmd_init(tools, model, console):
454
+ """掃描專案結構,用模型自動產生 CODEPILOT.md"""
455
+ console.print("\n[bold]🔍 掃描專案結構...[/]")
456
+
457
+ # 收集專案資訊
458
+ file_list = tools.list_files("*", max_depth=2)
459
+ git = tools.git_context()
460
+
461
+ # 嘗試讀取關鍵檔案
462
+ key_files = {}
463
+ for f in ["README.md", "README.rst", "package.json", "pyproject.toml",
464
+ "requirements.txt", "Cargo.toml", "go.mod", "Makefile",
465
+ "docker-compose.yml", "Dockerfile", ".gitignore"]:
466
+ full = os.path.join(tools.project_dir, f)
467
+ if os.path.exists(full):
468
+ try:
469
+ content = Path(full).read_text(encoding="utf-8", errors="replace")[:3000]
470
+ key_files[f] = content
471
+ except: pass
472
+
473
+ key_files_text = "\n\n".join(f"--- {k} ---\n{v}" for k, v in key_files.items())
474
+
475
+ prompt = f"""Analyze this project and generate a CODEPILOT.md configuration file.
476
+
477
+ ## Project Files (top 2 levels)
478
+ {file_list[:3000]}
479
+
480
+ ## Git Info
481
+ {git}
482
+
483
+ ## Key Config Files
484
+ {key_files_text[:6000]}
485
+
486
+ ## Instructions
487
+ Generate a markdown file with these sections:
488
+ 1. **Project Overview** — one-line description
489
+ 2. **Tech Stack** — languages, frameworks, databases
490
+ 3. **Code Style** — formatting tools, naming conventions
491
+ 4. **Testing** — test framework, how to run tests
492
+ 5. **Key Commands** — build, run, test, lint commands
493
+ 6. **Architecture** — key directories and their purpose
494
+ 7. **Rules** — important rules for AI to follow (e.g., "always write tests", "use TypeScript strict mode")
495
+
496
+ Be concise. Use bullet points. Write in the language matching the project (Chinese if README is Chinese, English otherwise)."""
497
+
498
+ with console.status("[bold cyan]分析專案中..."):
499
+ result = model.chat([{"role": "user", "content": prompt}], max_tokens=2048)
500
+
501
+ codepilot_path = os.path.join(tools.project_dir, "CODEPILOT.md")
502
+ Path(codepilot_path).write_text(result, encoding="utf-8")
503
+ console.print(f"\n[green]✅ 已產生 CODEPILOT.md[/]")
504
+ console.print(f"[dim]{result[:500]}...[/]")
505
+ console.print(f"\n[dim]檢查並編輯: {codepilot_path}[/]")
506
+ return result
507
+
508
+
509
+ # ============================================================
510
+ # P0-3: ERROR RECOVERY(錯誤自動恢復)
511
+ # ============================================================
512
+ MAX_RETRIES = 3
513
+
514
+ def chat_with_recovery(model, messages, ctx=None, console=None, fallback_model=None):
515
+ """帶自動恢復的 model.chat — 重試 + 壓縮 + fallback"""
516
+ last_error = None
517
+
518
+ for attempt in range(MAX_RETRIES):
519
+ try:
520
+ return model.chat(messages)
521
+ except Exception as e:
522
+ last_error = e
523
+ error_str = str(e).lower()
524
+
525
+ if console:
526
+ console.print(f" [yellow]⚠️ 嘗試 {attempt+1}/{MAX_RETRIES}: {type(e).__name__}[/]")
527
+
528
+ # 策略 1: context 太長 → 壓縮
529
+ if any(k in error_str for k in ["too long", "too_long", "context_length", "max_tokens", "prompt_too_long"]):
530
+ if ctx and hasattr(ctx, 'check_compact'):
531
+ if console: console.print(" [dim]🔄 壓縮對話歷史...[/]")
532
+ messages = ctx.check_compact(messages, model_chat_fn=model.chat)
533
+ continue
534
+ else:
535
+ # 手動截斷
536
+ if len(messages) > 6:
537
+ messages = [messages[0]] + messages[-4:]
538
+ continue
539
+
540
+ # 策略 2: rate limit → 等待重試
541
+ if any(k in error_str for k in ["rate_limit", "429", "too many"]):
542
+ wait = 2 ** attempt * 5 # 5s, 10s, 20s
543
+ if console: console.print(f" [dim]⏳ Rate limit, 等待 {wait}s...[/]")
544
+ time.sleep(wait)
545
+ continue
546
+
547
+ # 策略 3: 伺服器錯誤 → 等待重試
548
+ if any(k in error_str for k in ["500", "502", "503", "server", "timeout", "connection"]):
549
+ wait = 2 ** attempt * 3
550
+ if console: console.print(f" [dim]⏳ 伺服器錯誤, 等待 {wait}s...[/]")
551
+ time.sleep(wait)
552
+ continue
553
+
554
+ # 策略 4: 切換 fallback model
555
+ if fallback_model and attempt == MAX_RETRIES - 1:
556
+ if console: console.print(f" [yellow]🔄 切換到 fallback 模型...[/]")
557
+ try:
558
+ return fallback_model.chat(messages)
559
+ except: pass
560
+
561
+ # 其他錯誤直接 break
562
+ break
563
+
564
+ raise last_error or RuntimeError("chat failed after retries")
565
+
566
+
567
+ # ============================================================
568
+ # P0-4: VERIFICATION SUB-AGENT(驗證子代理)
569
+ # ============================================================
570
+ def run_verification(model, tools, console, edited_files=None):
571
+ """完成修改後自動跑測試驗證"""
572
+ console.print("\n[bold]🔍 Verification Agent[/]")
573
+
574
+ checks = []
575
+
576
+ # 1. 語法檢查修改過的 Python 文件
577
+ if edited_files:
578
+ for f in edited_files:
579
+ if f.endswith(".py") and os.path.exists(f):
580
+ try:
581
+ content = Path(f).read_text()
582
+ compile(content, f, "exec")
583
+ checks.append(f" ✅ {os.path.basename(f)} 語法正確")
584
+ except SyntaxError as e:
585
+ checks.append(f" ❌ {os.path.basename(f)} 語法錯誤: {e.msg} (line {e.lineno})")
586
+
587
+ # 2. 嘗試跑 pytest / npm test
588
+ test_commands = []
589
+ if os.path.exists(os.path.join(tools.project_dir, "pytest.ini")) or \
590
+ os.path.exists(os.path.join(tools.project_dir, "tests")) or \
591
+ os.path.exists(os.path.join(tools.project_dir, "test")):
592
+ test_commands.append(("pytest", f"{sys.executable} -m pytest --tb=short -q"))
593
+
594
+ if os.path.exists(os.path.join(tools.project_dir, "package.json")):
595
+ test_commands.append(("npm test", "npm test --if-present 2>&1 | head -30"))
596
+
597
+ if os.path.exists(os.path.join(tools.project_dir, "Makefile")):
598
+ # 檢查是否有 test target
599
+ makefile = Path(os.path.join(tools.project_dir, "Makefile")).read_text(errors="replace")
600
+ if "test:" in makefile:
601
+ test_commands.append(("make test", "make test 2>&1 | tail -20"))
602
+
603
+ for name, cmd in test_commands:
604
+ console.print(f" [dim]🧪 Running {name}...[/]")
605
+ result = tools.run_command(cmd, timeout=60)
606
+ # 判斷通過/失敗
607
+ result_lower = result.lower()
608
+ if any(k in result_lower for k in ["passed", "ok", "success", "0 error"]):
609
+ passed_match = re.search(r'(\d+) passed', result)
610
+ n = passed_match.group(1) if passed_match else ""
611
+ checks.append(f" ✅ {name}: {n} passed" if n else f" ✅ {name}: OK")
612
+ elif any(k in result_lower for k in ["failed", "error", "fail"]):
613
+ # 只顯示最後幾行
614
+ last_lines = "\n".join(result.strip().split("\n")[-5:])
615
+ checks.append(f" ❌ {name}: FAILED\n{last_lines}")
616
+ else:
617
+ checks.append(f" ⚠️ {name}: {result[:200]}")
618
+
619
+ if not checks:
620
+ checks.append(" [dim]沒有找到測試框架[/]")
621
+
622
+ for c in checks:
623
+ console.print(c)
624
+
625
+ return checks
626
+
627
+
628
+ # ============================================================
629
+ # P0-BONUS: HOOKS SYSTEM(post-edit 自動格式化)
630
+ # ============================================================
631
+ class Hooks:
632
+ """簡易 Hooks 系統 — 讀取 .codepilot/hooks.json"""
633
+
634
+ def __init__(self, project_dir):
635
+ self.project_dir = project_dir
636
+ self.hooks = {}
637
+ hooks_file = os.path.join(project_dir, ".codepilot", "hooks.json")
638
+ if os.path.exists(hooks_file):
639
+ try:
640
+ self.hooks = json.loads(Path(hooks_file).read_text())
641
+ except: pass
642
+
643
+ def run(self, event, context=None):
644
+ """執行 hook。context = {"file": "path/to/file.py"} 等"""
645
+ cmd_template = self.hooks.get(event)
646
+ if not cmd_template:
647
+ return None
648
+ # 替換變數
649
+ cmd = cmd_template
650
+ if context:
651
+ for k, v in context.items():
652
+ cmd = cmd.replace(f"{{{k}}}", str(v))
653
+ try:
654
+ result = subprocess.run(cmd, shell=True, cwd=self.project_dir,
655
+ capture_output=True, text=True, timeout=30)
656
+ return result.stdout + result.stderr if result.returncode != 0 else None
657
+ except:
658
+ return None
659
 
660
 
661
  def build_system_prompt(tools, project_memory=""):
 
873
  else:
874
  messages = [{"role": "system", "content": system_prompt}]
875
 
876
+ hooks = Hooks(project_dir) # P0-Bonus: Hooks 系統
877
+ edited_files_this_session = [] # 追蹤修改過的文件
878
+
879
+ console.print("[dim]/init /duel on|off /memo /verify /grind /ls /git /clear /status /train /quit[/]\n")
880
 
881
  while True:
882
  try: user_input = Prompt.ask("\n[bold green]🧑 You")
 
887
  # ---- 指令 ----
888
  if cmd in ("/quit", "/exit"): break
889
 
890
+ elif cmd == "/init":
891
+ result = cmd_init(tools, model, console)
892
+ # 重建 system prompt
893
+ system_prompt = ctx.build_system_prompt(tools.git_context())
894
+ messages[0] = {"role": "system", "content": system_prompt}
895
+ continue
896
+
897
+ elif cmd == "/verify":
898
+ run_verification(model, tools, console, edited_files_this_session)
899
+ continue
900
+
901
  elif cmd == "/duel on":
902
  if local_model_ref and cloud_model_ref:
903
  duel_mode = True; console.print("[yellow]⚔️ Duel 模式已開啟 — 每個問題自動雙模型比較[/]")
 
1029
  ctx.save_session(messages)
1030
  continue
1031
 
1032
+ # ---- 正常模式:單模型 + 工具循環 + 錯誤恢復 ----
1033
  messages.append({"role": "user", "content": user_input})
1034
  full_response = ""
1035
+ tools_used_this_turn = [] # 追蹤這輪用了哪些工具
1036
 
1037
  for rnd in range(10):
1038
  with console.status(f"[bold cyan]{'思考中' if rnd == 0 else f'工具 round {rnd+1}'}..."):
1039
+ try:
1040
+ # P0-3: 使用帶恢復的 chat
1041
+ response = chat_with_recovery(
1042
+ model, messages, ctx=ctx, console=console,
1043
+ fallback_model=local_model_ref if provider_key != "local" else None)
1044
+ except Exception as e:
1045
+ console.print(f"[red]❌ 所有重試失敗: {e}[/]")
1046
+ break
1047
 
1048
  tool_calls = parse_tool_calls(response)
1049
  text_parts = TOOL_PATTERN.sub("", response).strip()
 
1057
  results = []
1058
  for call in tool_calls:
1059
  console.print(f" [dim]🔧 {call['tool']}[/]")
1060
+ result = execute_tool(tools, call) # 已含 P0-2 截斷
1061
+ tools_used_this_turn.append(call["tool"])
1062
+
1063
+ # 追蹤修改的文件
1064
+ if call["tool"] in ("edit_file", "write_file") and "✅" in result:
1065
+ fpath = call["params"].get("path", "")
1066
+ if fpath:
1067
+ full_path = os.path.join(tools.cwd, fpath) if not os.path.isabs(fpath) else fpath
1068
+ if full_path not in edited_files_this_session:
1069
+ edited_files_this_session.append(full_path)
1070
+ # P0-Bonus: 觸發 post-edit hook
1071
+ hook_result = hooks.run(f"post_{call['tool']}", {"file": full_path})
1072
+ if hook_result:
1073
+ console.print(f" [dim]🪝 Hook: {hook_result[:100]}[/]")
1074
+
1075
+ # 顯示結果
1076
  if call["tool"] == "edit_file" and "✅" in result:
1077
  d = result.split("\n", 1)[1] if "\n" in result else ""
1078
  if d: console.print(Syntax(d, "diff", theme="monokai"))
 
1082
  results.append(f"[{call['tool']}] {result}")
1083
  messages.append({"role": "user", "content": "Tool results:\n" + "\n\n".join(results)})
1084
 
1085
+ # P0-4: 自動驗證 — 如果這輪有修改文件,自動跑測試
1086
+ if any(t in ("edit_file", "write_file") for t in tools_used_this_turn):
1087
+ if edited_files_this_session:
1088
+ console.print(f"\n[dim]🔍 Auto-verify ({len(edited_files_this_session)} files modified)...[/]")
1089
+ run_verification(model, tools, console, edited_files_this_session)
1090
+
1091
  # 回饋
1092
  console.print(f"\n[dim][green]y[/]=👍 [red]n[/]=👎 [yellow]e[/]=✏️ Enter=跳過[/]")
1093
  fb = Prompt.ask(" ", choices=["y","n","e",""], default="", show_choices=False)