Kaynağa Gözat

修改寻找agent v2版本

xueyiming 4 gün önce
ebeveyn
işleme
bbb4fceeee

+ 44 - 0
alembic/versions/20260812_13_add_find_agent_v2_runtime_fields.py

@@ -0,0 +1,44 @@
+"""add find_agent_v2 runtime accounting fields
+
+Revision ID: 20260812_13
+Revises: 20260811_12
+Create Date: 2026-08-12
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260812_13"
+down_revision: str | None = "20260811_12"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.add_column(
+        "find_agent_v2_run",
+        sa.Column("input_tokens", sa.BigInteger(), server_default="0", nullable=False),
+    )
+    op.add_column(
+        "find_agent_v2_run",
+        sa.Column("output_tokens", sa.BigInteger(), server_default="0", nullable=False),
+    )
+    op.add_column(
+        "find_agent_v2_run",
+        sa.Column("total_tokens", sa.BigInteger(), server_default="0", nullable=False),
+    )
+    op.add_column(
+        "find_agent_v2_run",
+        sa.Column("cost_usd", sa.Numeric(18, 8), server_default="0", nullable=False),
+    )
+
+
+def downgrade() -> None:
+    op.drop_column("find_agent_v2_run", "cost_usd")
+    op.drop_column("find_agent_v2_run", "total_tokens")
+    op.drop_column("find_agent_v2_run", "output_tokens")
+    op.drop_column("find_agent_v2_run", "input_tokens")

+ 43 - 29
find_agent_v2/AGENT_FLOW.md

@@ -5,7 +5,7 @@
 
 ## 1. 总体架构
 
-v2 使用“Python 外层业务循环 + 单轮固定 DAG + 节点内 ReAct”的结构。业务数据全部写入
+v2 使用“Python 外层业务循环 + 单轮真实 LangGraph + 节点内 LangChain ReAct”的结构。业务数据全部写入
 `find_agent_v2_*` 新表,和旧寻找 Agent 的运行表、工具和日志隔离。
 
 ```mermaid
@@ -14,16 +14,16 @@ flowchart TD
     B --> C["读取轮次开始快照"]
     C --> D["创建 round<br/>phase=planning"]
 
-    subgraph G["单轮固定 DAG"]
-        D --> P["Planner<br/>生成搜索计划"]
-        P --> S["Search<br/>搜索并写入候选"]
-        S --> Q{"存在 pending_evaluation?"}
-        Q -- 否 --> X["结束本轮"]
-        Q -- 是 --> E["Evidence<br/>补详情和画像"]
-        E --> V["Evaluator<br/>评分并申请分池"]
-        V --> R{"pending 已清零?"}
-        R -- 否 --> V
-        R -- 是 --> X
+    subgraph G["单轮受控自主图"]
+        D --> P["Supervisor<br/>提议 next_action / worker_count / evidence_scope"]
+        P --> Q["Policy Guard<br/>依据真实 DB 状态与预算审核"]
+        Q --> S["Search<br/>搜索并写入候选"]
+        Q --> E["Evidence Workers<br/>按建议范围补证"]
+        Q --> V["Evaluator Workers<br/>评分并申请分池"]
+        S --> P
+        E --> P
+        V --> P
+        Q --> X["结束本轮"]
     end
 
     X --> T{"valid primary ≥ 目标数?"}
@@ -73,27 +73,39 @@ sequenceDiagram
 已有 run 也可以通过 `run_prepared_find_agent_v2(run_id)` 执行。此入口从
 `find_agent_v2_run.input_json` 读取创建时保存的 `user_input`。
 
+终态 run 使用 `resume=True` 时不会删除历史 round、候选或证据,而是从 `current_round + 1`
+继续,创建新的 Obagent run,并把本次 Token/费用累加到历史运行用量。
+
 ## 3. 单轮节点和工具权限
 
-每个节点都会创建一个新的 `supply_agent.Agent` 实例,只注册本节点允许的工具,并移除通用
-`load_skill` 工具。节点之间通过数据库状态和轻量内存状态衔接,不共享一个长期 ReAct 会话。
+每个节点都会通过 LangChain `create_agent` 创建新的 ReAct Agent,只注册本节点允许的工具。
+节点之间通过数据库状态和轻量内存状态衔接,不共享一个长期 ReAct 会话。模型调用接入重试
+middleware,工具调用也接入独立重试;可通过 `FIND_AGENT_V2_FALLBACK_MODEL` 配置备用模型。
 
-| 节点 | LLM 职责 | 可用工具 | 上下文范围 | 最大 ReAct 轮数 |
+| 节点 | LLM 职责 | 可用工具 | 上下文范围 | 业务迭代提示值 |
 |---|---|---|---|---:|
-| Planner | 生成最多 3 个互补搜索方向 | 无 | 全量 run 状态 | 2 |
-| Search | 执行搜索计划,不补证、不评分 | `search_videos_v2`、`query_find_agent_v2_state` | 全量状态 | 10 |
-| Evidence | 为候选补视频详情和双侧年龄画像 | `fetch_candidate_details_v2`、`fetch_candidate_portraits_v2`、`query_pending_candidates_v2` | 仅 pending 候选 | 12 |
-| Evaluator | 给出 R/E/S/V,申请 `primary` 或 `rejected` | `evaluate_candidates_v2`、`query_pending_candidates_v2` | 仅 pending 候选 | 每次 12 |
+| Supervisor | 提议下一动作、搜索计划、补证范围和 Worker 数 | 无 | 全量 run 状态 | 2 |
+| Search | 执行搜索计划,不补证、不评分 | `search_videos_v2`、`query_find_agent_v2_state`、`delegate_agents_v2` | 全量状态 | 10 |
+| Evidence | 为候选补视频详情和双侧年龄画像 | `fetch_candidate_details_v2`、`fetch_candidate_portraits_v2`、`query_pending_candidates_v2`、`delegate_agents_v2` | 仅 pending 候选 | 12 |
+| Evaluator | 给出 R/E/S/V,申请 `primary` 或 `rejected` | `evaluate_candidates_v2`、`query_pending_candidates_v2`、`delegate_agents_v2` | 仅 pending 候选 | 每次 12 |
 | Report | 汇总最终结果 | `query_find_agent_v2_state` | 全量最终状态 | 4 |
 
-Evaluator 支持分批消费:宿主最多重新进入 Evaluator 64 次,直到 pending 清零。如果连续 3 次
-调用后 pending 数量都没有下降,则判定为技术失败,防止模型空响应造成无限循环。
+每个业务动作执行后都会回到 Supervisor。默认单轮最多 16 个业务动作、最多 3 次搜索动作;
+Worker 并发建议会被限制为 1~8。Evaluator 连续 3 次没有减少 pending 时判定技术失败。
+当动作预算耗尽,框架会强制补证或评估以安全收敛,仍无进展才失败。
+
+LangChain `create_agent` 的图超步预算与上表业务迭代提示值分离,当前为
+`max(64, 业务迭代提示值 × 6)`;模型节点、工具节点和 middleware 重试都会消耗超步。
+
+Search、Evidence、Evaluator 还可以通过 `delegate_agents_v2` 将最多 8 个相互独立的任务并发
+委派给同阶段 Worker。Worker 拥有与父节点相同的业务工具,但不能继续委派,避免递归失控;每路
+使用独立模型上下文,并在 Obagent 中通过 `branch_key` 展示。
 
 ## 4. 搜索与证据链路
 
 ```mermaid
 flowchart LR
-    SP["Planner 搜索计划"] --> ST["search_videos_v2"]
+    SP["Supervisor 搜索计划"] --> ST["search_videos_v2"]
     ST -->|provider=tikhub| TK["TikHub 搜索接口"]
     ST -->|其他 provider| IK["内部关键词搜索接口"]
     TK --> SR["find_agent_v2_search"]
@@ -222,9 +234,7 @@ flowchart TD
 技术异常统一进入 `failed/failed`。非技术失败的终态会继续运行只读 Report 节点;Report 失败只
 记录到内存 `state.failures`,不会反向改变已完成的业务终态。
 
-注意:`finalize()` 当前以固定数量 `5` 判断 `goal_met`,而外层 Agent 的
-`target_primary_count` 可以在构造时配置。默认值一致;如果将目标数配置为其他值,两处判断可能
-产生不一致,后续应统一为同一个规则快照字段。
+`finalize()` 使用与外层 Agent 相同的 `target_primary_count` 判断 `goal_met`,默认值为 5。
 
 ## 7. 数据表和状态归属
 
@@ -242,6 +252,8 @@ erDiagram
         string outcome_status
         int current_round
         int valid_primary_count
+        int total_tokens
+        decimal cost_usd
         string obagent_run_uid
     }
     FIND_AGENT_V2_ROUND {
@@ -293,11 +305,14 @@ flowchart TD
     R["observe.run<br/>project=find_agent_v2<br/>agent=find_agent_v2"]
     R --> G1["graph · 第 1 轮"]
     R --> G2["graph · 第 2 轮(如需要)"]
-    G1 --> P1["planner"]
+    G1 --> P1["supervisor(可重复进入)"]
     G1 --> S1["search"]
     G1 --> E1["evidence"]
     G1 --> V1["evaluator"]
-    G2 --> P2["planner/search/evidence/evaluator"]
+    S1 --> SW["search worker 扇出(可选)"]
+    E1 --> EW["evidence worker 扇出(可选)"]
+    V1 --> VW["evaluator worker/批次扇出"]
+    G2 --> P2["supervisor/search/evidence/evaluator"]
     R --> RP["report"]
 ```
 
@@ -320,8 +335,8 @@ flowchart TD
 |---|---|
 | `runner.py` | 创建 run,提供同步/异步/已准备任务入口 |
 | `agent.py` | 外层业务轮次、停止条件、终态和 Report |
-| `graph.py` | 单轮固定 DAG 和 Evaluator 消费保护 |
-| `runtime.py` | 每节点新建 ReAct Agent,执行物理工具隔离 |
+| `graph.py` | 编译真实 StateGraph、条件边和 Evaluator 消费保护 |
+| `runtime.py` | LangChain create_agent、重试/fallback、usage 和受控委派 |
 | `prompts.py` | 节点提示词和公共业务规则 |
 | `tools.py` | v2 工具定义和节点 allowlist |
 | `providers.py` | 搜索、详情、画像接口与响应归一化 |
@@ -330,4 +345,3 @@ flowchart TD
 | `models.py` | 五张独立 ORM 表 |
 | `observability.py` | Obagent run、graph、node 和 ReAct 轨迹 |
 | `state.py` | 轻量流程状态和返回结构 |
-

+ 13 - 3
find_agent_v2/README.md

@@ -9,8 +9,8 @@
 
 ```text
 Python 外循环(最多 N 个业务轮次)
-  └─ 单轮 DAG
-      planner → search → evidence → evaluator → END
+  └─ 单轮 LangGraph
+      supervisor ⇄ search / evidence / evaluator → END
   └─ 宿主代码校验并写终态
   └─ report(只读)
 ```
@@ -18,6 +18,9 @@ Python 外循环(最多 N 个业务轮次)
 每个 ReAct 节点只注册本阶段工具;业务重数据在独立数据库表中,内存 state 只保存轮次、阶段、
 计划和小型快照。
 
+节点运行时使用 LangChain `create_agent`,提供模型/工具重试、可选 fallback、Token/费用汇总和
+同阶段受控子 Agent 并发委派。单轮拓扑由真实 `StateGraph` 编译,Obagent 从编译图生成 spec。
+
 ## 可视化
 
 v2 只使用 obagent,不写项目原有的 `logs/*.jsonl`、本地可视化产物或 OSS 日志:
@@ -25,7 +28,7 @@ v2 只使用 obagent,不写项目原有的 `logs/*.jsonl`、本地可视化产
 ```text
 observe.run(project=find_agent_v2)
   ├─ graph(第 1 轮)
-  │   ├─ planner
+  │   ├─ supervisor
   │   ├─ search
   │   ├─ evidence
   │   └─ evaluator
@@ -90,6 +93,13 @@ result = run_find_agent_v2(user_input, run_id=run_id)
   --existing-run-id local-test-... --execute
 ```
 
+已经进入 terminal 状态的任务可保留历史轮次和候选、从下一轮恢复:
+
+```bash
+.venv/bin/python -m find_agent_v2.test_entry \
+  --existing-run-id local-test-... --resume --execute
+```
+
 ## 复用边界
 
 新包拥有自己的外部接口客户端、响应解析、年龄画像标准化、门禁和提示词。需求测试上下文只读取

+ 37 - 8
find_agent_v2/agent.py

@@ -16,7 +16,7 @@ from supply_agent.config import Settings
 
 
 class FindAgentV2:
-    """Python outer loop + one-round DAG + node-local ReAct."""
+    """Python outer loop + guarded Supervisor graph + node-local ReAct."""
 
     def __init__(
         self,
@@ -27,6 +27,8 @@ class FindAgentV2:
         models_by_role: dict[str, str] | None = None,
         max_rounds: int = 2,
         target_primary_count: int = 5,
+        max_actions_per_round: int = 16,
+        max_search_actions_per_round: int = 3,
         observer: ObagentObserver | None = None,
     ) -> None:
         self.service = service or get_find_agent_v2_service()
@@ -39,16 +41,28 @@ class FindAgentV2:
         self.models_by_role = dict(models_by_role or {})
         self.max_rounds = max(1, int(max_rounds))
         self.target_primary_count = max(1, int(target_primary_count))
+        self.max_actions_per_round = max(4, int(max_actions_per_round))
+        self.max_search_actions_per_round = max(1, int(max_search_actions_per_round))
 
-    async def arun(self, *, run_id: str, user_input: str) -> FindAgentResult:
+    async def arun(
+        self, *, run_id: str, user_input: str, resume: bool = False,
+    ) -> FindAgentResult:
         run = self.service.require_run(run_id)
+        if resume and str(run.get("status") or "") != "running":
+            run = self.service.prepare_resume(run_id)
         if str(run.get("status") or "") != "running":
             raise ValueError(f"run_id={run_id} 当前状态不可执行: {run.get('status')}")
         state = FindAgentState(run_id=run_id, user_input=user_input)
         graph = FindAgentRoundGraph(
             service=self.service, runner=self.node_runner, observer=self.observer,
+            max_actions=self.max_actions_per_round,
+            max_search_actions=self.max_search_actions_per_round,
+            target_primary_count=self.target_primary_count,
         )
-        default_model = self.models_by_role.get("planner", "google/gemini-3-flash-preview")
+        reset_usage = getattr(self.node_runner, "reset_usage", None)
+        if callable(reset_usage):
+            reset_usage()
+        default_model = self.models_by_role.get("supervisor", "google/gemini-3-flash-preview")
         with self.observer.run(
             run_id=run_id,
             demand_word=str(run.get("demand_word") or ""),
@@ -58,18 +72,24 @@ class FindAgentV2:
             self.service.set_obagent_run_uid(
                 run_id, getattr(observation_run, "run_uid", None),
             )
-            result = await self._arun_inner(state=state, graph=graph)
+            result = await self._arun_inner(
+                state=state, graph=graph, start_round=int(run.get("current_round") or 0) + 1,
+            )
+            usage = getattr(self.node_runner, "usage", None)
+            if isinstance(usage, dict):
+                self.service.add_usage(run_id, usage)
             observation_run.finish(final_output=result.final_output)
             return result
 
     async def _arun_inner(
-        self, *, state: FindAgentState, graph: FindAgentRoundGraph,
+        self, *, state: FindAgentState, graph: FindAgentRoundGraph, start_round: int = 1,
     ) -> FindAgentResult:
         run_id = state.run_id
         end_reason = ""
         failed = False
         try:
-            for round_index in range(1, self.max_rounds + 1):
+            end_round = start_round + self.max_rounds
+            for round_index in range(start_round, end_round):
                 state.round_index = round_index
                 state.previous_snapshot = self.service.snapshot(run_id)
                 self.service.begin_round(run_id, round_index, state.previous_snapshot)
@@ -85,7 +105,7 @@ class FindAgentV2:
                 if current.candidate_count <= state.previous_snapshot.candidate_count:
                     end_reason = "本轮未发现新增候选,搜索前沿已无信息增益"
                     break
-                if round_index == self.max_rounds:
+                if round_index == end_round - 1:
                     end_reason = f"达到最大业务轮数 {self.max_rounds}"
         except Exception as exc:
             failed = True
@@ -99,7 +119,12 @@ class FindAgentV2:
                 except Exception:
                     pass
 
-        final_run = self.service.finalize(run_id, failed=failed, reason=end_reason)
+        final_run = self.service.finalize(
+            run_id,
+            failed=failed,
+            reason=end_reason,
+            target_primary_count=self.target_primary_count,
+        )
         final_output = (
             f"find_agent_v2 {final_run['outcome_status']},"
             f"有效 primary={final_run['valid_primary_count']}。"
@@ -153,6 +178,8 @@ def create_find_agent_v2(
     evaluation_model: str | None = None,
     report_model: str | None = None,
     max_rounds: int = 2,
+    max_actions_per_round: int = 16,
+    max_search_actions_per_round: int = 3,
 ) -> FindAgentV2:
     return FindAgentV2(
         settings=settings,
@@ -165,4 +192,6 @@ def create_find_agent_v2(
             report=report_model,
         ),
         max_rounds=max_rounds,
+        max_actions_per_round=max_actions_per_round,
+        max_search_actions_per_round=max_search_actions_per_round,
     )

+ 1 - 1
find_agent_v2/context.py

@@ -51,5 +51,5 @@ def build_node_slots(
             "FindAgentV2Service.get_full_state",
             False,
         ),
-        InputSlot("本轮搜索计划", plan, "round_plan", "planner output"),
+        InputSlot("本轮搜索计划", plan, "round_plan", "supervisor output"),
     )

+ 328 - 98
find_agent_v2/graph.py

@@ -1,19 +1,26 @@
-"""One complete business round as a deterministic DAG.
-
-The project does not depend on LangGraph, so this module preserves the same boundary
-without adding a second Agent runtime: one invocation is one acyclic business round.
-"""
+"""One autonomous but policy-guarded business round compiled as LangGraph."""
 
 from __future__ import annotations
 
-from typing import Protocol
+import asyncio
+import json
+import re
+from typing import Any, Protocol
+
+from langgraph.graph import END, START, StateGraph
 
 from find_agent_v2.context import build_node_slots, render_node_context
-from find_agent_v2.observability import InputSlot, NullObserver
-from find_agent_v2.prompts import EVALUATOR_PROMPT, EVIDENCE_PROMPT, PLANNER_PROMPT, SEARCH_PROMPT
+from find_agent_v2.observability import InputSlot, NullObserver, graph_spec_for
+from find_agent_v2.prompts import EVALUATOR_PROMPT, EVIDENCE_PROMPT, SEARCH_PROMPT, SUPERVISOR_PROMPT
 from find_agent_v2.service import FindAgentV2Service
-from find_agent_v2.state import FindAgentState, NodeRun
-from find_agent_v2.tools import EVALUATION_TOOLS, EVIDENCE_TOOLS, SEARCH_TOOLS, ToolFn
+from find_agent_v2.state import FindAgentGraphState, FindAgentState, NodeRun
+from find_agent_v2.tools import (
+    EVALUATION_TOOLS,
+    EVIDENCE_TOOLS,
+    SEARCH_TOOLS,
+    ToolFn,
+    bound_candidate_tools,
+)
 
 
 class NodeRunner(Protocol):
@@ -27,138 +34,361 @@ class NodeRunner(Protocol):
         tools: tuple[ToolFn, ...] = (),
         max_iterations: int = 12,
         slots: tuple[InputSlot, ...] = (),
+        branch_key: str = "",
+        allow_delegation: bool = True,
     ) -> NodeRun: ...
 
 
 class FindAgentRoundGraph:
-    """planner -> search -> evidence -> evaluator -> END."""
+    """Supervisor-directed graph with deterministic policy approval."""
 
     def __init__(
-        self, *, service: FindAgentV2Service, runner: NodeRunner,
-        observer=None,
+        self, *, service: FindAgentV2Service, runner: NodeRunner, observer=None,
+        max_actions: int = 16, max_search_actions: int = 3,
+        target_primary_count: int = 5,
     ) -> None:
         self.service = service
         self.runner = runner
         self.observer = observer or NullObserver()
+        self.max_actions = max(4, int(max_actions))
+        self.max_search_actions = max(1, int(max_search_actions))
+        self.target_primary_count = max(1, int(target_primary_count))
+        self.app = self._build_graph()
+        self.obagent_spec = graph_spec_for(self.app)
 
-    def _full_state(self, state: FindAgentState, *, pending_only: bool = False):
+    def _full_state(self, state: FindAgentGraphState, *, pending_only: bool = False):
         return self.service.get_full_state(
-            state.run_id, pending_only=pending_only,
+            state["run_id"], pending_only=pending_only,
         )
 
-    def _context(self, state: FindAgentState, *, pending_only: bool = False) -> str:
+    def _context(self, state: FindAgentGraphState, *, pending_only: bool = False) -> str:
         return render_node_context(
-            user_input=state.user_input,
+            user_input=state["user_input"],
             full_state=self._full_state(state, pending_only=pending_only),
-            round_index=state.round_index,
-            plan=state.plan,
+            round_index=state["round_index"],
+            plan=state.get("plan", ""),
         )
 
     def _slots(
-        self, state: FindAgentState, *, pending_only: bool = False,
+        self, state: FindAgentGraphState, *, pending_only: bool = False,
     ) -> tuple[InputSlot, ...]:
         return build_node_slots(
-            user_input=state.user_input,
+            user_input=state["user_input"],
             full_state=self._full_state(state, pending_only=pending_only),
-            round_index=state.round_index,
-            plan=state.plan,
+            round_index=state["round_index"],
+            plan=state.get("plan", ""),
         )
 
-    async def invoke(self, state: FindAgentState) -> FindAgentState:
-        with self.observer.round(round_index=state.round_index) as round_observation:
-            state = await self._invoke_nodes(state)
-            round_observation.set_output({
-                "状态快照": state.snapshot.__dict__ if state.snapshot else {},
-                "本轮计划": state.plan,
-            }, ok=True)
-        return state
+    def _shard_state(
+        self, state: FindAgentGraphState, items: list[dict[str, Any]],
+    ) -> dict[str, Any]:
+        full_state = self._full_state(state, pending_only=True)
+        return {**full_state, "candidates": items}
 
-    async def _invoke_nodes(self, state: FindAgentState) -> FindAgentState:
-        state.phase = "planning"
-        plan = await self.runner.run_node(
-            node="planner",
-            round_index=state.round_index,
-            system_prompt=PLANNER_PROMPT,
+    def _shard_context(
+        self, state: FindAgentGraphState, items: list[dict[str, Any]],
+    ) -> str:
+        return render_node_context(
+            user_input=state["user_input"],
+            full_state=self._shard_state(state, items),
+            round_index=state["round_index"],
+            plan=state.get("plan", ""),
+        )
+
+    def _shard_slots(
+        self, state: FindAgentGraphState, items: list[dict[str, Any]],
+    ) -> tuple[InputSlot, ...]:
+        return build_node_slots(
+            user_input=state["user_input"],
+            full_state=self._shard_state(state, items),
+            round_index=state["round_index"],
+            plan=state.get("plan", ""),
+        )
+
+    @staticmethod
+    def _parse_supervisor(content: str) -> dict[str, Any]:
+        text = content.strip()
+        fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
+        if fenced:
+            text = fenced.group(1)
+        else:
+            start, end = text.find("{"), text.rfind("}")
+            if start >= 0 and end > start:
+                text = text[start:end + 1]
+        try:
+            value = json.loads(text)
+            return value if isinstance(value, dict) else {}
+        except (TypeError, ValueError):
+            return {}
+
+    def _approve_action(
+        self, state: FindAgentGraphState, proposal: dict[str, Any],
+    ) -> tuple[str, str, int, str]:
+        """Turn an LLM proposal into a safe, executable transition."""
+        pending = self._full_state(state, pending_only=True).get("candidates") or []
+        missing_detail = any(item.get("detail_status") == "pending" for item in pending)
+        missing_portrait = any(item.get("portrait_status") == "pending" for item in pending)
+        proposed = str(proposal.get("next_action") or "").lower()
+        reason = str(proposal.get("reason") or "")
+        searches = int(state.get("search_actions") or 0)
+        actions = int(state.get("action_count") or 0)
+        try:
+            worker_count = max(1, min(8, int(proposal.get("worker_count") or 4)))
+        except (TypeError, ValueError):
+            worker_count = 4
+        scope = str(proposal.get("evidence_scope") or "both").lower()
+        if scope not in {"detail", "portrait", "both"}:
+            scope = "both"
+
+        snapshot = self.service.snapshot(state["run_id"])
+        if not pending and snapshot.valid_primary_count >= self.target_primary_count:
+            return "finish", "有效 primary 已达到目标,结束本轮", worker_count, scope
+
+        if actions > self.max_actions:
+            if not pending:
+                return "finish", "安全收敛动作已完成", worker_count, scope
+            raise RuntimeError(
+                f"Supervisor 安全收敛动作未产生进展:actions={actions}, pending={len(pending)}"
+            )
+        if actions == self.max_actions:
+            if pending and not (missing_detail or missing_portrait):
+                return "evaluator", "动作预算耗尽,强制消费待评估候选", worker_count, scope
+            if pending:
+                return "evidence", "动作预算耗尽,强制补齐缺失证据", worker_count, "both"
+            return "finish", "达到单轮动作安全上限", worker_count, scope
+        if pending:
+            if missing_detail or missing_portrait:
+                if scope == "detail" and not missing_detail:
+                    scope = "portrait"
+                elif scope == "portrait" and not missing_portrait:
+                    scope = "detail"
+                return "evidence", reason or "存在缺失证据,框架要求先补证", worker_count, scope
+            return "evaluator", reason or "候选证据已处理,进入评估", worker_count, scope
+        if searches == 0:
+            return "search", reason or "本轮尚未搜索,框架要求先建立候选池", worker_count, scope
+        if proposed == "search" and searches < self.max_search_actions:
+            return "search", reason or "Supervisor 判断继续搜索仍有信息增益", worker_count, scope
+        return "finish", reason or "没有待处理候选,结束本轮", worker_count, scope
+
+    async def _supervisor(self, state: FindAgentGraphState) -> dict[str, Any]:
+        run = await self.runner.run_node(
+            node="supervisor",
+            round_index=state["round_index"],
+            system_prompt=SUPERVISOR_PROMPT,
             user_content=self._context(state),
             tools=(),
             max_iterations=2,
             slots=self._slots(state),
+            allow_delegation=False,
         )
-        state.node_runs.append(plan)
-        state.plan = plan.content.strip()
+        proposal = self._parse_supervisor(run.content)
+        action, reason, workers, scope = self._approve_action(state, proposal)
+        proposed_plan = proposal.get("plan")
+        plan = (
+            json.dumps(proposed_plan, ensure_ascii=False)
+            if isinstance(proposed_plan, dict)
+            else state.get("plan", "")
+        )
+        decision = {
+            "step": int(state.get("supervisor_step") or 0) + 1,
+            "proposed_action": proposal.get("next_action"),
+            "approved_action": action,
+            "reason": reason,
+            "worker_count": workers,
+            "evidence_scope": scope,
+        }
         self.service.update_round(
-            state.run_id, state.round_index, phase="searching", plan=state.plan,
+            state["run_id"], state["round_index"], phase="planning", plan=plan,
         )
+        return {
+            "phase": "planning", "plan": plan, "approved_action": action,
+            "supervisor_step": decision["step"], "worker_count": workers,
+            "evidence_scope": scope,
+            "decision_history": [*state.get("decision_history", []), decision],
+            "node_runs": [*state.get("node_runs", []), run],
+        }
 
-        state.phase = "searching"
-        search = await self.runner.run_node(
+    async def _search(self, state: FindAgentGraphState) -> dict[str, Any]:
+        self.service.update_round(state["run_id"], state["round_index"], phase="searching")
+        run = await self.runner.run_node(
             node="search",
-            round_index=state.round_index,
+            round_index=state["round_index"],
             system_prompt=SEARCH_PROMPT,
             user_content=self._context(state),
             tools=SEARCH_TOOLS,
             max_iterations=10,
             slots=self._slots(state),
         )
-        state.node_runs.append(search)
-        after_search = self.service.snapshot(state.run_id)
-
-        if after_search.pending_count:
-            state.phase = "evidence"
-            self.service.update_round(state.run_id, state.round_index, phase="evidence")
-            evidence = await self.runner.run_node(
-                node="evidence",
-                round_index=state.round_index,
+        return {
+            "phase": "searching",
+            "search_actions": int(state.get("search_actions") or 0) + 1,
+            "action_count": int(state.get("action_count") or 0) + 1,
+            "node_runs": [*state.get("node_runs", []), run],
+        }
+
+    async def _evidence(self, state: FindAgentGraphState) -> dict[str, Any]:
+        self.service.update_round(state["run_id"], state["round_index"], phase="evidence")
+        pending = self._full_state(state, pending_only=True)["candidates"]
+        detail_items = [item for item in pending if item.get("detail_status") == "pending"]
+        portrait_items = [item for item in pending if item.get("portrait_status") == "pending"]
+        scope = state.get("evidence_scope", "both")
+        if scope == "detail":
+            portrait_items = []
+        elif scope == "portrait":
+            detail_items = []
+        jobs = [
+            ("detail", detail_items[index:index + 8])
+            for index in range(0, len(detail_items), 8)
+        ] + [
+            ("portrait", portrait_items[index:index + 8])
+            for index in range(0, len(portrait_items), 8)
+        ]
+
+        semaphore = asyncio.Semaphore(max(1, min(8, int(state.get("worker_count") or 4))))
+
+        async def run_shard(
+            index: int, evidence_type: str, items: list[dict[str, Any]],
+        ) -> NodeRun:
+            candidate_ids = [int(item["candidate_id"]) for item in items]
+            selected = (
+                (EVIDENCE_TOOLS[0], EVIDENCE_TOOLS[2])
+                if evidence_type == "detail"
+                else (EVIDENCE_TOOLS[1], EVIDENCE_TOOLS[2])
+            )
+            async with semaphore:
+                return await self.runner.run_node(
+                    node="evidence",
+                round_index=state["round_index"],
                 system_prompt=EVIDENCE_PROMPT,
-                user_content=self._context(state, pending_only=True),
-                tools=EVIDENCE_TOOLS,
+                user_content=(
+                    f"你只负责当前 {evidence_type} 分片 candidate_ids={candidate_ids}。"
+                    f"必须为这些候选补齐 {evidence_type},不得访问其他候选。\n\n"
+                    + self._shard_context(state, items)
+                ),
+                tools=bound_candidate_tools(
+                    selected, run_id=state["run_id"], candidate_ids=candidate_ids,
+                ),
                 max_iterations=12,
-                slots=self._slots(state, pending_only=True),
-            )
-            state.node_runs.append(evidence)
-
-            state.phase = "evaluating"
-            self.service.update_round(state.run_id, state.round_index, phase="evaluating")
-            # A model may intentionally keep one tool payload small (for example,
-            # evaluate ten candidates at a time).  One evaluator invocation is
-            # therefore not proof that the queue has been drained.  Re-enter the
-            # node with a fresh DB snapshot until every candidate has a terminal
-            # bucket, while failing fast if an invocation makes no progress.
-            stagnant_attempts = 0
-            for _ in range(64):
-                before_evaluation = self.service.snapshot(state.run_id)
-                if not before_evaluation.pending_count:
-                    break
-                evaluation = await self.runner.run_node(
+                slots=self._shard_slots(state, items),
+                branch_key=f"{evidence_type}-shard-{index}",
+                allow_delegation=False,
+                )
+
+        runs = await asyncio.gather(*(
+            run_shard(index, evidence_type, items)
+            for index, (evidence_type, items) in enumerate(jobs, start=1)
+        )) if jobs else []
+        return {
+            "phase": "evidence",
+            "action_count": int(state.get("action_count") or 0) + 1,
+            "node_runs": [*state.get("node_runs", []), *runs],
+        }
+
+    async def _evaluator(self, state: FindAgentGraphState) -> dict[str, Any]:
+        self.service.update_round(state["run_id"], state["round_index"], phase="evaluating")
+        node_runs = list(state.get("node_runs", []))
+        before = self.service.snapshot(state["run_id"])
+        pending = self._full_state(state, pending_only=True)["candidates"]
+        shards = [pending[index:index + 8] for index in range(0, len(pending), 8)]
+        semaphore = asyncio.Semaphore(max(1, min(8, int(state.get("worker_count") or 4))))
+
+        async def run_shard(index: int, items: list[dict[str, Any]]) -> NodeRun:
+            candidate_ids = [int(item["candidate_id"]) for item in items]
+            async with semaphore:
+                return await self.runner.run_node(
                     node="evaluator",
-                    round_index=state.round_index,
+                    round_index=state["round_index"],
                     system_prompt=EVALUATOR_PROMPT,
-                    user_content=self._context(state, pending_only=True),
-                    tools=EVALUATION_TOOLS,
+                    user_content=(
+                        f"你只负责当前分片 candidate_ids={candidate_ids}。"
+                        "必须把这些候选全部分池,不得评估其他候选。\n\n"
+                        + self._shard_context(state, items)
+                    ),
+                    tools=bound_candidate_tools(
+                        EVALUATION_TOOLS, run_id=state["run_id"], candidate_ids=candidate_ids,
+                    ),
                     max_iterations=12,
-                    slots=self._slots(state, pending_only=True),
+                    slots=self._shard_slots(state, items),
+                    branch_key=f"step-{state.get('supervisor_step', 0)}-shard-{index}",
+                    allow_delegation=False,
                 )
-                state.node_runs.append(evaluation)
-                after_evaluation = self.service.snapshot(state.run_id)
-                if after_evaluation.pending_count >= before_evaluation.pending_count:
-                    stagnant_attempts += 1
-                    if stagnant_attempts >= 3:
-                        raise RuntimeError(
-                            "评估节点连续 3 次未消费 pending_evaluation 候选:"
-                            f"remaining={after_evaluation.pending_count}"
-                        )
-                else:
-                    stagnant_attempts = 0
-            else:
-                raise RuntimeError("评估批次超过安全上限 64")
-
-        state.snapshot = self.service.snapshot(state.run_id)
-        state.phase = "done"
-        self.service.update_round(
-            state.run_id,
-            state.round_index,
-            phase="done",
-            status="done",
-            snapshot=state.snapshot,
-        )
+
+        runs = await asyncio.gather(*(
+            run_shard(index, items) for index, items in enumerate(shards, start=1)
+        )) if shards else []
+        node_runs.extend(runs)
+        self.service.recount_valid_primary(state["run_id"])
+        after = self.service.snapshot(state["run_id"])
+        stagnant = int(state.get("evaluator_stagnation") or 0)
+        stagnant = stagnant + 1 if after.pending_count >= before.pending_count else 0
+        if stagnant >= 3:
+            raise RuntimeError(
+                "评估节点连续 3 次未消费 pending_evaluation 候选:"
+                f"remaining={after.pending_count}"
+            )
+        return {
+            "phase": "evaluating", "node_runs": node_runs,
+            "action_count": int(state.get("action_count") or 0) + 1,
+            "evaluator_stagnation": stagnant,
+        }
+
+    @staticmethod
+    def _route(state: FindAgentGraphState) -> str:
+        return state.get("approved_action", "finish")
+
+    def _build_graph(self):
+        builder = StateGraph(FindAgentGraphState)
+        builder.add_node("supervisor", self._supervisor)
+        builder.add_node("search", self._search)
+        builder.add_node("evidence", self._evidence)
+        builder.add_node("evaluator", self._evaluator)
+        builder.add_edge(START, "supervisor")
+        builder.add_conditional_edges("supervisor", self._route, {
+            "search": "search", "evidence": "evidence",
+            "evaluator": "evaluator", "finish": END,
+        })
+        builder.add_edge("search", "supervisor")
+        builder.add_edge("evidence", "supervisor")
+        builder.add_edge("evaluator", "supervisor")
+        return builder.compile()
+
+    async def invoke(self, state: FindAgentState) -> FindAgentState:
+        graph_state: FindAgentGraphState = {
+            "run_id": state.run_id,
+            "user_input": state.user_input,
+            "round_index": state.round_index,
+            "plan": state.plan,
+            "phase": state.phase,
+            "node_runs": [],
+            "snapshot": state.snapshot,
+            "supervisor_step": 0,
+            "action_count": 0,
+            "search_actions": 0,
+            "worker_count": 4,
+            "evidence_scope": "both",
+            "decision_history": [],
+            "evaluator_stagnation": 0,
+        }
+        with self.observer.round(
+            round_index=state.round_index, spec=self.obagent_spec,
+        ) as round_observation:
+            output = await self.app.ainvoke(
+                graph_state, config={"recursion_limit": max(64, self.max_actions * 4)},
+            )
+            state.plan = str(output.get("plan") or "")
+            state.node_runs.extend(output.get("node_runs") or [])
+            state.snapshot = self.service.snapshot(state.run_id)
+            state.phase = "done"
+            self.service.update_round(
+                state.run_id,
+                state.round_index,
+                phase="done",
+                status="done",
+                snapshot=state.snapshot,
+            )
+            round_observation.set_output({
+                "状态快照": state.snapshot.__dict__,
+                "本轮计划": state.plan,
+                "Supervisor决策轨迹": output.get("decision_history") or [],
+            }, ok=True)
         return state

+ 4 - 0
find_agent_v2/models.py

@@ -34,6 +34,10 @@ class FindAgentV2Run(Base):
     search_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
     candidate_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
     valid_primary_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    input_tokens: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
+    output_tokens: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
+    total_tokens: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
+    cost_usd: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False, default=0)
     intent_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
     stop_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
     obagent_run_uid: Mapped[str | None] = mapped_column(String(64), nullable=True)

+ 30 - 5
find_agent_v2/observability.py

@@ -13,7 +13,7 @@ OBAGENT_ROUND_ANCHOR = {"in": "run", "on": ["graph"]}
 DEFAULT_ENDPOINT = "http://ob.aiddit.com"
 
 MODULE_TITLES = {
-    "planner": "搜索规划",
+    "supervisor": "自主编排",
     "search": "候选搜索",
     "evidence": "证据补全",
     "evaluator": "评估与分池",
@@ -29,6 +29,29 @@ GRAPH_SPEC = {
     ]
 }
 
+GRAPH_MODULE_KEYS = {
+    key: f"{OBAGENT_PROJECT}.{OBAGENT_AGENT}.{key}"
+    for key in ("supervisor", "search", "evidence", "evaluator")
+}
+GRAPH_TITLES = {
+    key: MODULE_TITLES[key]
+    for key in ("supervisor", "search", "evidence", "evaluator")
+}
+
+
+def graph_spec_for(app) -> dict[str, Any]:
+    """Build the visualization spec from the compiled LangGraph itself."""
+    try:
+        from obagent_sdk.integrations.langgraph import graph_spec
+
+        return graph_spec(
+            app,
+            module_keys=GRAPH_MODULE_KEYS,
+            titles=GRAPH_TITLES,
+        )
+    except Exception:
+        return GRAPH_SPEC
+
 _configured = False
 
 
@@ -135,8 +158,9 @@ class _ModuleHandle:
         slots: tuple[InputSlot, ...],
         tools: tuple,
         model: str,
+        refs: dict[str, str] | None = None,
     ) -> str:
-        del fallback
+        del fallback, refs
         return self.ctx.declare(
             system_prompt=system_prompt,
             blocks=_blocks(slots),
@@ -192,7 +216,7 @@ class ObagentObserver:
             yield handle
 
     @contextmanager
-    def round(self, *, round_index: int):
+    def round(self, *, round_index: int, spec: dict[str, Any] | None = None):
         from obagent_sdk import observe
 
         with observe.module(
@@ -200,7 +224,7 @@ class ObagentObserver:
             kind="workflow",
             title=f"寻找 Agent · 第 {round_index} 轮",
             module_key="graph",
-            spec=GRAPH_SPEC,
+            spec=spec or GRAPH_SPEC,
         ) as ctx:
             from obagent_sdk.observe import InputBlock
 
@@ -211,7 +235,7 @@ class ObagentObserver:
             yield _ModuleHandle(ctx)
 
     @contextmanager
-    def node(self, *, node: str):
+    def node(self, *, node: str, branch_key: str = ""):
         from obagent_sdk import observe
 
         with observe.module(
@@ -219,5 +243,6 @@ class ObagentObserver:
             kind="agent",
             title=MODULE_TITLES[node],
             module_key=node,
+            branch_key=branch_key,
         ) as ctx:
             yield _ModuleHandle(ctx)

+ 26 - 10
find_agent_v2/prompts.py

@@ -29,37 +29,53 @@ COMMON_RULES = """
 - 优先批量调用工具,避免同一轮重复查询或重复补证。
 """
 
-PLANNER_PROMPT = COMMON_RULES + """
+SUPERVISOR_PROMPT = COMMON_RULES + """
 
-# 当前模块:搜索规划
+# 当前模块:Supervisor
 
-你只负责规划,不调用工具,也不评估候选。根据原始任务、已有搜索和候选快照,设计本轮最多
-3 个意图互补的搜索方向。避开已经没有信息增益的关键词。只输出 JSON:
-{"intent_summary":"...","searches":[{"keyword":"...","query_reason":"...","source_type":"demand|seed|point|mixed","provider":"internal_keyword|tikhub","max_pages":1}]}
+你负责根据真实数据库状态决定下一步,但不直接调用业务工具。每次只能提议一个动作:
+
+- `search`:继续扩大候选池,并在 `plan.searches` 中给出最多 3 个互补搜索方向。
+- `evidence`:补证,可用 `evidence_scope=detail|portrait|both` 决定顺序。
+- `evaluator`:证据已处理后评分分池。
+- `finish`:没有待处理候选且继续搜索已无信息增益时结束本轮。
+
+你还可以用 `worker_count` 建议 1~8 个并行 Worker。宿主会依据真实状态、工具权限和预算审核提议;
+非法跳转会被改写为安全动作。只输出 JSON,不要 Markdown:
+{"next_action":"search|evidence|evaluator|finish","reason":"...","worker_count":4,
+ "evidence_scope":"both","plan":{"intent_summary":"...","searches":[{"keyword":"...",
+ "query_reason":"...","source_type":"demand|seed|point|mixed","provider":"internal_keyword|tikhub",
+ "max_pages":1}]}}
 """
 
+# Backward-compatible import name for callers that customized the old prompt.
+PLANNER_PROMPT = SUPERVISOR_PROMPT
+
 SEARCH_PROMPT = COMMON_RULES + """
 
 # 当前模块:候选搜索
 
 执行本轮搜索计划。只能使用 `search_videos_v2` 和 `query_find_agent_v2_state`;不得获取详情或
-画像,不得评分或分池。优先把互不依赖的关键词放入一次批量搜索,完成后查询状态确认写入结果。
+画像,不得评分或分池。优先把互不依赖的关键词放入一次批量搜索;需要隔离上下文时可用
+`delegate_agents_v2` 并发委派独立搜索任务,完成后查询状态确认写入结果。
 """
 
 EVIDENCE_PROMPT = COMMON_RULES + """
 
 # 当前模块:证据补全
 
-使用 `query_pending_candidates_v2` 查询待评估候选,选择高潜候选批量调用 `fetch_candidate_details_v2` 和
-`fetch_candidate_portraits_v2`。不得继续搜索、评分或分池。上游失败也必须保留为明确证据状态。
+宿主会给你一个互斥的候选分片,并且只注册当前分片实际缺失的详情或画像工具。使用
+`query_pending_candidates_v2` 只读当前分片,并把分片内所有候选的当前证据补齐。不得继续搜索、
+评分、分池或访问分片外候选。上游失败也必须保留为明确证据状态。
 """
 
 EVALUATOR_PROMPT = COMMON_RULES + """
 
 # 当前模块:评估与分池
 
-使用 `query_pending_candidates_v2` 查询所有 pending_evaluation 候选,根据已保存证据给出 R/E/S/V,并批量调用
-`evaluate_candidates_v2`。所有待评估候选必须进入 primary 或 rejected;更新后再次查询确认。
+宿主会给你一个互斥的 pending_evaluation 候选分片。使用 `query_pending_candidates_v2` 只读当前
+分片,根据已保存证据给出 R/E/S/V,并批量调用 `evaluate_candidates_v2`。分片内所有候选必须
+进入 primary 或 rejected;更新后再次查询确认。不得访问分片外候选。
 不得搜索、补证或修改运行终态。
 """
 

+ 6 - 1
find_agent_v2/runner.py

@@ -35,9 +35,10 @@ async def arun_find_agent_v2(
     agent: FindAgentV2 | None = None,
     settings: Settings | None = None,
     model: str | None = None,
+    resume: bool = False,
 ) -> FindAgentResult:
     runner = agent or create_find_agent_v2(settings=settings, model=model)
-    return await runner.arun(run_id=run_id, user_input=user_input)
+    return await runner.arun(run_id=run_id, user_input=user_input, resume=resume)
 
 
 def run_find_agent_v2(
@@ -47,6 +48,7 @@ def run_find_agent_v2(
     agent: FindAgentV2 | None = None,
     settings: Settings | None = None,
     model: str | None = None,
+    resume: bool = False,
 ) -> FindAgentResult:
     try:
         asyncio.get_running_loop()
@@ -57,6 +59,7 @@ def run_find_agent_v2(
             agent=agent,
             settings=settings,
             model=model,
+            resume=resume,
         ))
     raise RuntimeError("run_find_agent_v2 不能在已运行的事件循环内调用;请使用 arun_find_agent_v2")
 
@@ -67,6 +70,7 @@ def run_prepared_find_agent_v2(
     agent: FindAgentV2 | None = None,
     settings: Settings | None = None,
     model: str | None = None,
+    resume: bool = False,
 ) -> FindAgentResult:
     """Execute a prepared run using its database-persisted immutable user input."""
     user_input = get_find_agent_v2_service().get_run_user_input(run_id)
@@ -76,4 +80,5 @@ def run_prepared_find_agent_v2(
         agent=agent,
         settings=settings,
         model=model,
+        resume=resume,
     )

+ 238 - 98
find_agent_v2/runtime.py

@@ -1,73 +1,101 @@
-"""Thin ReAct host used by each deterministic workflow node."""
+"""Independent LangChain ReAct host for find_agent_v2.
+
+The host owns model construction, retries, usage accounting, tool adaptation and
+controlled nested-agent delegation.  It never imports the legacy find-agent package.
+"""
 
 from __future__ import annotations
 
+import asyncio
+import json
+import os
 from collections.abc import Iterable
 from typing import Any
 
+from langchain.agents import create_agent
+from langchain.agents.middleware import ModelFallbackMiddleware, ModelRetryMiddleware, ToolRetryMiddleware
+from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
+from langchain_core.tools import StructuredTool
+from langchain_openai import ChatOpenAI
+from pydantic import BaseModel, Field
+
 from find_agent_v2.observability import InputSlot, ObagentObserver
 from find_agent_v2.state import NodeRun
-from find_agent_v2.tools import ToolFn, build_tool_registry
-from supply_agent import Agent
-from supply_agent.config import Settings
+from find_agent_v2.tools import ToolFn
+from supply_agent.config import Settings, get_settings
 
 
-class _ObagentEventCollector:
-    """Collect ReAct events in memory for obagent; never writes project log artifacts."""
+class DelegateRequest(BaseModel):
+    task: str = Field(description="交给子 Agent 的完整、独立任务")
 
-    def __init__(self) -> None:
-        self.events: list[dict[str, Any]] = []
 
-    def start_run(self, *_args, **_kwargs) -> str:
-        return ""
+class DelegateArgs(BaseModel):
+    requests: list[DelegateRequest] = Field(
+        min_length=1, max_length=8,
+        description="可并发执行的子 Agent 任务;有依赖的任务不要放在同一批",
+    )
 
-    def log_llm_input(self, iteration, model, messages, tools, temperature) -> None:
-        self.events.append({
-            "type": "llm_input",
-            "iteration": iteration,
-            "model": model,
-            "temperature": temperature,
-            "messages": [message.model_dump(mode="json") for message in messages],
-            "tools": [item.model_dump(mode="json") for item in (tools or [])],
-        })
 
-    def log_llm_output(
-        self, iteration, response, raw_response=None, *, model=None, provider="openrouter",
-    ) -> None:
-        usage = getattr(raw_response, "usage", None)
-        if hasattr(usage, "model_dump"):
-            usage = usage.model_dump()
-        self.events.append({
-            "type": "llm_output",
-            "iteration": iteration,
-            "model": model,
-            "provider": provider,
-            "response": response.model_dump(mode="json"),
-            "usage": usage,
-        })
-
-    def log_tool_call(
-        self, iteration, name, arguments, result, is_error=False, *, tool_call_id=None,
-    ) -> None:
-        self.events.append({
-            "type": "tool_call",
-            "iteration": iteration,
-            "name": name,
-            "arguments": arguments,
-            "result": result,
-            "is_error": bool(is_error),
-            "tool_call_id": tool_call_id,
-        })
+def _tool_name(fn: ToolFn) -> str:
+    return str(getattr(fn, "_tool_name", fn.__name__))
 
-    def log_skill_loaded(self, *_args, **_kwargs) -> None:
-        return None
 
-    def end_run(self, *_args, **_kwargs) -> None:
-        return None
+def _as_langchain_tool(fn: ToolFn) -> StructuredTool:
+    kwargs = {
+        "name": _tool_name(fn),
+        "description": str(getattr(fn, "_tool_description", fn.__doc__ or "")),
+    }
+    if asyncio.iscoroutinefunction(fn):
+        return StructuredTool.from_function(coroutine=fn, **kwargs)
+    return StructuredTool.from_function(func=fn, **kwargs)
+
+
+def _message_dict(message: BaseMessage) -> dict[str, Any]:
+    data = message.model_dump(mode="json")
+    data["type"] = message.type
+    return data
+
+
+def _usage(messages: list[BaseMessage]) -> dict[str, int | float]:
+    totals: dict[str, int | float] = {
+        "input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "cost": 0.0,
+    }
+    for message in messages:
+        usage = getattr(message, "usage_metadata", None) or {}
+        totals["input_tokens"] += int(usage.get("input_tokens") or 0)
+        totals["output_tokens"] += int(usage.get("output_tokens") or 0)
+        totals["total_tokens"] += int(usage.get("total_tokens") or 0)
+        response = getattr(message, "response_metadata", None) or {}
+        cost = response.get("cost") or (response.get("usage") or {}).get("cost")
+        if cost is not None:
+            totals["cost"] += float(cost)
+    totals["cost"] = round(float(totals["cost"]), 8)
+    return totals
+
+
+def _events(messages: list[BaseMessage]) -> list[dict[str, Any]]:
+    events: list[dict[str, Any]] = []
+    for message in messages:
+        if isinstance(message, AIMessage):
+            events.append({
+                "type": "llm_output",
+                "content": message.content,
+                "tool_calls": message.tool_calls,
+                "usage": message.usage_metadata,
+            })
+        elif isinstance(message, ToolMessage):
+            events.append({
+                "type": "tool_call",
+                "name": message.name,
+                "tool_call_id": message.tool_call_id,
+                "result": message.content,
+                "status": message.status,
+            })
+    return events
 
 
 class FindAgentNodeHost:
-    """Build a fresh, physically capability-limited Agent for every node."""
+    """Create a fresh LangChain Agent for every workflow or delegated node."""
 
     def __init__(
         self,
@@ -77,10 +105,108 @@ class FindAgentNodeHost:
         default_model: str = "google/gemini-3-flash-preview",
         observer: ObagentObserver | None = None,
     ) -> None:
-        self.settings = settings
+        self.settings = settings or get_settings()
         self.models_by_role = dict(models_by_role or {})
         self.default_model = default_model
         self.observer = observer or ObagentObserver()
+        self.usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "cost": 0.0}
+
+    def reset_usage(self) -> None:
+        self.usage = {
+            "input_tokens": 0,
+            "output_tokens": 0,
+            "total_tokens": 0,
+            "cost": 0.0,
+        }
+
+    def _model(self, role: str) -> ChatOpenAI:
+        model_name = self.models_by_role.get(role, self.default_model)
+        return ChatOpenAI(
+            model=model_name,
+            api_key=self.settings.openrouter_api_key,
+            base_url=self.settings.openrouter_base_url,
+            timeout=self.settings.openrouter_timeout_seconds,
+            temperature=0.2,
+            max_retries=0,  # retries are observable LangChain middleware below
+            default_headers={
+                "HTTP-Referer": self.settings.openrouter_site_url,
+                "X-Title": self.settings.openrouter_site_name,
+            },
+        )
+
+    def _middleware(self, role: str):
+        items: list[Any] = [
+            ModelRetryMiddleware(max_retries=2, on_failure="error"),
+            ToolRetryMiddleware(max_retries=2, on_failure="continue"),
+        ]
+        fallback = os.getenv("FIND_AGENT_V2_FALLBACK_MODEL", "").strip()
+        if fallback and fallback != self.models_by_role.get(role, self.default_model):
+            items.insert(0, ModelFallbackMiddleware(self._model_for_name(fallback)))
+        return items
+
+    def _model_for_name(self, model_name: str) -> ChatOpenAI:
+        return ChatOpenAI(
+            model=model_name,
+            api_key=self.settings.openrouter_api_key,
+            base_url=self.settings.openrouter_base_url,
+            timeout=self.settings.openrouter_timeout_seconds,
+            temperature=0.2,
+            max_retries=0,
+        )
+
+    def _delegate_tool(
+        self,
+        *,
+        parent_node: str,
+        round_index: int,
+        system_prompt: str,
+        tools: tuple[ToolFn, ...],
+        max_iterations: int,
+        slots: tuple[InputSlot, ...],
+    ) -> StructuredTool:
+        async def delegate_agents_v2(requests: list[DelegateRequest]) -> str:
+            """并发委派多个相互独立的任务给同阶段子 Agent。"""
+            normalized = [
+                item if isinstance(item, DelegateRequest) else DelegateRequest.model_validate(item)
+                for item in requests
+            ]
+
+            async def run_one(index: int, request: DelegateRequest) -> dict[str, Any]:
+                result = await self.run_node(
+                    node=parent_node,
+                    round_index=round_index,
+                    system_prompt=system_prompt,
+                    user_content=request.task,
+                    tools=tools,
+                    max_iterations=max_iterations,
+                    slots=slots,
+                    branch_key=f"delegate-{index}",
+                    allow_delegation=False,
+                )
+                return {
+                    "branch": index,
+                    "content": result.content,
+                    "iterations": result.iterations,
+                    "tool_calls_made": result.tool_calls_made,
+                }
+
+            results = await asyncio.gather(*(
+                run_one(index, request) for index, request in enumerate(normalized, start=1)
+            ), return_exceptions=True)
+            output = []
+            for index, result in enumerate(results, start=1):
+                if isinstance(result, BaseException):
+                    output.append({"branch": index, "error": f"{type(result).__name__}: {result}"})
+                else:
+                    output.append(result)
+            return json.dumps({"delegated": output}, ensure_ascii=False)
+
+        return StructuredTool.from_function(
+            coroutine=delegate_agents_v2,
+            name="delegate_agents_v2",
+            description="将多个无依赖任务并发委派给拥有相同阶段工具权限的子 Agent。",
+            args_schema=DelegateArgs,
+        )
 
     async def run_node(
         self,
@@ -92,55 +218,69 @@ class FindAgentNodeHost:
         tools: Iterable[ToolFn] = (),
         max_iterations: int = 12,
         slots: tuple[InputSlot, ...] = (),
+        branch_key: str = "",
+        allow_delegation: bool = True,
     ) -> NodeRun:
         tool_functions = tuple(tools)
-        model = self.models_by_role.get(node, self.default_model)
-        event_collector = _ObagentEventCollector()
-        agent = Agent(
-            settings=self.settings,
-            name=f"find_agent_v2.{node}",
-            model=model,
+        model_name = self.models_by_role.get(node, self.default_model)
+        langchain_tools = [_as_langchain_tool(fn) for fn in tool_functions]
+        if allow_delegation and node in {"search", "evidence", "evaluator"}:
+            langchain_tools.append(self._delegate_tool(
+                parent_node=node,
+                round_index=round_index,
+                system_prompt=system_prompt,
+                tools=tool_functions,
+                max_iterations=max_iterations,
+                slots=slots,
+            ))
+        agent = create_agent(
+            model=self._model(node),
+            tools=langchain_tools,
             system_prompt=system_prompt,
-            tools=build_tool_registry(tool_functions),
-            max_iterations=max_iterations,
-            temperature=0.2,
-            # v2 must not write the project's JSONL/log/OSS visualization artifacts.
-            logger=event_collector,
+            middleware=self._middleware(node),
+            name=f"find_agent_v2_{node}",
         )
-        # Stage capabilities are exact. The generic load_skill tool is not part of this workflow.
-        agent.tools.unregister("load_skill")
-        try:
-            with self.observer.node(node=node) as observation:
-                try:
-                    actual_user_content = observation.declare(
-                        fallback=user_content,
-                        system_prompt=system_prompt,
-                        slots=slots,
-                        tools=tool_functions,
-                        model=model,
-                    )
-                    result = await agent.arun_core(actual_user_content)
-                    messages = [message.model_dump(mode="json") for message in result.messages]
-                    process = {
-                        "messages": messages,
-                        "events": event_collector.events,
-                        "iterations": result.iterations,
-                        "tool_calls_made": result.tool_calls_made,
-                    }
-                    observation.record_react(output=process, ok=True)
-                    observation.set_output(
-                        {"agent输出": result.content, **process}, ok=True,
-                    )
-                    return NodeRun.from_agent_result(node, round_index, result)
-                except Exception as exc:
-                    error = {"error": f"{type(exc).__name__}: {exc}"}
-                    observation.record_react(output=error, ok=False)
-                    observation.set_output(error, ok=False)
-                    raise
-        finally:
-            client = getattr(agent.llm, "_async_client", None)
-            if client is not None:
-                await client.close()
+        with self.observer.node(node=node, branch_key=branch_key) as observation:
+            actual_user_content = observation.declare(
+                fallback=user_content,
+                system_prompt=system_prompt,
+                slots=slots,
+                tools=tuple(langchain_tools),
+                model=model_name,
+                refs=({"delegate": "same-stage-worker"} if allow_delegation else None),
+            )
+            result = await agent.ainvoke(
+                {"messages": [{"role": "user", "content": actual_user_content}]},
+                # create_agent counts model and tool nodes separately, and middleware
+                # retries also consume supersteps. Keep this budget distinct from
+                # the business-level no-progress guard in graph.py.
+                config={"recursion_limit": max(64, max_iterations * 6)},
+            )
+            messages: list[BaseMessage] = list(result.get("messages") or [])
+            usage = _usage(messages)
+            for key in ("input_tokens", "output_tokens", "total_tokens"):
+                self.usage[key] = int(self.usage[key]) + int(usage[key])
+            self.usage["cost"] = round(float(self.usage["cost"]) + float(usage["cost"]), 8)
+            process = {
+                "messages": [_message_dict(message) for message in messages],
+                "events": _events(messages),
+                "usage": usage,
+                "iterations": sum(isinstance(message, AIMessage) for message in messages),
+                "tool_calls_made": sum(
+                    len(message.tool_calls) for message in messages if isinstance(message, AIMessage)
+                ),
+            }
+            last_ai = next((message for message in reversed(messages) if isinstance(message, AIMessage)), None)
+            content = str(last_ai.content if last_ai is not None else "")
+            observation.record_react(output=process, ok=True)
+            observation.set_output({"agent输出": content, **process}, ok=True)
+            return NodeRun(
+                node=node,
+                round_index=round_index,
+                content=content,
+                iterations=int(process["iterations"]),
+                tool_calls_made=int(process["tool_calls_made"]),
+            )
 
 
 def normalize_models(
@@ -154,7 +294,7 @@ def normalize_models(
 ) -> dict[str, str]:
     base = model or "google/gemini-3-flash-preview"
     return {
-        "planner": planning or base,
+        "supervisor": planning or base,
         "search": search or base,
         "evidence": evidence or base,
         "evaluator": evaluation or base,

+ 55 - 2
find_agent_v2/service.py

@@ -126,6 +126,10 @@ class FindAgentV2Service:
                 "search_count": row.search_count,
                 "candidate_count": row.candidate_count,
                 "valid_primary_count": row.valid_primary_count,
+                "input_tokens": row.input_tokens,
+                "output_tokens": row.output_tokens,
+                "total_tokens": row.total_tokens,
+                "cost_usd": float(row.cost_usd or 0),
                 "intent_summary": row.intent_summary,
                 "stop_reason": row.stop_reason,
                 "obagent_run_uid": row.obagent_run_uid,
@@ -141,6 +145,32 @@ class FindAgentV2Service:
                 raise FindAgentV2RunNotFound(run_id)
             row.obagent_run_uid = str(run_uid)[:64]
 
+    def add_usage(self, run_id: str, usage: dict[str, Any]) -> None:
+        """Accumulate one execution attempt; resume never erases earlier usage."""
+        with get_session() as session:
+            row = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
+            if row is None:
+                raise FindAgentV2RunNotFound(run_id)
+            row.input_tokens += int(usage.get("input_tokens") or 0)
+            row.output_tokens += int(usage.get("output_tokens") or 0)
+            row.total_tokens += int(usage.get("total_tokens") or 0)
+            row.cost_usd = (
+                Decimal(str(row.cost_usd or 0)) + Decimal(str(usage.get("cost") or 0))
+            ).quantize(Decimal("0.00000001"))
+
+    def prepare_resume(self, run_id: str) -> dict[str, Any]:
+        """Reset a terminal run for a recovery round without deleting audit rows."""
+        with get_session() as session:
+            row = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
+            if row is None:
+                raise FindAgentV2RunNotFound(run_id)
+            if row.status == "running":
+                raise ValueError(f"run_id={run_id} 已处于 running,不能重复 resume")
+            row.status = "running"
+            row.outcome_status = None
+            row.stop_reason = None
+        return self.require_run(run_id)
+
     def require_run(self, run_id: str) -> dict[str, Any]:
         run = self.lookup_run(run_id)
         if run is None:
@@ -400,6 +430,22 @@ class FindAgentV2Service:
             run.valid_primary_count = len({row.aweme_id for row in primaries if row.gate_status == "pass"})
         return output
 
+    def recount_valid_primary(self, run_id: str) -> int:
+        """Recompute the denormalized primary counter after parallel worker writes."""
+        with get_session() as session:
+            run = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
+            if run is None:
+                raise FindAgentV2RunNotFound(run_id)
+            count = int(session.scalar(select(func.count(func.distinct(
+                FindAgentV2Candidate.aweme_id,
+            ))).where(
+                FindAgentV2Candidate.run_id == run_id,
+                FindAgentV2Candidate.decision_bucket == "primary",
+                FindAgentV2Candidate.gate_status == "pass",
+            )) or 0)
+            run.valid_primary_count = count
+            return count
+
     def get_full_state(
         self, run_id: str, *, limit: int = 100, pending_only: bool = False,
     ) -> dict[str, Any]:
@@ -446,11 +492,18 @@ class FindAgentV2Service:
             outcome_status=run.get("outcome_status") or "",
         )
 
-    def finalize(self, run_id: str, *, failed: bool = False, reason: str = "") -> dict[str, Any]:
+    def finalize(
+        self,
+        run_id: str,
+        *,
+        failed: bool = False,
+        reason: str = "",
+        target_primary_count: int = 5,
+    ) -> dict[str, Any]:
         snapshot = self.snapshot(run_id)
         if failed:
             outcome, status = "failed", "failed"
-        elif snapshot.valid_primary_count >= 5:
+        elif snapshot.valid_primary_count >= max(1, int(target_primary_count)):
             outcome, status = "goal_met", "finished"
         elif snapshot.valid_primary_count > 0:
             outcome, status = "partial", "finished"

+ 22 - 1
find_agent_v2/state.py

@@ -7,11 +7,12 @@ This state only contains orchestration pointers and audit summaries.
 from __future__ import annotations
 
 from dataclasses import dataclass, field
-from typing import Any, Literal
+from typing import Any, Literal, TypedDict
 
 from supply_agent.types import AgentResult
 
 Phase = Literal["planning", "searching", "evidence", "evaluating", "done"]
+SupervisorAction = Literal["search", "evidence", "evaluator", "finish"]
 EndKind = Literal["goal_met", "partial", "no_match", "failed", "stopped"]
 
 
@@ -72,6 +73,26 @@ class FindAgentState:
     failures: list[dict[str, Any]] = field(default_factory=list)
 
 
+class FindAgentGraphState(TypedDict, total=False):
+    """Serializable state used by the real one-round LangGraph."""
+
+    run_id: str
+    user_input: str
+    round_index: int
+    plan: str
+    phase: Phase
+    node_runs: list[NodeRun]
+    snapshot: DiscoverySnapshot | None
+    supervisor_step: int
+    approved_action: SupervisorAction
+    action_count: int
+    search_actions: int
+    worker_count: int
+    evidence_scope: str
+    decision_history: list[dict[str, Any]]
+    evaluator_stagnation: int
+
+
 @dataclass(frozen=True)
 class FindAgentResult:
     """Workflow result with technical and business status kept separate."""

+ 4 - 1
find_agent_v2/test_entry.py

@@ -18,6 +18,7 @@ def main() -> None:
     parser.add_argument("--existing-run-id", default=None, help="读取并执行已准备的 v2 run")
     parser.add_argument("--execute", action="store_true", help="准备后立即调用模型和外部搜索")
     parser.add_argument("--model", default=None)
+    parser.add_argument("--resume", action="store_true", help="从 terminal run 的下一轮恢复")
     args = parser.parse_args()
 
     if args.existing_run_id:
@@ -38,7 +39,9 @@ def main() -> None:
         output = {"prepared": prepared.summary(), "executed": False}
     if args.execute:
         result = (
-            run_prepared_find_agent_v2(args.existing_run_id, model=args.model)
+            run_prepared_find_agent_v2(
+                args.existing_run_id, model=args.model, resume=args.resume,
+            )
             if args.existing_run_id
             else run_find_agent_v2(
                 prepared.user_input,

+ 69 - 0
find_agent_v2/tools.py

@@ -19,6 +19,75 @@ from supply_agent.tools.registry import ToolRegistry
 ToolFn = Callable[..., Any]
 
 
+def bound_candidate_tools(
+    functions: tuple[ToolFn, ...], *, run_id: str, candidate_ids: list[int],
+) -> tuple[ToolFn, ...]:
+    """Physically constrain worker tools to one run and one candidate shard."""
+    allowed = {int(value) for value in candidate_ids}
+    output: list[ToolFn] = []
+    for original in functions:
+        name = str(getattr(original, "_tool_name", original.__name__))
+        if name == "query_pending_candidates_v2":
+            def make_query(fn_name: str, worker_run_id: str, worker_allowed: set[int]):
+                @tool(name=fn_name, description="仅查询当前 Worker 分片中的待评估候选。")
+                def bound_query(run_id: str, limit: int = 100) -> str:
+                    if run_id != worker_run_id:
+                        return json.dumps({"error": "run_id 不属于当前 Worker"}, ensure_ascii=False)
+                    state = get_find_agent_v2_service().get_full_state(
+                        run_id, limit=limit, pending_only=True,
+                    )
+                    state["candidates"] = [
+                        item for item in state["candidates"]
+                        if int(item["candidate_id"]) in worker_allowed
+                    ]
+                    return json.dumps(state, ensure_ascii=False, default=str)
+
+                return bound_query
+
+            output.append(make_query(name, run_id, allowed))
+            continue
+
+        def make_async(fn: ToolFn, fn_name: str, worker_run_id: str, worker_allowed: set[int]):
+            @tool(name=fn_name, description=getattr(fn, "_tool_description", ""))
+            async def bound_async(run_id: str, candidate_ids: list[int]) -> str:
+                if run_id != worker_run_id:
+                    return json.dumps({"error": "run_id 不属于当前 Worker"}, ensure_ascii=False)
+                requested = [int(value) for value in candidate_ids]
+                if not requested or not set(requested) <= worker_allowed:
+                    return json.dumps({
+                        "error": "candidate_ids 超出当前 Worker 分片",
+                        "allowed_candidate_ids": sorted(worker_allowed),
+                    }, ensure_ascii=False)
+                return await fn(run_id=run_id, candidate_ids=requested)
+
+            return bound_async
+
+        def make_sync(fn: ToolFn, fn_name: str, worker_run_id: str, worker_allowed: set[int]):
+            @tool(name=fn_name, description=getattr(fn, "_tool_description", ""))
+            def bound_sync(run_id: str, items: list[dict[str, Any]]) -> str:
+                if run_id != worker_run_id:
+                    return json.dumps({"error": "run_id 不属于当前 Worker"}, ensure_ascii=False)
+                requested = {int(item.get("candidate_id") or 0) for item in items}
+                if not requested or not requested <= worker_allowed:
+                    return json.dumps({
+                        "error": "items 超出当前 Worker 分片",
+                        "allowed_candidate_ids": sorted(worker_allowed),
+                    }, ensure_ascii=False)
+                return fn(run_id=run_id, items=items)
+
+            return bound_sync
+
+        wrapper: ToolFn
+        if name in {"fetch_candidate_details_v2", "fetch_candidate_portraits_v2"}:
+            wrapper = make_async(original, name, run_id, allowed)
+        elif name == "evaluate_candidates_v2":
+            wrapper = make_sync(original, name, run_id, allowed)
+        else:
+            wrapper = original
+        output.append(wrapper)
+    return tuple(output)
+
+
 @tool
 async def search_videos_v2(run_id: str, round_index: int, searches: list[dict[str, Any]]) -> str:
     """批量搜索并仅写入 find_agent_v2_search/candidate;searches 最多 6 项。"""

+ 3 - 0
pyproject.toml

@@ -25,6 +25,9 @@ dependencies = [
     "uvicorn[standard]>=0.32.0",
     "markdown>=3.6",
     "obagent-sdk>=0.5.6",
+    "langgraph>=1.2.11,<2",
+    "langchain>=1.3.15,<2",
+    "langchain-openai>=1.4.3,<2",
 ]
 
 [project.optional-dependencies]

+ 3 - 0
requirements.txt

@@ -10,6 +10,9 @@ rich>=13.0
 python-dotenv>=1.0.0
 markdown>=3.6
 obagent-sdk>=0.5.6
+langgraph>=1.2.11,<2
+langchain>=1.3.15,<2
+langchain-openai>=1.4.3,<2
 
 # Database
 sqlalchemy>=2.0

+ 134 - 9
tests/supply_agent/test_find_agent_v2.py

@@ -1,12 +1,16 @@
 from __future__ import annotations
 
+import json
 from dataclasses import replace
 from pathlib import Path
 from types import SimpleNamespace
 
 import pytest
+from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
+from langchain_core.messages import AIMessage
 
 from find_agent_v2.graph import FindAgentRoundGraph
+from find_agent_v2.runtime import DelegateArgs, FindAgentNodeHost
 from find_agent_v2.demand_context import (
     V2DemandContext,
     V2ReferencePoint,
@@ -28,6 +32,7 @@ from find_agent_v2.observability import (
     OBAGENT_AGENT,
     OBAGENT_PROJECT,
     OBAGENT_ROUND_ANCHOR,
+    NullObserver,
 )
 from find_agent_v2.prompts import COMMON_RULES
 from find_agent_v2.providers import normalize_age_pair
@@ -59,6 +64,9 @@ def test_v2_orm_uses_only_new_table_namespace() -> None:
         "find_agent_v2_evidence",
     }
     assert "obagent_run_uid" in FindAgentV2Run.__table__.columns
+    assert {"input_tokens", "output_tokens", "total_tokens", "cost_usd"} <= {
+        column.name for column in FindAgentV2Run.__table__.columns
+    }
 
 
 def test_v2_package_has_no_legacy_business_imports() -> None:
@@ -166,9 +174,56 @@ def test_obagent_identity_and_round_structure_are_stable() -> None:
     assert OBAGENT_AGENT == "find_agent_v2"
     assert OBAGENT_ROUND_ANCHOR == {"in": "run", "on": ["graph"]}
     assert [node["key"] for node in GRAPH_SPEC["nodes"]] == [
-        "planner", "search", "evidence", "evaluator",
+        "supervisor", "search", "evidence", "evaluator",
     ]
-    assert set(MODULE_TITLES) == {"planner", "search", "evidence", "evaluator", "report"}
+    assert set(MODULE_TITLES) == {"supervisor", "search", "evidence", "evaluator", "report"}
+
+
+def test_round_graph_is_a_compiled_langgraph() -> None:
+    service = _FakeService(pending_after_search=0)
+    graph = FindAgentRoundGraph(service=service, runner=_FakeRunner(service))
+    drawable = graph.app.get_graph()
+    assert {"supervisor", "search", "evidence", "evaluator"} <= set(drawable.nodes)
+    assert graph.obagent_spec.get("nodes")
+
+
+def test_runtime_exposes_bounded_delegate_schema() -> None:
+    field = DelegateArgs.model_fields["requests"]
+    assert field.metadata
+    assert FindAgentNodeHost.__module__ == "find_agent_v2.runtime"
+
+
+def test_runtime_usage_can_be_reset_between_resume_attempts() -> None:
+    host = FindAgentNodeHost(observer=NullObserver())
+    host.usage["total_tokens"] = 99
+    host.reset_usage()
+    assert host.usage == {
+        "input_tokens": 0,
+        "output_tokens": 0,
+        "total_tokens": 0,
+        "cost": 0.0,
+    }
+
+
+@pytest.mark.asyncio
+async def test_langchain_runtime_runs_without_network(monkeypatch) -> None:
+    host = FindAgentNodeHost(observer=NullObserver())
+    fake_model = FakeMessagesListChatModel(responses=[AIMessage(content="done")])
+    monkeypatch.setattr(host, "_model", lambda _role: fake_model)
+
+    result = await host.run_node(
+        node="supervisor",
+        round_index=1,
+        system_prompt="plan",
+        user_content="task",
+        tools=(),
+        max_iterations=2,
+        allow_delegation=False,
+    )
+
+    assert result.content == "done"
+    assert result.iterations == 1
+    assert result.tool_calls_made == 0
 
 
 def test_stage_tool_allowlists_are_physical_and_isolated() -> None:
@@ -202,13 +257,30 @@ class _FakeService:
         self.updates: list[dict] = []
 
     def get_full_state(self, run_id: str, **_kwargs):
-        return {"run": {"run_id": run_id}, "searches": [], "candidates": []}
+        snapshot = self.snapshot(run_id)
+        evidence_status = "pending" if self.stage in {"start", "searched"} else "success"
+        return {
+            "run": {"run_id": run_id},
+            "searches": [],
+            "candidates": [
+                {
+                    "candidate_id": index,
+                    "decision_bucket": "pending_evaluation",
+                    "detail_status": evidence_status,
+                    "portrait_status": evidence_status,
+                }
+                for index in range(1, snapshot.pending_count + 1)
+            ],
+        }
 
     def snapshot(self, _run_id: str) -> DiscoverySnapshot:
         base = DiscoverySnapshot("running", 0, 0, 0, 0, 0, 0)
         if self.stage == "searched":
             return replace(base, search_count=1, candidate_count=self.pending_after_search,
                            pending_count=self.pending_after_search)
+        if self.stage in {"evidenced", "batched"}:
+            return replace(base, search_count=1, candidate_count=self.pending_after_search,
+                           pending_count=self.pending_after_search)
         if self.stage == "evaluated":
             return replace(base, search_count=1, candidate_count=self.pending_after_search,
                            rejected_count=self.pending_after_search)
@@ -217,6 +289,9 @@ class _FakeService:
     def update_round(self, _run_id: str, _round_index: int, **kwargs) -> None:
         self.updates.append(kwargs)
 
+    def recount_valid_primary(self, _run_id: str) -> int:
+        return 0
+
 
 class _FakeRunner:
     def __init__(self, service: _FakeService) -> None:
@@ -227,13 +302,32 @@ class _FakeRunner:
         self.calls.append((node, _names(tools)))
         if node == "search":
             self.service.stage = "searched"
+        elif node == "evidence":
+            self.service.stage = "evidenced"
         elif node == "evaluator":
             self.service.stage = "evaluated"
-        return NodeRun(node, round_index, '{"searches": []}', 1, 0)
+        if node == "supervisor":
+            if self.service.stage == "evaluated":
+                next_action = "finish"
+            elif self.service.stage in {"evidenced", "batched"}:
+                next_action = "evaluator"
+            elif self.service.stage == "searched" and not self.service.pending_after_search:
+                next_action = "finish"
+            else:
+                next_action = "search"
+            content = json.dumps({
+                "next_action": next_action,
+                "worker_count": 4,
+                "evidence_scope": "both",
+                "plan": {"searches": []},
+            })
+        else:
+            content = '{"searches": []}'
+        return NodeRun(node, round_index, content, 1, 0)
 
 
 @pytest.mark.asyncio
-async def test_round_graph_runs_fixed_stage_order_and_allowlists() -> None:
+async def test_round_graph_supervisor_routes_with_guarded_allowlists() -> None:
     service = _FakeService(pending_after_search=2)
     runner = _FakeRunner(service)
     graph = FindAgentRoundGraph(service=service, runner=runner)
@@ -241,11 +335,16 @@ async def test_round_graph_runs_fixed_stage_order_and_allowlists() -> None:
 
     result = await graph.invoke(state)
 
-    assert [name for name, _ in runner.calls] == ["planner", "search", "evidence", "evaluator"]
+    assert [name for name, _ in runner.calls] == [
+        "supervisor", "search", "supervisor", "evidence", "evidence",
+        "supervisor", "evaluator", "supervisor",
+    ]
     assert runner.calls[0][1] == set()
     assert runner.calls[1][1] == _names(SEARCH_TOOLS)
-    assert runner.calls[2][1] == _names(EVIDENCE_TOOLS)
-    assert runner.calls[3][1] == _names(EVALUATION_TOOLS)
+    assert runner.calls[2][1] == set()
+    assert runner.calls[3][1] <= _names(EVIDENCE_TOOLS)
+    assert runner.calls[4][1] <= _names(EVIDENCE_TOOLS)
+    assert runner.calls[6][1] == _names(EVALUATION_TOOLS)
     assert result.phase == "done"
     assert result.snapshot is not None and result.snapshot.pending_count == 0
 
@@ -258,7 +357,9 @@ async def test_round_graph_skips_evidence_and_evaluation_without_candidates() ->
 
     await graph.invoke(FindAgentState(run_id="new-run", user_input="task", round_index=1))
 
-    assert [name for name, _ in runner.calls] == ["planner", "search"]
+    assert [name for name, _ in runner.calls] == [
+        "supervisor", "search", "supervisor",
+    ]
 
 
 @pytest.mark.asyncio
@@ -305,6 +406,30 @@ async def test_round_graph_reenters_evaluator_until_pending_queue_is_empty() ->
     assert result.snapshot is not None and result.snapshot.pending_count == 0
 
 
+def test_supervisor_policy_overrides_unsafe_finish_and_clamps_workers() -> None:
+    service = _FakeService(pending_after_search=2)
+    service.stage = "searched"
+    graph = FindAgentRoundGraph(service=service, runner=_FakeRunner(service))
+    action, _reason, workers, scope = graph._approve_action(
+        {"run_id": "r", "search_actions": 1, "action_count": 1},
+        {"next_action": "finish", "worker_count": 99, "evidence_scope": "invalid"},
+    )
+    assert action == "evidence"
+    assert workers == 8
+    assert scope == "both"
+
+
+def test_supervisor_can_choose_an_extra_search_within_budget() -> None:
+    service = _FakeService(pending_after_search=0)
+    service.stage = "searched"
+    graph = FindAgentRoundGraph(service=service, runner=_FakeRunner(service))
+    action, *_ = graph._approve_action(
+        {"run_id": "r", "search_actions": 1, "action_count": 1},
+        {"next_action": "search", "worker_count": 2},
+    )
+    assert action == "search"
+
+
 @pytest.mark.asyncio
 async def test_round_graph_retries_one_stagnant_evaluator_response() -> None:
     service = _FakeService(pending_after_search=2)