ソースを参照

fix(recursive): 阻断远程 Agent 绕过本地任务协议

agent 工具先恢复父 Trace 的持久模式和能力上下文,再决定远端路由;Recursive revision 2 遇到 remote_* 时稳定失败且不会发出 HTTP 请求。

修复 goal focus 自动知识注入直接调用 remote_librarian 的旁路:Recursive revision 2 在导入和调用远端函数前跳过,Trace 或策略读取失败时同样失败关闭。

Legacy 与 Recursive revision 1 继续保留现有 KnowHub 远端行为;同步更新动态工具说明和架构文档,避免模型在 revision 2 中反复尝试不受支持的远端类型。

新增公开 agent 路由和 Goal 自动注入回归,验证 Recursive 远端 mock 调用次数为零,同时保持 Legacy 兼容。
SamLee 14 時間 前
コミット
4b432867f2

+ 3 - 1
cyber_agent/docs/agent-modes.md

@@ -60,7 +60,9 @@ Recursive revision 2 的新根 Trace 必须同时具备 `RootTaskAnchor`、其
 
 Recursive 的 5 层和 6 孩子只统计 `agent` 工具创建的本地业务 Trace。已创建后失败或停止的孩子仍占直接孩子名额;`continue_from` 续跑原孩子不新增名额。批量请求会先检查整批数量,超限时整批拒绝。
 
-`remote_*` 调用保留原来的 `task/messages` 接口,不计入本地深度、每父 6 孩子或当前本地树预算。
+Legacy 和 Recursive revision 1 的 `remote_*` 调用保留原来的
+`task/messages` 接口,不计入本地深度和孩子配额。Recursive revision 2
+稳定拒绝 `remote_*`,避免远端路由绕过结构化任务、树级预算与审核门禁。
 
 **实现**:`cyber_agent/tools/builtin/subagent.py:_run_agents`
 

+ 9 - 2
cyber_agent/docs/architecture.md

@@ -689,7 +689,9 @@ async def agent(
 
 **参数**:
 
-- `agent_type`: 子 Agent 类型;`remote_` 前缀路由到 KnowHub `/api/agent`,其他值作为本地 preset 名称
+- `agent_type`: 子 Agent 类型;Legacy/Recursive revision 1 的 `remote_`
+  前缀路由到 KnowHub `/api/agent`,Recursive revision 2 拒绝该前缀,
+  其他值作为本地 preset 名称
 - `skills`: 覆盖 preset 默认值,显式指定注入 system prompt 的 skill 列表
 - `continue_from`: 续跑单个已有 Trace;Recursive 只允许续跑当前父级审核后批准修订的直接孩子
 
@@ -700,6 +702,7 @@ async def agent(
 **Recursive revision 2**:
 
 - 使用 `RootTaskAnchor + TaskBrief → TaskReport → ValidationResult → TaskReview` 的逐层任务协议;已审核协议内容可通过 ContextRef 逐边授权、按需读取,根结果也必须通过独立 Validator。
+- `agent` 仅允许本地 `task_brief` 委派,`remote_*` 失败关闭。
 
 ### evaluate 工具
 
@@ -722,7 +725,11 @@ async def evaluate(
 
 ### 远程 Agent 与跨设备 Trace
 
-当前已实现的远程入口是 `agent_type="remote_*"`,由 `cyber_agent/tools/builtin/subagent.py:_run_remote_agent` 调用 KnowHub `/api/agent`,保留 `task/messages/continue_from/skills` 接口。
+当前已实现的远程入口是 `agent_type="remote_*"`,由
+`cyber_agent/tools/builtin/subagent.py:_run_remote_agent` 调用 KnowHub
+`/api/agent`,保留 `task/messages/continue_from/skills` 接口;该入口仅供
+Legacy、Recursive revision 1 或直接 SDK 调用,不对 Recursive revision 2
+的 `agent` 工具开放。
 
 `agent_url`、`agent://...` Trace ID 和 `HybridTraceStore` 自动路由属于规划中能力,当前公开 `agent()` 签名不接受 `agent_url`。
 

+ 2 - 1
cyber_agent/docs/recursive-task-protocol.md

@@ -20,7 +20,8 @@ Recursive 任务协议把本地父子 Agent 协作约束为:
   → 修订原孩子 / 创建新孩子 / 重新规划当前层 / 完成或失败
 ```
 
-这套协议只用于本地 Recursive Agent。`remote_*` 和 Legacy 不进入本协议。模式、深度、孩子配额、调度和资源预算见 [Agent 运行模式](./agent-modes.md)。
+这套协议只用于本地 Recursive Agent。Recursive revision 2 不允许
+`remote_*`;Legacy 和 Recursive revision 1 的远端路由不进入本协议。模式、深度、孩子配额、调度和资源预算见 [Agent 运行模式](./agent-modes.md)。
 
 ---
 

+ 9 - 4
cyber_agent/docs/tools.md

@@ -919,7 +919,7 @@ async def agent(
 | `agent_type` | 执行位置 | 通信通道 | 典型用途 |
 |--------------|---------|---------|---------|
 | 未传 `agent_type` 或不带 `remote_` 前缀 | **本地** 进程内 | 共享文件系统 + message | 项目内委托任务,可通过文件路径传递大数据 |
-| `remote_` 前缀(`remote_librarian` / `remote_research` 等) | **远端** KnowHub 服务器 | **仅 message 通道** | 跨项目复用的能力(知识查询、深度调研) |
+| `remote_` 前缀(仅 Legacy / Recursive revision 1) | **远端** KnowHub 服务器 | **仅 message 通道** | 跨项目复用的能力(知识查询、深度调研) |
 
 **为什么要前缀**:远端 Agent 无法访问调用方的本地文件。前缀告诉模型"不要在 task 里引用本地路径,所有输入必须 inline;所有输出在 response message 里拿"。要传大文件时,先通过 `upload_knowledge` / `toolhub` 等基础设施上传到共享存储,再把 ID 传给远端 Agent。
 
@@ -943,7 +943,7 @@ async def agent(
 | 单个 `task` / `task_brief` | delegate | ❌ | 按 Trace 持久化的 `AGENT_MODE` 和深度授权 |
 | `task` / `task_brief` 列表 | explore | Legacy 并行;Recursive 默认串行 | 与单任务使用同一模式策略 |
 
-**messages 参数**(Legacy 和 `remote_*` 保留):
+**messages 参数**(Legacy 和 Recursive revision 1 的 `remote_*` 保留):
 - `None`:无预置消息
 - `Messages`(1D 列表):所有 agent 共享
 - `List[Messages]`(2D 列表):per-agent 独立消息
@@ -976,7 +976,10 @@ AGENT_MODE=legacy      # 或 recursive
   继续新建;失败或停止的已创建孩子仍占名额。
 - `continue_from` 不占新名额,但必须是当前 Trace 的直接孩子,且
   UID、模式、revision 和深度均一致。
-- `agent_type="remote_*"` 保持现有路由行为,不计入本地深度和孩子配额。
+- Legacy 和 Recursive revision 1 的 `agent_type="remote_*"` 保持现有
+  路由行为,不计入本地深度和孩子配额。Recursive revision 2 为了不
+  绕过 TaskBrief、资源预算和父级审核门禁,稳定拒绝 `remote_*`,不发出
+  HTTP 请求。
 - `evaluate` 和 `bash_command` 不向本地 Sub-Agent 开放。
 
 旧配置 `AGENT_RECURSION_ENABLED` 已删除。环境中仍存在时创建或续跑
@@ -1025,7 +1028,9 @@ read_context_ref(ref_id: str, version: str)
 
 #### 远端模式
 
-`remote_` 前缀的 `agent_type` 通过 HTTP 调用 KnowHub 服务器的 `POST /api/agent` 端点执行。
+Legacy 和 Recursive revision 1 中,`remote_` 前缀的 `agent_type` 通过
+HTTP 调用 KnowHub 服务器的 `POST /api/agent` 端点执行。Recursive
+revision 2 的 `agent` 工具不进入这条路由。
 
 | 字段 | 客户端传 | 服务器说了算 |
 |------|---------|-------------|

+ 73 - 31
cyber_agent/tools/builtin/subagent.py

@@ -1,7 +1,8 @@
 """Sub-Agent 的创建、调度、汇合与旧评估工具。
 
 ``agent`` 由父 Agent 调用:Legacy 只创建一层,Recursive 还会执行深度、孩子数、
-权限、预算和审核门禁;本地任务进程内运行,``remote_*`` 仍通过 KnowHub HTTP 路由。
+权限、预算和审核门禁;本地任务进程内运行。Legacy 和 Recursive
+revision 1 的 ``remote_*`` 仍通过 KnowHub HTTP 路由,revision 2 失败关闭。
 ``evaluate`` 保留给 Legacy 和旧的 Recursive revision 1;revision 2 使用框架管理的独立 Validator。
 """
 
@@ -50,10 +51,16 @@ from cyber_agent.core.resource_budget import (
     ResourceBudgetExceeded,
     ResourceBudgetStateError,
 )
+from cyber_agent.core.memory import compute_memory_identity
+from cyber_agent.core.run_snapshot import (
+    RunConfigSnapshotV1,
+    persist_run_config_snapshot,
+)
 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
 from cyber_agent.trace.websocket import broadcast_sub_trace_started, broadcast_sub_trace_completed
+from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
 
 
 # ===== 远端路由常量 =====
@@ -1222,11 +1229,33 @@ async def _run_agents(
             tool_groups=[],  # tools 是精确白名单,不再合并默认 core 组
             name=task_label[:50],
             skills=skills,
-            knowledge=context.get("knowledge_config"),
+            knowledge=context.get("knowledge_config") or KnowledgeConfig(),
             context=child_record["context"],
             child_execution_mode=child_execution_mode,
             max_parallel_children=max_parallel_children,
         )
+        # Recursive children are persisted before their coroutine is scheduled so
+        # the parent can account for queued work.  Bind the immutable run snapshot
+        # before execution; otherwise the first local resume would look exactly
+        # like an unsafe pre-snapshot historical Recursive trace.
+        if child_record["is_new"]:
+            persisted_child = await store.get_trace(cur_stid)
+            if not persisted_child:
+                raise RuntimeError(f"pre-created child Trace disappeared: {cur_stid}")
+            child_memory_identity = (
+                compute_memory_identity(child_config.memory)
+                if child_config.memory
+                else None
+            )
+            child_snapshot = RunConfigSnapshotV1.from_run_config(
+                child_config,
+                memory_identity=child_memory_identity,
+            )
+            persist_run_config_snapshot(persisted_child.context, child_snapshot)
+            await store.update_trace(
+                cur_stid,
+                context=persisted_child.context,
+            )
         execution_specs.append({
             "index": i,
             "trace_id": cur_stid,
@@ -1490,7 +1519,7 @@ async def _run_remote_agent(
 
 # ===== 工具定义 =====
 
-@tool(description="创建子 Agent 执行任务(本地执行或路由到远端服务器,由 agent_type 决定)", hidden_params=["context"], groups=["core"])
+@tool(description="创建子 Agent 执行任务;Recursive revision 2 仅允许本地结构化委派", hidden_params=["context"], groups=["core"])
 async def agent(
     task: Optional[Union[str, List[str]]] = None,
     task_brief: Optional[Union[TaskBrief, List[TaskBrief]]] = None,
@@ -1504,7 +1533,9 @@ async def agent(
     父 Agent 用来创建或续跑直属子 Agent 的公开工具。
 
     路由规则:
-    - agent_type 以 "remote_" 开头:HTTP 调用 KnowHub 服务器的 /api/agent(仅单任务,无本地文件访问)
+    - Legacy/Recursive revision 1 中 agent_type 以 "remote_" 开头:HTTP
+      调用 KnowHub 服务器的 /api/agent(仅单任务,无本地文件访问)
+    - Recursive revision 2 不支持 remote Agent,也不会发出 HTTP 请求
     - 否则本地执行:Legacy/Recursive revision 1 使用 ``task``;revision 2 使用 ``task_brief``
 
     Args:
@@ -1512,7 +1543,8 @@ async def agent(
         task_brief: Recursive revision 2 的结构化任务说明。
         messages: 预置消息。1D 列表=所有 agent 共享;2D 列表=per-agent
         continue_from: 继续已有 trace(仅单任务)
-        agent_type: 子 Agent 类型。带 "remote_" 前缀走远端;否则本地 preset
+        agent_type: 子 Agent 类型。Legacy/Recursive revision 1 带
+                    "remote_" 前缀走远端;否则本地 preset
         skills: 指定本次调用使用的 skill 列表
                 - 本地:附加到 system prompt
                 - 远端:原样发送,由远端实现决定最终权限
@@ -1523,6 +1555,34 @@ async def agent(
     except ValueError as exc:
         return {"status": "failed", "error": str(exc)}
 
+    # 任何路由决策都必须来自已持久化的父 Trace 策略。先看
+    # ``remote_*`` 会让 Recursive revision 2 绕过本地协议、预算与审核门禁。
+    if not context:
+        return {"status": "failed", "error": "context is required"}
+
+    store = context.get("store")
+    trace_id = context.get("trace_id")
+    goal_id = context.get("goal_id")
+    runner = context.get("runner")
+
+    missing = []
+    if not store:
+        missing.append("store")
+    if not trace_id:
+        missing.append("trace_id")
+    if not runner:
+        missing.append("runner")
+    if missing:
+        return {"status": "failed", "error": f"Missing required context: {', '.join(missing)}"}
+
+    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)}
+
     if isinstance(messages, str):
         try:
             messages = json.loads(messages)
@@ -1545,6 +1605,14 @@ async def agent(
 
     # 远端路由:agent_type 以 remote_ 开头
     if agent_type and agent_type.startswith(REMOTE_PREFIX):
+        if policy.requires_task_protocol:
+            return {
+                "status": "failed",
+                "error": (
+                    "Recursive revision 2 does not support remote agents; "
+                    "use a local task_brief delegation"
+                ),
+            }
         if not isinstance(task, str):
             return {"status": "failed", "error": "remote agent 只支持单任务 (task: str)"}
         # 归一化 messages:远端只接受 1D Messages 或 None
@@ -1561,32 +1629,6 @@ async def agent(
             skills=skills,
         )
 
-    # 本地路径:需要 context
-    if not context:
-        return {"status": "failed", "error": "context is required"}
-
-    store = context.get("store")
-    trace_id = context.get("trace_id")
-    goal_id = context.get("goal_id")
-    runner = context.get("runner")
-
-    missing = []
-    if not store:
-        missing.append("store")
-    if not trace_id:
-        missing.append("trace_id")
-    if not runner:
-        missing.append("runner")
-    if missing:
-        return {"status": "failed", "error": f"Missing required context: {', '.join(missing)}"}
-
-    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)}
     if policy.requires_task_protocol and messages is not None:
         return {
             "status": "failed",

+ 30 - 0
cyber_agent/trace/goal_tool.py

@@ -48,6 +48,36 @@ async def inject_knowledge_for_goal(
         logger.debug(f"[Knowledge Inject] 知识注入已禁用,跳过")
         return None
 
+    # Goal focus 过去直接调用 ``_run_remote_agent``,会绕过 agent()
+    # 已实施的 Recursive revision 2 远端门禁。这里必须从当前
+    # Trace 恢复权威策略;读取失败时不能猜测为 Legacy 继续出网。
+    if store is not None or trace_id is not None:
+        if store is None or not trace_id:
+            logger.warning("[Knowledge Inject] Trace context incomplete; skip remote injection")
+            goal.knowledge = []
+            return None
+        try:
+            trace = await store.get_trace(trace_id)
+            if trace is None:
+                logger.warning(
+                    f"[Knowledge Inject] Trace not found: {trace_id}; skip remote injection"
+                )
+                goal.knowledge = []
+                return None
+            policy = policy_from_context(trace.context)
+        except Exception as exc:
+            logger.warning(
+                f"[Knowledge Inject] Cannot restore Trace policy; skip remote injection: {exc}"
+            )
+            goal.knowledge = []
+            return None
+        if policy.requires_task_protocol:
+            logger.info(
+                "[Knowledge Inject] Recursive revision 2 forbids remote Librarian; skip"
+            )
+            goal.knowledge = []
+            return None
+
     try:
         import re
         from cyber_agent.tools.builtin.subagent import _run_remote_agent

+ 75 - 0
tests/test_agent_mode.py

@@ -10,6 +10,8 @@ from cyber_agent.core.agent_mode import (
     policy_from_environment,
 )
 from cyber_agent.core.runner import AgentRunner, RunConfig
+from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
+from cyber_agent.trace.goal_tool import goal_tool
 from cyber_agent.trace.goal_models import GoalTree
 from cyber_agent.trace.models import Trace
 from cyber_agent.trace.store import FileSystemTraceStore
@@ -160,6 +162,79 @@ class AgentModePolicyTest(unittest.IsolatedAsyncioTestCase):
         self.assertEqual("completed", parent.status)
         self.assertEqual("所有子目标已完成", parent.summary)
 
+    async def test_recursive_revision_2_focus_skips_remote_knowledge(self):
+        trace_id = "recursive-v2-knowledge"
+        await self.store.create_trace(Trace(
+            trace_id=trace_id,
+            mode="agent",
+            task="recursive task",
+            context={
+                "agent_mode": "recursive",
+                "agent_mode_revision": 2,
+            },
+        ))
+        tree = GoalTree(mission="recursive goals")
+        focused = tree.add_goals(["research one fact"])[0]
+        await self.store.update_goal_tree(trace_id, tree)
+        remote = AsyncMock(return_value={
+            "status": "completed",
+            "summary": "[knowledge-forged]",
+        })
+
+        with patch(
+            "cyber_agent.tools.builtin.subagent._run_remote_agent",
+            new=remote,
+        ):
+            result = await goal_tool(
+                tree=tree,
+                store=self.store,
+                trace_id=trace_id,
+                focus="1",
+                knowledge_config=KnowledgeConfig(enable_injection=True),
+                context={"sequence": 1},
+            )
+
+        self.assertIn("切换焦点", result)
+        self.assertNotIn("已注入", result)
+        self.assertEqual([], focused.knowledge)
+        remote.assert_not_awaited()
+
+    async def test_legacy_focus_keeps_remote_knowledge_injection(self):
+        trace_id = "legacy-knowledge"
+        await self.store.create_trace(Trace(
+            trace_id=trace_id,
+            mode="agent",
+            task="legacy task",
+            context={
+                "agent_mode": "legacy",
+                "agent_mode_revision": 1,
+            },
+        ))
+        tree = GoalTree(mission="legacy goals")
+        focused = tree.add_goals(["research one fact"])[0]
+        await self.store.update_goal_tree(trace_id, tree)
+        remote = AsyncMock(return_value={
+            "status": "completed",
+            "summary": "Use [knowledge-official] as evidence",
+        })
+
+        with patch(
+            "cyber_agent.tools.builtin.subagent._run_remote_agent",
+            new=remote,
+        ):
+            result = await goal_tool(
+                tree=tree,
+                store=self.store,
+                trace_id=trace_id,
+                focus="1",
+                knowledge_config=KnowledgeConfig(enable_injection=True),
+                context={"sequence": 1},
+            )
+
+        self.assertIn("已注入 1 条", result)
+        self.assertEqual([{"id": "knowledge-official"}], focused.knowledge)
+        remote.assert_awaited_once()
+
 
 if __name__ == "__main__":
     unittest.main()

+ 27 - 1
tests/test_subagent_recursion.py

@@ -65,6 +65,7 @@ class SubAgentModeTest(unittest.IsolatedAsyncioTestCase):
         uid="user-1",
         *,
         agent_mode="recursive",
+        agent_mode_revision=1,
         include_mode=True,
     ):
         context = {
@@ -74,7 +75,7 @@ class SubAgentModeTest(unittest.IsolatedAsyncioTestCase):
         if include_mode:
             context.update({
                 "agent_mode": agent_mode,
-                "agent_mode_revision": 1,
+                "agent_mode_revision": agent_mode_revision,
             })
         trace = Trace(
             trace_id=trace_id,
@@ -374,6 +375,31 @@ class SubAgentModeTest(unittest.IsolatedAsyncioTestCase):
         self.assertEqual("completed", result["status"])
         self.assertEqual(before, len(await self._local_children(child["sub_trace_id"])))
 
+    async def test_recursive_revision_2_remote_agent_fails_before_http(self):
+        root_id = await self._create_root(
+            "recursive-v2-remote",
+            agent_mode_revision=2,
+        )
+        remote = AsyncMock(return_value={"status": "completed"})
+        with patch(
+            "cyber_agent.tools.builtin.subagent._run_remote_agent",
+            new=remote,
+        ):
+            result = await agent(
+                task="remote task",
+                agent_type="remote_research",
+                context=self._context(root_id),
+            )
+
+        self.assertEqual("failed", result["status"])
+        self.assertEqual(
+            "Recursive revision 2 does not support remote agents; "
+            "use a local task_brief delegation",
+            result["error"],
+        )
+        remote.assert_not_awaited()
+        self.assertEqual([], await self._local_children(root_id))
+
 
 if __name__ == "__main__":
     unittest.main()