hello_code_cli.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. from __future__ import annotations
  2. import argparse
  3. import os
  4. import re
  5. import logging
  6. from pathlib import Path
  7. try:
  8. from dotenv import load_dotenv # type: ignore
  9. except Exception: # pragma: no cover
  10. def load_dotenv(*args, **kwargs): # type: ignore
  11. return False
  12. from core.llm import HelloAgentsLLM
  13. from core.exceptions import HelloAgentsException
  14. from core.config import Config
  15. from code_agent.agentic import CodeAgent
  16. from code_agent.executors.apply_patch_executor import ApplyPatchExecutor, PatchApplyError
  17. from utils.cli_ui import c, hr, PRIMARY, ACCENT, INFO, WARN, ERROR
  18. # 匹配 Codex 风格补丁块(宽松,跨行,允许前导空白或代码围栏)
  19. PATCH_RE = re.compile(r"\s*\*\*\* Begin Patch[\s\S]*?\*\*\* End Patch", re.MULTILINE)
  20. # 备用:从 ```patch/```diff 围栏中提取补丁主体
  21. PATCH_FENCE_RE = re.compile(
  22. r"```(?:patch|diff|text)?\s*(\*\*\* Begin Patch[\s\S]*?\*\*\* End Patch)\s*```",
  23. re.MULTILINE,
  24. )
  25. def _extract_patch(text: str) -> str | None:
  26. """
  27. 从 LLM 响应文本中提取补丁块。
  28. 补丁块通常由 *** Begin Patch 和 *** End Patch 包围。
  29. """
  30. # 优先匹配代码围栏内的补丁
  31. m = PATCH_FENCE_RE.search(text)
  32. if m:
  33. return m.group(1)
  34. # 退回普通匹配(允许前导空白)
  35. m = PATCH_RE.search(text)
  36. return m.group(0).strip() if m else None
  37. def _normalize_patch(patch_text: str) -> str:
  38. """
  39. 规范化补丁文本,以宽容处理模型的一些格式错误。
  40. - 接受 'Delete File:' / 'Update File:' / 'Add File:' (即使缺少前导 '*** ')
  41. - 保持执行器所需的标准 Codex 风格格式。
  42. """
  43. lines = patch_text.splitlines()
  44. out: list[str] = []
  45. for line in lines:
  46. stripped = line.strip()
  47. if stripped.startswith(("Add File:", "Update File:", "Delete File:")) and not stripped.startswith("*** "):
  48. out.append("*** " + stripped)
  49. continue
  50. out.append(line)
  51. return "\n".join(out)
  52. def _patch_requires_confirmation(patch_text: str) -> bool:
  53. """
  54. 判断补丁是否需要用户确认。
  55. 策略:
  56. - 包含文件删除操作
  57. - 涉及文件数量过多 (>= 6)
  58. - 变更行数过多 (>= 400)
  59. """
  60. # MVP: Delete File / too many files / too big => confirm
  61. if "*** Delete File:" in patch_text:
  62. return True
  63. file_ops = patch_text.count("*** Add File:") + patch_text.count("*** Update File:") + patch_text.count("*** Delete File:")
  64. if file_ops >= 6:
  65. return True
  66. changed_lines = 0
  67. for line in patch_text.splitlines():
  68. if line.startswith("+") or line.startswith("-"):
  69. changed_lines += 1
  70. return changed_lines >= 400
  71. def main(argv: list[str] | None = None) -> int:
  72. """
  73. CLI 入口点。
  74. 初始化 LLM、CodebaseMaintainer 和 PatchExecutor,并进入交互式循环。
  75. """
  76. # 1. 解析命令行参数
  77. parser = argparse.ArgumentParser(description="HelloAgents-style Code Agent CLI (Codex/Claude-like)")
  78. parser.add_argument("--repo", type=str, default=".", help="Repository root (workspace). Default: .")
  79. parser.add_argument("--project", type=str, default=None, help="Project name (default: repo folder name)")
  80. args = parser.parse_args(argv)
  81. # 2. 初始化环境和 LLM
  82. repo_root = Path(args.repo).resolve()
  83. load_dotenv(dotenv_path=repo_root / ".env", override=False)
  84. project = args.project or repo_root.name
  85. config = Config.from_env()
  86. llm = HelloAgentsLLM() # auto-detect provider from env
  87. # reduce noisy HTTP client logs in the CLI
  88. logging.getLogger("httpx").setLevel(logging.WARNING)
  89. logging.getLogger("openai").setLevel(logging.WARNING)
  90. logging.getLogger("openai._base_client").setLevel(logging.WARNING)
  91. logging.getLogger("memory").setLevel(logging.WARNING)
  92. print(c(hr("=", 80), INFO))
  93. print(c("HelloAgents Code Agent CLI", PRIMARY))
  94. print(c(f"workspace: {repo_root}", INFO))
  95. print(c(f"LLM: provider={llm.provider} model={llm.model} base_url={llm.base_url}", INFO))
  96. print(c(f"state: {Path(config.helloagents_dir).as_posix()}", INFO))
  97. print(c(hr("=", 80), INFO))
  98. # Optional preflight to surface auth issues early.
  99. try:
  100. _ = llm.invoke([{"role": "user", "content": "ping"}], max_tokens=1)
  101. except HelloAgentsException as e:
  102. print(c("LLM 预检失败(通常是 API key/base_url/model 配置问题)。", ERROR))
  103. print(c(f"error: {e}", ERROR))
  104. print(c("请检查 .env 中的 DEEPSEEK_API_KEY / LLM_* 配置是否正确。", WARN))
  105. return 2
  106. # 3. 初始化核心组件(ReAct + tools)
  107. agent = CodeAgent(repo_root=repo_root, llm=llm, config=config)
  108. patch_executor = ApplyPatchExecutor(repo_root=repo_root)
  109. # 4. 进入交互循环
  110. print(c("输入自然语言需求开始;命令:", INFO))
  111. print(c(" :quit", ACCENT) + c(" 退出", INFO))
  112. print(c(" :plan <目标>", ACCENT) + c(" 强制生成计划", INFO))
  113. while True:
  114. try:
  115. user_in = input(c("👤 > ", PRIMARY))
  116. except (EOFError, KeyboardInterrupt):
  117. print("\n" + c("bye", INFO))
  118. return 0
  119. if user_in is None:
  120. continue
  121. user_in = user_in.strip()
  122. if not user_in:
  123. print(c("请提供具体指令或问题。", WARN))
  124. continue
  125. if user_in in {":q", ":quit", "quit", "exit"}:
  126. print(c("bye", INFO))
  127. return 0
  128. if user_in.startswith(":plan"):
  129. goal = user_in[len(":plan") :].strip() or "请为当前任务生成一个可执行计划"
  130. response = agent.registry.execute_tool("plan", goal)
  131. print("\n" + c("🤖 plan", PRIMARY))
  132. print(response + "\n")
  133. continue
  134. # 5. 运行一轮对话(ReAct 可能按需调用终端/笔记/记忆)
  135. try:
  136. response = agent.run_turn(user_in)
  137. except HelloAgentsException as e:
  138. print(c(f"LLM 调用失败: {e}", ERROR))
  139. continue
  140. # 对于 direct reply(未经过 ReAct 的控制台打印),在 CLI 里补打一份输出
  141. if getattr(agent, "last_direct_reply", False):
  142. print(c("🤖 assistant", PRIMARY))
  143. print(response)
  144. # 7. 提取并应用补丁
  145. patch_text = _extract_patch(response)
  146. if not patch_text:
  147. continue
  148. patch_text = _normalize_patch(patch_text)
  149. # Ignore empty patch blocks
  150. if patch_text.strip() == "*** Begin Patch\n*** End Patch":
  151. continue
  152. needs_confirm = _patch_requires_confirmation(patch_text)
  153. if needs_confirm:
  154. # If user just answered y/n as the *current* input, treat it as confirmation for this patch.
  155. if user_in.strip().lower() in {"n", "no"}:
  156. print("已取消补丁应用。")
  157. continue
  158. if user_in.strip().lower() not in {"y", "yes"}:
  159. print("\n⚠️ 检测到高风险补丁(删除/大规模变更)。是否应用?(y/n)")
  160. ans = input("confirm> ").strip().lower()
  161. if ans not in {"y", "yes"}:
  162. print("已取消补丁应用。")
  163. continue
  164. try:
  165. res = patch_executor.apply(patch_text)
  166. print("\n" + c("✅ Patch applied", PRIMARY))
  167. print(c(f"files: {', '.join(res.files_changed) if res.files_changed else '(none)'}", INFO))
  168. if res.backups:
  169. print(c(f"backups: {len(res.backups)} (in .helloagents/backups/...)", INFO))
  170. # 记录到 NoteTool(action)
  171. agent.note_tool.run({
  172. "action": "create",
  173. "title": "Patch applied",
  174. "content": f"User input:\n{user_in}\n\nPatch:\n\n```text\n{patch_text}\n```\n\nFiles:\n"
  175. + "\n".join([f"- {p}" for p in res.files_changed]),
  176. "note_type": "action",
  177. "tags": [project, "patch_applied"],
  178. })
  179. except PatchApplyError as e:
  180. print("\n" + c(f"❌ Patch failed: {e}", ERROR))
  181. agent.note_tool.run({
  182. "action": "create",
  183. "title": "Patch failed",
  184. "content": f"Error: {e}\n\nUser input:\n{user_in}\n\nPatch:\n\n```text\n{patch_text}\n```\n",
  185. "note_type": "blocker",
  186. "tags": [project, "patch_failed"],
  187. })
  188. continue
  189. return 0
  190. if __name__ == "__main__":
  191. raise SystemExit(main())