| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220 |
- from __future__ import annotations
- import argparse
- import os
- import re
- import logging
- from pathlib import Path
- try:
- from dotenv import load_dotenv # type: ignore
- except Exception: # pragma: no cover
- def load_dotenv(*args, **kwargs): # type: ignore
- return False
- from core.llm import HelloAgentsLLM
- from core.exceptions import HelloAgentsException
- from core.config import Config
- from code_agent.agentic import CodeAgent
- from code_agent.executors.apply_patch_executor import ApplyPatchExecutor, PatchApplyError
- from utils.cli_ui import c, hr, PRIMARY, ACCENT, INFO, WARN, ERROR
- # 匹配 Codex 风格补丁块(宽松,跨行,允许前导空白或代码围栏)
- PATCH_RE = re.compile(r"\s*\*\*\* Begin Patch[\s\S]*?\*\*\* End Patch", re.MULTILINE)
- # 备用:从 ```patch/```diff 围栏中提取补丁主体
- PATCH_FENCE_RE = re.compile(
- r"```(?:patch|diff|text)?\s*(\*\*\* Begin Patch[\s\S]*?\*\*\* End Patch)\s*```",
- re.MULTILINE,
- )
- def _extract_patch(text: str) -> str | None:
- """
- 从 LLM 响应文本中提取补丁块。
- 补丁块通常由 *** Begin Patch 和 *** End Patch 包围。
- """
- # 优先匹配代码围栏内的补丁
- m = PATCH_FENCE_RE.search(text)
- if m:
- return m.group(1)
- # 退回普通匹配(允许前导空白)
- m = PATCH_RE.search(text)
- return m.group(0).strip() if m else None
- def _normalize_patch(patch_text: str) -> str:
- """
- 规范化补丁文本,以宽容处理模型的一些格式错误。
- - 接受 'Delete File:' / 'Update File:' / 'Add File:' (即使缺少前导 '*** ')
- - 保持执行器所需的标准 Codex 风格格式。
- """
- lines = patch_text.splitlines()
- out: list[str] = []
- for line in lines:
- stripped = line.strip()
- if stripped.startswith(("Add File:", "Update File:", "Delete File:")) and not stripped.startswith("*** "):
- out.append("*** " + stripped)
- continue
- out.append(line)
- return "\n".join(out)
- def _patch_requires_confirmation(patch_text: str) -> bool:
- """
- 判断补丁是否需要用户确认。
- 策略:
- - 包含文件删除操作
- - 涉及文件数量过多 (>= 6)
- - 变更行数过多 (>= 400)
- """
- # MVP: Delete File / too many files / too big => confirm
- if "*** Delete File:" in patch_text:
- return True
- file_ops = patch_text.count("*** Add File:") + patch_text.count("*** Update File:") + patch_text.count("*** Delete File:")
- if file_ops >= 6:
- return True
- changed_lines = 0
- for line in patch_text.splitlines():
- if line.startswith("+") or line.startswith("-"):
- changed_lines += 1
- return changed_lines >= 400
- def main(argv: list[str] | None = None) -> int:
- """
- CLI 入口点。
- 初始化 LLM、CodebaseMaintainer 和 PatchExecutor,并进入交互式循环。
- """
- # 1. 解析命令行参数
- parser = argparse.ArgumentParser(description="HelloAgents-style Code Agent CLI (Codex/Claude-like)")
- parser.add_argument("--repo", type=str, default=".", help="Repository root (workspace). Default: .")
- parser.add_argument("--project", type=str, default=None, help="Project name (default: repo folder name)")
- args = parser.parse_args(argv)
- # 2. 初始化环境和 LLM
- repo_root = Path(args.repo).resolve()
- load_dotenv(dotenv_path=repo_root / ".env", override=False)
- project = args.project or repo_root.name
- config = Config.from_env()
- llm = HelloAgentsLLM() # auto-detect provider from env
- # reduce noisy HTTP client logs in the CLI
- logging.getLogger("httpx").setLevel(logging.WARNING)
- logging.getLogger("openai").setLevel(logging.WARNING)
- logging.getLogger("openai._base_client").setLevel(logging.WARNING)
- logging.getLogger("memory").setLevel(logging.WARNING)
- print(c(hr("=", 80), INFO))
- print(c("HelloAgents Code Agent CLI", PRIMARY))
- print(c(f"workspace: {repo_root}", INFO))
- print(c(f"LLM: provider={llm.provider} model={llm.model} base_url={llm.base_url}", INFO))
- print(c(f"state: {Path(config.helloagents_dir).as_posix()}", INFO))
- print(c(hr("=", 80), INFO))
- # Optional preflight to surface auth issues early.
- try:
- _ = llm.invoke([{"role": "user", "content": "ping"}], max_tokens=1)
- except HelloAgentsException as e:
- print(c("LLM 预检失败(通常是 API key/base_url/model 配置问题)。", ERROR))
- print(c(f"error: {e}", ERROR))
- print(c("请检查 .env 中的 DEEPSEEK_API_KEY / LLM_* 配置是否正确。", WARN))
- return 2
- # 3. 初始化核心组件(ReAct + tools)
- agent = CodeAgent(repo_root=repo_root, llm=llm, config=config)
- patch_executor = ApplyPatchExecutor(repo_root=repo_root)
- # 4. 进入交互循环
- print(c("输入自然语言需求开始;命令:", INFO))
- print(c(" :quit", ACCENT) + c(" 退出", INFO))
- print(c(" :plan <目标>", ACCENT) + c(" 强制生成计划", INFO))
- while True:
- try:
- user_in = input(c("👤 > ", PRIMARY))
- except (EOFError, KeyboardInterrupt):
- print("\n" + c("bye", INFO))
- return 0
- if user_in is None:
- continue
- user_in = user_in.strip()
- if not user_in:
- print(c("请提供具体指令或问题。", WARN))
- continue
- if user_in in {":q", ":quit", "quit", "exit"}:
- print(c("bye", INFO))
- return 0
- if user_in.startswith(":plan"):
- goal = user_in[len(":plan") :].strip() or "请为当前任务生成一个可执行计划"
- response = agent.registry.execute_tool("plan", goal)
- print("\n" + c("🤖 plan", PRIMARY))
- print(response + "\n")
- continue
- # 5. 运行一轮对话(ReAct 可能按需调用终端/笔记/记忆)
- try:
- response = agent.run_turn(user_in)
- except HelloAgentsException as e:
- print(c(f"LLM 调用失败: {e}", ERROR))
- continue
- # 对于 direct reply(未经过 ReAct 的控制台打印),在 CLI 里补打一份输出
- if getattr(agent, "last_direct_reply", False):
- print(c("🤖 assistant", PRIMARY))
- print(response)
-
- # 7. 提取并应用补丁
- patch_text = _extract_patch(response)
- if not patch_text:
- continue
- patch_text = _normalize_patch(patch_text)
- # Ignore empty patch blocks
- if patch_text.strip() == "*** Begin Patch\n*** End Patch":
- continue
- needs_confirm = _patch_requires_confirmation(patch_text)
- if needs_confirm:
- # If user just answered y/n as the *current* input, treat it as confirmation for this patch.
- if user_in.strip().lower() in {"n", "no"}:
- print("已取消补丁应用。")
- continue
- if user_in.strip().lower() not in {"y", "yes"}:
- print("\n⚠️ 检测到高风险补丁(删除/大规模变更)。是否应用?(y/n)")
- ans = input("confirm> ").strip().lower()
- if ans not in {"y", "yes"}:
- print("已取消补丁应用。")
- continue
- try:
- res = patch_executor.apply(patch_text)
- print("\n" + c("✅ Patch applied", PRIMARY))
- print(c(f"files: {', '.join(res.files_changed) if res.files_changed else '(none)'}", INFO))
- if res.backups:
- print(c(f"backups: {len(res.backups)} (in .helloagents/backups/...)", INFO))
- # 记录到 NoteTool(action)
- agent.note_tool.run({
- "action": "create",
- "title": "Patch applied",
- "content": f"User input:\n{user_in}\n\nPatch:\n\n```text\n{patch_text}\n```\n\nFiles:\n"
- + "\n".join([f"- {p}" for p in res.files_changed]),
- "note_type": "action",
- "tags": [project, "patch_applied"],
- })
- except PatchApplyError as e:
- print("\n" + c(f"❌ Patch failed: {e}", ERROR))
- agent.note_tool.run({
- "action": "create",
- "title": "Patch failed",
- "content": f"Error: {e}\n\nUser input:\n{user_in}\n\nPatch:\n\n```text\n{patch_text}\n```\n",
- "note_type": "blocker",
- "tags": [project, "patch_failed"],
- })
- continue
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
|