Преглед изворни кода

引入 legacy 与 recursive 双模式

SamLee пре 1 дан
родитељ
комит
28104596d4

+ 2 - 2
.env.template

@@ -5,8 +5,8 @@ BROWSER_USE_API_KEY=
 QWEN_BASE_URL=
 QWEN_API_KEY=
 
-# 本地 Sub-Agent 递归(默认关闭;开启后固定最多 5 层、每个父 Agent 最多 6 个直接孩子)
-AGENT_RECURSION_ENABLED=false
+# Sub-Agent 模式:legacy 保持一层旧行为;recursive 开启五层递归和六孩子配额
+AGENT_MODE=legacy
 
 # sonnet模型
 OPEN_ROUTER_API_KEY=

+ 112 - 0
cyber_agent/core/agent_mode.py

@@ -0,0 +1,112 @@
+"""Agent delegation mode policy.
+
+The environment selects a mode only when a root Trace is created.  The chosen
+mode is then persisted on the Trace and inherited by every local descendant so
+that a running tree cannot change behaviour after a process configuration
+change.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+import os
+from typing import Any, Mapping, MutableMapping
+
+
+AGENT_MODE_ENV = "AGENT_MODE"
+REMOVED_RECURSION_ENV = "AGENT_RECURSION_ENABLED"
+AGENT_MODE_CONTEXT_KEY = "agent_mode"
+AGENT_MODE_REVISION_CONTEXT_KEY = "agent_mode_revision"
+
+
+class AgentMode(str, Enum):
+    LEGACY = "legacy"
+    RECURSIVE = "recursive"
+
+
+@dataclass(frozen=True)
+class AgentPolicy:
+    mode: AgentMode
+    revision: int
+    max_depth: int
+    max_children_per_parent: int | None
+    accumulate_sub_trace_ids: bool
+    show_sub_trace_shortcuts: bool
+
+    @property
+    def requires_task_protocol(self) -> bool:
+        return self.mode is AgentMode.RECURSIVE and self.revision >= 2
+
+    def to_context(self) -> dict[str, Any]:
+        return {
+            AGENT_MODE_CONTEXT_KEY: self.mode.value,
+            AGENT_MODE_REVISION_CONTEXT_KEY: self.revision,
+        }
+
+
+def _policy(mode: AgentMode, revision: int = 1) -> AgentPolicy:
+    if mode is AgentMode.LEGACY:
+        return AgentPolicy(
+            mode=mode,
+            revision=1,
+            max_depth=1,
+            max_children_per_parent=None,
+            accumulate_sub_trace_ids=False,
+            show_sub_trace_shortcuts=False,
+        )
+    return AgentPolicy(
+        mode=mode,
+        revision=revision,
+        max_depth=5,
+        max_children_per_parent=6,
+        accumulate_sub_trace_ids=True,
+        show_sub_trace_shortcuts=True,
+    )
+
+
+def assert_removed_config_absent() -> None:
+    if REMOVED_RECURSION_ENV in os.environ:
+        raise ValueError(
+            f"{REMOVED_RECURSION_ENV} has been removed; "
+            f"set {AGENT_MODE_ENV}=legacy or {AGENT_MODE_ENV}=recursive instead"
+        )
+
+
+def policy_from_environment(*, recursive_revision: int = 1) -> AgentPolicy:
+    """Resolve the policy for a newly-created root Trace."""
+    assert_removed_config_absent()
+    raw_mode = os.getenv(AGENT_MODE_ENV, AgentMode.LEGACY.value).strip().lower()
+    try:
+        mode = AgentMode(raw_mode)
+    except ValueError as exc:
+        allowed = ", ".join(mode.value for mode in AgentMode)
+        raise ValueError(
+            f"Invalid {AGENT_MODE_ENV}={raw_mode!r}; expected one of: {allowed}"
+        ) from exc
+    revision = recursive_revision if mode is AgentMode.RECURSIVE else 1
+    return _policy(mode, revision)
+
+
+def policy_from_context(context: Mapping[str, Any] | None) -> AgentPolicy:
+    """Resolve a persisted Trace policy; missing metadata is legacy."""
+    context = context or {}
+    raw_mode = context.get(AGENT_MODE_CONTEXT_KEY, AgentMode.LEGACY.value)
+    try:
+        mode = AgentMode(str(raw_mode).strip().lower())
+    except ValueError as exc:
+        raise ValueError(f"Invalid persisted agent mode: {raw_mode!r}") from exc
+
+    raw_revision = context.get(AGENT_MODE_REVISION_CONTEXT_KEY, 1)
+    if not isinstance(raw_revision, int) or raw_revision < 1:
+        raise ValueError(f"Invalid persisted agent mode revision: {raw_revision!r}")
+    return _policy(mode, raw_revision)
+
+
+def apply_policy_to_context(
+    context: MutableMapping[str, Any] | None,
+    policy: AgentPolicy,
+) -> dict[str, Any]:
+    merged = dict(context or {})
+    merged.update(policy.to_context())
+    return merged

+ 28 - 1
cyber_agent/core/runner.py

@@ -38,6 +38,13 @@ from cyber_agent.skill.skill_loader import load_skills_from_dir
 from cyber_agent.tools import ToolRegistry, get_tool_registry
 from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
 from cyber_agent.core.memory import MemoryConfig
+from cyber_agent.core.agent_mode import (
+    AGENT_MODE_CONTEXT_KEY,
+    apply_policy_to_context,
+    assert_removed_config_absent,
+    policy_from_context,
+    policy_from_environment,
+)
 from cyber_agent.core.prompts import (
     DEFAULT_SYSTEM_PREFIX,
     TRUNCATION_HINT,
@@ -498,6 +505,7 @@ class AgentRunner:
         Returns:
             (trace, goal_tree, next_sequence)
         """
+        assert_removed_config_absent()
         if config.trace_id:
             return await self._prepare_existing_trace(config)
         else:
@@ -509,6 +517,8 @@ class AgentRunner:
         config: RunConfig,
     ) -> Tuple[Trace, Optional[GoalTree], int]:
         """创建新 Trace"""
+        # 在任何标题生成/LLM 调用前完成模式校验。
+        policy = policy_from_environment()
         trace_id = str(uuid.uuid4())
 
         # 生成任务名称
@@ -517,6 +527,10 @@ class AgentRunner:
         # 准备工具 Schema
         tool_schemas = self._get_tool_schemas(config.tools, config.tool_groups, config.exclude_tools)
 
+        trace_context = apply_policy_to_context(config.context, policy)
+        trace_context.setdefault("agent_depth", 0)
+        trace_context.setdefault("root_trace_id", trace_id)
+
         trace_obj = Trace(
             trace_id=trace_id,
             mode="agent",
@@ -528,7 +542,7 @@ class AgentRunner:
             model=config.model,
             tools=tool_schemas,
             llm_params={"temperature": config.temperature, **config.extra_llm_params},
-            context=config.context,
+            context=trace_context,
             status="running",
         )
 
@@ -552,6 +566,19 @@ class AgentRunner:
         if not trace_obj:
             raise ValueError(f"Trace not found: {config.trace_id}")
 
+        # Historical traces predate AGENT_MODE.  They resume in safe Legacy mode
+        # and the choice is persisted immediately; environment changes never
+        # mutate an existing tree.
+        if AGENT_MODE_CONTEXT_KEY not in trace_obj.context:
+            legacy_policy = policy_from_context(None)
+            trace_obj.context = apply_policy_to_context(trace_obj.context, legacy_policy)
+            await self.trace_store.update_trace(
+                config.trace_id,
+                context=trace_obj.context,
+            )
+        else:
+            policy_from_context(trace_obj.context)  # validate persisted metadata
+
         goal_tree = await self.trace_store.get_goal_tree(config.trace_id)
         if goal_tree is None:
             # 防御性兜底:trace 存在但 goal.json 丢失时,创建空树

+ 21 - 13
cyber_agent/docs/tools.md

@@ -941,8 +941,8 @@ async def agent(
 
 | task 类型 | 模式 | 并行执行 | 工具权限 |
 |-----------|------|---------|---------|
-| `str`(单任务) | delegate | ❌ | 完整(递归关闭时除 agent/evaluate;递归开启时按深度开放 agent) |
-| `List[str]`(多任务) | explore | ✅ | 沿用现有 Sub-Agent 工具集,并按递归深度控制 `agent` |
+| `str`(单任务) | delegate | ❌ | 按 Trace 持久化的 `AGENT_MODE` 和深度授权 |
+| `List[str]`(多任务) | explore | ✅ | 与单任务使用同一模式策略 |
 
 **messages 参数**:
 - `None`:无预置消息
@@ -954,23 +954,31 @@ async def agent(
 **单任务(delegate)**:适合委托专门任务,完整工具权限;支持 `continue_from` 续跑已有 Sub-Trace。
 **多任务(explore)**:适合对比多个方案,并行执行,不支持 `continue_from`。
 
-#### 本地 Sub-Agent 递归实验
+#### Legacy / Recursive 模式
 
-递归默认关闭。在运行环境设置以下变量后,本地 Sub-Agent
-可继续调用 `agent` 创建下一层本地 Sub-Agent:
+服务通过一个配置选择新根 Trace 的 Sub-Agent 能力,默认为 `legacy`:
 
 ```bash
-AGENT_RECURSION_ENABLED=true
+AGENT_MODE=legacy      # 或 recursive
 ```
 
-实验版限制为固定值,不由模型或 `RunConfig` 放大:
-
-- 主 Agent 是 `depth=0`;本地 Sub-Agent 最深到 `depth=5`。
-- `depth=5` 不再获得 `agent` 工具,运行时入口也会拒绝继续新建。
-- 主 Agent 和每层本地 Sub-Agent 都最多累计创建 6 个直接本地孩子。
-- 已创建后失败或停止的孩子仍占名额;`continue_from` 续跑已有孩子不占新名额。
+模式在根 Trace 创建时写入 `context.agent_mode` 和
+`context.agent_mode_revision`,所有本地子孙继承该值。环境变量后续变化不会
+改变已有 Trace;缺少模式字段的历史 Trace 按 `legacy` 续跑。
+
+- `legacy`:主 Agent 可重复创建任意数量的一级 Sub-Agent;一级
+  Sub-Agent 不获得 `agent`,运行时也拒绝创建孙 Agent。
+- `recursive`:主 Agent 是 `depth=0`,本地 Sub-Agent 最深到
+  `depth=5`;每个父 Agent 最多累计创建 6 个直接本地孩子。
+- `recursive` 的 `depth=5` 不再获得 `agent`,运行时入口也会拒绝
+  继续新建;失败或停止的已创建孩子仍占名额。
+- `continue_from` 不占新名额,但必须是当前 Trace 的直接孩子,且
+  UID、模式、revision 和深度均一致。
 - `agent_type="remote_*"` 保持现有路由行为,不计入本地深度和孩子配额。
-- `evaluate` 和 `bash_command` 不向递归 Sub-Agent 开放。
+- `evaluate` 和 `bash_command` 不向本地 Sub-Agent 开放。
+
+旧配置 `AGENT_RECURSION_ENABLED` 已删除。环境中仍存在时创建或续跑
+Trace 会直接报错,必须迁移到 `AGENT_MODE`。
 
 #### 远端模式
 

+ 179 - 63
cyber_agent/tools/builtin/subagent.py

@@ -13,6 +13,13 @@ from datetime import datetime
 from typing import Any, Dict, List, Optional, Union
 
 from cyber_agent.tools import tool
+from cyber_agent.core.agent_mode import (
+    AgentMode,
+    AgentPolicy,
+    apply_policy_to_context,
+    assert_removed_config_absent,
+    policy_from_context,
+)
 from cyber_agent.trace.models import Trace, Messages
 from cyber_agent.trace.trace_id import generate_sub_trace_id
 from cyber_agent.trace.goal_models import GoalTree
@@ -22,9 +29,6 @@ from cyber_agent.trace.websocket import broadcast_sub_trace_started, broadcast_s
 # ===== 远端路由常量 =====
 
 REMOTE_PREFIX = "remote_"
-MAX_AGENT_DEPTH = 5
-MAX_LOCAL_CHILDREN_PER_TRACE = 6
-
 # POC 阶段使用进程内锁保护“检查配额 -> 创建 Trace”。
 # 多 worker 部署时需要换成跨进程的原子配额。
 _LOCAL_AGENT_SPAWN_LOCK = asyncio.Lock()
@@ -39,13 +43,6 @@ def _remote_agent_timeout() -> float:
     return float(os.getenv("REMOTE_AGENT_TIMEOUT", "600"))
 
 
-def _recursion_enabled() -> bool:
-    """是否允许本地 Sub-Agent 继续创建 Sub-Agent。"""
-    return os.getenv("AGENT_RECURSION_ENABLED", "false").strip().lower() in {
-        "1", "true", "yes", "on",
-    }
-
-
 # 兼容旧代码对 module-level 常量的引用(运行时值 = 首次 import 时的快照)
 KNOWHUB_API = _knowhub_api()
 REMOTE_AGENT_TIMEOUT = _remote_agent_timeout()
@@ -158,27 +155,32 @@ async def _update_collaborator(
 async def _update_goal_start(
     store, trace_id: str, goal_id: str, mode: str,
     sub_trace_ids: List[Dict[str, str]],
+    *,
+    accumulate_sub_trace_ids: bool,
 ) -> None:
     """标记 Goal 开始执行"""
     if not goal_id:
         return
 
-    tree = await store.get_goal_tree(trace_id)
-    goal = tree.find(goal_id) if tree else None
-    existing_entries = goal.sub_trace_ids if goal and goal.sub_trace_ids else []
-    merged_entries: Dict[str, Dict[str, str]] = {}
-    for entry in [*existing_entries, *sub_trace_ids]:
-        if isinstance(entry, str):
-            merged_entries[entry] = {"trace_id": entry, "mission": entry}
-        elif isinstance(entry, dict) and entry.get("trace_id"):
-            merged_entries[entry["trace_id"]] = entry
+    next_entries = sub_trace_ids
+    if accumulate_sub_trace_ids:
+        tree = await store.get_goal_tree(trace_id)
+        goal = tree.find(goal_id) if tree else None
+        existing_entries = goal.sub_trace_ids if goal and goal.sub_trace_ids else []
+        merged_entries: Dict[str, Dict[str, str]] = {}
+        for entry in [*existing_entries, *sub_trace_ids]:
+            if isinstance(entry, str):
+                merged_entries[entry] = {"trace_id": entry, "mission": entry}
+            elif isinstance(entry, dict) and entry.get("trace_id"):
+                merged_entries[entry["trace_id"]] = entry
+        next_entries = list(merged_entries.values())
 
     await store.update_goal(
         trace_id, goal_id,
         type="agent_call",
         agent_call_mode=mode,
         status="in_progress",
-        sub_trace_ids=list(merged_entries.values()),
+        sub_trace_ids=next_entries,
     )
 
 
@@ -216,12 +218,16 @@ def _aggregate_stats(results: List[Dict[str, Any]]) -> Dict[str, Any]:
     }
 
 
-def _get_allowed_tools(context: dict, agent_depth: int) -> Optional[List[str]]:
+def _get_allowed_tools(
+    context: dict,
+    agent_depth: int,
+    policy: AgentPolicy,
+) -> Optional[List[str]]:
     """按子 Agent 深度生成唯一的工具权限列表。"""
     runner = context.get("runner")
     if runner and hasattr(runner, "tools") and hasattr(runner.tools, "get_tool_names"):
         blocked_tools = {"evaluate", "bash_command"}
-        if not _recursion_enabled() or agent_depth >= MAX_AGENT_DEPTH:
+        if policy.mode is AgentMode.LEGACY or agent_depth >= policy.max_depth:
             blocked_tools.add("agent")
         return [
             name for name in runner.tools.get_tool_names()
@@ -481,13 +487,17 @@ async def _run_agents(
     single (len(tasks)==1): delegate 模式,全量工具
     multi (len(tasks)>1): explore 模式,并行执行
 
-    AGENT_RECURSION_ENABLED=true 时,depth < MAX_AGENT_DEPTH 的本地
-    Sub-Agent 可继续使用 agent 工具
+    本地 Sub-Agent 的深度、直接孩子配额和工具权限由父 Trace
+    已持久化的 AgentPolicy 决定,不重新读取环境变量
     """
     single = len(tasks) == 1
     parent_trace = await store.get_trace(trace_id)
     if not parent_trace:
         return {"status": "failed", "error": f"Parent trace not found: {trace_id}"}
+    try:
+        policy = policy_from_context(parent_trace.context)
+    except ValueError as exc:
+        return {"status": "failed", "error": str(exc)}
 
     # continue_from: 复用已有 trace(仅 single)
     sub_trace_id = None
@@ -510,68 +520,82 @@ async def _run_agents(
                 "status": "failed",
                 "error": "continue_from trace owner does not match the current trace owner",
             }
+        if existing.context.get("created_by_tool") != "agent":
+            return {
+                "status": "failed",
+                "error": "continue_from must reference a child created by the agent tool",
+            }
+        try:
+            child_policy = policy_from_context(existing.context)
+        except ValueError as exc:
+            return {"status": "failed", "error": str(exc)}
+        if (
+            child_policy.mode is not policy.mode
+            or child_policy.revision != policy.revision
+        ):
+            return {
+                "status": "failed",
+                "error": "continue_from trace Agent mode does not match the current trace",
+            }
 
         sub_trace_id = continue_from
         continued = True
         goal_tree = await store.get_goal_tree(continue_from)
         mission = goal_tree.mission if goal_tree else tasks[0]
+        parent_depth, expected_root_trace_id = await _resolve_trace_lineage(
+            store, parent_trace
+        )
         child_depth, root_trace_id = await _resolve_trace_lineage(store, existing)
+        if (
+            child_depth != parent_depth + 1
+            or root_trace_id != expected_root_trace_id
+        ):
+            return {
+                "status": "failed",
+                "error": "continue_from trace lineage does not match the current trace",
+            }
+        if child_depth > policy.max_depth:
+            return {
+                "status": "failed",
+                "error": (
+                    "continue_from trace exceeds the persisted Agent mode depth: "
+                    f"depth={child_depth}, max={policy.max_depth}"
+                ),
+            }
         all_sub_trace_ids = [{"trace_id": sub_trace_id, "mission": mission}]
         child_records = [{
             "trace_id": sub_trace_id,
             "depth": child_depth,
-            "context": {
+            "context": apply_policy_to_context({
                 **existing.context,
                 "agent_depth": child_depth,
                 "root_trace_id": root_trace_id,
-            },
+            }, policy),
             "is_new": False,
         }]
     else:
         parent_depth, root_trace_id = await _resolve_trace_lineage(store, parent_trace)
-        if parent_depth > 0 and not _recursion_enabled():
-            return {
-                "status": "failed",
-                "error": "Local Sub-Agent recursion is disabled",
-            }
-        if parent_depth >= MAX_AGENT_DEPTH:
+        if parent_depth >= policy.max_depth:
             return {
                 "status": "failed",
                 "error": (
                     f"Local Sub-Agent depth limit reached: "
-                    f"depth={parent_depth}, max={MAX_AGENT_DEPTH}"
+                    f"depth={parent_depth}, max={policy.max_depth}, "
+                    f"mode={policy.mode.value}"
                 ),
             }
 
-        # 将配额检查和 Trace 创建放在同一进程内临界区,
-        # 保证两个并发 agent() 调用不会共同突破 6 个直接孩子。
-        async with _LOCAL_AGENT_SPAWN_LOCK:
-            existing_children = await store.list_traces(
-                parent_trace_id=trace_id,
-                created_by_tool="agent",
-                limit=MAX_LOCAL_CHILDREN_PER_TRACE + 1,
-            )
-            requested_children = len(tasks)
-            if len(existing_children) + requested_children > MAX_LOCAL_CHILDREN_PER_TRACE:
-                return {
-                    "status": "failed",
-                    "error": (
-                        f"Local child Agent limit exceeded: existing={len(existing_children)}, "
-                        f"requested={requested_children}, "
-                        f"max={MAX_LOCAL_CHILDREN_PER_TRACE}"
-                    ),
-                }
-
+        async def create_child_traces() -> None:
             child_depth = parent_depth + 1
             for i, task_item in enumerate(tasks):
                 resolved_agent_type = agent_type or ("delegate" if single else "explore")
                 suffix = "delegate" if single else f"explore-{i+1:03d}"
                 stid = generate_sub_trace_id(trace_id, suffix)
-                child_context = {
+                child_context = apply_policy_to_context({
                     "created_by_tool": "agent",
                     "agent_depth": child_depth,
                     "root_trace_id": root_trace_id,
-                }
+                }, policy)
                 sub_trace = Trace(
                     trace_id=stid,
                     mode="agent",
@@ -597,14 +621,40 @@ async def _run_agents(
                 })
 
             if single:
+                nonlocal sub_trace_id
                 sub_trace_id = child_records[0]["trace_id"]
 
-            await _update_goal_start(
-                store, trace_id, goal_id,
-                "delegate" if single else "explore",
-                all_sub_trace_ids,
-            )
-            goal_started = True
+        child_limit = policy.max_children_per_parent
+        if child_limit is None:
+            await create_child_traces()
+        else:
+            # Recursive 模式在单进程临界区内完成“计数 -> 创建”,
+            # 保证并发批次不会共同突破六个直接孩子。
+            async with _LOCAL_AGENT_SPAWN_LOCK:
+                existing_children = await store.list_traces(
+                    parent_trace_id=trace_id,
+                    created_by_tool="agent",
+                    limit=child_limit + 1,
+                )
+                requested_children = len(tasks)
+                if len(existing_children) + requested_children > child_limit:
+                    return {
+                        "status": "failed",
+                        "error": (
+                            "Local child Agent limit exceeded: "
+                            f"existing={len(existing_children)}, "
+                            f"requested={requested_children}, max={child_limit}"
+                        ),
+                    }
+                await create_child_traces()
+
+        await _update_goal_start(
+            store, trace_id, goal_id,
+            "delegate" if single else "explore",
+            all_sub_trace_ids,
+            accumulate_sub_trace_ids=policy.accumulate_sub_trace_ids,
+        )
+        goal_started = True
 
     # 创建执行协程。Trace 在上面已经按批次预创建,
     # 因此孩子配额不会被并发调用绕过。
@@ -632,7 +682,7 @@ async def _run_agents(
 
         # 构建消息
         agent_msgs = list(msgs) + [{"role": "user", "content": task_item}]
-        allowed_tools = _get_allowed_tools(context, child_depth)
+        allowed_tools = _get_allowed_tools(context, child_depth, policy)
 
         debug = getattr(runner, 'debug', False)
         agent_label = (agent_type or ("delegate" if single else f"explore-{i+1}"))
@@ -667,6 +717,7 @@ async def _run_agents(
             store, trace_id, goal_id,
             "delegate" if single else "explore",
             all_sub_trace_ids,
+            accumulate_sub_trace_ids=policy.accumulate_sub_trace_ids,
         )
 
     # 执行
@@ -868,6 +919,11 @@ async def agent(
                 - 远端:由服务器按 agent_type 白名单过滤(如 remote_librarian 允许 ask_strategy / upload_strategy)
         context: 框架自动注入的上下文
     """
+    try:
+        assert_removed_config_absent()
+    except ValueError as exc:
+        return {"status": "failed", "error": str(exc)}
+
     # 远端路由:agent_type 以 remote_ 开头
     if agent_type and agent_type.startswith(REMOTE_PREFIX):
         if not isinstance(task, str):
@@ -997,18 +1053,71 @@ async def evaluate(
 
     # 获取父 Trace 信息
     parent_trace = await store.get_trace(trace_id)
+    if not parent_trace:
+        return {"status": "failed", "error": f"Parent trace not found: {trace_id}"}
+    try:
+        policy = policy_from_context(parent_trace.context)
+    except ValueError as exc:
+        return {"status": "failed", "error": str(exc)}
 
     # 处理 continue_from 或创建新 Sub-Trace
     if continue_from:
         existing_trace = await store.get_trace(continue_from)
         if not existing_trace:
             return {"status": "failed", "error": f"Continue-from trace not found: {continue_from}"}
+        if existing_trace.parent_trace_id != trace_id:
+            return {
+                "status": "failed",
+                "error": "continue_from must reference a direct child of the current trace",
+            }
+        if existing_trace.uid != parent_trace.uid:
+            return {
+                "status": "failed",
+                "error": "continue_from trace owner does not match the current trace owner",
+            }
+        if existing_trace.context.get("created_by_tool") != "evaluate":
+            return {
+                "status": "failed",
+                "error": "continue_from must reference a child created by evaluate",
+            }
+        try:
+            existing_policy = policy_from_context(existing_trace.context)
+        except ValueError as exc:
+            return {"status": "failed", "error": str(exc)}
+        if (
+            existing_policy.mode is not policy.mode
+            or existing_policy.revision != policy.revision
+        ):
+            return {
+                "status": "failed",
+                "error": "continue_from trace Agent mode does not match the current trace",
+            }
+        parent_depth, expected_root_trace_id = await _resolve_trace_lineage(
+            store, parent_trace
+        )
+        child_depth, root_trace_id = await _resolve_trace_lineage(
+            store, existing_trace
+        )
+        if (
+            child_depth != parent_depth + 1
+            or root_trace_id != expected_root_trace_id
+        ):
+            return {
+                "status": "failed",
+                "error": "continue_from trace lineage does not match the current trace",
+            }
         sub_trace_id = continue_from
         goal_tree = await store.get_goal_tree(continue_from)
         mission = goal_tree.mission if goal_tree else eval_prompt
         sub_trace_ids = [{"trace_id": sub_trace_id, "mission": mission}]
     else:
         sub_trace_id = generate_sub_trace_id(trace_id, "evaluate")
+        parent_depth, root_trace_id = await _resolve_trace_lineage(store, parent_trace)
+        evaluator_context = apply_policy_to_context({
+            "created_by_tool": "evaluate",
+            "agent_depth": parent_depth + 1,
+            "root_trace_id": root_trace_id,
+        }, policy)
         sub_trace = Trace(
             trace_id=sub_trace_id,
             mode="agent",
@@ -1019,7 +1128,7 @@ async def evaluate(
             uid=parent_trace.uid if parent_trace else None,
             model=parent_trace.model if parent_trace else None,
             status="running",
-            context={"created_by_tool": "evaluate"},
+            context=evaluator_context,
             created_at=datetime.now(),
         )
         await store.create_trace(sub_trace)
@@ -1033,7 +1142,14 @@ async def evaluate(
         )
 
     # 更新主 Goal 为 in_progress
-    await _update_goal_start(store, trace_id, current_goal_id, "evaluate", sub_trace_ids)
+    await _update_goal_start(
+        store,
+        trace_id,
+        current_goal_id,
+        "evaluate",
+        sub_trace_ids,
+        accumulate_sub_trace_ids=policy.accumulate_sub_trace_ids,
+    )
 
     # 注册为活跃协作者
     eval_name = f"评估: {(goal_id or 'unknown')[:20]}"

+ 17 - 13
frontend/react-template/src/components/FlowChart/FlowChart.tsx

@@ -2,6 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImper
 import type { ForwardRefRenderFunction } from "react";
 import type { Goal } from "../../types/goal";
 import type { Edge as EdgeType, Message, MessageContent } from "../../types/message";
+import type { AgentMode } from "../../types/trace";
+import {
+  visibleSubTraceEntries,
+  type SubTraceEntry,
+} from "./subTraceEntries";
 import { ArrowMarkers } from "./components/ArrowMarkers";
 import styles from "./styles/FlowChart.module.css";
 import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
@@ -13,6 +18,7 @@ interface FlowChartProps {
   invalidBranches?: Message[][];
   onNodeClick?: (node: Goal | Message, edge?: EdgeType) => void;
   onSubTraceClick?: (parentGoal: Goal, entry: SubTraceEntry) => void;
+  agentMode?: AgentMode;
 }
 
 export interface FlowChartRef {
@@ -21,7 +27,6 @@ export interface FlowChartRef {
   scrollToSequence: (sequence: number) => void;
 }
 
-export type SubTraceEntry = { id: string; mission?: string };
 type FlowMessage = Message & { _injectedGoalId?: string };
 type NodeMarker = "agent_call" | "agent_return" | "agent_exit" | "agent_resume";
 type FoldedNodeData = { goalId: string; count: number };
@@ -74,16 +79,6 @@ const isContinueMessage = (message: Message): boolean =>
     }),
   );
 
-const normalizeSubTraceEntries = (goal: Goal): SubTraceEntry[] =>
-  (goal.sub_trace_ids ?? [])
-    .map((item) => {
-      if (typeof item === "string") return { id: item };
-      if (!item || typeof item.trace_id !== "string") return null;
-      return { id: item.trace_id, mission: item.mission };
-    })
-    .filter((entry): entry is SubTraceEntry => entry !== null && entry.id.length > 0)
-    .slice(0, 6);
-
 interface LayoutNodeBase {
   id: string;
   x: number;
@@ -113,7 +108,14 @@ interface LayoutEdge {
 }
 
 const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps> = (
-  { goals, msgGroups = {}, invalidBranches, onNodeClick, onSubTraceClick },
+  {
+    goals,
+    msgGroups = {},
+    invalidBranches,
+    onNodeClick,
+    onSubTraceClick,
+    agentMode = "legacy",
+  },
   ref,
 ) => {
   const displayGoals = useMemo(() => {
@@ -1108,7 +1110,9 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
 
                 const isGoal = node.type === "goal";
                 const data = node.data as Goal;
-                const subTraceEntries = isGoal ? normalizeSubTraceEntries(data) : [];
+                const subTraceEntries = isGoal
+                  ? visibleSubTraceEntries(data, agentMode)
+                  : [];
                 let text = isGoal ? data.description : (node.data as Message).description || "";
 
                 let thumbnail: string | null = null;

+ 24 - 4
frontend/react-template/src/components/FlowChart/hooks/useFlowChartData.ts

@@ -3,6 +3,7 @@ import { useWebSocket } from "../../../hooks/useWebSocket";
 import { request } from "../../../api/client";
 import type { Goal } from "../../../types/goal";
 import type { Message } from "../../../types/message";
+import type { AgentMode } from "../../../types/trace";
 
 // WebSocket 数据解析与状态聚合(goals + msgGroups)
 const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -51,9 +52,11 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
   const currentEventIdRef = useRef(0);
   const maxSequenceRef = useRef(0);
   const restReloadingRef = useRef(false);
+  const requestGenerationRef = useRef(0);
   const [reloading, setReloading] = useState(false);
   const [invalidBranches, setInvalidBranches] = useState<Message[][]>([]);
   const [traceCompleted, setTraceCompleted] = useState(false);
+  const [agentMode, setAgentMode] = useState<AgentMode>("legacy");
 
   const messageComparator = useCallback((a: Message, b: Message): number => {
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -89,6 +92,7 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
   );
 
   useEffect(() => {
+    requestGenerationRef.current += 1;
     setGoals([]);
     setMessages([]);
     setMsgGroups({});
@@ -98,6 +102,7 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
     maxSequenceRef.current = 0;
     restReloadingRef.current = false;
     setTraceCompleted(false);
+    setAgentMode("legacy");
   }, [traceId]);
 
   const reloadViaRest = useCallback(async () => {
@@ -105,17 +110,21 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
     if (restReloadingRef.current) return;
     restReloadingRef.current = true;
     setReloading(true);
+    const requestGeneration = requestGenerationRef.current;
     let nextSinceEventId: number | null = null;
     try {
       const [traceJson, messagesJson] = await Promise.all([
         request<unknown>(`/api/traces/${traceId}`),
         request<unknown>(`/api/traces/${traceId}/messages?mode=all`),
       ]);
+      if (requestGeneration !== requestGenerationRef.current) return null;
 
       const traceRoot = isRecord(traceJson) ? traceJson : {};
       const trace = isRecord(traceRoot.trace) ? traceRoot.trace : undefined;
       const goalTree = isRecord(traceRoot.goal_tree) ? traceRoot.goal_tree : undefined;
       const goalList = goalTree && Array.isArray(goalTree.goals) ? (goalTree.goals as Goal[]) : [];
+      const traceContext = trace && isRecord(trace.context) ? trace.context : undefined;
+      setAgentMode(traceContext?.agent_mode === "recursive" ? "recursive" : "legacy");
 
       const lastEventId = trace && typeof trace.last_event_id === "number" ? trace.last_event_id : undefined;
       if (typeof lastEventId === "number") {
@@ -179,9 +188,11 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
       // REST 请求完成后,允许建立 WebSocket 连接
       setReadyToConnect(true);
     } finally {
-      restReloadingRef.current = false;
-      setReloading(false);
-      setReadyToConnect(true);
+      if (requestGeneration === requestGenerationRef.current) {
+        restReloadingRef.current = false;
+        setReloading(false);
+        setReadyToConnect(true);
+      }
     }
     return nextSinceEventId;
   }, [messageComparator, traceId]);
@@ -408,5 +419,14 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
   // 只有当 traceId 存在且 REST 加载完成 (readyToConnect) 后才连接 WebSocket
   const { connected } = useWebSocket(readyToConnect ? traceId : null, wsOptions);
 
-  return { goals, messages, msgGroups, connected, reloading, invalidBranches, traceCompleted };
+  return {
+    goals,
+    messages,
+    msgGroups,
+    connected,
+    reloading,
+    invalidBranches,
+    traceCompleted,
+    agentMode,
+  };
 };

+ 20 - 0
frontend/react-template/src/components/FlowChart/subTraceEntries.ts

@@ -0,0 +1,20 @@
+import type { Goal } from "../../types/goal";
+import type { AgentMode } from "../../types/trace";
+
+export type SubTraceEntry = { id: string; mission?: string };
+
+export const normalizeSubTraceEntries = (goal: Goal): SubTraceEntry[] =>
+  (goal.sub_trace_ids ?? [])
+    .map((item) => {
+      if (typeof item === "string") return { id: item };
+      if (!item || typeof item.trace_id !== "string") return null;
+      return { id: item.trace_id, mission: item.mission };
+    })
+    .filter((entry): entry is SubTraceEntry => entry !== null && entry.id.length > 0)
+    .slice(0, 6);
+
+export const visibleSubTraceEntries = (
+  goal: Goal,
+  agentMode: AgentMode,
+): SubTraceEntry[] =>
+  agentMode === "recursive" ? normalizeSubTraceEntries(goal) : [];

+ 10 - 4
frontend/react-template/src/components/MainContent/MainContent.tsx

@@ -50,10 +50,15 @@ export const MainContent: FC<MainContentProps> = ({
   const [cachedGoals, setCachedGoals] = useState<Goal[]>([]);
   const [cachedMsgGroups, setCachedMsgGroups] = useState<Record<string, Message[]>>({});
   const [cachedInvalidBranches, setCachedInvalidBranches] = useState<Message[][]>([]);
-  const { goals, connected, msgGroups, reloading, invalidBranches, traceCompleted } = useFlowChartData(
-    traceId,
-    messageRefreshTrigger,
-  );
+  const {
+    goals,
+    connected,
+    msgGroups,
+    reloading,
+    invalidBranches,
+    traceCompleted,
+    agentMode,
+  } = useFlowChartData(traceId, messageRefreshTrigger);
   console.log("%c [ msgGroups ]-34", "font-size:13px; background:pink; color:#bf2c9f;", msgGroups);
   const displayGoals = goals.length > 0 ? goals : cachedGoals;
   const displayMsgGroups = Object.keys(msgGroups).length > 0 ? msgGroups : cachedMsgGroups;
@@ -203,6 +208,7 @@ export const MainContent: FC<MainContentProps> = ({
             goals={displayGoals}
             msgGroups={displayMsgGroups}
             invalidBranches={displayInvalidBranches}
+            agentMode={agentMode}
             onNodeClick={onNodeClick}
             onSubTraceClick={(_parentGoal, entry) => {
               onTraceChange?.(entry.id, entry.mission || entry.id);

+ 2 - 0
frontend/react-template/src/types/trace.ts

@@ -1,5 +1,7 @@
 import type { Goal, BranchContext } from "./goal";
 
+export type AgentMode = "legacy" | "recursive";
+
 export interface TraceListItem {
   trace_id: string;
   mode: string;