| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196 |
- """
- Trace 控制 API:新建、续跑、回溯、停止与反思。
- 路由将请求转为 RunConfig 并在后台驱动 AgentRunner,客户端通过 WebSocket 读取事件。
- Recursive 的根完成标准和子树停止也在这一 API 边界传递给实际 Runner。
- 端点:
- POST /api/traces — 新建 Trace 并执行
- POST /api/traces/{id}/run — 运行(统一续跑 + 回溯)
- POST /api/traces/{id}/stop — 停止运行中的 Trace
- POST /api/traces/{id}/reflect — 反思,在 trace 末尾追加反思 prompt 运行,结果追加到 experiences 文件
- GET /api/traces/running — 列出正在运行的 Trace
- GET /api/experiences — 读取经验文件内容
- """
- import asyncio
- import logging
- import re
- import uuid
- import os
- from datetime import datetime
- from typing import Any, Dict, List, Optional
- from fastapi import APIRouter, HTTPException
- from pydantic import BaseModel, Field, model_validator
- from cyber_agent.core.task_protocol import RootTaskAnchor
- logger = logging.getLogger(__name__)
- router = APIRouter(prefix="/api/traces", tags=["run"])
- # 经验 API 使用独立 prefix
- experiences_router = APIRouter(prefix="/api", tags=["experiences"])
- # ===== 全局 Runner(由 api_server.py 注入)=====
- _runner = None
- def set_runner(runner):
- """注入 AgentRunner 实例"""
- global _runner
- _runner = runner
- def _get_runner():
- if _runner is None:
- raise HTTPException(
- status_code=503,
- detail="AgentRunner not configured. Server is in read-only mode.",
- )
- return _runner
- # ===== Request / Response 模型 =====
- class CreateRequest(BaseModel):
- """新建执行"""
- messages: List[Dict[str, Any]] = Field(
- ...,
- description="OpenAI SDK 格式的输入消息。可包含 system + user 消息;若无 system 消息则从 skills 自动构建",
- )
- model: Optional[str] = Field(None, description="模型名称;省略时采用项目/框架默认值")
- temperature: Optional[float] = Field(None)
- max_iterations: Optional[int] = Field(None, gt=0)
- tools: Optional[List[str]] = Field(None, description="工具白名单(None = 全部)")
- name: Optional[str] = Field(None, description="任务名称(None = 自动生成)")
- uid: Optional[str] = Field(None)
- project_name: Optional[str] = Field(None, description="示例项目名称,若提供则动态加载其执行环境")
- root_task_anchor: Optional[RootTaskAnchor] = Field(
- None,
- description="Recursive 根任务必须显式提供的不可变目标、完成标准和硬约束;Legacy 忽略",
- )
- @model_validator(mode="before")
- @classmethod
- def reject_removed_root_criteria(cls, value):
- if isinstance(value, dict) and "root_completion_criteria" in value:
- raise ValueError(
- "root_completion_criteria has been removed; use root_task_anchor"
- )
- return value
- class TraceRunRequest(BaseModel):
- """运行(统一续跑 + 回溯)"""
- messages: List[Dict[str, Any]] = Field(
- default_factory=list,
- description="追加的新消息(可为空,用于重新生成场景)",
- )
- after_message_id: Optional[str] = Field(
- None,
- description="从哪条消息后续跑。None = 从末尾续跑,message_id = 从该消息后运行(自动判断续跑/回溯)",
- )
- class ReflectRequest(BaseModel):
- """反思请求"""
- focus: Optional[str] = Field(None, description="反思重点(可选)")
- class RunResponse(BaseModel):
- """操作响应(立即返回,后台执行)"""
- trace_id: str
- status: str = "started"
- message: str = ""
- class StopResponse(BaseModel):
- """停止响应"""
- trace_id: str
- status: str # "stopping" | "not_running"
- class ReflectResponse(BaseModel):
- """反思响应"""
- trace_id: str
- reflection: str
- class CompactResponse(BaseModel):
- """压缩响应"""
- trace_id: str
- previous_count: int
- new_count: int
- message: str = ""
- class ToolApprovalDecision(BaseModel):
- tool_call_id: str = Field(min_length=1)
- decision: str = Field(description="approve / reject")
- edited_arguments: Optional[Dict[str, Any]] = None
- class DecideToolApprovalRequest(BaseModel):
- decisions: List[ToolApprovalDecision] = Field(min_length=1)
- # ===== 提取审核(见 cyber_agent/docs/memory.md 第三节) =====
- class PendingExtractionModel(BaseModel):
- extraction_id: str
- sequence: Optional[int] = None
- goal_id: Optional[str] = None
- branch_id: Optional[str] = None
- payload: Dict[str, Any]
- reviewed: bool = False
- decision: Optional[str] = None
- committed: bool = False
- class ListExtractionsResponse(BaseModel):
- trace_id: str
- count: int
- items: List[PendingExtractionModel]
- class ReviewRequest(BaseModel):
- decision: str = Field(..., description="approve / edit / discard")
- edited_payload: Optional[Dict[str, Any]] = Field(
- None, description="decision=edit 时必填;只对本次 review 生效"
- )
- class ReviewResponse(BaseModel):
- trace_id: str
- extraction_id: str
- decision: str
- class CommitResponse(BaseModel):
- trace_id: str
- committed_count: int
- failed_count: int
- skipped_count: int
- committed: List[str]
- knowledge_ids: List[str]
- failed: List[Dict[str, str]]
- skipped: List[str]
- # ===== 后台执行 =====
- _running_tasks: Dict[str, asyncio.Task] = {}
- # 记住每个运行中 Trace 真正使用的 Runner,避免 Example 专属 Runner 的续跑/停止误落到全局 Runner。
- # 映射只覆盖当前进程活跃任务,由两个后台执行入口按对象身份清理。
- _running_runners: Dict[str, Any] = {}
- _approval_locks: Dict[str, asyncio.Lock] = {}
- def _require_mutable_trace(trace) -> None:
- """将核心协议的只读门禁统一映射为 HTTP 409。"""
- from cyber_agent.core.agent_mode import (
- RECURSIVE_REVISION_READ_ONLY_ERROR,
- require_mutable_trace_policy,
- )
- try:
- require_mutable_trace_policy(trace.context)
- except ValueError as exc:
- if str(exc) == RECURSIVE_REVISION_READ_ONLY_ERROR:
- raise HTTPException(status_code=409, detail=str(exc)) from exc
- raise
- async def _cancel_and_wait_for_running_task(trace_id: str) -> None:
- """等待旧后台运行完成 finally 清理,再允许同一 Trace 续跑。"""
- old_task = _running_tasks.get(trace_id)
- if not old_task:
- return
- old_task.cancel()
- try:
- await old_task
- except asyncio.CancelledError:
- pass
- if _running_tasks.get(trace_id) is old_task:
- _running_tasks.pop(trace_id, None)
- async def _restore_runner_and_config(
- *,
- trace,
- base_runner,
- after_sequence: Optional[int] = None,
- force_side_branch: Optional[List[str]] = None,
- approval_batch_id: Optional[str] = None,
- ):
- """Restore the persisted run contract and its trusted project environment."""
- import importlib
- from cyber_agent.core.agent_mode import (
- AgentMode,
- RECURSIVE_REVISION_READ_ONLY_ERROR,
- policy_from_context,
- require_mutable_trace_policy,
- )
- from cyber_agent.core.run_snapshot import (
- RUN_CONFIG_SNAPSHOT_CONTEXT_KEY,
- RunConfigSnapshotV1,
- RunConfigSnapshotError,
- load_run_config_snapshot,
- persist_run_config_snapshot,
- )
- from cyber_agent.core.runner import RunConfig
- try:
- require_mutable_trace_policy(trace.context)
- except ValueError as exc:
- if str(exc) == RECURSIVE_REVISION_READ_ONLY_ERROR:
- raise HTTPException(status_code=409, detail=str(exc)) from exc
- raise
- config = RunConfig(
- trace_id=trace.trace_id,
- after_sequence=after_sequence,
- force_side_branch=force_side_branch,
- approval_batch_id=approval_batch_id,
- )
- project_name = None
- if RUN_CONFIG_SNAPSHOT_CONTEXT_KEY in (trace.context or {}):
- try:
- snapshot = load_run_config_snapshot(trace.context)
- except RunConfigSnapshotError as exc:
- raise HTTPException(status_code=409, detail=str(exc)) from exc
- config.apply_snapshot(snapshot)
- # apply_snapshot intentionally leaves invocation-only controls untouched.
- config.trace_id = trace.trace_id
- config.after_sequence = after_sequence
- config.force_side_branch = force_side_branch
- config.approval_batch_id = approval_batch_id
- project_name = snapshot.project_name
- elif policy_from_context(trace.context).mode is AgentMode.RECURSIVE:
- raise HTTPException(
- status_code=409,
- detail="This Recursive trace predates RunConfig snapshots; create a new trace",
- )
- else:
- # Infer and persist the Legacy contract before selecting its Runner.
- # Otherwise an old project Trace would resume once on the global
- # Runner and only learn its project_name too late inside AgentRunner.
- inferred = RunConfig(
- model=trace.model or "gpt-4o",
- temperature=float((trace.llm_params or {}).get("temperature", 0.3)),
- tools=[
- item.get("function", {}).get("name")
- for item in (trace.tools or [])
- if item.get("function", {}).get("name")
- ],
- tool_groups=None,
- agent_type=trace.agent_type or "default",
- uid=trace.uid,
- extra_llm_params={
- key: value
- for key, value in (trace.llm_params or {}).items()
- if key != "temperature"
- },
- context=(
- {"project_name": trace.context.get("project_name")}
- if trace.context.get("project_name")
- else {}
- ),
- )
- snapshot = RunConfigSnapshotV1.from_run_config(
- inferred,
- memory_identity=None,
- legacy_inferred=True,
- )
- persist_run_config_snapshot(trace.context, snapshot)
- if not base_runner.trace_store:
- raise HTTPException(status_code=503, detail="TraceStore not configured")
- await base_runner.trace_store.update_trace(
- trace.trace_id,
- context=trace.context,
- )
- config.apply_snapshot(snapshot)
- config.trace_id = trace.trace_id
- config.after_sequence = after_sequence
- config.force_side_branch = force_side_branch
- config.approval_batch_id = approval_batch_id
- project_name = snapshot.project_name
- runner = base_runner
- if project_name:
- if not re.fullmatch(r"[A-Za-z0-9_]+", project_name):
- raise HTTPException(status_code=409, detail="Invalid persisted project_name")
- module_name = f"examples.{project_name}.run"
- try:
- example_module = importlib.import_module(module_name)
- if hasattr(example_module, "init_project_env"):
- project_runner, _project_msgs, _default_config = (
- await example_module.init_project_env()
- )
- runner = project_runner
- except ImportError as exc:
- if getattr(exc, "name", None) == module_name:
- logger.warning(
- "Project %s has no custom run.py; using the default runner",
- project_name,
- )
- else:
- raise HTTPException(
- status_code=409,
- detail=f"Failed to restore project environment: {project_name}",
- ) from exc
- except Exception as exc:
- logger.exception("Failed to restore project %s", project_name)
- raise HTTPException(
- status_code=409,
- detail=f"Failed to restore project environment: {project_name}",
- ) from exc
- return runner, config
- async def _run_in_background(trace_id: str, messages: List[Dict], config, runner_instance=None):
- """后台执行已知 Trace ID 的 Agent,消费 run() 的所有 yield。
- run_trace() 续跑/回溯时调用,并登记实际 Runner 供 stop_trace() 命中 Recursive 子树。"""
- runner = runner_instance or _get_runner()
- current_task = asyncio.current_task()
- if current_task:
- _running_tasks[trace_id] = current_task
- _running_runners[trace_id] = runner
- try:
- async for _item in runner.run(messages=messages, config=config):
- pass # WebSocket 广播由 runner 内部的 store 事件驱动
- except Exception as e:
- logger.error(f"Background run failed for {trace_id}: {e}")
- finally:
- if _running_tasks.get(trace_id) is current_task:
- _running_tasks.pop(trace_id, None)
- if _running_runners.get(trace_id) is runner:
- _running_runners.pop(trace_id, None)
- async def _run_with_trace_signal(
- messages: List[Dict], config, trace_id_future: asyncio.Future, runner_instance=None
- ):
- """后台新建 Agent,通过 Future 将首个 Trace 对象的 ID 传回 API。
- create_and_run() 调用它,同时登记实际 Runner,使后续停止不会丢失 Example 专属运行环境。"""
- from cyber_agent.trace.models import Trace
- runner = runner_instance or _get_runner()
- trace_id: Optional[str] = None
- current_task = asyncio.current_task()
- try:
- async for item in runner.run(messages=messages, config=config):
- if isinstance(item, Trace) and not trace_id_future.done():
- trace_id = item.trace_id
- if current_task:
- _running_tasks[trace_id] = current_task
- _running_runners[trace_id] = runner
- trace_id_future.set_result(trace_id)
- except Exception as e:
- if not trace_id_future.done():
- trace_id_future.set_exception(e)
- logger.error(f"Background run failed: {e}")
- finally:
- if trace_id:
- if _running_tasks.get(trace_id) is current_task:
- _running_tasks.pop(trace_id, None)
- if _running_runners.get(trace_id) is runner:
- _running_runners.pop(trace_id, None)
- # ===== 路由 =====
- @router.post("", response_model=RunResponse)
- async def create_and_run(req: CreateRequest):
- """
- 新建 Trace 并开始执行,将 HTTP 参数和 Example 默认值合并为 RunConfig。
- Recursive 的 root_task_anchor 从此传入 Runner 完成门禁;获取 trace_id 后立即返回,执行继续留在后台。
- """
- import importlib
- from dataclasses import replace
- from cyber_agent.core.runner import RunConfig
- runner = None
- config = None
- messages = req.messages
- if req.project_name:
- try:
- # 动态加载对应 example 的 run.py
- module_name = f"examples.{req.project_name}.run"
- example_module = importlib.import_module(module_name)
- if hasattr(example_module, "init_project_env"):
- # 获取该 example 专属的 runner, 带上下文 messages, 以及默认 config
- runner, example_messages, default_config = await example_module.init_project_env(req.messages)
- messages = example_messages
-
- # Preserve every project default, then apply only fields explicitly
- # supplied by the request before the Runner snapshots the result.
- config = replace(default_config)
- if req.model is not None:
- config.model = req.model
- if req.temperature is not None:
- config.temperature = req.temperature
- if req.max_iterations is not None:
- config.max_iterations = req.max_iterations
- if req.tools is not None:
- config.tools = list(req.tools)
- if req.name is not None:
- config.name = req.name
- if req.uid is not None:
- config.uid = req.uid
- config.root_task_anchor = req.root_task_anchor
- config.context = {
- **(default_config.context or {}),
- "project_name": req.project_name,
- }
- except ImportError as e:
- if getattr(e, "name", None) == module_name:
- logger.warning(f"Project '{req.project_name}' has no custom run.py, falling back to default.")
- else:
- import traceback
- logger.error(f"Error INSIDE {module_name}:\n{traceback.format_exc()}")
- except Exception as e:
- import traceback
- logger.error(f"Unexpected error loading project environment for {req.project_name}:\n{traceback.format_exc()}")
-
- if not runner:
- _get_runner() # 验证全局默认 Runner 已配置
- config = RunConfig(
- model=req.model or "gpt-4o",
- temperature=req.temperature if req.temperature is not None else 0.3,
- max_iterations=req.max_iterations or 200,
- tools=req.tools,
- name=req.name,
- uid=req.uid,
- root_task_anchor=req.root_task_anchor,
- context={"project_name": req.project_name} if req.project_name else {}
- )
- # 启动后台执行,通过 Future 等待 trace_id(Phase 1 完成后即返回)
- trace_id_future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
- task = asyncio.create_task(
- _run_with_trace_signal(messages, config, trace_id_future, runner_instance=runner)
- )
- trace_id = await trace_id_future
- return RunResponse(
- trace_id=trace_id,
- status="started",
- message=f"Execution started. Watch via WebSocket: /api/traces/{trace_id}/watch",
- )
- async def _cleanup_incomplete_tool_calls(store, trace_id: str, after_sequence: int) -> int:
- """
- 找到安全的插入点,保证不会把新消息插在一个不完整的工具调用序列中间。
- 场景:
- 1. after_sequence 刚好是一条带 tool_calls 的 assistant 消息,
- 但其部分/全部 tool response 还没生成 → 回退到该 assistant 之前。
- 2. after_sequence 是某条 tool response,但同一批 tool_calls 中
- 还有其他 response 未生成 → 回退到该 assistant 之前。
- 核心逻辑:从 after_sequence 往前找,定位到包含它的那条 assistant 消息,
- 检查该 assistant 的所有 tool_calls 是否都有对应的 tool response。
- 如果不完整,就把截断点回退到该 assistant 消息之前(即其 parent_sequence)。
- Args:
- store: TraceStore
- trace_id: Trace ID
- after_sequence: 用户指定的插入位置
- Returns:
- 调整后的安全截断点(<= after_sequence)
- """
- all_messages = await store.get_trace_messages(trace_id)
- if not all_messages:
- return after_sequence
- by_seq = {msg.sequence: msg for msg in all_messages}
- target = by_seq.get(after_sequence)
- if target is None:
- return after_sequence
- # 找到"所属的 assistant 消息":
- # - 如果 target 本身是 assistant → 就是它
- # - 如果 target 是 tool → 沿 parent_sequence 往上找 assistant
- assistant_msg = None
- if target.role == "assistant":
- assistant_msg = target
- elif target.role == "tool":
- cur = target
- while cur and cur.role == "tool":
- parent_seq = cur.parent_sequence
- cur = by_seq.get(parent_seq) if parent_seq is not None else None
- if cur and cur.role == "assistant":
- assistant_msg = cur
- if assistant_msg is None:
- return after_sequence
- # 该 assistant 是否带 tool_calls?
- content = assistant_msg.content
- if not isinstance(content, dict) or not content.get("tool_calls"):
- return after_sequence
- # 收集所有 tool_call_ids
- expected_ids = set()
- for tc in content["tool_calls"]:
- if isinstance(tc, dict) and tc.get("id"):
- expected_ids.add(tc["id"])
- if not expected_ids:
- return after_sequence
- # 查找已有的 tool responses
- found_ids = set()
- for msg in all_messages:
- if msg.role == "tool" and msg.tool_call_id in expected_ids:
- found_ids.add(msg.tool_call_id)
- missing = expected_ids - found_ids
- if not missing:
- # 全部 tool response 都在,这是一个完整的序列
- return after_sequence
- # 不完整 → 回退到 assistant 之前
- safe = assistant_msg.parent_sequence
- if safe is None:
- # assistant 已经是第一条消息,没有更早的位置
- safe = assistant_msg.sequence - 1
- logger.info(
- "检测到不完整的工具调用 (assistant seq=%d, 缺少 %d/%d tool responses),"
- "自动回退插入点:%d -> %d",
- assistant_msg.sequence, len(missing), len(expected_ids),
- after_sequence, safe,
- )
- return safe
- def _parse_sequence_from_message_id(message_id: str) -> int:
- """从 message_id 末尾解析 sequence 整数(格式:{trace_id}-{sequence:04d})"""
- try:
- return int(message_id.rsplit("-", 1)[-1])
- except (ValueError, IndexError):
- raise HTTPException(
- status_code=422,
- detail=f"Invalid after_message_id format: {message_id!r}",
- )
- @router.post("/{trace_id}/run", response_model=RunResponse)
- async def run_trace(trace_id: str, req: TraceRunRequest):
- """
- 运行已有 Trace,统一处理续跑与回溯。
- 它优先取活跃 Trace 实际 Runner,再由 AgentRunner 恢复持久化的 Legacy/Recursive 模式和协议状态。
- - after_message_id 为 null(或省略):从末尾续跑
- - after_message_id 为 message_id 字符串:从该消息后运行(Runner 自动判断续跑/回溯)
- - messages 为空 + after_message_id 有值:重新生成(从该位置重跑,不插入新消息)
- **自动清理不完整工具调用**:
- 如果人工插入 message 的位置打断了一个工具调用过程(assistant 消息有 tool_calls
- 但缺少对应的 tool responses),框架会自动检测并调整插入位置,确保不会产生不一致的状态。
- """
- runner = _running_runners.get(trace_id) or _get_runner()
- # 将 message_id 转换为内部使用的 sequence 整数
- after_sequence: Optional[int] = None
- if req.after_message_id is not None:
- after_sequence = _parse_sequence_from_message_id(req.after_message_id)
- if not runner.trace_store:
- raise HTTPException(status_code=503, detail="TraceStore not configured")
- trace = await runner.trace_store.get_trace(trace_id)
- if not trace:
- raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
- # Restore the immutable run contract and project environment before any
- # status/rewind decision. Status checks must use the same Runner that will
- # actually resume the Trace.
- runner, config = await _restore_runner_and_config(
- trace=trace,
- base_runner=runner,
- after_sequence=after_sequence,
- )
- # 验证 trace 状态
- if runner.trace_store:
- if trace.status == "waiting_confirmation":
- raise HTTPException(
- status_code=409,
- detail="Trace is waiting for tool approval; use the tool-approvals endpoint",
- )
- # 自动检查并清理不完整的工具调用
- if after_sequence is not None and req.messages:
- adjusted_seq = await _cleanup_incomplete_tool_calls(
- runner.trace_store, trace_id, after_sequence
- )
- if adjusted_seq != after_sequence:
- logger.info(
- f"已自动调整插入位置:{after_sequence} -> {adjusted_seq}"
- )
- after_sequence = adjusted_seq
- # 检查是否已在运行
- if trace_id in _running_tasks and not _running_tasks[trace_id].done():
- # 竞态窗口修复:task 还没退出,但 store 里 trace 已经是 stopped/failed
- # 这发生在 cancel_event 触发后 → store 更新 → task finally 块还未执行之间
- # 此时可以安全地强制清除旧 task,允许续跑
- store_trace = None
- if runner.trace_store:
- store_trace = await runner.trace_store.get_trace(trace_id)
- if store_trace and store_trace.status in ("stopped", "failed", "completed"):
- logger.info(
- f"run_trace: task for {trace_id} not done yet but store status={store_trace.status!r}, "
- "forcing cleanup to allow resume"
- )
- await _cancel_and_wait_for_running_task(trace_id)
- else:
- raise HTTPException(status_code=409, detail="Trace is already running")
- # 恢复运行时,将状态从 stopped 改回 running,并广播状态变化
- if runner.trace_store and trace_id:
- current_trace = await runner.trace_store.get_trace(trace_id)
- if current_trace and current_trace.status == "stopped":
- await runner.trace_store.update_trace(trace_id, status="running")
- # 广播状态变化给前端
- from cyber_agent.trace.websocket import broadcast_trace_status_changed
- await broadcast_trace_status_changed(trace_id, "running")
- task = asyncio.create_task(_run_in_background(trace_id, req.messages, config, runner_instance=runner))
- _running_tasks[trace_id] = task
- mode = "rewind" if after_sequence is not None else "continue"
- return RunResponse(
- trace_id=trace_id,
- status="started",
- message=f"Run ({mode}) started. Watch via WebSocket: /api/traces/{trace_id}/watch",
- )
- @router.get("/{trace_id}/tool-approvals/pending")
- async def get_pending_tool_approval(trace_id: str):
- runner = _running_runners.get(trace_id) or _get_runner()
- if not runner.trace_store or not hasattr(
- runner.trace_store,
- "get_tool_approval_batch",
- ):
- raise HTTPException(status_code=503, detail="Tool approval store unavailable")
- trace = await runner.trace_store.get_trace(trace_id)
- if not trace:
- raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
- batch = await runner.trace_store.get_tool_approval_batch(trace_id)
- if batch is None or batch.status != "pending":
- return {"trace_id": trace_id, "batch": None}
- return {"trace_id": trace_id, "batch": batch.model_dump(mode="json")}
- @router.post("/{trace_id}/tool-approvals/{batch_id}", response_model=RunResponse)
- async def decide_tool_approval(
- trace_id: str,
- batch_id: str,
- req: DecideToolApprovalRequest,
- ):
- from cyber_agent.tools.approval import tool_argument_hash
- runner = _running_runners.get(trace_id) or _get_runner()
- if not runner.trace_store or not hasattr(
- runner.trace_store,
- "get_tool_approval_batch",
- ):
- raise HTTPException(status_code=503, detail="Tool approval store unavailable")
- lock = _approval_locks.setdefault(trace_id, asyncio.Lock())
- async with lock:
- trace = await runner.trace_store.get_trace(trace_id)
- if not trace:
- raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
- runner, config = await _restore_runner_and_config(
- trace=trace,
- base_runner=runner,
- approval_batch_id=batch_id,
- )
- if not runner.trace_store:
- raise HTTPException(status_code=503, detail="TraceStore not configured")
- trace = await runner.trace_store.get_trace(trace_id)
- if not trace:
- raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
- batch = await runner.trace_store.get_tool_approval_batch(trace_id)
- if batch is None or batch.batch_id != batch_id:
- raise HTTPException(status_code=404, detail="Tool approval batch not found")
- if batch.status != "pending" or trace.status != "waiting_confirmation":
- raise HTTPException(status_code=409, detail="Tool approval was already decided")
- if trace_id in _running_tasks and not _running_tasks[trace_id].done():
- raise HTTPException(status_code=409, detail="Trace is already running")
- decisions = {item.tool_call_id: item for item in req.decisions}
- if len(decisions) != len(req.decisions):
- raise HTTPException(status_code=422, detail="Duplicate tool_call_id decision")
- expected_ids = {call.tool_call_id for call in batch.pending_calls}
- if set(decisions) != expected_ids:
- raise HTTPException(
- status_code=422,
- detail=(
- "Decisions must cover every pending tool call exactly once; "
- f"expected={sorted(expected_ids)}"
- ),
- )
- for call in batch.pending_calls:
- decision = decisions[call.tool_call_id]
- if decision.decision not in {"approve", "reject"}:
- raise HTTPException(
- status_code=422,
- detail="decision must be approve or reject",
- )
- if decision.decision == "reject":
- if decision.edited_arguments is not None:
- raise HTTPException(
- status_code=422,
- detail="rejected calls cannot edit arguments",
- )
- call.decision = "rejected"
- else:
- edited = (
- dict(decision.edited_arguments)
- if decision.edited_arguments is not None
- else dict(call.original_arguments)
- )
- changed = {
- key
- for key in set(call.original_arguments) | set(edited)
- if call.original_arguments.get(key) != edited.get(key)
- or (key in call.original_arguments) != (key in edited)
- }
- forbidden = changed - set(call.editable_params)
- if forbidden:
- raise HTTPException(
- status_code=422,
- detail=f"Arguments are not editable: {sorted(forbidden)}",
- )
- call.effective_arguments = edited
- call.argument_hash = tool_argument_hash(
- tool_call_id=call.tool_call_id,
- tool_name=call.tool_name,
- arguments=edited,
- )
- call.decision = "approved"
- call.decided_at = datetime.now().isoformat()
- batch.status = "decided"
- batch.updated_at = datetime.now().isoformat()
- await runner.trace_store.replace_tool_approval_batch(trace_id, batch)
- await runner.trace_store.update_trace(trace_id, status="running")
- task = asyncio.create_task(
- _run_in_background(
- trace_id,
- [],
- config,
- runner_instance=runner,
- )
- )
- _running_tasks[trace_id] = task
- return RunResponse(
- trace_id=trace_id,
- status="started",
- message="Approved tool batch resume started",
- )
- @router.post("/{trace_id}/stop", response_model=StopResponse)
- async def stop_trace(trace_id: str):
- """
- 向运行中 Trace 的实际 Runner 发送协作式停止信号。
- Legacy 只停当前 Trace;Recursive 由 AgentRunner.stop() 向当前进程的活跃子孙传播。
- """
- runner = _running_runners.get(trace_id) or _get_runner()
- if runner.trace_store:
- trace = await runner.trace_store.get_trace(trace_id)
- if trace:
- _require_mutable_trace(trace)
- if trace and trace.status == "waiting_confirmation":
- batch = (
- await runner.trace_store.get_tool_approval_batch(trace_id)
- if hasattr(runner.trace_store, "get_tool_approval_batch")
- else None
- )
- if batch and batch.status == "pending":
- batch.status = "cancelled"
- batch.updated_at = datetime.now().isoformat()
- await runner.trace_store.replace_tool_approval_batch(trace_id, batch)
- await runner.trace_store.update_trace(trace_id, status="stopped")
- try:
- from cyber_agent.trace.websocket import broadcast_trace_status_changed
- await broadcast_trace_status_changed(trace_id, "stopped")
- except Exception:
- pass
- return StopResponse(trace_id=trace_id, status="stopping")
- # 通过 runner 的 stop 方法设置取消信号
- stopped = await runner.stop(trace_id)
- if not stopped:
- # 检查是否在 _running_tasks 但 runner 不知道(可能已完成)
- if trace_id in _running_tasks:
- task = _running_tasks[trace_id]
- if not task.done():
- task.cancel()
- _running_tasks.pop(trace_id, None)
- return StopResponse(trace_id=trace_id, status="stopping")
- return StopResponse(trace_id=trace_id, status="not_running")
- return StopResponse(trace_id=trace_id, status="stopping")
- @router.post("/{trace_id}/reflect", response_model=ReflectResponse)
- async def reflect_trace(trace_id: str, req: ReflectRequest):
- """
- 触发反思
- 通过 force_side_branch="reflection" 触发侧分支多轮 agent 模式,
- LLM 可以调用工具(如 knowledge_search, knowledge_save)进行多轮推理。
- 反思消息标记为侧分支(branch_type="reflection"),不在主路径上。
- """
- runner = _get_runner()
- if not runner.trace_store:
- raise HTTPException(status_code=503, detail="TraceStore not configured")
- # 验证 trace 存在
- trace = await runner.trace_store.get_trace(trace_id)
- if not trace:
- raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
- runner, config = await _restore_runner_and_config(
- trace=trace,
- base_runner=runner,
- force_side_branch=["reflection"],
- )
- # 检查是否仍在运行
- if trace_id in _running_tasks and not _running_tasks[trace_id].done():
- raise HTTPException(status_code=409, detail="Cannot reflect on a running trace. Stop it first.")
- # 如果有 focus,可以通过追加消息传递(可选)
- messages = []
- if req.focus:
- messages = [{"role": "user", "content": f"反思重点:{req.focus}"}]
- # 启动反思任务(后台执行)
- task = asyncio.create_task(_run_trace_background(runner, messages, config))
- _running_tasks[trace_id] = task
- return ReflectResponse(
- trace_id=trace_id,
- reflection="反思任务已启动,通过 WebSocket 监听实时更新",
- )
- @router.get("/{trace_id}/extractions", response_model=ListExtractionsResponse)
- async def list_extractions(trace_id: str, include_reviewed: bool = False):
- """列出 trace 的待审核提取条目。"""
- runner = _get_runner()
- if not runner.trace_store:
- raise HTTPException(status_code=503, detail="TraceStore not configured")
- from cyber_agent.trace.extraction_review import list_pending
- pendings = await list_pending(
- runner.trace_store, trace_id, include_reviewed=include_reviewed
- )
- return ListExtractionsResponse(
- trace_id=trace_id,
- count=len(pendings),
- items=[
- PendingExtractionModel(
- extraction_id=p.extraction_id,
- sequence=p.sequence,
- goal_id=p.goal_id,
- branch_id=p.branch_id,
- payload=p.payload,
- reviewed=p.reviewed,
- decision=p.decision,
- committed=p.committed,
- )
- for p in pendings
- ],
- )
- @router.post(
- "/{trace_id}/extractions/{extraction_id}/review",
- response_model=ReviewResponse,
- )
- async def review_extraction(trace_id: str, extraction_id: str, req: ReviewRequest):
- """对单条 pending 提交 review 决策(approve/edit/discard)。"""
- runner = _get_runner()
- if not runner.trace_store:
- raise HTTPException(status_code=503, detail="TraceStore not configured")
- trace = await runner.trace_store.get_trace(trace_id)
- if not trace:
- raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
- _require_mutable_trace(trace)
- if req.decision not in ("approve", "edit", "discard"):
- raise HTTPException(
- status_code=400,
- detail=f"decision must be approve/edit/discard, got {req.decision}",
- )
- if req.decision == "edit" and not req.edited_payload:
- raise HTTPException(
- status_code=400, detail="decision=edit 必须提供 edited_payload"
- )
- from cyber_agent.trace.extraction_review import review_one
- await review_one(
- runner.trace_store,
- trace_id,
- extraction_id,
- req.decision, # type: ignore[arg-type]
- edited_payload=req.edited_payload,
- )
- return ReviewResponse(
- trace_id=trace_id, extraction_id=extraction_id, decision=req.decision
- )
- @router.post("/{trace_id}/extractions/commit", response_model=CommitResponse)
- async def commit_extractions(trace_id: str):
- """批量把已 approved/edited 的条目上传到 KnowHub。"""
- runner = _get_runner()
- if not runner.trace_store:
- raise HTTPException(status_code=503, detail="TraceStore not configured")
- trace = await runner.trace_store.get_trace(trace_id)
- if not trace:
- raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
- _require_mutable_trace(trace)
- from cyber_agent.trace.extraction_review import commit_approved
- report = await commit_approved(runner.trace_store, trace_id)
- return CommitResponse(
- trace_id=trace_id,
- committed_count=len(report.committed),
- failed_count=len(report.failed),
- skipped_count=len(report.skipped),
- committed=report.committed,
- knowledge_ids=report.knowledge_ids,
- failed=report.failed,
- skipped=report.skipped,
- )
- @router.post("/{trace_id}/compact", response_model=CompactResponse)
- async def compact_trace(trace_id: str):
- """
- 压缩 Trace 的上下文 (Compact)
- 通过 force_side_branch="compression" 触发侧分支多轮 agent 模式,
- LLM 可以调用工具(如 goal)进行多轮推理。
- 压缩消息标记为侧分支(branch_type="compression"),不在主路径上。
- """
- runner = _get_runner()
- if not runner.trace_store:
- raise HTTPException(status_code=503, detail="TraceStore not configured")
- # 验证 trace 存在
- trace = await runner.trace_store.get_trace(trace_id)
- if not trace:
- raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
- runner, config = await _restore_runner_and_config(
- trace=trace,
- base_runner=runner,
- force_side_branch=["compression"],
- )
- # 检查是否仍在运行
- if trace_id in _running_tasks and not _running_tasks[trace_id].done():
- raise HTTPException(status_code=409, detail="Cannot compact a running trace. Stop it first.")
- # 启动压缩任务(后台执行)
- task = asyncio.create_task(_run_trace_background(runner, [], config))
- _running_tasks[trace_id] = task
- return CompactResponse(
- trace_id=trace_id,
- previous_count=0, # 无法立即获取,需通过 WebSocket 监听
- new_count=0,
- message="压缩任务已启动,通过 WebSocket 监听实时更新",
- )
- @router.get("/running", tags=["run"])
- async def list_running():
- """列出正在运行的 Trace(包含活跃状态判断)"""
- from datetime import datetime, timedelta
- runner = _get_runner()
- running = []
- for tid, task in list(_running_tasks.items()):
- if task.done():
- _running_tasks.pop(tid, None)
- else:
- # 获取trace详情,检查最后活动时间
- trace_info = {"trace_id": tid, "is_active": True}
- if runner.trace_store:
- try:
- trace = await runner.trace_store.get_trace(tid)
- if trace:
- # 判断是否真正活跃:最后活动时间在30秒内
- if hasattr(trace, 'last_activity_at') and trace.last_activity_at:
- time_since_activity = (datetime.now() - trace.last_activity_at).total_seconds()
- trace_info["is_active"] = time_since_activity < 30
- trace_info["seconds_since_activity"] = int(time_since_activity)
- trace_info["status"] = trace.status
- except Exception:
- pass
- running.append(trace_info)
- return {"running": running}
- async def reconcile_traces():
- """
- 状态对齐:启动时恢复审批门禁,再清理其他残留的 running 状态。
- 审批文件是这个局部状态机的持久化真相:pending 恢复等待;
- decided 且尚未 executing 时安全恢复执行;executing 则失败关闭。
- """
- runner = _get_runner()
- if not runner or not runner.trace_store:
- logger.warning("[Reconciliation] Runner or TraceStore not initialized, skipping.")
- return
- try:
- traces = await runner.trace_store.list_traces(limit=10_000)
- if not traces:
- return
- count = 0
- for trace in traces:
- tid = trace.trace_id
- if tid in _running_tasks and not _running_tasks[tid].done():
- continue
- # Recursive revision 2 是历史只读记录,启动对账不得修改
- # 其审批文件或 Trace.status。
- from cyber_agent.core.agent_mode import policy_from_context
- if policy_from_context(trace.context).is_read_only:
- continue
- batch = (
- await runner.trace_store.get_tool_approval_batch(tid)
- if hasattr(runner.trace_store, "get_tool_approval_batch")
- else None
- )
- if batch and batch.status == "pending" and trace.status in {
- "running", "stopped", "waiting_confirmation",
- }:
- if trace.status != "waiting_confirmation":
- logger.info(
- "[Reconciliation] Restoring pending approval %s: %s -> waiting_confirmation",
- tid,
- trace.status,
- )
- await runner.trace_store.update_trace(
- tid,
- status="waiting_confirmation",
- error_message=None,
- completed_at=None,
- )
- count += 1
- continue
- if batch and batch.status == "decided" and trace.status in {
- "running", "stopped", "waiting_confirmation",
- }:
- logger.info(
- "[Reconciliation] Resuming safely-decided approval batch for %s",
- tid,
- )
- restored_runner, config = await _restore_runner_and_config(
- trace=trace,
- base_runner=runner,
- approval_batch_id=batch.batch_id,
- )
- await restored_runner.trace_store.update_trace(
- tid,
- status="running",
- error_message=None,
- completed_at=None,
- )
- task = asyncio.create_task(
- _run_in_background(
- tid,
- [],
- config,
- runner_instance=restored_runner,
- )
- )
- _running_tasks[tid] = task
- count += 1
- continue
- if batch and batch.status == "executing":
- batch.status = "execution_unknown"
- batch.updated_at = datetime.now().isoformat()
- for call in batch.calls:
- if call.execution_status == "executing":
- call.execution_status = "execution_unknown"
- await runner.trace_store.replace_tool_approval_batch(tid, batch)
- await runner.trace_store.update_trace(
- tid,
- status="failed",
- error_message=(
- "Tool execution outcome is unknown after process restart; "
- "automatic retry was refused"
- ),
- )
- count += 1
- continue
- if trace.status == "running":
- logger.info(f"[Reconciliation] Fixing trace {tid}: running -> stopped")
- await runner.trace_store.update_trace(
- tid,
- status="stopped",
- result_summary="[Reconciliation] 任务由于服务重启或异常中断已自动停止。"
- )
- count += 1
-
- if count > 0:
- logger.info(f"[Reconciliation] Successfully reconciled {count} traces.")
- except Exception as e:
- logger.exception("[Reconciliation] Failed to reconcile traces: %s", e)
- raise
- # ===== 经验 API =====
- @experiences_router.get("/experiences")
- async def list_experiences():
- """读取经验文件内容"""
- runner = _get_runner()
- experiences_path = getattr(runner, "experiences_path", "./.cache/experiences.md")
- if not experiences_path or not os.path.exists(experiences_path):
- return {"content": "", "path": experiences_path}
- with open(experiences_path, "r", encoding="utf-8") as f:
- content = f.read()
- return {"content": content, "path": experiences_path}
|