assistant.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. """
  2. agent67.core.assistant — lambda 的大脑 🐑 (v2)
  3. v2 升级:
  4. - 使用 lambdagent 内置 21 工具(替代手写 6 工具)
  5. - ToolGateway 安全网关(替代手写 safety.py)
  6. - Hook 系统全局审计
  7. - ContextManager 防状态爆炸
  8. - 保留 macOS 专属工具(browser, app, system, screenshot)
  9. Lambda 表达式:
  10. lambda = Memory(Loop(Brain >> parse_action >> route_execute))
  11. """
  12. from __future__ import annotations
  13. import json
  14. import re
  15. import sys
  16. import os
  17. from pathlib import Path
  18. from typing import Any
  19. # 路径设置
  20. PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
  21. sys.path.insert(0, str(PROJECT_ROOT))
  22. from lambdagent import Lam, Tool, Loop, Context
  23. from lambdagent.extensions import Memory, Guard
  24. from lambdagent.context_manager import ContextManager
  25. from lambdagent.hooks import HookRegistry
  26. # 内置工具
  27. from lambdagent.builtin_tools.registry import BUILTIN_TOOLS, resolve_tools
  28. from lambdagent.builtin_tools.terminal_ui import TerminalUI
  29. from .claude_lam import ClaudeLam
  30. from .ollama_lam import OllamaLam
  31. from .config import BACKEND_OLLAMA, BACKEND_CLAUDE_CODE, BACKEND_API
  32. from .prompt import SYSTEM_PROMPT
  33. # macOS 专属工具(保留)
  34. from ..tools.browser_controller import control_browser
  35. from ..tools.app_controller import control_app
  36. from ..tools.system_info import query_system
  37. from ..tools.screenshot import take_screenshot
  38. # ════════════════════════════════════════════════════════════
  39. # 工具注册表 (v2: 内置工具 + macOS 工具)
  40. # ════════════════════════════════════════════════════════════
  41. def _build_tool_registry() -> dict:
  42. """合并内置工具 + macOS 专属工具"""
  43. # 内置 21 个工具
  44. tools = dict(BUILTIN_TOOLS)
  45. # macOS 专属(保留原有能力)
  46. tools["browser"] = Tool("browser", control_browser)
  47. tools["app"] = Tool("app", control_app)
  48. tools["system"] = Tool("system", query_system)
  49. tools["screenshot"] = Tool("screenshot", take_screenshot)
  50. # 完成信号
  51. tools["done"] = Tool("done", lambda x: x)
  52. return tools
  53. TOOL_REGISTRY = _build_tool_registry()
  54. # ════════════════════════════════════════════════════════════
  55. # 解析 & 执行引擎 (v2)
  56. # ════════════════════════════════════════════════════════════
  57. def _extract_json_blocks(text: str) -> list[str]:
  58. """Extract JSON code blocks, handling nested ``` in content.
  59. Strategy: find ```json markers, then find the matching ``` closer
  60. by looking for a ``` that is followed by a newline or end-of-string
  61. (not inside a JSON string value).
  62. """
  63. blocks = []
  64. i = 0
  65. marker = "```json"
  66. while i < len(text):
  67. start = text.find(marker, i)
  68. if start == -1:
  69. break
  70. # Skip past the marker and optional newline
  71. content_start = start + len(marker)
  72. if content_start < len(text) and text[content_start] == '\n':
  73. content_start += 1
  74. # Find the closing ``` — look for ``` at start of line or after newline
  75. # that's NOT inside a JSON string
  76. best_end = -1
  77. j = content_start
  78. while j < len(text):
  79. pos = text.find("```", j)
  80. if pos == -1:
  81. break
  82. # Check if this ``` is a real closer (not inside JSON string content)
  83. # Heuristic: if the text between content_start and pos contains
  84. # a valid-looking JSON with "action", it's the closer
  85. candidate = text[content_start:pos].strip()
  86. if candidate and ('"action"' in candidate or '"tool"' in candidate):
  87. try:
  88. # Try to parse — if it works, this is the right closer
  89. _try_parse_json(candidate)
  90. best_end = pos
  91. break
  92. except Exception:
  93. pass
  94. # Even if parse fails, if it looks like JSON, take it
  95. if candidate.startswith("{") and candidate.rstrip().endswith("}"):
  96. best_end = pos
  97. break
  98. j = pos + 3
  99. if best_end != -1:
  100. block = text[content_start:best_end].strip()
  101. if block:
  102. blocks.append(block)
  103. i = best_end + 3
  104. else:
  105. # Fallback: take everything until the next ```
  106. end = text.find("```", content_start)
  107. if end != -1:
  108. block = text[content_start:end].strip()
  109. if block:
  110. blocks.append(block)
  111. i = end + 3
  112. else:
  113. break
  114. return blocks
  115. def _try_parse_json(json_str: str) -> dict | None:
  116. """Try multiple strategies to parse potentially malformed JSON from LLM output."""
  117. # Strategy 1: direct parse
  118. try:
  119. return json.loads(json_str)
  120. except json.JSONDecodeError:
  121. pass
  122. # Strategy 2: fix newlines inside JSON string values
  123. # LLM outputs real newlines inside "content": "line1\nline2"
  124. # We need to escape them but ONLY inside string values
  125. try:
  126. fixed = _fix_json_newlines(json_str)
  127. return json.loads(fixed)
  128. except (json.JSONDecodeError, Exception):
  129. pass
  130. # Strategy 3: balanced brace extraction + newline fix
  131. try:
  132. depth = 0
  133. start = json_str.index("{")
  134. in_string = False
  135. escape = False
  136. for i in range(start, len(json_str)):
  137. c = json_str[i]
  138. if escape:
  139. escape = False
  140. continue
  141. if c == '\\':
  142. escape = True
  143. continue
  144. if c == '"':
  145. in_string = not in_string
  146. continue
  147. if not in_string:
  148. if c == '{':
  149. depth += 1
  150. elif c == '}':
  151. depth -= 1
  152. if depth == 0:
  153. candidate = json_str[start:i+1]
  154. fixed = _fix_json_newlines(candidate)
  155. return json.loads(fixed)
  156. except (json.JSONDecodeError, ValueError):
  157. pass
  158. return None
  159. def _fix_json_newlines(s: str) -> str:
  160. """Replace real newlines inside JSON string values with \\n.
  161. Walks the string character by character, only replacing newlines
  162. when inside a quoted string value.
  163. """
  164. result = []
  165. in_string = False
  166. escape = False
  167. for c in s:
  168. if escape:
  169. result.append(c)
  170. escape = False
  171. continue
  172. if c == '\\':
  173. result.append(c)
  174. escape = True
  175. continue
  176. if c == '"':
  177. in_string = not in_string
  178. result.append(c)
  179. continue
  180. if c == '\n' and in_string:
  181. result.append('\\n')
  182. continue
  183. result.append(c)
  184. return ''.join(result)
  185. def parse_and_execute(llm_output: str, hooks: HookRegistry = None) -> tuple[str, bool, str]:
  186. """
  187. 解析 LLM 输出,提取工具调用并执行。
  188. Returns: (result_text, is_done, tool_name)
  189. """
  190. matches = _extract_json_blocks(llm_output)
  191. if not matches:
  192. matches = re.findall(r'\{[^{}]*(?:"action"|"tool")\s*:.*?\}', llm_output, re.DOTALL)
  193. if not matches:
  194. return llm_output, False, ""
  195. # 尝试每个 match(从第一个开始),找到第一个包含 action/tool 的可解析 JSON
  196. data = None
  197. for candidate in matches:
  198. candidate = candidate.strip()
  199. parsed = _try_parse_json(candidate)
  200. if parsed and ("action" in parsed or "tool" in parsed):
  201. data = parsed
  202. break
  203. if data is None:
  204. return f"(无法解析 JSON: {matches[0][:100]})\n{llm_output}", False, ""
  205. # 统一格式: action/tool 都支持
  206. tool_name = data.get("action", data.get("tool", ""))
  207. # 完成信号
  208. if tool_name in ("done", "terminate"):
  209. answer = data.get("summary", data.get("input", {}).get("answer", "")) if isinstance(data.get("input"), dict) else data.get("summary", "任务完成")
  210. return (str(answer) if answer else "任务完成"), True, tool_name
  211. # 路由到工具
  212. tool = TOOL_REGISTRY.get(tool_name)
  213. if not tool:
  214. return f"❌ 未知工具: {tool_name}. 可用: {', '.join(sorted(TOOL_REGISTRY.keys())[:15])}...", False, tool_name
  215. # 准备输入
  216. tool_input = data.get("input", {})
  217. if not tool_input:
  218. # 兼容旧格式: {"tool": "shell", "command": "ls"} → input = {"command": "ls"}
  219. tool_input = {k: v for k, v in data.items() if k not in ("action", "tool")}
  220. if isinstance(tool_input, dict):
  221. tool_input_str = json.dumps(tool_input, ensure_ascii=False)
  222. else:
  223. tool_input_str = str(tool_input)
  224. # Hook: pre_tool
  225. if hooks:
  226. hooks.fire("pre_tool", term=tool, input=tool_input_str, ctx=None)
  227. # 执行
  228. import time
  229. t0 = time.time()
  230. try:
  231. if hasattr(tool, 'apply'):
  232. result = tool.apply(tool_input_str)
  233. else:
  234. result = tool(tool_input_str)
  235. except Exception as e:
  236. result = f"[ERROR] {e}"
  237. duration_ms = (time.time() - t0) * 1000
  238. # Hook: post_tool
  239. if hooks:
  240. output_wrapper = {"value": result}
  241. hooks.fire("post_tool", term=tool, input=tool_input_str,
  242. output=output_wrapper, duration_ms=duration_ms, ctx=None)
  243. result = output_wrapper["value"]
  244. return str(result), False, tool_name
  245. # ════════════════════════════════════════════════════════════
  246. # PersonalAssistant v2
  247. # ════════════════════════════════════════════════════════════
  248. class PersonalAssistant:
  249. """
  250. lambda 个人助理 v2 = Memory(ReAct(Brain)) + 21 内置工具 + Hook
  251. 新增能力:
  252. - 21 个内置工具 (文件/代码/Shell/Git/Web/Notebook/Task)
  253. - ToolGateway 安全网关
  254. - Hook 全局审计
  255. - ContextManager 上下文压缩
  256. - TerminalUI 流式显示
  257. """
  258. def __init__(self, model: str = "sonnet", use_api: bool = False,
  259. backend: str = "", verbose: bool = False):
  260. # Brain (LLM)
  261. if backend == BACKEND_OLLAMA:
  262. self.brain = OllamaLam(
  263. "lambda_v2", prompt=SYSTEM_PROMPT,
  264. model=model, temperature=0.3, max_tokens=4096,
  265. )
  266. elif use_api or backend == BACKEND_API:
  267. self.brain = Lam(
  268. "lambda_v2", prompt=SYSTEM_PROMPT,
  269. model=model, temperature=0.3, max_tokens=4096,
  270. )
  271. else:
  272. self.brain = ClaudeLam(
  273. "lambda_v2", prompt=SYSTEM_PROMPT,
  274. model=model, max_tokens=4096,
  275. inject_override=False,
  276. )
  277. self.ctx = Context()
  278. self.conversation_history: list[dict] = []
  279. self.max_history = 30
  280. # v2 新增
  281. self.hooks = HookRegistry()
  282. self.context_manager = ContextManager(max_tokens=100000, keep_recent=5)
  283. self.ui = TerminalUI(verbose=verbose)
  284. # 注册审计 hook
  285. self._total_tokens = 0
  286. self._total_tool_calls = 0
  287. self.hooks.register("post_tool", self._audit_tool)
  288. def _audit_tool(self, **kwargs):
  289. """全局工具调用审计"""
  290. self._total_tool_calls += 1
  291. def _build_input(self, user_msg: str, observations: list[str] | None = None) -> str:
  292. """构建完整输入(历史 + 消息 + 工具结果)"""
  293. parts = []
  294. # 对话历史
  295. if self.conversation_history:
  296. parts.append("=== 对话历史 ===")
  297. for entry in self.conversation_history[-self.max_history:]:
  298. content = entry["content"]
  299. if len(content) > 300:
  300. content = content[:300] + "..."
  301. parts.append(f"[{entry['role']}] {content}")
  302. parts.append("=== 历史结束 ===\n")
  303. # 工具观察 (sliding window: summarize old, keep recent in full)
  304. if observations:
  305. _KEEP_RECENT = 5
  306. parts.append("=== 工具执行结果 ===")
  307. if len(observations) > _KEEP_RECENT:
  308. # Summarize old observations
  309. parts.append("[之前的工具调用摘要]")
  310. for obs in observations[:-_KEEP_RECENT]:
  311. # Extract tool name and first line of result
  312. lines = obs.split("\n")
  313. tool_line = lines[0] if lines else ""
  314. result_line = lines[1][:120] if len(lines) > 1 else ""
  315. parts.append(f" {tool_line}: {result_line}...")
  316. parts.append("")
  317. # Recent observations in full
  318. for obs in observations[-_KEEP_RECENT:]:
  319. parts.append(obs)
  320. else:
  321. for obs in observations:
  322. parts.append(obs)
  323. parts.append("=== 结果结束 ===\n")
  324. parts.append(
  325. "请基于以上结果决定下一步。直接输出一个JSON代码块调用下一个工具。\n"
  326. "注意:工具名是 ReadFile(不是Read)、WriteFile(不是Write)、EditFile(不是Edit)、"
  327. "Bash(不是bash或shell)、ListFiles(不是Glob或ls)。"
  328. )
  329. else:
  330. parts.append(f"[用户] {user_msg}")
  331. state = "\n".join(parts)
  332. # v2: 上下文压缩
  333. if self.context_manager.should_compact(state):
  334. state = self.context_manager.compact(state)
  335. return state
  336. def chat(self, user_msg: str) -> str:
  337. """
  338. 与 lambda 对话。完整 ReAct 循环 (v2 流式交互)。
  339. """
  340. self.conversation_history.append({"role": "用户", "content": user_msg})
  341. observations = []
  342. max_steps = 30
  343. final_response = ""
  344. import time as _time
  345. for step in range(max_steps):
  346. # 显示当前步骤状态
  347. _step_label = f"[步骤 {step + 1}/{max_steps}]"
  348. # 构建输入
  349. if step == 0:
  350. llm_input = self._build_input(user_msg)
  351. else:
  352. llm_input = self._build_input(user_msg, observations)
  353. # 显示思考状态(如果不是流式模式,显示 spinner)
  354. is_streaming = hasattr(self.brain, 'stream') and self.brain.stream
  355. if not is_streaming:
  356. print(f" ⏳ {_step_label} 思考中...", end="\r", flush=True)
  357. # Brain 思考 (β-规约) — 流式模式下 ClaudeLam 会实时输出
  358. t0 = _time.time()
  359. llm_output = self.brain.apply(llm_input, self.ctx)
  360. think_ms = (_time.time() - t0) * 1000
  361. if not is_streaming:
  362. # 清除 spinner
  363. print(f" ✅ {_step_label} 思考完成 ({think_ms:.0f}ms) ")
  364. # 解析并执行 (v2: 支持内置工具 + hooks)
  365. result, is_done, tool_name = parse_and_execute(str(llm_output), self.hooks)
  366. if is_done:
  367. text_part = re.sub(r'```json.*?```', '', str(llm_output), flags=re.DOTALL).strip()
  368. final_response = text_part + ("\n" + result if result else "")
  369. print(f" ✅ {_step_label} 任务完成")
  370. break
  371. if result != str(llm_output):
  372. # 工具被执行
  373. text_part = re.sub(r'```json.*?```', '', str(llm_output), flags=re.DOTALL).strip()
  374. # 显示思考内容
  375. if text_part and not is_streaming:
  376. print(f" 💭 {text_part[:200]}")
  377. # 显示工具调用
  378. print(f" 🔧 {_step_label} 调用工具: {tool_name or '?'}")
  379. # 工具结果预览
  380. result_preview = result[:300].replace("\n", "\n ")
  381. print(f" 结果: {result_preview}")
  382. print()
  383. observations.append(f"[工具: {tool_name}]\n[执行结果] {result}")
  384. else:
  385. # 纯文本回复,没有工具调用
  386. # 检查是否应该继续(用户任务可能还没完成)
  387. output_str = str(llm_output)
  388. has_pending_action = any(kw in user_msg for kw in [
  389. "保存", "写入", "创建文件", "save", "write",
  390. "生成报告", "导出", "保存到", "写到",
  391. ])
  392. if has_pending_action and step < max_steps - 2 and not any(
  393. sig in output_str.lower() for sig in ["任务完成", "已保存", "已写入", "已创建"]
  394. ):
  395. # 用户要求了文件操作但 LLM 只输出了文本,继续推动
  396. observations.append(
  397. f"[LLM回复] {output_str[:500]}\n"
  398. f"[系统提醒] 你已生成了内容但还没有保存到文件。"
  399. f"请立即调用WriteFile工具将内容写入用户要求的路径。"
  400. )
  401. print(f" 💭 {_step_label} 已生成内容,继续执行文件保存...")
  402. else:
  403. # 真的是最终回复
  404. final_response = output_str
  405. break
  406. else:
  407. final_response = f"(达到最大步数 {max_steps})\n最后的观察:\n" + (
  408. observations[-1] if observations else "无"
  409. )
  410. self.conversation_history.append({
  411. "role": "lambda",
  412. "content": final_response[:500],
  413. })
  414. return final_response
  415. def print_trace(self):
  416. """打印 β-规约追踪 (v2: 使用 TerminalUI)"""
  417. if self.ctx.trace:
  418. self.ui.trace(self.ctx.trace)
  419. else:
  420. print(" (无追踪记录)")
  421. def print_stats(self):
  422. """打印会话统计"""
  423. print(f"\n📊 会话统计:")
  424. print(f" β-规约步数: {len(self.ctx.trace)}")
  425. print(f" 工具调用数: {self._total_tool_calls}")
  426. print(f" 对话轮数: {len(self.conversation_history) // 2}")