run_api.py 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196
  1. """
  2. Trace 控制 API:新建、续跑、回溯、停止与反思。
  3. 路由将请求转为 RunConfig 并在后台驱动 AgentRunner,客户端通过 WebSocket 读取事件。
  4. Recursive 的根完成标准和子树停止也在这一 API 边界传递给实际 Runner。
  5. 端点:
  6. POST /api/traces — 新建 Trace 并执行
  7. POST /api/traces/{id}/run — 运行(统一续跑 + 回溯)
  8. POST /api/traces/{id}/stop — 停止运行中的 Trace
  9. POST /api/traces/{id}/reflect — 反思,在 trace 末尾追加反思 prompt 运行,结果追加到 experiences 文件
  10. GET /api/traces/running — 列出正在运行的 Trace
  11. GET /api/experiences — 读取经验文件内容
  12. """
  13. import asyncio
  14. import logging
  15. import re
  16. import uuid
  17. import os
  18. from datetime import datetime
  19. from typing import Any, Dict, List, Optional
  20. from fastapi import APIRouter, HTTPException
  21. from pydantic import BaseModel, Field, model_validator
  22. from cyber_agent.core.task_protocol import RootTaskAnchor
  23. logger = logging.getLogger(__name__)
  24. router = APIRouter(prefix="/api/traces", tags=["run"])
  25. # 经验 API 使用独立 prefix
  26. experiences_router = APIRouter(prefix="/api", tags=["experiences"])
  27. # ===== 全局 Runner(由 api_server.py 注入)=====
  28. _runner = None
  29. def set_runner(runner):
  30. """注入 AgentRunner 实例"""
  31. global _runner
  32. _runner = runner
  33. def _get_runner():
  34. if _runner is None:
  35. raise HTTPException(
  36. status_code=503,
  37. detail="AgentRunner not configured. Server is in read-only mode.",
  38. )
  39. return _runner
  40. # ===== Request / Response 模型 =====
  41. class CreateRequest(BaseModel):
  42. """新建执行"""
  43. messages: List[Dict[str, Any]] = Field(
  44. ...,
  45. description="OpenAI SDK 格式的输入消息。可包含 system + user 消息;若无 system 消息则从 skills 自动构建",
  46. )
  47. model: Optional[str] = Field(None, description="模型名称;省略时采用项目/框架默认值")
  48. temperature: Optional[float] = Field(None)
  49. max_iterations: Optional[int] = Field(None, gt=0)
  50. tools: Optional[List[str]] = Field(None, description="工具白名单(None = 全部)")
  51. name: Optional[str] = Field(None, description="任务名称(None = 自动生成)")
  52. uid: Optional[str] = Field(None)
  53. project_name: Optional[str] = Field(None, description="示例项目名称,若提供则动态加载其执行环境")
  54. root_task_anchor: Optional[RootTaskAnchor] = Field(
  55. None,
  56. description="Recursive 根任务必须显式提供的不可变目标、完成标准和硬约束;Legacy 忽略",
  57. )
  58. @model_validator(mode="before")
  59. @classmethod
  60. def reject_removed_root_criteria(cls, value):
  61. if isinstance(value, dict) and "root_completion_criteria" in value:
  62. raise ValueError(
  63. "root_completion_criteria has been removed; use root_task_anchor"
  64. )
  65. return value
  66. class TraceRunRequest(BaseModel):
  67. """运行(统一续跑 + 回溯)"""
  68. messages: List[Dict[str, Any]] = Field(
  69. default_factory=list,
  70. description="追加的新消息(可为空,用于重新生成场景)",
  71. )
  72. after_message_id: Optional[str] = Field(
  73. None,
  74. description="从哪条消息后续跑。None = 从末尾续跑,message_id = 从该消息后运行(自动判断续跑/回溯)",
  75. )
  76. class ReflectRequest(BaseModel):
  77. """反思请求"""
  78. focus: Optional[str] = Field(None, description="反思重点(可选)")
  79. class RunResponse(BaseModel):
  80. """操作响应(立即返回,后台执行)"""
  81. trace_id: str
  82. status: str = "started"
  83. message: str = ""
  84. class StopResponse(BaseModel):
  85. """停止响应"""
  86. trace_id: str
  87. status: str # "stopping" | "not_running"
  88. class ReflectResponse(BaseModel):
  89. """反思响应"""
  90. trace_id: str
  91. reflection: str
  92. class CompactResponse(BaseModel):
  93. """压缩响应"""
  94. trace_id: str
  95. previous_count: int
  96. new_count: int
  97. message: str = ""
  98. class ToolApprovalDecision(BaseModel):
  99. tool_call_id: str = Field(min_length=1)
  100. decision: str = Field(description="approve / reject")
  101. edited_arguments: Optional[Dict[str, Any]] = None
  102. class DecideToolApprovalRequest(BaseModel):
  103. decisions: List[ToolApprovalDecision] = Field(min_length=1)
  104. # ===== 提取审核(见 cyber_agent/docs/memory.md 第三节) =====
  105. class PendingExtractionModel(BaseModel):
  106. extraction_id: str
  107. sequence: Optional[int] = None
  108. goal_id: Optional[str] = None
  109. branch_id: Optional[str] = None
  110. payload: Dict[str, Any]
  111. reviewed: bool = False
  112. decision: Optional[str] = None
  113. committed: bool = False
  114. class ListExtractionsResponse(BaseModel):
  115. trace_id: str
  116. count: int
  117. items: List[PendingExtractionModel]
  118. class ReviewRequest(BaseModel):
  119. decision: str = Field(..., description="approve / edit / discard")
  120. edited_payload: Optional[Dict[str, Any]] = Field(
  121. None, description="decision=edit 时必填;只对本次 review 生效"
  122. )
  123. class ReviewResponse(BaseModel):
  124. trace_id: str
  125. extraction_id: str
  126. decision: str
  127. class CommitResponse(BaseModel):
  128. trace_id: str
  129. committed_count: int
  130. failed_count: int
  131. skipped_count: int
  132. committed: List[str]
  133. knowledge_ids: List[str]
  134. failed: List[Dict[str, str]]
  135. skipped: List[str]
  136. # ===== 后台执行 =====
  137. _running_tasks: Dict[str, asyncio.Task] = {}
  138. # 记住每个运行中 Trace 真正使用的 Runner,避免 Example 专属 Runner 的续跑/停止误落到全局 Runner。
  139. # 映射只覆盖当前进程活跃任务,由两个后台执行入口按对象身份清理。
  140. _running_runners: Dict[str, Any] = {}
  141. _approval_locks: Dict[str, asyncio.Lock] = {}
  142. def _require_mutable_trace(trace) -> None:
  143. """将核心协议的只读门禁统一映射为 HTTP 409。"""
  144. from cyber_agent.core.agent_mode import (
  145. RECURSIVE_REVISION_READ_ONLY_ERROR,
  146. require_mutable_trace_policy,
  147. )
  148. try:
  149. require_mutable_trace_policy(trace.context)
  150. except ValueError as exc:
  151. if str(exc) == RECURSIVE_REVISION_READ_ONLY_ERROR:
  152. raise HTTPException(status_code=409, detail=str(exc)) from exc
  153. raise
  154. async def _cancel_and_wait_for_running_task(trace_id: str) -> None:
  155. """等待旧后台运行完成 finally 清理,再允许同一 Trace 续跑。"""
  156. old_task = _running_tasks.get(trace_id)
  157. if not old_task:
  158. return
  159. old_task.cancel()
  160. try:
  161. await old_task
  162. except asyncio.CancelledError:
  163. pass
  164. if _running_tasks.get(trace_id) is old_task:
  165. _running_tasks.pop(trace_id, None)
  166. async def _restore_runner_and_config(
  167. *,
  168. trace,
  169. base_runner,
  170. after_sequence: Optional[int] = None,
  171. force_side_branch: Optional[List[str]] = None,
  172. approval_batch_id: Optional[str] = None,
  173. ):
  174. """Restore the persisted run contract and its trusted project environment."""
  175. import importlib
  176. from cyber_agent.core.agent_mode import (
  177. AgentMode,
  178. RECURSIVE_REVISION_READ_ONLY_ERROR,
  179. policy_from_context,
  180. require_mutable_trace_policy,
  181. )
  182. from cyber_agent.core.run_snapshot import (
  183. RUN_CONFIG_SNAPSHOT_CONTEXT_KEY,
  184. RunConfigSnapshotV1,
  185. RunConfigSnapshotError,
  186. load_run_config_snapshot,
  187. persist_run_config_snapshot,
  188. )
  189. from cyber_agent.core.runner import RunConfig
  190. try:
  191. require_mutable_trace_policy(trace.context)
  192. except ValueError as exc:
  193. if str(exc) == RECURSIVE_REVISION_READ_ONLY_ERROR:
  194. raise HTTPException(status_code=409, detail=str(exc)) from exc
  195. raise
  196. config = RunConfig(
  197. trace_id=trace.trace_id,
  198. after_sequence=after_sequence,
  199. force_side_branch=force_side_branch,
  200. approval_batch_id=approval_batch_id,
  201. )
  202. project_name = None
  203. if RUN_CONFIG_SNAPSHOT_CONTEXT_KEY in (trace.context or {}):
  204. try:
  205. snapshot = load_run_config_snapshot(trace.context)
  206. except RunConfigSnapshotError as exc:
  207. raise HTTPException(status_code=409, detail=str(exc)) from exc
  208. config.apply_snapshot(snapshot)
  209. # apply_snapshot intentionally leaves invocation-only controls untouched.
  210. config.trace_id = trace.trace_id
  211. config.after_sequence = after_sequence
  212. config.force_side_branch = force_side_branch
  213. config.approval_batch_id = approval_batch_id
  214. project_name = snapshot.project_name
  215. elif policy_from_context(trace.context).mode is AgentMode.RECURSIVE:
  216. raise HTTPException(
  217. status_code=409,
  218. detail="This Recursive trace predates RunConfig snapshots; create a new trace",
  219. )
  220. else:
  221. # Infer and persist the Legacy contract before selecting its Runner.
  222. # Otherwise an old project Trace would resume once on the global
  223. # Runner and only learn its project_name too late inside AgentRunner.
  224. inferred = RunConfig(
  225. model=trace.model or "gpt-4o",
  226. temperature=float((trace.llm_params or {}).get("temperature", 0.3)),
  227. tools=[
  228. item.get("function", {}).get("name")
  229. for item in (trace.tools or [])
  230. if item.get("function", {}).get("name")
  231. ],
  232. tool_groups=None,
  233. agent_type=trace.agent_type or "default",
  234. uid=trace.uid,
  235. extra_llm_params={
  236. key: value
  237. for key, value in (trace.llm_params or {}).items()
  238. if key != "temperature"
  239. },
  240. context=(
  241. {"project_name": trace.context.get("project_name")}
  242. if trace.context.get("project_name")
  243. else {}
  244. ),
  245. )
  246. snapshot = RunConfigSnapshotV1.from_run_config(
  247. inferred,
  248. memory_identity=None,
  249. legacy_inferred=True,
  250. )
  251. persist_run_config_snapshot(trace.context, snapshot)
  252. if not base_runner.trace_store:
  253. raise HTTPException(status_code=503, detail="TraceStore not configured")
  254. await base_runner.trace_store.update_trace(
  255. trace.trace_id,
  256. context=trace.context,
  257. )
  258. config.apply_snapshot(snapshot)
  259. config.trace_id = trace.trace_id
  260. config.after_sequence = after_sequence
  261. config.force_side_branch = force_side_branch
  262. config.approval_batch_id = approval_batch_id
  263. project_name = snapshot.project_name
  264. runner = base_runner
  265. if project_name:
  266. if not re.fullmatch(r"[A-Za-z0-9_]+", project_name):
  267. raise HTTPException(status_code=409, detail="Invalid persisted project_name")
  268. module_name = f"examples.{project_name}.run"
  269. try:
  270. example_module = importlib.import_module(module_name)
  271. if hasattr(example_module, "init_project_env"):
  272. project_runner, _project_msgs, _default_config = (
  273. await example_module.init_project_env()
  274. )
  275. runner = project_runner
  276. except ImportError as exc:
  277. if getattr(exc, "name", None) == module_name:
  278. logger.warning(
  279. "Project %s has no custom run.py; using the default runner",
  280. project_name,
  281. )
  282. else:
  283. raise HTTPException(
  284. status_code=409,
  285. detail=f"Failed to restore project environment: {project_name}",
  286. ) from exc
  287. except Exception as exc:
  288. logger.exception("Failed to restore project %s", project_name)
  289. raise HTTPException(
  290. status_code=409,
  291. detail=f"Failed to restore project environment: {project_name}",
  292. ) from exc
  293. return runner, config
  294. async def _run_in_background(trace_id: str, messages: List[Dict], config, runner_instance=None):
  295. """后台执行已知 Trace ID 的 Agent,消费 run() 的所有 yield。
  296. run_trace() 续跑/回溯时调用,并登记实际 Runner 供 stop_trace() 命中 Recursive 子树。"""
  297. runner = runner_instance or _get_runner()
  298. current_task = asyncio.current_task()
  299. if current_task:
  300. _running_tasks[trace_id] = current_task
  301. _running_runners[trace_id] = runner
  302. try:
  303. async for _item in runner.run(messages=messages, config=config):
  304. pass # WebSocket 广播由 runner 内部的 store 事件驱动
  305. except Exception as e:
  306. logger.error(f"Background run failed for {trace_id}: {e}")
  307. finally:
  308. if _running_tasks.get(trace_id) is current_task:
  309. _running_tasks.pop(trace_id, None)
  310. if _running_runners.get(trace_id) is runner:
  311. _running_runners.pop(trace_id, None)
  312. async def _run_with_trace_signal(
  313. messages: List[Dict], config, trace_id_future: asyncio.Future, runner_instance=None
  314. ):
  315. """后台新建 Agent,通过 Future 将首个 Trace 对象的 ID 传回 API。
  316. create_and_run() 调用它,同时登记实际 Runner,使后续停止不会丢失 Example 专属运行环境。"""
  317. from cyber_agent.trace.models import Trace
  318. runner = runner_instance or _get_runner()
  319. trace_id: Optional[str] = None
  320. current_task = asyncio.current_task()
  321. try:
  322. async for item in runner.run(messages=messages, config=config):
  323. if isinstance(item, Trace) and not trace_id_future.done():
  324. trace_id = item.trace_id
  325. if current_task:
  326. _running_tasks[trace_id] = current_task
  327. _running_runners[trace_id] = runner
  328. trace_id_future.set_result(trace_id)
  329. except Exception as e:
  330. if not trace_id_future.done():
  331. trace_id_future.set_exception(e)
  332. logger.error(f"Background run failed: {e}")
  333. finally:
  334. if trace_id:
  335. if _running_tasks.get(trace_id) is current_task:
  336. _running_tasks.pop(trace_id, None)
  337. if _running_runners.get(trace_id) is runner:
  338. _running_runners.pop(trace_id, None)
  339. # ===== 路由 =====
  340. @router.post("", response_model=RunResponse)
  341. async def create_and_run(req: CreateRequest):
  342. """
  343. 新建 Trace 并开始执行,将 HTTP 参数和 Example 默认值合并为 RunConfig。
  344. Recursive 的 root_task_anchor 从此传入 Runner 完成门禁;获取 trace_id 后立即返回,执行继续留在后台。
  345. """
  346. import importlib
  347. from dataclasses import replace
  348. from cyber_agent.core.runner import RunConfig
  349. runner = None
  350. config = None
  351. messages = req.messages
  352. if req.project_name:
  353. try:
  354. # 动态加载对应 example 的 run.py
  355. module_name = f"examples.{req.project_name}.run"
  356. example_module = importlib.import_module(module_name)
  357. if hasattr(example_module, "init_project_env"):
  358. # 获取该 example 专属的 runner, 带上下文 messages, 以及默认 config
  359. runner, example_messages, default_config = await example_module.init_project_env(req.messages)
  360. messages = example_messages
  361. # Preserve every project default, then apply only fields explicitly
  362. # supplied by the request before the Runner snapshots the result.
  363. config = replace(default_config)
  364. if req.model is not None:
  365. config.model = req.model
  366. if req.temperature is not None:
  367. config.temperature = req.temperature
  368. if req.max_iterations is not None:
  369. config.max_iterations = req.max_iterations
  370. if req.tools is not None:
  371. config.tools = list(req.tools)
  372. if req.name is not None:
  373. config.name = req.name
  374. if req.uid is not None:
  375. config.uid = req.uid
  376. config.root_task_anchor = req.root_task_anchor
  377. config.context = {
  378. **(default_config.context or {}),
  379. "project_name": req.project_name,
  380. }
  381. except ImportError as e:
  382. if getattr(e, "name", None) == module_name:
  383. logger.warning(f"Project '{req.project_name}' has no custom run.py, falling back to default.")
  384. else:
  385. import traceback
  386. logger.error(f"Error INSIDE {module_name}:\n{traceback.format_exc()}")
  387. except Exception as e:
  388. import traceback
  389. logger.error(f"Unexpected error loading project environment for {req.project_name}:\n{traceback.format_exc()}")
  390. if not runner:
  391. _get_runner() # 验证全局默认 Runner 已配置
  392. config = RunConfig(
  393. model=req.model or "gpt-4o",
  394. temperature=req.temperature if req.temperature is not None else 0.3,
  395. max_iterations=req.max_iterations or 200,
  396. tools=req.tools,
  397. name=req.name,
  398. uid=req.uid,
  399. root_task_anchor=req.root_task_anchor,
  400. context={"project_name": req.project_name} if req.project_name else {}
  401. )
  402. # 启动后台执行,通过 Future 等待 trace_id(Phase 1 完成后即返回)
  403. trace_id_future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
  404. task = asyncio.create_task(
  405. _run_with_trace_signal(messages, config, trace_id_future, runner_instance=runner)
  406. )
  407. trace_id = await trace_id_future
  408. return RunResponse(
  409. trace_id=trace_id,
  410. status="started",
  411. message=f"Execution started. Watch via WebSocket: /api/traces/{trace_id}/watch",
  412. )
  413. async def _cleanup_incomplete_tool_calls(store, trace_id: str, after_sequence: int) -> int:
  414. """
  415. 找到安全的插入点,保证不会把新消息插在一个不完整的工具调用序列中间。
  416. 场景:
  417. 1. after_sequence 刚好是一条带 tool_calls 的 assistant 消息,
  418. 但其部分/全部 tool response 还没生成 → 回退到该 assistant 之前。
  419. 2. after_sequence 是某条 tool response,但同一批 tool_calls 中
  420. 还有其他 response 未生成 → 回退到该 assistant 之前。
  421. 核心逻辑:从 after_sequence 往前找,定位到包含它的那条 assistant 消息,
  422. 检查该 assistant 的所有 tool_calls 是否都有对应的 tool response。
  423. 如果不完整,就把截断点回退到该 assistant 消息之前(即其 parent_sequence)。
  424. Args:
  425. store: TraceStore
  426. trace_id: Trace ID
  427. after_sequence: 用户指定的插入位置
  428. Returns:
  429. 调整后的安全截断点(<= after_sequence)
  430. """
  431. all_messages = await store.get_trace_messages(trace_id)
  432. if not all_messages:
  433. return after_sequence
  434. by_seq = {msg.sequence: msg for msg in all_messages}
  435. target = by_seq.get(after_sequence)
  436. if target is None:
  437. return after_sequence
  438. # 找到"所属的 assistant 消息":
  439. # - 如果 target 本身是 assistant → 就是它
  440. # - 如果 target 是 tool → 沿 parent_sequence 往上找 assistant
  441. assistant_msg = None
  442. if target.role == "assistant":
  443. assistant_msg = target
  444. elif target.role == "tool":
  445. cur = target
  446. while cur and cur.role == "tool":
  447. parent_seq = cur.parent_sequence
  448. cur = by_seq.get(parent_seq) if parent_seq is not None else None
  449. if cur and cur.role == "assistant":
  450. assistant_msg = cur
  451. if assistant_msg is None:
  452. return after_sequence
  453. # 该 assistant 是否带 tool_calls?
  454. content = assistant_msg.content
  455. if not isinstance(content, dict) or not content.get("tool_calls"):
  456. return after_sequence
  457. # 收集所有 tool_call_ids
  458. expected_ids = set()
  459. for tc in content["tool_calls"]:
  460. if isinstance(tc, dict) and tc.get("id"):
  461. expected_ids.add(tc["id"])
  462. if not expected_ids:
  463. return after_sequence
  464. # 查找已有的 tool responses
  465. found_ids = set()
  466. for msg in all_messages:
  467. if msg.role == "tool" and msg.tool_call_id in expected_ids:
  468. found_ids.add(msg.tool_call_id)
  469. missing = expected_ids - found_ids
  470. if not missing:
  471. # 全部 tool response 都在,这是一个完整的序列
  472. return after_sequence
  473. # 不完整 → 回退到 assistant 之前
  474. safe = assistant_msg.parent_sequence
  475. if safe is None:
  476. # assistant 已经是第一条消息,没有更早的位置
  477. safe = assistant_msg.sequence - 1
  478. logger.info(
  479. "检测到不完整的工具调用 (assistant seq=%d, 缺少 %d/%d tool responses),"
  480. "自动回退插入点:%d -> %d",
  481. assistant_msg.sequence, len(missing), len(expected_ids),
  482. after_sequence, safe,
  483. )
  484. return safe
  485. def _parse_sequence_from_message_id(message_id: str) -> int:
  486. """从 message_id 末尾解析 sequence 整数(格式:{trace_id}-{sequence:04d})"""
  487. try:
  488. return int(message_id.rsplit("-", 1)[-1])
  489. except (ValueError, IndexError):
  490. raise HTTPException(
  491. status_code=422,
  492. detail=f"Invalid after_message_id format: {message_id!r}",
  493. )
  494. @router.post("/{trace_id}/run", response_model=RunResponse)
  495. async def run_trace(trace_id: str, req: TraceRunRequest):
  496. """
  497. 运行已有 Trace,统一处理续跑与回溯。
  498. 它优先取活跃 Trace 实际 Runner,再由 AgentRunner 恢复持久化的 Legacy/Recursive 模式和协议状态。
  499. - after_message_id 为 null(或省略):从末尾续跑
  500. - after_message_id 为 message_id 字符串:从该消息后运行(Runner 自动判断续跑/回溯)
  501. - messages 为空 + after_message_id 有值:重新生成(从该位置重跑,不插入新消息)
  502. **自动清理不完整工具调用**:
  503. 如果人工插入 message 的位置打断了一个工具调用过程(assistant 消息有 tool_calls
  504. 但缺少对应的 tool responses),框架会自动检测并调整插入位置,确保不会产生不一致的状态。
  505. """
  506. runner = _running_runners.get(trace_id) or _get_runner()
  507. # 将 message_id 转换为内部使用的 sequence 整数
  508. after_sequence: Optional[int] = None
  509. if req.after_message_id is not None:
  510. after_sequence = _parse_sequence_from_message_id(req.after_message_id)
  511. if not runner.trace_store:
  512. raise HTTPException(status_code=503, detail="TraceStore not configured")
  513. trace = await runner.trace_store.get_trace(trace_id)
  514. if not trace:
  515. raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
  516. # Restore the immutable run contract and project environment before any
  517. # status/rewind decision. Status checks must use the same Runner that will
  518. # actually resume the Trace.
  519. runner, config = await _restore_runner_and_config(
  520. trace=trace,
  521. base_runner=runner,
  522. after_sequence=after_sequence,
  523. )
  524. # 验证 trace 状态
  525. if runner.trace_store:
  526. if trace.status == "waiting_confirmation":
  527. raise HTTPException(
  528. status_code=409,
  529. detail="Trace is waiting for tool approval; use the tool-approvals endpoint",
  530. )
  531. # 自动检查并清理不完整的工具调用
  532. if after_sequence is not None and req.messages:
  533. adjusted_seq = await _cleanup_incomplete_tool_calls(
  534. runner.trace_store, trace_id, after_sequence
  535. )
  536. if adjusted_seq != after_sequence:
  537. logger.info(
  538. f"已自动调整插入位置:{after_sequence} -> {adjusted_seq}"
  539. )
  540. after_sequence = adjusted_seq
  541. # 检查是否已在运行
  542. if trace_id in _running_tasks and not _running_tasks[trace_id].done():
  543. # 竞态窗口修复:task 还没退出,但 store 里 trace 已经是 stopped/failed
  544. # 这发生在 cancel_event 触发后 → store 更新 → task finally 块还未执行之间
  545. # 此时可以安全地强制清除旧 task,允许续跑
  546. store_trace = None
  547. if runner.trace_store:
  548. store_trace = await runner.trace_store.get_trace(trace_id)
  549. if store_trace and store_trace.status in ("stopped", "failed", "completed"):
  550. logger.info(
  551. f"run_trace: task for {trace_id} not done yet but store status={store_trace.status!r}, "
  552. "forcing cleanup to allow resume"
  553. )
  554. await _cancel_and_wait_for_running_task(trace_id)
  555. else:
  556. raise HTTPException(status_code=409, detail="Trace is already running")
  557. # 恢复运行时,将状态从 stopped 改回 running,并广播状态变化
  558. if runner.trace_store and trace_id:
  559. current_trace = await runner.trace_store.get_trace(trace_id)
  560. if current_trace and current_trace.status == "stopped":
  561. await runner.trace_store.update_trace(trace_id, status="running")
  562. # 广播状态变化给前端
  563. from cyber_agent.trace.websocket import broadcast_trace_status_changed
  564. await broadcast_trace_status_changed(trace_id, "running")
  565. task = asyncio.create_task(_run_in_background(trace_id, req.messages, config, runner_instance=runner))
  566. _running_tasks[trace_id] = task
  567. mode = "rewind" if after_sequence is not None else "continue"
  568. return RunResponse(
  569. trace_id=trace_id,
  570. status="started",
  571. message=f"Run ({mode}) started. Watch via WebSocket: /api/traces/{trace_id}/watch",
  572. )
  573. @router.get("/{trace_id}/tool-approvals/pending")
  574. async def get_pending_tool_approval(trace_id: str):
  575. runner = _running_runners.get(trace_id) or _get_runner()
  576. if not runner.trace_store or not hasattr(
  577. runner.trace_store,
  578. "get_tool_approval_batch",
  579. ):
  580. raise HTTPException(status_code=503, detail="Tool approval store unavailable")
  581. trace = await runner.trace_store.get_trace(trace_id)
  582. if not trace:
  583. raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
  584. batch = await runner.trace_store.get_tool_approval_batch(trace_id)
  585. if batch is None or batch.status != "pending":
  586. return {"trace_id": trace_id, "batch": None}
  587. return {"trace_id": trace_id, "batch": batch.model_dump(mode="json")}
  588. @router.post("/{trace_id}/tool-approvals/{batch_id}", response_model=RunResponse)
  589. async def decide_tool_approval(
  590. trace_id: str,
  591. batch_id: str,
  592. req: DecideToolApprovalRequest,
  593. ):
  594. from cyber_agent.tools.approval import tool_argument_hash
  595. runner = _running_runners.get(trace_id) or _get_runner()
  596. if not runner.trace_store or not hasattr(
  597. runner.trace_store,
  598. "get_tool_approval_batch",
  599. ):
  600. raise HTTPException(status_code=503, detail="Tool approval store unavailable")
  601. lock = _approval_locks.setdefault(trace_id, asyncio.Lock())
  602. async with lock:
  603. trace = await runner.trace_store.get_trace(trace_id)
  604. if not trace:
  605. raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
  606. runner, config = await _restore_runner_and_config(
  607. trace=trace,
  608. base_runner=runner,
  609. approval_batch_id=batch_id,
  610. )
  611. if not runner.trace_store:
  612. raise HTTPException(status_code=503, detail="TraceStore not configured")
  613. trace = await runner.trace_store.get_trace(trace_id)
  614. if not trace:
  615. raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
  616. batch = await runner.trace_store.get_tool_approval_batch(trace_id)
  617. if batch is None or batch.batch_id != batch_id:
  618. raise HTTPException(status_code=404, detail="Tool approval batch not found")
  619. if batch.status != "pending" or trace.status != "waiting_confirmation":
  620. raise HTTPException(status_code=409, detail="Tool approval was already decided")
  621. if trace_id in _running_tasks and not _running_tasks[trace_id].done():
  622. raise HTTPException(status_code=409, detail="Trace is already running")
  623. decisions = {item.tool_call_id: item for item in req.decisions}
  624. if len(decisions) != len(req.decisions):
  625. raise HTTPException(status_code=422, detail="Duplicate tool_call_id decision")
  626. expected_ids = {call.tool_call_id for call in batch.pending_calls}
  627. if set(decisions) != expected_ids:
  628. raise HTTPException(
  629. status_code=422,
  630. detail=(
  631. "Decisions must cover every pending tool call exactly once; "
  632. f"expected={sorted(expected_ids)}"
  633. ),
  634. )
  635. for call in batch.pending_calls:
  636. decision = decisions[call.tool_call_id]
  637. if decision.decision not in {"approve", "reject"}:
  638. raise HTTPException(
  639. status_code=422,
  640. detail="decision must be approve or reject",
  641. )
  642. if decision.decision == "reject":
  643. if decision.edited_arguments is not None:
  644. raise HTTPException(
  645. status_code=422,
  646. detail="rejected calls cannot edit arguments",
  647. )
  648. call.decision = "rejected"
  649. else:
  650. edited = (
  651. dict(decision.edited_arguments)
  652. if decision.edited_arguments is not None
  653. else dict(call.original_arguments)
  654. )
  655. changed = {
  656. key
  657. for key in set(call.original_arguments) | set(edited)
  658. if call.original_arguments.get(key) != edited.get(key)
  659. or (key in call.original_arguments) != (key in edited)
  660. }
  661. forbidden = changed - set(call.editable_params)
  662. if forbidden:
  663. raise HTTPException(
  664. status_code=422,
  665. detail=f"Arguments are not editable: {sorted(forbidden)}",
  666. )
  667. call.effective_arguments = edited
  668. call.argument_hash = tool_argument_hash(
  669. tool_call_id=call.tool_call_id,
  670. tool_name=call.tool_name,
  671. arguments=edited,
  672. )
  673. call.decision = "approved"
  674. call.decided_at = datetime.now().isoformat()
  675. batch.status = "decided"
  676. batch.updated_at = datetime.now().isoformat()
  677. await runner.trace_store.replace_tool_approval_batch(trace_id, batch)
  678. await runner.trace_store.update_trace(trace_id, status="running")
  679. task = asyncio.create_task(
  680. _run_in_background(
  681. trace_id,
  682. [],
  683. config,
  684. runner_instance=runner,
  685. )
  686. )
  687. _running_tasks[trace_id] = task
  688. return RunResponse(
  689. trace_id=trace_id,
  690. status="started",
  691. message="Approved tool batch resume started",
  692. )
  693. @router.post("/{trace_id}/stop", response_model=StopResponse)
  694. async def stop_trace(trace_id: str):
  695. """
  696. 向运行中 Trace 的实际 Runner 发送协作式停止信号。
  697. Legacy 只停当前 Trace;Recursive 由 AgentRunner.stop() 向当前进程的活跃子孙传播。
  698. """
  699. runner = _running_runners.get(trace_id) or _get_runner()
  700. if runner.trace_store:
  701. trace = await runner.trace_store.get_trace(trace_id)
  702. if trace:
  703. _require_mutable_trace(trace)
  704. if trace and trace.status == "waiting_confirmation":
  705. batch = (
  706. await runner.trace_store.get_tool_approval_batch(trace_id)
  707. if hasattr(runner.trace_store, "get_tool_approval_batch")
  708. else None
  709. )
  710. if batch and batch.status == "pending":
  711. batch.status = "cancelled"
  712. batch.updated_at = datetime.now().isoformat()
  713. await runner.trace_store.replace_tool_approval_batch(trace_id, batch)
  714. await runner.trace_store.update_trace(trace_id, status="stopped")
  715. try:
  716. from cyber_agent.trace.websocket import broadcast_trace_status_changed
  717. await broadcast_trace_status_changed(trace_id, "stopped")
  718. except Exception:
  719. pass
  720. return StopResponse(trace_id=trace_id, status="stopping")
  721. # 通过 runner 的 stop 方法设置取消信号
  722. stopped = await runner.stop(trace_id)
  723. if not stopped:
  724. # 检查是否在 _running_tasks 但 runner 不知道(可能已完成)
  725. if trace_id in _running_tasks:
  726. task = _running_tasks[trace_id]
  727. if not task.done():
  728. task.cancel()
  729. _running_tasks.pop(trace_id, None)
  730. return StopResponse(trace_id=trace_id, status="stopping")
  731. return StopResponse(trace_id=trace_id, status="not_running")
  732. return StopResponse(trace_id=trace_id, status="stopping")
  733. @router.post("/{trace_id}/reflect", response_model=ReflectResponse)
  734. async def reflect_trace(trace_id: str, req: ReflectRequest):
  735. """
  736. 触发反思
  737. 通过 force_side_branch="reflection" 触发侧分支多轮 agent 模式,
  738. LLM 可以调用工具(如 knowledge_search, knowledge_save)进行多轮推理。
  739. 反思消息标记为侧分支(branch_type="reflection"),不在主路径上。
  740. """
  741. runner = _get_runner()
  742. if not runner.trace_store:
  743. raise HTTPException(status_code=503, detail="TraceStore not configured")
  744. # 验证 trace 存在
  745. trace = await runner.trace_store.get_trace(trace_id)
  746. if not trace:
  747. raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
  748. runner, config = await _restore_runner_and_config(
  749. trace=trace,
  750. base_runner=runner,
  751. force_side_branch=["reflection"],
  752. )
  753. # 检查是否仍在运行
  754. if trace_id in _running_tasks and not _running_tasks[trace_id].done():
  755. raise HTTPException(status_code=409, detail="Cannot reflect on a running trace. Stop it first.")
  756. # 如果有 focus,可以通过追加消息传递(可选)
  757. messages = []
  758. if req.focus:
  759. messages = [{"role": "user", "content": f"反思重点:{req.focus}"}]
  760. # 启动反思任务(后台执行)
  761. task = asyncio.create_task(_run_trace_background(runner, messages, config))
  762. _running_tasks[trace_id] = task
  763. return ReflectResponse(
  764. trace_id=trace_id,
  765. reflection="反思任务已启动,通过 WebSocket 监听实时更新",
  766. )
  767. @router.get("/{trace_id}/extractions", response_model=ListExtractionsResponse)
  768. async def list_extractions(trace_id: str, include_reviewed: bool = False):
  769. """列出 trace 的待审核提取条目。"""
  770. runner = _get_runner()
  771. if not runner.trace_store:
  772. raise HTTPException(status_code=503, detail="TraceStore not configured")
  773. from cyber_agent.trace.extraction_review import list_pending
  774. pendings = await list_pending(
  775. runner.trace_store, trace_id, include_reviewed=include_reviewed
  776. )
  777. return ListExtractionsResponse(
  778. trace_id=trace_id,
  779. count=len(pendings),
  780. items=[
  781. PendingExtractionModel(
  782. extraction_id=p.extraction_id,
  783. sequence=p.sequence,
  784. goal_id=p.goal_id,
  785. branch_id=p.branch_id,
  786. payload=p.payload,
  787. reviewed=p.reviewed,
  788. decision=p.decision,
  789. committed=p.committed,
  790. )
  791. for p in pendings
  792. ],
  793. )
  794. @router.post(
  795. "/{trace_id}/extractions/{extraction_id}/review",
  796. response_model=ReviewResponse,
  797. )
  798. async def review_extraction(trace_id: str, extraction_id: str, req: ReviewRequest):
  799. """对单条 pending 提交 review 决策(approve/edit/discard)。"""
  800. runner = _get_runner()
  801. if not runner.trace_store:
  802. raise HTTPException(status_code=503, detail="TraceStore not configured")
  803. trace = await runner.trace_store.get_trace(trace_id)
  804. if not trace:
  805. raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
  806. _require_mutable_trace(trace)
  807. if req.decision not in ("approve", "edit", "discard"):
  808. raise HTTPException(
  809. status_code=400,
  810. detail=f"decision must be approve/edit/discard, got {req.decision}",
  811. )
  812. if req.decision == "edit" and not req.edited_payload:
  813. raise HTTPException(
  814. status_code=400, detail="decision=edit 必须提供 edited_payload"
  815. )
  816. from cyber_agent.trace.extraction_review import review_one
  817. await review_one(
  818. runner.trace_store,
  819. trace_id,
  820. extraction_id,
  821. req.decision, # type: ignore[arg-type]
  822. edited_payload=req.edited_payload,
  823. )
  824. return ReviewResponse(
  825. trace_id=trace_id, extraction_id=extraction_id, decision=req.decision
  826. )
  827. @router.post("/{trace_id}/extractions/commit", response_model=CommitResponse)
  828. async def commit_extractions(trace_id: str):
  829. """批量把已 approved/edited 的条目上传到 KnowHub。"""
  830. runner = _get_runner()
  831. if not runner.trace_store:
  832. raise HTTPException(status_code=503, detail="TraceStore not configured")
  833. trace = await runner.trace_store.get_trace(trace_id)
  834. if not trace:
  835. raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
  836. _require_mutable_trace(trace)
  837. from cyber_agent.trace.extraction_review import commit_approved
  838. report = await commit_approved(runner.trace_store, trace_id)
  839. return CommitResponse(
  840. trace_id=trace_id,
  841. committed_count=len(report.committed),
  842. failed_count=len(report.failed),
  843. skipped_count=len(report.skipped),
  844. committed=report.committed,
  845. knowledge_ids=report.knowledge_ids,
  846. failed=report.failed,
  847. skipped=report.skipped,
  848. )
  849. @router.post("/{trace_id}/compact", response_model=CompactResponse)
  850. async def compact_trace(trace_id: str):
  851. """
  852. 压缩 Trace 的上下文 (Compact)
  853. 通过 force_side_branch="compression" 触发侧分支多轮 agent 模式,
  854. LLM 可以调用工具(如 goal)进行多轮推理。
  855. 压缩消息标记为侧分支(branch_type="compression"),不在主路径上。
  856. """
  857. runner = _get_runner()
  858. if not runner.trace_store:
  859. raise HTTPException(status_code=503, detail="TraceStore not configured")
  860. # 验证 trace 存在
  861. trace = await runner.trace_store.get_trace(trace_id)
  862. if not trace:
  863. raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
  864. runner, config = await _restore_runner_and_config(
  865. trace=trace,
  866. base_runner=runner,
  867. force_side_branch=["compression"],
  868. )
  869. # 检查是否仍在运行
  870. if trace_id in _running_tasks and not _running_tasks[trace_id].done():
  871. raise HTTPException(status_code=409, detail="Cannot compact a running trace. Stop it first.")
  872. # 启动压缩任务(后台执行)
  873. task = asyncio.create_task(_run_trace_background(runner, [], config))
  874. _running_tasks[trace_id] = task
  875. return CompactResponse(
  876. trace_id=trace_id,
  877. previous_count=0, # 无法立即获取,需通过 WebSocket 监听
  878. new_count=0,
  879. message="压缩任务已启动,通过 WebSocket 监听实时更新",
  880. )
  881. @router.get("/running", tags=["run"])
  882. async def list_running():
  883. """列出正在运行的 Trace(包含活跃状态判断)"""
  884. from datetime import datetime, timedelta
  885. runner = _get_runner()
  886. running = []
  887. for tid, task in list(_running_tasks.items()):
  888. if task.done():
  889. _running_tasks.pop(tid, None)
  890. else:
  891. # 获取trace详情,检查最后活动时间
  892. trace_info = {"trace_id": tid, "is_active": True}
  893. if runner.trace_store:
  894. try:
  895. trace = await runner.trace_store.get_trace(tid)
  896. if trace:
  897. # 判断是否真正活跃:最后活动时间在30秒内
  898. if hasattr(trace, 'last_activity_at') and trace.last_activity_at:
  899. time_since_activity = (datetime.now() - trace.last_activity_at).total_seconds()
  900. trace_info["is_active"] = time_since_activity < 30
  901. trace_info["seconds_since_activity"] = int(time_since_activity)
  902. trace_info["status"] = trace.status
  903. except Exception:
  904. pass
  905. running.append(trace_info)
  906. return {"running": running}
  907. async def reconcile_traces():
  908. """
  909. 状态对齐:启动时恢复审批门禁,再清理其他残留的 running 状态。
  910. 审批文件是这个局部状态机的持久化真相:pending 恢复等待;
  911. decided 且尚未 executing 时安全恢复执行;executing 则失败关闭。
  912. """
  913. runner = _get_runner()
  914. if not runner or not runner.trace_store:
  915. logger.warning("[Reconciliation] Runner or TraceStore not initialized, skipping.")
  916. return
  917. try:
  918. traces = await runner.trace_store.list_traces(limit=10_000)
  919. if not traces:
  920. return
  921. count = 0
  922. for trace in traces:
  923. tid = trace.trace_id
  924. if tid in _running_tasks and not _running_tasks[tid].done():
  925. continue
  926. # Recursive revision 2 是历史只读记录,启动对账不得修改
  927. # 其审批文件或 Trace.status。
  928. from cyber_agent.core.agent_mode import policy_from_context
  929. if policy_from_context(trace.context).is_read_only:
  930. continue
  931. batch = (
  932. await runner.trace_store.get_tool_approval_batch(tid)
  933. if hasattr(runner.trace_store, "get_tool_approval_batch")
  934. else None
  935. )
  936. if batch and batch.status == "pending" and trace.status in {
  937. "running", "stopped", "waiting_confirmation",
  938. }:
  939. if trace.status != "waiting_confirmation":
  940. logger.info(
  941. "[Reconciliation] Restoring pending approval %s: %s -> waiting_confirmation",
  942. tid,
  943. trace.status,
  944. )
  945. await runner.trace_store.update_trace(
  946. tid,
  947. status="waiting_confirmation",
  948. error_message=None,
  949. completed_at=None,
  950. )
  951. count += 1
  952. continue
  953. if batch and batch.status == "decided" and trace.status in {
  954. "running", "stopped", "waiting_confirmation",
  955. }:
  956. logger.info(
  957. "[Reconciliation] Resuming safely-decided approval batch for %s",
  958. tid,
  959. )
  960. restored_runner, config = await _restore_runner_and_config(
  961. trace=trace,
  962. base_runner=runner,
  963. approval_batch_id=batch.batch_id,
  964. )
  965. await restored_runner.trace_store.update_trace(
  966. tid,
  967. status="running",
  968. error_message=None,
  969. completed_at=None,
  970. )
  971. task = asyncio.create_task(
  972. _run_in_background(
  973. tid,
  974. [],
  975. config,
  976. runner_instance=restored_runner,
  977. )
  978. )
  979. _running_tasks[tid] = task
  980. count += 1
  981. continue
  982. if batch and batch.status == "executing":
  983. batch.status = "execution_unknown"
  984. batch.updated_at = datetime.now().isoformat()
  985. for call in batch.calls:
  986. if call.execution_status == "executing":
  987. call.execution_status = "execution_unknown"
  988. await runner.trace_store.replace_tool_approval_batch(tid, batch)
  989. await runner.trace_store.update_trace(
  990. tid,
  991. status="failed",
  992. error_message=(
  993. "Tool execution outcome is unknown after process restart; "
  994. "automatic retry was refused"
  995. ),
  996. )
  997. count += 1
  998. continue
  999. if trace.status == "running":
  1000. logger.info(f"[Reconciliation] Fixing trace {tid}: running -> stopped")
  1001. await runner.trace_store.update_trace(
  1002. tid,
  1003. status="stopped",
  1004. result_summary="[Reconciliation] 任务由于服务重启或异常中断已自动停止。"
  1005. )
  1006. count += 1
  1007. if count > 0:
  1008. logger.info(f"[Reconciliation] Successfully reconciled {count} traces.")
  1009. except Exception as e:
  1010. logger.exception("[Reconciliation] Failed to reconcile traces: %s", e)
  1011. raise
  1012. # ===== 经验 API =====
  1013. @experiences_router.get("/experiences")
  1014. async def list_experiences():
  1015. """读取经验文件内容"""
  1016. runner = _get_runner()
  1017. experiences_path = getattr(runner, "experiences_path", "./.cache/experiences.md")
  1018. if not experiences_path or not os.path.exists(experiences_path):
  1019. return {"content": "", "path": experiences_path}
  1020. with open(experiences_path, "r", encoding="utf-8") as f:
  1021. content = f.read()
  1022. return {"content": content, "path": experiences_path}