run_api.py 52 KB

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