run.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763
  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 asyncio
  17. import json
  18. from pathlib import Path
  19. from typing import Any
  20. # Clash Verge TUN 模式兼容:禁止 httpx/urllib 自动检测系统 HTTP 代理
  21. # TUN 虚拟网卡已在网络层接管所有流量,不需要应用层再走 HTTP 代理,
  22. # 否则 httpx 检测到 macOS 系统代理 (127.0.0.1:7897) 会导致 ConnectError
  23. # os.environ.setdefault("no_proxy", "*")
  24. # 添加项目根目录到 Python 路径
  25. sys.path.insert(0, str(Path(__file__).parent.parent.parent))
  26. from dotenv import load_dotenv
  27. load_dotenv()
  28. from agent.llm.prompts import SimplePrompt
  29. from agent.core.runner import AgentRunner, RunConfig
  30. from agent.core.presets import AgentPreset, register_preset
  31. from agent.trace import (
  32. FileSystemTraceStore,
  33. Trace,
  34. Message,
  35. )
  36. from examples.create.html import trace_to_html
  37. from agent.llm import create_openrouter_llm_call
  38. from agent.tools import get_tool_registry
  39. DEFAULT_MODEL = "anthropic/claude-sonnet-4.5"
  40. # DEFAULT_MODEL = "google/gemini-3-flash-preview"
  41. # ===== 非阻塞 stdin 检测 =====
  42. if sys.platform == 'win32':
  43. import msvcrt
  44. def check_stdin() -> str | None:
  45. """
  46. 跨平台非阻塞检查 stdin 输入。
  47. Windows: 使用 msvcrt.kbhit()
  48. macOS/Linux: 使用 select.select()
  49. """
  50. if sys.platform == 'win32':
  51. # 检查是否有按键按下
  52. if msvcrt.kbhit():
  53. # 读取按下的字符(msvcrt.getwch 是非阻塞读取宽字符)
  54. ch = msvcrt.getwch().lower()
  55. if ch == 'p':
  56. return 'pause'
  57. if ch == 'q':
  58. return 'quit'
  59. # 如果是其他按键,可以选择消耗掉或者忽略
  60. return None
  61. else:
  62. # Unix/Mac 逻辑
  63. ready, _, _ = select.select([sys.stdin], [], [], 0)
  64. if ready:
  65. line = sys.stdin.readline().strip().lower()
  66. if line in ('p', 'pause'):
  67. return 'pause'
  68. if line in ('q', 'quit'):
  69. return 'quit'
  70. return None
  71. # ===== 格式化打印 =====
  72. def _format_json(obj: Any, indent: int = 2) -> str:
  73. """格式化 JSON 对象为字符串"""
  74. try:
  75. return json.dumps(obj, indent=indent, ensure_ascii=False)
  76. except (TypeError, ValueError):
  77. # 如果无法序列化为 JSON,返回字符串表示
  78. return str(obj)
  79. def _print_message_details(message: Message):
  80. """完整打印消息的详细信息"""
  81. print("\n" + "=" * 80)
  82. print(f"[Message #{message.sequence}] {message.role.upper()}")
  83. print("=" * 80)
  84. # 基本信息
  85. if message.goal_id:
  86. print(f"Goal ID: {message.goal_id}")
  87. if message.parent_sequence is not None:
  88. print(f"Parent Sequence: {message.parent_sequence}")
  89. if message.tool_call_id:
  90. print(f"Tool Call ID: {message.tool_call_id}")
  91. # 内容打印
  92. if message.role == "user":
  93. print("\n[输入内容]")
  94. print("-" * 80)
  95. if isinstance(message.content, str):
  96. print(message.content)
  97. else:
  98. print(_format_json(message.content))
  99. elif message.role == "assistant":
  100. content = message.content
  101. if isinstance(content, dict):
  102. text = content.get("text", "")
  103. tool_calls = content.get("tool_calls")
  104. if text:
  105. print("\n[LLM 文本回复]")
  106. print("-" * 80)
  107. print(text)
  108. if tool_calls:
  109. print(f"\n[工具调用] (共 {len(tool_calls)} 个)")
  110. print("-" * 80)
  111. for idx, tc in enumerate(tool_calls, 1):
  112. func = tc.get("function", {})
  113. tool_name = func.get("name", "unknown")
  114. tool_id = tc.get("id", "unknown")
  115. arguments = func.get("arguments", {})
  116. print(f"\n工具 #{idx}: {tool_name}")
  117. print(f" Call ID: {tool_id}")
  118. print(f" 参数:")
  119. # 尝试解析 arguments(可能是字符串或字典)
  120. if isinstance(arguments, str):
  121. try:
  122. parsed_args = json.loads(arguments)
  123. print(_format_json(parsed_args, indent=4))
  124. except json.JSONDecodeError:
  125. print(f" {arguments}")
  126. else:
  127. print(_format_json(arguments, indent=4))
  128. elif isinstance(content, str):
  129. print("\n[LLM 文本回复]")
  130. print("-" * 80)
  131. print(content)
  132. else:
  133. print("\n[内容]")
  134. print("-" * 80)
  135. print(_format_json(content))
  136. if message.finish_reason:
  137. print(f"\n完成原因: {message.finish_reason}")
  138. elif message.role == "tool":
  139. content = message.content
  140. print("\n[工具执行结果]")
  141. print("-" * 80)
  142. if isinstance(content, dict):
  143. tool_name = content.get("tool_name", "unknown")
  144. result = content.get("result", content)
  145. print(f"工具名称: {tool_name}")
  146. print(f"\n返回结果:")
  147. if isinstance(result, str):
  148. print(result)
  149. elif isinstance(result, list):
  150. # 可能是多模态内容(包含图片)
  151. for idx, item in enumerate(result, 1):
  152. if isinstance(item, dict) and item.get("type") == "image_url":
  153. print(f" [{idx}] 图片 (base64, 已省略显示)")
  154. else:
  155. print(f" [{idx}] {item}")
  156. else:
  157. print(_format_json(result))
  158. else:
  159. print(str(content) if content is not None else "(无内容)")
  160. elif message.role == "system":
  161. print("\n[系统提示]")
  162. print("-" * 80)
  163. if isinstance(message.content, str):
  164. print(message.content)
  165. else:
  166. print(_format_json(message.content))
  167. # Token 和成本信息
  168. if message.prompt_tokens is not None or message.completion_tokens is not None:
  169. print("\n[Token 使用]")
  170. print("-" * 80)
  171. if message.prompt_tokens is not None:
  172. print(f" 输入 Tokens: {message.prompt_tokens:,}")
  173. if message.completion_tokens is not None:
  174. print(f" 输出 Tokens: {message.completion_tokens:,}")
  175. if message.reasoning_tokens is not None:
  176. print(f" 推理 Tokens: {message.reasoning_tokens:,}")
  177. if message.cache_creation_tokens is not None:
  178. print(f" 缓存创建 Tokens: {message.cache_creation_tokens:,}")
  179. if message.cache_read_tokens is not None:
  180. print(f" 缓存读取 Tokens: {message.cache_read_tokens:,}")
  181. if message.tokens:
  182. print(f" 总计 Tokens: {message.tokens:,}")
  183. if message.cost is not None:
  184. print(f"\n[成本] ${message.cost:.6f}")
  185. if message.duration_ms is not None:
  186. print(f"[执行时间] {message.duration_ms}ms")
  187. print("=" * 80 + "\n")
  188. # ===== 交互菜单 =====
  189. def _read_multiline() -> str:
  190. """
  191. 读取多行输入,以连续两次回车(空行)结束。
  192. 单次回车只是换行,不会提前终止输入。
  193. """
  194. print("\n请输入干预消息(连续输入两次回车结束):")
  195. lines: list[str] = []
  196. blank_count = 0
  197. while True:
  198. line = input()
  199. if line == "":
  200. blank_count += 1
  201. if blank_count >= 2:
  202. break
  203. lines.append("") # 保留单个空行
  204. else:
  205. blank_count = 0
  206. lines.append(line)
  207. # 去掉尾部多余空行
  208. while lines and lines[-1] == "":
  209. lines.pop()
  210. return "\n".join(lines)
  211. async def show_interactive_menu(
  212. runner: AgentRunner,
  213. trace_id: str,
  214. current_sequence: int,
  215. store: FileSystemTraceStore,
  216. ):
  217. """
  218. 显示交互式菜单,让用户选择操作。
  219. 进入本函数前不再有后台线程占用 stdin,所以 input() 能正常工作。
  220. """
  221. print("\n" + "=" * 60)
  222. print(" 执行已暂停")
  223. print("=" * 60)
  224. print("请选择操作:")
  225. print(" 1. 插入干预消息并继续")
  226. print(" 2. 触发经验总结(reflect)")
  227. print(" 3. 查看当前 GoalTree")
  228. print(" 4. 手动压缩上下文(compact)")
  229. print(" 5. 继续执行")
  230. print(" 6. 停止执行")
  231. print("=" * 60)
  232. while True:
  233. choice = input("请输入选项 (1-6): ").strip()
  234. if choice == "1":
  235. text = _read_multiline()
  236. if not text:
  237. print("未输入任何内容,取消操作")
  238. continue
  239. print(f"\n将插入干预消息并继续执行...")
  240. # 从 store 读取实际的 last_sequence,避免本地 current_sequence 过时
  241. live_trace = await store.get_trace(trace_id)
  242. actual_sequence = live_trace.last_sequence if live_trace and live_trace.last_sequence else current_sequence
  243. return {
  244. "action": "continue",
  245. "messages": [{"role": "user", "content": text}],
  246. "after_sequence": actual_sequence,
  247. }
  248. elif choice == "2":
  249. # 触发经验总结
  250. print("\n触发经验总结...")
  251. focus = input("请输入反思重点(可选,直接回车跳过): ").strip()
  252. from agent.trace.compaction import build_reflect_prompt
  253. # 保存当前 head_sequence
  254. trace = await store.get_trace(trace_id)
  255. saved_head = trace.head_sequence
  256. prompt = build_reflect_prompt()
  257. if focus:
  258. prompt += f"\n\n请特别关注:{focus}"
  259. print("正在生成反思...")
  260. reflect_cfg = RunConfig(trace_id=trace_id, max_iterations=1, tools=[])
  261. reflection_text = ""
  262. try:
  263. result = await runner.run_result(
  264. messages=[{"role": "user", "content": prompt}],
  265. config=reflect_cfg,
  266. )
  267. reflection_text = result.get("summary", "")
  268. finally:
  269. # 恢复 head_sequence(反思消息成为侧枝)
  270. await store.update_trace(trace_id, head_sequence=saved_head)
  271. # 追加到 experiences 文件
  272. if reflection_text:
  273. from datetime import datetime
  274. experiences_path = runner.experiences_path or "./.cache/experiences.md"
  275. os.makedirs(os.path.dirname(experiences_path), exist_ok=True)
  276. header = f"\n\n---\n\n## {trace_id} ({datetime.now().strftime('%Y-%m-%d %H:%M')})\n\n"
  277. with open(experiences_path, "a", encoding="utf-8") as f:
  278. f.write(header + reflection_text + "\n")
  279. print(f"\n反思已保存到: {experiences_path}")
  280. print("\n--- 反思内容 ---")
  281. print(reflection_text)
  282. print("--- 结束 ---\n")
  283. else:
  284. print("未生成反思内容")
  285. continue
  286. elif choice == "3":
  287. goal_tree = await store.get_goal_tree(trace_id)
  288. if goal_tree and goal_tree.goals:
  289. print("\n当前 GoalTree:")
  290. print(goal_tree.to_prompt())
  291. else:
  292. print("\n当前没有 Goal")
  293. continue
  294. elif choice == "4":
  295. # 手动压缩上下文
  296. print("\n正在执行上下文压缩(compact)...")
  297. try:
  298. goal_tree = await store.get_goal_tree(trace_id)
  299. trace = await store.get_trace(trace_id)
  300. if not trace:
  301. print("未找到 Trace,无法压缩")
  302. continue
  303. # 重建当前 history
  304. main_path = await store.get_main_path_messages(trace_id, trace.head_sequence)
  305. history = [msg.to_llm_dict() for msg in main_path]
  306. head_seq = main_path[-1].sequence if main_path else 0
  307. next_seq = head_seq + 1
  308. compact_config = RunConfig(trace_id=trace_id)
  309. new_history, new_head, new_seq = await runner._compress_history(
  310. trace_id=trace_id,
  311. history=history,
  312. goal_tree=goal_tree,
  313. config=compact_config,
  314. sequence=next_seq,
  315. head_seq=head_seq,
  316. )
  317. print(f"\n✅ 压缩完成: {len(history)} 条消息 → {len(new_history)} 条")
  318. except Exception as e:
  319. print(f"\n❌ 压缩失败: {e}")
  320. continue
  321. elif choice == "5":
  322. print("\n继续执行...")
  323. return {"action": "continue"}
  324. elif choice == "6":
  325. print("\n停止执行...")
  326. return {"action": "stop"}
  327. else:
  328. print("无效选项,请重新输入")
  329. async def main():
  330. # 解析命令行参数
  331. parser = argparse.ArgumentParser(description="任务 (Agent 模式 + 交互增强)")
  332. parser.add_argument(
  333. "--trace", type=str, default=None,
  334. help="已有的 Trace ID,用于恢复继续执行(不指定则新建)",
  335. )
  336. args = parser.parse_args()
  337. # 路径配置
  338. base_dir = Path(__file__).parent
  339. project_root = base_dir.parent.parent
  340. prompt_path = base_dir / "create.prompt"
  341. output_dir = base_dir / "output_1"
  342. output_dir.mkdir(exist_ok=True)
  343. # 加载项目级 presets(examples/create/presets.json)
  344. presets_path = base_dir / "presets.json"
  345. if presets_path.exists():
  346. import json
  347. with open(presets_path, "r", encoding="utf-8") as f:
  348. project_presets = json.load(f)
  349. for name, cfg in project_presets.items():
  350. register_preset(name, AgentPreset(**cfg))
  351. print(f" - 已加载项目 presets: {list(project_presets.keys())}")
  352. # Skills 目录(可选:用户自定义 skills)
  353. # 注意:内置 skills(agent/memory/skills/)会自动加载
  354. skills_dir = str(base_dir / "skills")
  355. print("=" * 60)
  356. print("mcp/skills 发现、获取、评价 分析任务 (Agent 模式 + 交互增强)")
  357. print("=" * 60)
  358. print()
  359. print("💡 交互提示:")
  360. print(" - 执行过程中输入 'p' 或 'pause' 暂停并进入交互模式")
  361. print(" - 执行过程中输入 'q' 或 'quit' 停止执行")
  362. print("=" * 60)
  363. print()
  364. # 1. 加载 prompt
  365. print("1. 加载 prompt 配置...")
  366. prompt = SimplePrompt(prompt_path)
  367. # 读取 system.md 并替换 {system} 占位符
  368. system_md_path = base_dir / "PRD" / "system.md"
  369. if system_md_path.exists():
  370. system_content = system_md_path.read_text(encoding='utf-8')
  371. if 'system' in prompt._messages and '{system}' in prompt._messages['system']:
  372. prompt._messages['system'] = prompt._messages['system'].replace('{system}', system_content)
  373. else:
  374. print(f" - 警告: system.md 文件不存在: {system_md_path}")
  375. # 读取 create_process.md 并替换 {create_process} 占位符
  376. create_process_md_path = base_dir / "PRD" / "create_process.md"
  377. if create_process_md_path.exists():
  378. create_process_content = create_process_md_path.read_text(encoding='utf-8')
  379. if 'system' in prompt._messages and '{create_process}' in prompt._messages['system']:
  380. prompt._messages['system'] = prompt._messages['system'].replace('{create_process}', create_process_content)
  381. print(f" - 已替换 create_process.md 内容到 prompt")
  382. else:
  383. print(f" - 警告: prompt 中未找到 {{create_process}} 占位符")
  384. else:
  385. print(f" - 警告: create_process.md 文件不存在: {create_process_md_path}")
  386. # 读取 user.md 并替换 {input} 占位符
  387. input_md_path = base_dir / "PRD" / "input.md"
  388. if input_md_path.exists():
  389. user_content = input_md_path.read_text(encoding='utf-8')
  390. if 'user' in prompt._messages and '{input}' in prompt._messages['user']:
  391. prompt._messages['user'] = prompt._messages['user'].replace('{input}', user_content)
  392. print(f" - 已替换 user.md 内容到 prompt")
  393. else:
  394. print(f" - 警告: prompt 中未找到 {{input}} 占位符")
  395. else:
  396. print(f" - 警告: user.md 文件不存在: {input_md_path}")
  397. output_md_path = base_dir / "PRD" / "output.md"
  398. if output_md_path.exists():
  399. user_content = output_md_path.read_text(encoding='utf-8')
  400. if 'user' in prompt._messages and '{output}' in prompt._messages['user']:
  401. prompt._messages['user'] = prompt._messages['user'].replace('{output}', user_content)
  402. print(f" - 已替换 user.md 内容到 prompt")
  403. else:
  404. print(f" - 警告: prompt 中未找到 {{output}} 占位符")
  405. else:
  406. print(f" - 警告: user.md 文件不存在: {output_md_path}")
  407. print("\n替换后的prompt:")
  408. print("=" * 60)
  409. print("System:")
  410. print("-" * 60)
  411. print(prompt._messages.get('system', ''))
  412. print("=" * 60)
  413. if 'user' in prompt._messages:
  414. print("\nUser:")
  415. print("-" * 60)
  416. print(prompt._messages['user'])
  417. print("=" * 60)
  418. print()
  419. # 2. 构建消息(仅新建时使用,恢复时消息已在 trace 中)
  420. print("2. 构建任务消息...")
  421. messages = prompt.build_messages()
  422. # 3. 创建 Agent Runner(配置 skills)
  423. print("3. 创建 Agent Runner...")
  424. print(f" - Skills 目录: {skills_dir}")
  425. print(f" - 模型: {prompt.config.get('model', 'sonnet-4.5')}")
  426. # 加载自定义工具
  427. print(" - 加载自定义工具: topic_search")
  428. import examples.create.tool # 选题检索工具,用于在数据库中匹配已有帖子选题
  429. store = FileSystemTraceStore(base_path=".trace")
  430. runner = AgentRunner(
  431. trace_store=store,
  432. llm_call=create_openrouter_llm_call(model=prompt.config.get('model', DEFAULT_MODEL)),
  433. skills_dir=skills_dir,
  434. experiences_path="./.cache/experiences.md",
  435. debug=True
  436. )
  437. # 4. 判断是新建还是恢复
  438. resume_trace_id = args.trace
  439. if resume_trace_id:
  440. # 验证 trace 存在
  441. existing_trace = await store.get_trace(resume_trace_id)
  442. if not existing_trace:
  443. print(f"\n错误: Trace 不存在: {resume_trace_id}")
  444. sys.exit(1)
  445. print(f"4. 恢复已有 Trace: {resume_trace_id[:8]}...")
  446. print(f" - 状态: {existing_trace.status}")
  447. print(f" - 消息数: {existing_trace.total_messages}")
  448. print(f" - 任务: {existing_trace.task}")
  449. else:
  450. print(f"4. 启动新 Agent 模式...")
  451. print()
  452. final_response = ""
  453. current_trace_id = resume_trace_id
  454. current_sequence = 0
  455. should_exit = False
  456. try:
  457. # 恢复模式:不发送初始消息,只指定 trace_id 续跑
  458. if resume_trace_id:
  459. initial_messages = None # None = 未设置,触发早期菜单检查
  460. config = RunConfig(
  461. model=prompt.config.get('model', DEFAULT_MODEL),
  462. temperature=float(prompt.config.get('temperature', 0.3)),
  463. max_iterations=1000,
  464. trace_id=resume_trace_id,
  465. )
  466. else:
  467. initial_messages = messages
  468. config = RunConfig(
  469. model=prompt.config.get('model', DEFAULT_MODEL),
  470. temperature=float(prompt.config.get('temperature', 0.3)),
  471. max_iterations=1000,
  472. name="社交媒体内容解构、建构、评估任务",
  473. )
  474. while not should_exit:
  475. # 如果是续跑,需要指定 trace_id
  476. if current_trace_id:
  477. config.trace_id = current_trace_id
  478. # 清理上一轮的响应,避免失败后显示旧内容
  479. final_response = ""
  480. # 如果 trace 已完成/失败且没有新消息,直接进入交互菜单
  481. # 注意:initial_messages 为 None 表示未设置(首次加载),[] 表示有意为空(用户选择"继续")
  482. if current_trace_id and initial_messages is None:
  483. check_trace = await store.get_trace(current_trace_id)
  484. if check_trace and check_trace.status in ("completed", "failed"):
  485. if check_trace.status == "completed":
  486. print(f"\n[Trace] ✅ 已完成")
  487. print(f" - Total messages: {check_trace.total_messages}")
  488. print(f" - Total cost: ${check_trace.total_cost:.4f}")
  489. else:
  490. print(f"\n[Trace] ❌ 已失败: {check_trace.error_message}")
  491. current_sequence = check_trace.head_sequence
  492. menu_result = await show_interactive_menu(
  493. runner, current_trace_id, current_sequence, store
  494. )
  495. if menu_result["action"] == "stop":
  496. break
  497. elif menu_result["action"] == "continue":
  498. new_messages = menu_result.get("messages", [])
  499. if new_messages:
  500. initial_messages = new_messages
  501. config.after_sequence = menu_result.get("after_sequence")
  502. else:
  503. # 无新消息:对 failed trace 意味着重试,对 completed 意味着继续
  504. initial_messages = []
  505. config.after_sequence = None
  506. continue
  507. break
  508. # 对 stopped/running 等非终态的 trace,直接续跑
  509. initial_messages = []
  510. print(f"{'▶️ 开始执行...' if not current_trace_id else '▶️ 继续执行...'}")
  511. # 执行 Agent
  512. paused = False
  513. try:
  514. async for item in runner.run(messages=initial_messages, config=config):
  515. # 检查用户中断
  516. cmd = check_stdin()
  517. if cmd == 'pause':
  518. # 暂停执行
  519. print("\n⏸️ 正在暂停执行...")
  520. if current_trace_id:
  521. await runner.stop(current_trace_id)
  522. # 等待一小段时间让 runner 处理 stop 信号
  523. await asyncio.sleep(0.5)
  524. # 显示交互菜单
  525. menu_result = await show_interactive_menu(
  526. runner, current_trace_id, current_sequence, store
  527. )
  528. if menu_result["action"] == "stop":
  529. should_exit = True
  530. paused = True
  531. break
  532. elif menu_result["action"] == "continue":
  533. # 检查是否有新消息需要插入
  534. new_messages = menu_result.get("messages", [])
  535. if new_messages:
  536. # 有干预消息,需要重新启动循环
  537. initial_messages = new_messages
  538. after_seq = menu_result.get("after_sequence")
  539. if after_seq is not None:
  540. config.after_sequence = after_seq
  541. paused = True
  542. break
  543. else:
  544. # 没有新消息,需要重启执行
  545. initial_messages = []
  546. config.after_sequence = None
  547. paused = True
  548. break
  549. elif cmd == 'quit':
  550. print("\n🛑 用户请求停止...")
  551. if current_trace_id:
  552. await runner.stop(current_trace_id)
  553. should_exit = True
  554. break
  555. # 处理 Trace 对象(整体状态变化)
  556. if isinstance(item, Trace):
  557. current_trace_id = item.trace_id
  558. if item.status == "running":
  559. print(f"[Trace] 开始: {item.trace_id[:8]}...")
  560. elif item.status == "completed":
  561. print(f"\n[Trace] ✅ 完成")
  562. print(f" - Total messages: {item.total_messages}")
  563. print(f" - Total tokens: {item.total_tokens}")
  564. print(f" - Total cost: ${item.total_cost:.4f}")
  565. elif item.status == "failed":
  566. print(f"\n[Trace] ❌ 失败: {item.error_message}")
  567. elif item.status == "stopped":
  568. print(f"\n[Trace] ⏸️ 已停止")
  569. # 处理 Message 对象(执行过程)
  570. elif isinstance(item, Message):
  571. current_sequence = item.sequence
  572. # 完整打印所有消息详情
  573. _print_message_details(item)
  574. # 保留原有的简化输出逻辑(用于最终响应)
  575. if item.role == "assistant":
  576. content = item.content
  577. if isinstance(content, dict):
  578. text = content.get("text", "")
  579. tool_calls = content.get("tool_calls")
  580. if text and not tool_calls:
  581. # 纯文本回复(最终响应)
  582. final_response = text
  583. except Exception as e:
  584. print(f"\n执行出错: {e}")
  585. import traceback
  586. traceback.print_exc()
  587. # paused → 菜单已在暂停时内联显示过
  588. if paused:
  589. if should_exit:
  590. break
  591. continue
  592. # quit → 直接退出
  593. if should_exit:
  594. break
  595. # Runner 退出(完成/失败/停止/异常)→ 显示交互菜单
  596. if current_trace_id:
  597. menu_result = await show_interactive_menu(
  598. runner, current_trace_id, current_sequence, store
  599. )
  600. if menu_result["action"] == "stop":
  601. break
  602. elif menu_result["action"] == "continue":
  603. new_messages = menu_result.get("messages", [])
  604. if new_messages:
  605. initial_messages = new_messages
  606. config.after_sequence = menu_result.get("after_sequence")
  607. else:
  608. initial_messages = []
  609. config.after_sequence = None
  610. continue
  611. break
  612. except KeyboardInterrupt:
  613. print("\n\n用户中断 (Ctrl+C)")
  614. if current_trace_id:
  615. await runner.stop(current_trace_id)
  616. finally:
  617. # 进程退出时自动生成 messages HTML 到 .trace/<id>/ 目录
  618. if current_trace_id:
  619. try:
  620. html_path = store.base_path / current_trace_id / "messages.html"
  621. await trace_to_html(current_trace_id, html_path, base_path=str(store.base_path))
  622. print(f"\n✓ Messages 可视化已保存: {html_path}")
  623. except Exception as e:
  624. print(f"\n⚠ 生成 HTML 失败: {e}")
  625. # 6. 输出结果
  626. if final_response:
  627. print()
  628. print("=" * 60)
  629. print("Agent 响应:")
  630. print("=" * 60)
  631. print(final_response)
  632. print("=" * 60)
  633. print()
  634. # 7. 保存结果
  635. output_file = output_dir / "result.txt"
  636. with open(output_file, 'w', encoding='utf-8') as f:
  637. f.write(final_response)
  638. print(f"✓ 结果已保存到: {output_file}")
  639. print()
  640. # 可视化提示
  641. if current_trace_id:
  642. html_path = store.base_path / current_trace_id / "messages.html"
  643. print("=" * 60)
  644. print("可视化:")
  645. print("=" * 60)
  646. print(f"1. 本地 HTML: {html_path}")
  647. print()
  648. print("2. API Server:")
  649. print(" python3 api_server.py")
  650. print(" http://localhost:8000/api/traces")
  651. print()
  652. print(f"3. Trace ID: {current_trace_id}")
  653. print("=" * 60)
  654. if __name__ == "__main__":
  655. asyncio.run(main())