assistant.py 19 KB

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