assistant.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  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]:
  186. """
  187. 解析 LLM 输出,提取工具调用并执行。
  188. v2: 统一 action/tool 格式,支持内置工具 + macOS 工具
  189. 策略:尝试所有 ```json 块(从第一个开始),找到第一个可执行的
  190. """
  191. # 尝试从 ```json ... ``` 代码块提取
  192. # 使用贪婪匹配找到最外层的 ```json...``` 对
  193. # 注意:内容中可能含有 ``` 标记(如 markdown 代码块),需要特殊处理
  194. matches = _extract_json_blocks(llm_output)
  195. if not matches:
  196. # fallback: 找内联 JSON
  197. matches = re.findall(r'\{[^{}]*(?:"action"|"tool")\s*:.*?\}', llm_output, re.DOTALL)
  198. if not matches:
  199. return llm_output, False
  200. # 尝试每个 match(从第一个开始),找到第一个包含 action/tool 的可解析 JSON
  201. data = None
  202. for candidate in matches:
  203. candidate = candidate.strip()
  204. parsed = _try_parse_json(candidate)
  205. if parsed and ("action" in parsed or "tool" in parsed):
  206. data = parsed
  207. break
  208. if data is None:
  209. return f"(无法解析 JSON: {matches[0][:100]})\n{llm_output}", False
  210. # 统一格式: action/tool 都支持
  211. tool_name = data.get("action", data.get("tool", ""))
  212. # 完成信号
  213. if tool_name in ("done", "terminate"):
  214. answer = data.get("summary", data.get("input", {}).get("answer", "")) if isinstance(data.get("input"), dict) else data.get("summary", "任务完成")
  215. return str(answer) if answer else "任务完成", True
  216. # 路由到工具
  217. tool = TOOL_REGISTRY.get(tool_name)
  218. if not tool:
  219. return f"❌ 未知工具: {tool_name}. 可用: {', '.join(sorted(TOOL_REGISTRY.keys())[:15])}...", False
  220. # 准备输入
  221. tool_input = data.get("input", {})
  222. if not tool_input:
  223. # 兼容旧格式: {"tool": "shell", "command": "ls"} → input = {"command": "ls"}
  224. tool_input = {k: v for k, v in data.items() if k not in ("action", "tool")}
  225. if isinstance(tool_input, dict):
  226. tool_input_str = json.dumps(tool_input, ensure_ascii=False)
  227. else:
  228. tool_input_str = str(tool_input)
  229. # Hook: pre_tool
  230. if hooks:
  231. hooks.fire("pre_tool", term=tool, input=tool_input_str, ctx=None)
  232. # 执行
  233. import time
  234. t0 = time.time()
  235. try:
  236. if hasattr(tool, 'apply'):
  237. result = tool.apply(tool_input_str)
  238. else:
  239. result = tool(tool_input_str)
  240. except Exception as e:
  241. result = f"[ERROR] {e}"
  242. duration_ms = (time.time() - t0) * 1000
  243. # Hook: post_tool
  244. if hooks:
  245. output_wrapper = {"value": result}
  246. hooks.fire("post_tool", term=tool, input=tool_input_str,
  247. output=output_wrapper, duration_ms=duration_ms, ctx=None)
  248. result = output_wrapper["value"]
  249. return str(result), False
  250. # ════════════════════════════════════════════════════════════
  251. # PersonalAssistant v2
  252. # ════════════════════════════════════════════════════════════
  253. class PersonalAssistant:
  254. """
  255. lambda 个人助理 v2 = Memory(ReAct(Brain)) + 21 内置工具 + Hook
  256. 新增能力:
  257. - 21 个内置工具 (文件/代码/Shell/Git/Web/Notebook/Task)
  258. - ToolGateway 安全网关
  259. - Hook 全局审计
  260. - ContextManager 上下文压缩
  261. - TerminalUI 流式显示
  262. """
  263. def __init__(self, model: str = "sonnet", use_api: bool = False,
  264. backend: str = "", verbose: bool = False):
  265. # Brain (LLM)
  266. if backend == BACKEND_OLLAMA:
  267. self.brain = OllamaLam(
  268. "lambda_v2", prompt=SYSTEM_PROMPT,
  269. model=model, temperature=0.3, max_tokens=4096,
  270. )
  271. elif use_api or backend == BACKEND_API:
  272. self.brain = Lam(
  273. "lambda_v2", prompt=SYSTEM_PROMPT,
  274. model=model, temperature=0.3, max_tokens=4096,
  275. )
  276. else:
  277. self.brain = ClaudeLam(
  278. "lambda_v2", prompt=SYSTEM_PROMPT,
  279. model=model, max_tokens=4096,
  280. )
  281. self.ctx = Context()
  282. self.conversation_history: list[dict] = []
  283. self.max_history = 30
  284. # v2 新增
  285. self.hooks = HookRegistry()
  286. self.context_manager = ContextManager(max_tokens=100000, keep_recent=5)
  287. self.ui = TerminalUI(verbose=verbose)
  288. # 注册审计 hook
  289. self._total_tokens = 0
  290. self._total_tool_calls = 0
  291. self.hooks.register("post_tool", self._audit_tool)
  292. def _audit_tool(self, **kwargs):
  293. """全局工具调用审计"""
  294. self._total_tool_calls += 1
  295. def _build_input(self, user_msg: str, observations: list[str] | None = None) -> str:
  296. """构建完整输入(历史 + 消息 + 工具结果)"""
  297. parts = []
  298. # 对话历史
  299. if self.conversation_history:
  300. parts.append("=== 对话历史 ===")
  301. for entry in self.conversation_history[-self.max_history:]:
  302. content = entry["content"]
  303. if len(content) > 300:
  304. content = content[:300] + "..."
  305. parts.append(f"[{entry['role']}] {content}")
  306. parts.append("=== 历史结束 ===\n")
  307. # 工具观察
  308. if observations:
  309. parts.append("=== 工具执行结果 ===")
  310. for obs in observations:
  311. parts.append(obs)
  312. parts.append("=== 结果结束 ===\n")
  313. parts.append("请基于以上结果决定下一步。你的工具已验证可用。直接输出一个JSON代码块调用下一个工具。绝不要说'工具不可用'或让用户手动操作。")
  314. else:
  315. parts.append(f"[用户] {user_msg}")
  316. state = "\n".join(parts)
  317. # v2: 上下文压缩
  318. if self.context_manager.should_compact(state):
  319. state = self.context_manager.compact(state)
  320. return state
  321. def chat(self, user_msg: str) -> str:
  322. """
  323. 与 lambda 对话。完整 ReAct 循环 (v2 流式交互)。
  324. """
  325. self.conversation_history.append({"role": "用户", "content": user_msg})
  326. observations = []
  327. max_steps = 15
  328. final_response = ""
  329. import time as _time
  330. for step in range(max_steps):
  331. # 显示当前步骤状态
  332. _step_label = f"[步骤 {step + 1}/{max_steps}]"
  333. # 构建输入
  334. if step == 0:
  335. llm_input = self._build_input(user_msg)
  336. else:
  337. llm_input = self._build_input(user_msg, observations)
  338. # 显示思考状态(如果不是流式模式,显示 spinner)
  339. is_streaming = hasattr(self.brain, 'stream') and self.brain.stream
  340. if not is_streaming:
  341. print(f" ⏳ {_step_label} 思考中...", end="\r", flush=True)
  342. # Brain 思考 (β-规约) — 流式模式下 ClaudeLam 会实时输出
  343. t0 = _time.time()
  344. llm_output = self.brain.apply(llm_input, self.ctx)
  345. think_ms = (_time.time() - t0) * 1000
  346. if not is_streaming:
  347. # 清除 spinner
  348. print(f" ✅ {_step_label} 思考完成 ({think_ms:.0f}ms) ")
  349. # 解析并执行 (v2: 支持内置工具 + hooks)
  350. result, is_done = parse_and_execute(str(llm_output), self.hooks)
  351. if is_done:
  352. text_part = re.sub(r'```json.*?```', '', str(llm_output), flags=re.DOTALL).strip()
  353. final_response = text_part + ("\n" + result if result else "")
  354. print(f" ✅ {_step_label} 任务完成")
  355. break
  356. if result != str(llm_output):
  357. # 工具被执行
  358. text_part = re.sub(r'```json.*?```', '', str(llm_output), flags=re.DOTALL).strip()
  359. json_matches = re.findall(r'\{[^{}]*(?:"action"|"tool")\s*:.*?\}', str(llm_output))
  360. tool_call = json_matches[-1] if json_matches else ""
  361. tool_name = ""
  362. try:
  363. tool_name = json.loads(tool_call).get("action", json.loads(tool_call).get("tool", ""))
  364. except Exception:
  365. pass
  366. # 显示思考内容
  367. if text_part and not is_streaming:
  368. print(f" 💭 {text_part[:200]}")
  369. # 显示工具调用(带进度)
  370. t0_tool = _time.time()
  371. print(f" 🔧 {_step_label} 调用工具: {tool_name or 'unknown'}")
  372. # 工具输入预览
  373. try:
  374. tool_input_data = json.loads(tool_call).get("input", {})
  375. if isinstance(tool_input_data, dict):
  376. input_preview = json.dumps(tool_input_data, ensure_ascii=False)[:120]
  377. else:
  378. input_preview = str(tool_input_data)[:120]
  379. print(f" 输入: {input_preview}")
  380. except Exception:
  381. pass
  382. tool_ms = (_time.time() - t0_tool) * 1000
  383. # 工具结果预览
  384. result_preview = result[:300].replace("\n", "\n ")
  385. print(f" 结果: {result_preview}")
  386. print()
  387. observations.append(f"[工具调用] {tool_call}\n[执行结果] {result}")
  388. else:
  389. # 纯文本回复,没有工具调用
  390. # 检查是否应该继续(用户任务可能还没完成)
  391. output_str = str(llm_output)
  392. has_pending_action = any(kw in user_msg for kw in [
  393. "保存", "写入", "创建文件", "save", "write",
  394. "生成报告", "导出", "保存到", "写到",
  395. ])
  396. if has_pending_action and step < max_steps - 2 and not any(
  397. sig in output_str.lower() for sig in ["任务完成", "已保存", "已写入", "已创建"]
  398. ):
  399. # 用户要求了文件操作但 LLM 只输出了文本,继续推动
  400. observations.append(
  401. f"[LLM回复] {output_str[:500]}\n"
  402. f"[系统提醒] 你已生成了内容但还没有保存到文件。"
  403. f"请立即调用WriteFile工具将内容写入用户要求的路径。"
  404. )
  405. print(f" 💭 {_step_label} 已生成内容,继续执行文件保存...")
  406. else:
  407. # 真的是最终回复
  408. final_response = output_str
  409. break
  410. else:
  411. final_response = f"(达到最大步数 {max_steps})\n最后的观察:\n" + (
  412. observations[-1] if observations else "无"
  413. )
  414. self.conversation_history.append({
  415. "role": "lambda",
  416. "content": final_response[:500],
  417. })
  418. return final_response
  419. def print_trace(self):
  420. """打印 β-规约追踪 (v2: 使用 TerminalUI)"""
  421. if self.ctx.trace:
  422. self.ui.trace(self.ctx.trace)
  423. else:
  424. print(" (无追踪记录)")
  425. def print_stats(self):
  426. """打印会话统计"""
  427. print(f"\n📊 会话统计:")
  428. print(f" β-规约步数: {len(self.ctx.trace)}")
  429. print(f" 工具调用数: {self._total_tool_calls}")
  430. print(f" 对话轮数: {len(self.conversation_history) // 2}")