Spaces:
Sleeping
Sleeping
P0: /init + tool budget reduction + error recovery + auto-verify + hooks
Browse files- 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":
|
| 422 |
-
elif n == "edit_file":
|
| 423 |
-
elif n == "write_file":
|
| 424 |
-
elif n == "run_command":
|
| 425 |
-
elif n == "search_files":
|
| 426 |
-
elif n == "list_files":
|
| 427 |
-
elif n == "git_status":
|
| 428 |
-
else:
|
| 429 |
-
except Exception as 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 796 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|