run.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. """
  2. 故事分析示例(增强版)
  3. 使用 Agent 模式 + Skills
  4. 新增功能:
  5. 1. 支持命令行随时打断(输入 'p' 暂停,'q' 退出)
  6. 2. 暂停后可插入干预消息
  7. 3. 支持触发经验总结
  8. 4. 查看当前 GoalTree
  9. 5. 框架层自动清理不完整的工具调用
  10. 6. 支持通过 --trace <ID> 恢复已有 Trace 继续执行
  11. """
  12. import argparse
  13. import os
  14. import sys
  15. import select
  16. import msvcrt
  17. from datetime import datetime
  18. import sys
  19. import asyncio
  20. from pathlib import Path
  21. # Clash Verge TUN 模式兼容:禁止 httpx/urllib 自动检测系统 HTTP 代理
  22. # TUN 虚拟网卡已在网络层接管所有流量,不需要应用层再走 HTTP 代理,
  23. # 否则 httpx 检测到 macOS 系统代理 (127.0.0.1:7897) 会导致 ConnectError
  24. os.environ.setdefault("no_proxy", "*")
  25. # 添加项目根目录到 Python 路径
  26. sys.path.insert(0, str(Path(__file__).parent.parent.parent))
  27. from dotenv import load_dotenv
  28. load_dotenv()
  29. from agent.llm.prompts import SimplePrompt
  30. from agent.core.runner import AgentRunner, RunConfig
  31. from agent.trace import (
  32. FileSystemTraceStore,
  33. Trace,
  34. Message,
  35. )
  36. from agent.llm import create_openrouter_llm_call
  37. # ===== 非阻塞 stdin 检测 =====
  38. def check_stdin() -> str | None:
  39. # 针对 Windows 的修复方案
  40. if sys.platform == "win32":
  41. if msvcrt.kbhit(): # 检查是否有按键按下
  42. ch = msvcrt.getch().decode('utf-8').lower()
  43. if ch == 'p': return 'pause'
  44. if ch == 'q': return 'quit'
  45. return None
  46. # ===== 交互菜单 =====
  47. def _read_multiline() -> str:
  48. """
  49. 读取多行输入,以连续两次回车(空行)结束。
  50. 单次回车只是换行,不会提前终止输入。
  51. """
  52. print("\n请输入干预消息(连续输入两次回车结束):")
  53. lines: list[str] = []
  54. blank_count = 0
  55. while True:
  56. line = input()
  57. if line == "":
  58. blank_count += 1
  59. if blank_count >= 2:
  60. break
  61. lines.append("") # 保留单个空行
  62. else:
  63. blank_count = 0
  64. lines.append(line)
  65. # 去掉尾部多余空行
  66. while lines and lines[-1] == "":
  67. lines.pop()
  68. return "\n".join(lines)
  69. async def show_interactive_menu(
  70. runner: AgentRunner,
  71. trace_id: str,
  72. current_sequence: int,
  73. store: FileSystemTraceStore,
  74. ):
  75. """
  76. 显示交互式菜单,让用户选择操作。
  77. 进入本函数前不再有后台线程占用 stdin,所以 input() 能正常工作。
  78. """
  79. print("\n" + "=" * 60)
  80. print(" 执行已暂停")
  81. print("=" * 60)
  82. print("请选择操作:")
  83. print(" 1. 插入干预消息并继续")
  84. print(" 2. 触发经验总结(reflect)")
  85. print(" 3. 查看当前 GoalTree")
  86. print(" 4. 手动压缩上下文(compact)")
  87. print(" 5. 继续执行")
  88. print(" 6. 停止执行")
  89. print(" 7. 经验库瘦身(合并相似经验)")
  90. print("=" * 60)
  91. while True:
  92. choice = input("请输入选项 (1-7): ").strip()
  93. if choice == "1":
  94. text = _read_multiline()
  95. if not text:
  96. print("未输入任何内容,取消操作")
  97. continue
  98. print(f"\n将插入干预消息并继续执行...")
  99. return {
  100. "action": "continue",
  101. "messages": [{"role": "user", "content": text}],
  102. "after_sequence": current_sequence,
  103. }
  104. elif choice == "2":
  105. # 触发经验总结
  106. print("\n触发经验总结...")
  107. focus = input("请输入反思重点(可选,直接回车跳过): ").strip()
  108. from agent.trace.compaction import build_reflect_prompt
  109. import re
  110. import uuid
  111. # 1. 执行反思生成
  112. trace = await store.get_trace(trace_id)
  113. saved_head = trace.head_sequence
  114. prompt = build_reflect_prompt()
  115. if focus:
  116. prompt += f"\n\n请特别关注:{focus}"
  117. print("正在生成反思...")
  118. reflect_cfg = RunConfig(trace_id=trace_id, max_iterations=1, tools=[])
  119. reflection_text = ""
  120. try:
  121. result = await runner.run_result(
  122. messages=[{"role": "user", "content": prompt}],
  123. config=reflect_cfg,
  124. )
  125. reflection_text = result.get("summary", "")
  126. finally:
  127. await store.update_trace(trace_id, head_sequence=saved_head)
  128. # 2. 结构化解析与保存 (ACE Curator 逻辑)
  129. if reflection_text:
  130. # experiences_path = runner.experiences_path # 已废弃,使用知识系统 or "./.cache/experiences.md"
  131. os.makedirs(os.path.dirname(experiences_path), exist_ok=True)
  132. # 正则匹配:- [intent:..., state:...] 内容
  133. pattern = r"- \[(intent:.*?, state:.*?)\] (.*)"
  134. matches = re.findall(pattern, reflection_text)
  135. structured_entries = []
  136. for tags_str, content in matches:
  137. # 生成唯一 ID
  138. ex_id = f"ex_{datetime.now().strftime('%m%d%H%M')}_{uuid.uuid4().hex[:4]}"
  139. # 提取标签详情
  140. intent_match = re.search(r"intent:(.*?),", tags_str)
  141. state_match = re.search(r"state:(.*)", tags_str)
  142. intents = [i.strip() for i in intent_match.group(1).split(",")] if intent_match else []
  143. states = [s.strip() for s in state_match.group(1).split(",")] if state_match else []
  144. # 构造符合 ACE 规范的结构化条目 [cite: 184, 185]
  145. entry = f"""---
  146. id: {ex_id}
  147. trace_id: {trace_id}
  148. tags: {{intent: {intents}, state: {states}}}
  149. metrics: {{helpful: 1, harmful: 0}}
  150. created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
  151. ---
  152. - {content}
  153. - 经验ID: [{ex_id}]
  154. """
  155. structured_entries.append(entry)
  156. if structured_entries:
  157. with open(experiences_path, "a", encoding="utf-8") as f:
  158. f.write("\n\n" + "\n\n".join(structured_entries))
  159. print(f"\n✅ 已成功提取并保存 {len(structured_entries)} 条结构化经验到: {experiences_path}")
  160. else:
  161. print("\n⚠️ 未能解析出符合格式的经验条目,请检查 REFLECT_PROMPT。")
  162. print("\n--- 原始反思内容 ---")
  163. print(reflection_text)
  164. print("--- 结束 ---\n")
  165. else:
  166. print("未生成反思内容")
  167. continue
  168. elif choice == "3":
  169. goal_tree = await store.get_goal_tree(trace_id)
  170. if goal_tree and goal_tree.goals:
  171. print("\n当前 GoalTree:")
  172. print(goal_tree.to_prompt())
  173. else:
  174. print("\n当前没有 Goal")
  175. continue
  176. elif choice == "4":
  177. # 手动压缩上下文
  178. print("\n正在执行上下文压缩(compact)...")
  179. try:
  180. goal_tree = await store.get_goal_tree(trace_id)
  181. trace = await store.get_trace(trace_id)
  182. if not trace:
  183. print("未找到 Trace,无法压缩")
  184. continue
  185. # 重建当前 history
  186. main_path = await store.get_main_path_messages(trace_id, trace.head_sequence)
  187. history = [msg.to_llm_dict() for msg in main_path]
  188. head_seq = main_path[-1].sequence if main_path else 0
  189. next_seq = head_seq + 1
  190. compact_config = RunConfig(trace_id=trace_id)
  191. new_history, new_head, new_seq = await runner._compress_history(
  192. trace_id=trace_id,
  193. history=history,
  194. goal_tree=goal_tree,
  195. config=compact_config,
  196. sequence=next_seq,
  197. head_seq=head_seq,
  198. )
  199. print(f"\n✅ 压缩完成: {len(history)} 条消息 → {len(new_history)} 条")
  200. except Exception as e:
  201. print(f"\n❌ 压缩失败: {e}")
  202. continue
  203. elif choice == "5":
  204. print("\n继续执行...")
  205. return {"action": "continue"}
  206. elif choice == "6":
  207. print("\n停止执行...")
  208. return {"action": "stop"}
  209. elif choice == "7":
  210. # 经验库瘦身
  211. print("\n正在执行经验库瘦身...")
  212. from agent.tools.builtin.experience import slim_experiences
  213. try:
  214. result = await slim_experiences()
  215. print(f"\n{result}")
  216. except Exception as e:
  217. print(f"\n经验库瘦身失败: {e}")
  218. continue
  219. else:
  220. print("无效选项,请重新输入")
  221. async def main():
  222. # 解析命令行参数
  223. parser = argparse.ArgumentParser(description="故事分析任务 (Agent 模式 + 交互增强)")
  224. parser.add_argument(
  225. "--trace", type=str, default=None,
  226. help="已有的 Trace ID,用于恢复继续执行(不指定则新建)",
  227. )
  228. args = parser.parse_args()
  229. # 路径配置
  230. base_dir = Path(__file__).parent
  231. project_root = base_dir.parent.parent
  232. prompt_path = base_dir / "test.prompt"
  233. output_dir = base_dir / "output_1"
  234. output_dir.mkdir(exist_ok=True)
  235. # Skills 目录(可选:用户自定义 skills)
  236. # 注意:内置 skills(agent/skills/core.md)会自动加载
  237. skills_dir = None # 或者指定自定义 skills 目录,如: project_root / "skills"
  238. print("=" * 60)
  239. print("故事分析任务 (Agent 模式 + 交互增强)")
  240. print("=" * 60)
  241. print()
  242. print("💡 交互提示:")
  243. print(" - 执行过程中输入 'p' 或 'pause' 暂停并进入交互模式")
  244. print(" - 执行过程中输入 'q' 或 'quit' 停止执行")
  245. print("=" * 60)
  246. print()
  247. # 1. 加载 prompt
  248. print("1. 加载 prompt 配置...")
  249. prompt = SimplePrompt(prompt_path)
  250. # 2. 构建消息(仅新建时使用,恢复时消息已在 trace 中)
  251. print("2. 构建任务消息...")
  252. messages = prompt.build_messages()
  253. print(f" - 任务已构建,系统将分析 input_1/ 目录下的故事文件")
  254. # 3. 创建 Agent Runner(配置 skills)
  255. print("3. 创建 Agent Runner...")
  256. print(f" - Skills 目录: {skills_dir}")
  257. print(f" - 模型: {prompt.config.get('model', 'sonnet-4.5')}")
  258. store = FileSystemTraceStore(base_path=".trace")
  259. runner = AgentRunner(
  260. trace_store=store,
  261. llm_call=create_openrouter_llm_call(model=f"anthropic/claude-{prompt.config.get('model', 'sonnet-4.5')}"),
  262. skills_dir=skills_dir,
  263. debug=True
  264. )
  265. # 4. 判断是新建还是恢复
  266. resume_trace_id = args.trace
  267. if resume_trace_id:
  268. # 验证 trace 存在
  269. existing_trace = await store.get_trace(resume_trace_id)
  270. if not existing_trace:
  271. print(f"\n错误: Trace 不存在: {resume_trace_id}")
  272. sys.exit(1)
  273. print(f"4. 恢复已有 Trace: {resume_trace_id[:8]}...")
  274. print(f" - 状态: {existing_trace.status}")
  275. print(f" - 消息数: {existing_trace.total_messages}")
  276. print(f" - 任务: {existing_trace.task}")
  277. else:
  278. print(f"4. 启动新 Agent 模式...")
  279. print()
  280. final_response = ""
  281. current_trace_id = resume_trace_id
  282. current_sequence = 0
  283. should_exit = False
  284. try:
  285. # 恢复模式:不发送初始消息,只指定 trace_id 续跑
  286. if resume_trace_id:
  287. initial_messages = None # None = 未设置,触发早期菜单检查
  288. config = RunConfig(
  289. model=f"claude-{prompt.config.get('model', 'sonnet-4.5')}",
  290. temperature=float(prompt.config.get('temperature', 0.3)),
  291. max_iterations=1000,
  292. trace_id=resume_trace_id,
  293. )
  294. else:
  295. initial_messages = messages
  296. config = RunConfig(
  297. model=f"claude-{prompt.config.get('model', 'sonnet-4.5')}",
  298. temperature=float(prompt.config.get('temperature', 0.3)),
  299. max_iterations=1000,
  300. name="故事分析任务",
  301. )
  302. while not should_exit:
  303. # 如果是续跑,需要指定 trace_id
  304. if current_trace_id:
  305. config.trace_id = current_trace_id
  306. # 清理上一轮的响应,避免失败后显示旧内容
  307. final_response = ""
  308. # 如果 trace 已完成/失败且没有新消息,直接进入交互菜单
  309. # 注意:initial_messages 为 None 表示未设置(首次加载),[] 表示有意为空(用户选择"继续")
  310. if current_trace_id and initial_messages is None:
  311. check_trace = await store.get_trace(current_trace_id)
  312. if check_trace and check_trace.status in ("completed", "failed"):
  313. if check_trace.status == "completed":
  314. print(f"\n[Trace] ✅ 已完成")
  315. print(f" - Total messages: {check_trace.total_messages}")
  316. print(f" - Total cost: ${check_trace.total_cost:.4f}")
  317. else:
  318. print(f"\n[Trace] ❌ 已失败: {check_trace.error_message}")
  319. current_sequence = check_trace.head_sequence
  320. menu_result = await show_interactive_menu(
  321. runner, current_trace_id, current_sequence, store
  322. )
  323. if menu_result["action"] == "stop":
  324. break
  325. elif menu_result["action"] == "continue":
  326. new_messages = menu_result.get("messages", [])
  327. if new_messages:
  328. initial_messages = new_messages
  329. config.after_sequence = menu_result.get("after_sequence")
  330. else:
  331. # 无新消息:对 failed trace 意味着重试,对 completed 意味着继续
  332. initial_messages = []
  333. config.after_sequence = None
  334. continue
  335. break
  336. # 对 stopped/running 等非终态的 trace,直接续跑
  337. initial_messages = []
  338. print(f"{'▶️ 开始执行...' if not current_trace_id else '▶️ 继续执行...'}")
  339. # 执行 Agent
  340. paused = False
  341. try:
  342. async for item in runner.run(messages=initial_messages, config=config):
  343. # 检查用户中断
  344. cmd = check_stdin()
  345. if cmd == 'pause':
  346. # 暂停执行
  347. print("\n⏸️ 正在暂停执行...")
  348. if current_trace_id:
  349. await runner.stop(current_trace_id)
  350. # 等待一小段时间让 runner 处理 stop 信号
  351. await asyncio.sleep(0.5)
  352. # 显示交互菜单
  353. menu_result = await show_interactive_menu(
  354. runner, current_trace_id, current_sequence, store
  355. )
  356. if menu_result["action"] == "stop":
  357. should_exit = True
  358. paused = True
  359. break
  360. elif menu_result["action"] == "continue":
  361. # 检查是否有新消息需要插入
  362. new_messages = menu_result.get("messages", [])
  363. if new_messages:
  364. # 有干预消息,需要重新启动循环
  365. initial_messages = new_messages
  366. after_seq = menu_result.get("after_sequence")
  367. if after_seq is not None:
  368. config.after_sequence = after_seq
  369. paused = True
  370. break
  371. else:
  372. # 没有新消息,需要重启执行
  373. initial_messages = []
  374. config.after_sequence = None
  375. paused = True
  376. break
  377. elif cmd == 'quit':
  378. print("\n🛑 用户请求停止...")
  379. if current_trace_id:
  380. await runner.stop(current_trace_id)
  381. should_exit = True
  382. break
  383. # 处理 Trace 对象(整体状态变化)
  384. if isinstance(item, Trace):
  385. current_trace_id = item.trace_id
  386. if item.status == "running":
  387. print(f"[Trace] 开始: {item.trace_id[:8]}...")
  388. elif item.status == "completed":
  389. print(f"\n[Trace] ✅ 完成")
  390. print(f" - Total messages: {item.total_messages}")
  391. print(f" - Total tokens: {item.total_tokens}")
  392. print(f" - Total cost: ${item.total_cost:.4f}")
  393. elif item.status == "failed":
  394. print(f"\n[Trace] ❌ 失败: {item.error_message}")
  395. elif item.status == "stopped":
  396. print(f"\n[Trace] ⏸️ 已停止")
  397. # 处理 Message 对象(执行过程)
  398. elif isinstance(item, Message):
  399. current_sequence = item.sequence
  400. if item.role == "assistant":
  401. content = item.content
  402. if isinstance(content, dict):
  403. text = content.get("text", "")
  404. tool_calls = content.get("tool_calls")
  405. if text and not tool_calls:
  406. # 纯文本回复(最终响应)
  407. final_response = text
  408. print(f"\n[Response] Agent 回复:")
  409. print(text)
  410. elif text:
  411. preview = text[:150] + "..." if len(text) > 150 else text
  412. print(f"[Assistant] {preview}")
  413. if tool_calls:
  414. for tc in tool_calls:
  415. tool_name = tc.get("function", {}).get("name", "unknown")
  416. print(f"[Tool Call] 🛠️ {tool_name}")
  417. elif item.role == "tool":
  418. content = item.content
  419. if isinstance(content, dict):
  420. tool_name = content.get("tool_name", "unknown")
  421. print(f"[Tool Result] ✅ {tool_name}")
  422. if item.description:
  423. desc = item.description[:80] if len(item.description) > 80 else item.description
  424. print(f" {desc}...")
  425. except Exception as e:
  426. print(f"\n执行出错: {e}")
  427. import traceback
  428. traceback.print_exc()
  429. # paused → 菜单已在暂停时内联显示过
  430. if paused:
  431. if should_exit:
  432. break
  433. continue
  434. # quit → 直接退出
  435. if should_exit:
  436. break
  437. # Runner 退出(完成/失败/停止/异常)→ 显示交互菜单
  438. if current_trace_id:
  439. menu_result = await show_interactive_menu(
  440. runner, current_trace_id, current_sequence, store
  441. )
  442. if menu_result["action"] == "stop":
  443. break
  444. elif menu_result["action"] == "continue":
  445. new_messages = menu_result.get("messages", [])
  446. if new_messages:
  447. initial_messages = new_messages
  448. config.after_sequence = menu_result.get("after_sequence")
  449. else:
  450. initial_messages = []
  451. config.after_sequence = None
  452. continue
  453. break
  454. except KeyboardInterrupt:
  455. print("\n\n用户中断 (Ctrl+C)")
  456. if current_trace_id:
  457. await runner.stop(current_trace_id)
  458. # 6. 输出结果
  459. if final_response:
  460. print()
  461. print("=" * 60)
  462. print("Agent 响应:")
  463. print("=" * 60)
  464. print(final_response)
  465. print("=" * 60)
  466. print()
  467. # 7. 保存结果
  468. output_file = output_dir / "result.txt"
  469. with open(output_file, 'w', encoding='utf-8') as f:
  470. f.write(final_response)
  471. print(f"✓ 结果已保存到: {output_file}")
  472. print()
  473. # 可视化提示
  474. if current_trace_id:
  475. print("=" * 60)
  476. print("可视化 Step Tree:")
  477. print("=" * 60)
  478. print("1. 启动 API Server:")
  479. print(" python3 api_server.py")
  480. print()
  481. print("2. 浏览器访问:")
  482. print(" http://43.106.118.91:8000/api/traces")
  483. print()
  484. print(f"3. Trace ID: {current_trace_id}")
  485. print("=" * 60)
  486. if __name__ == "__main__":
  487. asyncio.run(main())