Procházet zdrojové kódy

寻找agent v2迭代

xueyiming před 6 dny
rodič
revize
ac229c2a1a

+ 5 - 4
find_agent_v2/agent.py

@@ -177,9 +177,10 @@ class FindAgentV2:
                 self.service.begin_round(run_id, round_index, state.previous_snapshot)
                 await graph.invoke(state)
                 current = state.snapshot or self.service.snapshot(run_id)
-                if current.pending_count:
+                pending_count = self.service.count_pending_candidates(run_id)
+                if pending_count:
                     failed = True
-                    end_reason = f"第 {round_index} 轮结束仍有 {current.pending_count} 条待评估候选"
+                    end_reason = f"第 {round_index} 轮结束仍有 {pending_count} 条待评估候选"
                     break
                 exploration = decide_continued_exploration(
                     state.previous_snapshot, current,
@@ -219,7 +220,7 @@ class FindAgentV2:
             try:
                 report_assignment = ReportAssignment(
                     run_id=run_id,
-                    final_state=self.service.get_full_state(run_id),
+                    final_state=self.service.get_report_state(run_id),
                 )
                 report = await self.node_runner.run_node(
                     node="report",
@@ -230,7 +231,7 @@ class FindAgentV2:
                     max_iterations=4,
                     slots=assignment_slots(
                         report_assignment,
-                        source="FindAgentV2Service.get_full_state(final)",
+                        source="FindAgentV2Service.get_report_state",
                     ),
                 )
                 state.node_runs.append(report)

+ 216 - 142
find_agent_v2/graph.py

@@ -23,6 +23,7 @@ from find_agent_v2.service import FindAgentV2Service
 from find_agent_v2.state import (
     EvidenceAssignment,
     EvaluationAssignment,
+    ExecutionPlan,
     FindAgentGraphState,
     FindAgentState,
     NodeRun,
@@ -78,26 +79,13 @@ class FindAgentRoundGraph:
         self.app = self._build_graph()
         self.obagent_spec = graph_spec_for(self.app)
 
-    def _full_state(self, state: FindAgentGraphState, *, pending_only: bool = False):
-        return self.service.get_full_state(
-            state["run_id"],
-            pending_only=pending_only,
-        )
-
     @staticmethod
     def _assignment_input(assignment, *, source: str) -> tuple[str, tuple[InputSlot, ...]]:
         return render_assignment(assignment), assignment_slots(assignment, source=source)
 
     def _supervisor_state(self, state: FindAgentGraphState) -> dict[str, Any]:
         """Compact progress projection; routing never needs full candidate payloads."""
-        full_state = self._full_state(state)
-        run = full_state.get("run") or {}
-        candidates = list(full_state.get("candidates") or [])
-        pending_candidates = [
-            item
-            for item in candidates
-            if item.get("decision_bucket") == "pending_evaluation"
-        ]
+        run = self.service.require_run(state["run_id"])
         return {
             "run": {
                 key: run.get(key)
@@ -106,61 +94,74 @@ class FindAgentRoundGraph:
                     "candidate_count", "valid_primary_count",
                 )
             },
-            "searches": full_state.get("searches") or [],
-            "candidate_progress": {
-                "pending_count": sum(
-                    1 for _item in pending_candidates
-                ),
-                "primary_count": sum(
-                    item.get("decision_bucket") == "primary" for item in candidates
-                ),
-                "rejected_count": sum(
-                    item.get("decision_bucket") == "rejected" for item in candidates
-                ),
-                "detail_pending_count": sum(
-                    item.get("detail_status") == "pending"
-                    for item in pending_candidates
-                ),
-                "detail_success_count": sum(
-                    item.get("detail_status") == "success"
-                    for item in pending_candidates
-                ),
-                "detail_failed_count": sum(
-                    item.get("detail_status") == "failed"
-                    for item in pending_candidates
-                ),
-                "portrait_pending_count": sum(
-                    item.get("portrait_status") == "pending"
-                    for item in pending_candidates
-                ),
-                "portrait_success_count": sum(
-                    item.get("portrait_status") == "success"
-                    for item in pending_candidates
-                ),
-                "portrait_failed_count": sum(
-                    item.get("portrait_status") == "failed"
-                    for item in pending_candidates
-                ),
-                "evidence_completed_count": sum(
-                    item.get("detail_status") != "pending"
-                    and item.get("portrait_status") != "pending"
-                    for item in pending_candidates
-                ),
-                "evidence_success_count": sum(
-                    item.get("detail_status") == "success"
-                    and item.get("portrait_status") == "success"
-                    for item in pending_candidates
-                ),
-            },
+            "searches": [
+                {
+                    key: item.get(key)
+                    for key in (
+                        "keyword", "provider", "status", "result_count", "has_more",
+                    )
+                }
+                for item in self.service.get_search_summaries(state["run_id"])
+            ],
+            "candidate_progress": self.service.get_candidate_progress(state["run_id"]),
+        }
+
+    def _deterministic_supervisor_decision(
+        self, state: FindAgentGraphState,
+    ) -> SupervisorDecision | None:
+        """Route state-machine steps in code; reserve the LLM for exploration choices."""
+        progress = self.service.get_candidate_progress(state["run_id"])
+        pending = int(progress.get("pending_count") or 0)
+        if not pending:
+            return None
+        detail_pending = int(progress.get("detail_pending_count") or 0)
+        portrait_pending = int(progress.get("portrait_pending_count") or 0)
+        worker_count = max(1, min(8, int(state.get("worker_count") or 4)))
+        if detail_pending or portrait_pending:
+            scope = (
+                "both" if detail_pending and portrait_pending
+                else "detail" if detail_pending
+                else "portrait"
+            )
+            return SupervisorDecision(
+                next_action="evidence",
+                reason="宿主检测到待补证候选,执行确定性证据路由",
+                worker_count=worker_count,
+                evidence_scope=scope,
+            )
+        return SupervisorDecision(
+            next_action="evaluator",
+            reason="宿主确认全部证据尝试已结束,执行确定性评估路由",
+            worker_count=worker_count,
+            evidence_scope="both",
+        )
+
+    @staticmethod
+    def _supervisor_plan_projection(execution_plan: ExecutionPlan) -> dict[str, Any]:
+        """Exclude executed SearchTask details already represented by search summaries."""
+        return {
+            "schema_version": execution_plan.schema_version,
+            "demand_brief": execution_plan.demand_brief.model_dump(mode="json"),
+            "evaluation_brief": execution_plan.evaluation_brief.model_dump(mode="json"),
         }
 
+    @staticmethod
+    def _evaluation_candidate_projection(item: dict[str, Any]) -> dict[str, Any]:
+        keys = (
+            "candidate_id", "aweme_id", "title", "video_url", "source_keywords", "tags",
+            "publish_at", "duration_seconds", "play_count", "like_count", "comment_count",
+            "collect_count", "share_count", "content_50_plus_ratio", "account_50_plus_ratio",
+            "detail_status", "portrait_status",
+        )
+        return {key: item.get(key) for key in keys if item.get(key) is not None}
+
     def _video_understanding_ids(
         self,
         state: FindAgentGraphState,
         items: list[dict[str, Any]],
     ) -> list[int]:
         """Only candidates passing every deterministic hard gate may use video understanding."""
-        run = self._full_state(state).get("run") or {}
+        run = self.service.require_run(state["run_id"])
         rules = run.get("rule_config") or {}
         selected: list[int] = []
         for item in items:
@@ -195,9 +196,10 @@ class FindAgentRoundGraph:
         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)
+        progress = self.service.get_candidate_progress(state["run_id"])
+        pending_count = int(progress.get("pending_count") or 0)
+        missing_detail = int(progress.get("detail_pending_count") or 0) > 0
+        missing_portrait = int(progress.get("portrait_pending_count") or 0) > 0
         proposed = str(proposal.get("next_action") or "").lower()
         reason = str(proposal.get("reason") or "")
         searches = int(state.get("search_actions") or 0)
@@ -211,18 +213,18 @@ class FindAgentRoundGraph:
             scope = "both"
 
         if actions > self.max_actions:
-            if not pending:
+            if not pending_count:
                 return "finish", "安全收敛动作已完成", worker_count, scope
             raise RuntimeError(
-                f"Supervisor 安全收敛动作未产生进展:actions={actions}, pending={len(pending)}"
+                f"Supervisor 安全收敛动作未产生进展:actions={actions}, pending={pending_count}"
             )
         if actions == self.max_actions:
-            if pending and not (missing_detail or missing_portrait):
+            if pending_count and not (missing_detail or missing_portrait):
                 return "evaluator", "动作预算耗尽,强制消费待评估候选", worker_count, scope
-            if pending:
+            if pending_count:
                 return "evidence", "动作预算耗尽,强制补齐缺失证据", worker_count, "both"
             return "finish", "达到单轮动作安全上限", worker_count, scope
-        if pending:
+        if pending_count:
             if missing_detail or missing_portrait:
                 if scope == "detail" and not missing_detail:
                     scope = "portrait"
@@ -275,14 +277,15 @@ class FindAgentRoundGraph:
             assignment = SupervisorAssignment(
                 run_id=state["run_id"],
                 round_index=state["round_index"],
-                execution_plan=execution_plan,
+                execution_plan=self._supervisor_plan_projection(execution_plan),
                 execution_state=self._supervisor_state(state),
             )
             user_content, slots = self._assignment_input(
                 assignment,
-                source="ExecutionPlan + FindAgentV2Service.get_full_state",
+                source="ExecutionPlan compact projection + database aggregates",
             )
             structured_runner = getattr(self.runner, "run_supervision", None)
+            host_runner = getattr(self.runner, "run_host_supervision", None)
             def approve_for_observation(supervision: SupervisorDecision) -> dict[str, Any]:
                 proposal_payload = supervision.model_dump(mode="json")
                 approved, approved_reason, approved_workers, approved_scope = (
@@ -300,7 +303,21 @@ class FindAgentRoundGraph:
                 observed_decision.update(decision_payload)
                 return {"Supervisor决策校验": decision_payload}
 
-            if callable(structured_runner):
+            deterministic = (
+                self._deterministic_supervisor_decision(state)
+                if callable(host_runner)
+                else None
+            )
+            if deterministic is not None:
+                run, supervision = await host_runner(
+                    round_index=state["round_index"],
+                    decision=deterministic,
+                    system_prompt=SUPERVISOR_PROMPT,
+                    user_content=user_content,
+                    slots=slots,
+                    output_enricher=approve_for_observation,
+                )
+            elif callable(structured_runner):
                 run, supervision = await structured_runner(
                     round_index=state["round_index"],
                     system_prompt=SUPERVISOR_PROMPT,
@@ -382,7 +399,7 @@ class FindAgentRoundGraph:
                 str(item.get("provider") or ""),
                 str(item.get("query_reason") or ""),
             )
-            for item in self._full_state(state).get("searches") or []
+            for item in self.service.get_search_summaries(state["run_id"])
         }
         tasks = [
             item
@@ -408,15 +425,25 @@ class FindAgentRoundGraph:
             assignment,
             source="ExecutionPlan.search_tasks",
         )
-        run = await self.runner.run_node(
-            node="search",
-            round_index=state["round_index"],
-            system_prompt=SEARCH_PROMPT,
-            user_content=user_content,
-            tools=SEARCH_TOOLS,
-            max_iterations=10,
-            slots=slots,
-        )
+        host_runner = getattr(self.runner, "run_search_assignment", None)
+        if callable(host_runner):
+            run = await host_runner(
+                round_index=state["round_index"],
+                assignment=assignment,
+                system_prompt=SEARCH_PROMPT,
+                user_content=user_content,
+                slots=slots,
+            )
+        else:
+            run = await self.runner.run_node(
+                node="search",
+                round_index=state["round_index"],
+                system_prompt=SEARCH_PROMPT,
+                user_content=user_content,
+                tools=SEARCH_TOOLS,
+                max_iterations=10,
+                slots=slots,
+            )
         return {
             "phase": "searching",
             "search_actions": int(state.get("search_actions") or 0) + 1,
@@ -426,25 +453,20 @@ class FindAgentRoundGraph:
 
     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))))
+        worker_count = max(1, min(8, int(state.get("worker_count") or 4)))
+        batch_limit = worker_count * 8
+        evidence_types = (
+            ("detail",) if scope == "detail"
+            else ("portrait",) if scope == "portrait"
+            else ("detail", "portrait")
+        )
+        semaphore = asyncio.Semaphore(worker_count)
+        node_runs = list(state.get("node_runs", []))
+        batch_index = 0
 
         async def run_shard(
-            index: int,
+            branch_key: str,
             evidence_type: str,
             items: list[dict[str, Any]],
         ) -> NodeRun:
@@ -466,6 +488,16 @@ class FindAgentRoundGraph:
                 else (EVIDENCE_TOOLS[1], EVIDENCE_TOOLS[2])
             )
             async with semaphore:
+                host_runner = getattr(self.runner, "run_evidence_assignment", None)
+                if callable(host_runner):
+                    return await host_runner(
+                        round_index=state["round_index"],
+                        assignment=assignment,
+                        system_prompt=EVIDENCE_PROMPT,
+                        user_content=user_content,
+                        slots=slots,
+                        branch_key=branch_key,
+                    )
                 return await self.runner.run_node(
                     node="evidence",
                     round_index=state["round_index"],
@@ -478,46 +510,67 @@ class FindAgentRoundGraph:
                     ),
                     max_iterations=12,
                     slots=slots,
-                    branch_key=f"{evidence_type}-shard-{index}",
+                    branch_key=branch_key,
                     allow_delegation=False,
                 )
 
-        runs = (
-            await asyncio.gather(
-                *(
-                    run_shard(index, evidence_type, items)
-                    for index, (evidence_type, items) in enumerate(jobs, start=1)
+        while True:
+            batch_index += 1
+            jobs: list[tuple[str, list[dict[str, Any]]]] = []
+            selected_ids: dict[str, list[int]] = {}
+            for evidence_type in evidence_types:
+                candidate_ids = self.service.list_pending_evidence_ids(
+                    state["run_id"], evidence_type, limit=batch_limit,
                 )
-            )
-            if jobs
-            else []
-        )
+                selected_ids[evidence_type] = candidate_ids
+                candidates = self.service.candidate_inputs(state["run_id"], candidate_ids)
+                jobs.extend(
+                    (evidence_type, candidates[index : index + 8])
+                    for index in range(0, len(candidates), 8)
+                )
+            if not jobs:
+                break
+            runs = await asyncio.gather(*(
+                run_shard(
+                    f"{evidence_type}-batch-{batch_index}-shard-{index}",
+                    evidence_type,
+                    items,
+                )
+                for index, (evidence_type, items) in enumerate(jobs, start=1)
+            ))
+            node_runs.extend(runs)
+            for evidence_type, candidate_ids in selected_ids.items():
+                status_key = f"{evidence_type}_status"
+                remaining = [
+                    int(item["candidate_id"])
+                    for item in self.service.candidate_inputs(state["run_id"], candidate_ids)
+                    if item.get(status_key) == "pending"
+                ]
+                if remaining:
+                    raise RuntimeError(
+                        f"证据分批未完整消费:type={evidence_type}, pending={remaining}"
+                    )
         return {
             "phase": "evidence",
             "action_count": int(state.get("action_count") or 0) + 1,
-            "node_runs": [*state.get("node_runs", []), *runs],
+            "node_runs": node_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"]
-        run = self._full_state(state).get("run") or {}
+        run = self.service.require_run(state["run_id"])
         rules = run.get("rule_config") or {}
-        eligible: list[dict[str, Any]] = []
-        gate_failures: list[tuple[int, dict[str, Any]]] = []
-        for item in pending:
-            gate = evaluate_candidate_gate(item, rules)
-            if gate.get("status") == "pass":
-                eligible.append(item)
-            else:
-                gate_failures.append((int(item["candidate_id"]), gate))
-        self.service.reject_failed_gates(state["run_id"], gate_failures)
-        shards = [eligible[index : index + 8] for index in range(0, len(eligible), 8)]
-        semaphore = asyncio.Semaphore(max(1, min(8, int(state.get("worker_count") or 4))))
+        worker_count = max(1, min(8, int(state.get("worker_count") or 4)))
+        batch_limit = worker_count * 8
+        semaphore = asyncio.Semaphore(worker_count)
+        batch_index = 0
 
-        async def run_shard(index: int, items: list[dict[str, Any]]) -> NodeRun:
+        async def run_shard(
+            batch_number: int,
+            index: int,
+            items: list[dict[str, Any]],
+        ) -> NodeRun:
             candidate_ids = [int(item["candidate_id"]) for item in items]
             video_understanding_ids = self._video_understanding_ids(state, items)
             execution_plan = state.get("execution_plan")
@@ -532,7 +585,7 @@ class FindAgentRoundGraph:
                 current_datetime=str(rules.get("current_datetime") or ""),
                 timezone=str(rules.get("timezone") or ""),
                 video_understanding_candidate_ids=video_understanding_ids,
-                candidates=items,
+                candidates=[self._evaluation_candidate_projection(item) for item in items],
             )
             user_content, slots = self._assignment_input(
                 assignment,
@@ -555,7 +608,10 @@ class FindAgentRoundGraph:
                         system_prompt=EVALUATOR_PROMPT,
                         user_content=user_content,
                         slots=slots,
-                        branch_key=f"step-{state.get('supervisor_step', 0)}-shard-{index}",
+                        branch_key=(
+                            f"step-{state.get('supervisor_step', 0)}-"
+                            f"batch-{batch_number}-shard-{index}"
+                        ),
                         tools=video_tools,
                     )
                     normalized = normalize_evaluation_items(
@@ -585,31 +641,49 @@ class FindAgentRoundGraph:
                     ),
                     max_iterations=12,
                     slots=slots,
-                    branch_key=f"step-{state.get('supervisor_step', 0)}-shard-{index}",
+                    branch_key=(
+                        f"step-{state.get('supervisor_step', 0)}-"
+                        f"batch-{batch_number}-shard-{index}"
+                    ),
                     allow_delegation=False,
                 )
 
-        runs = (
-            await asyncio.gather(
-                *(run_shard(index, items) for index, items in enumerate(shards, start=1))
+        while True:
+            batch_index += 1
+            candidate_ids = self.service.list_ready_evaluation_ids(
+                state["run_id"], limit=batch_limit,
             )
-            if shards
-            else []
-        )
-        node_runs.extend(runs)
+            if not candidate_ids:
+                break
+            items = self.service.candidate_inputs(state["run_id"], candidate_ids)
+            eligible: list[dict[str, Any]] = []
+            gate_failures: list[tuple[int, dict[str, Any]]] = []
+            for item in items:
+                gate = evaluate_candidate_gate(item, rules)
+                if gate.get("status") == "pass":
+                    eligible.append(item)
+                else:
+                    gate_failures.append((int(item["candidate_id"]), gate))
+            self.service.reject_failed_gates(state["run_id"], gate_failures)
+            shards = [eligible[index : index + 8] for index in range(0, len(eligible), 8)]
+            runs = await asyncio.gather(*(
+                run_shard(batch_index, index, shard)
+                for index, shard in enumerate(shards, start=1)
+            )) if shards else []
+            node_runs.extend(runs)
+            remaining = [
+                int(item["candidate_id"])
+                for item in self.service.candidate_inputs(state["run_id"], candidate_ids)
+                if item.get("decision_bucket") == "pending_evaluation"
+            ]
+            if remaining:
+                raise RuntimeError(f"评估分批未完整消费:pending={remaining}")
         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(
-                f"评估节点连续 3 次未消费 pending_evaluation 候选:remaining={after.pending_count}"
-            )
         return {
             "phase": "evaluating",
             "node_runs": node_runs,
             "action_count": int(state.get("action_count") or 0) + 1,
-            "evaluator_stagnation": stagnant,
+            "evaluator_stagnation": 0,
         }
 
     @staticmethod

+ 111 - 4
find_agent_v2/runtime.py

@@ -20,9 +20,20 @@ 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, PlanningDecision, SupervisorDecision
-from find_agent_v2.tools import ToolFn
-from find_agent_v2.tools import EvaluationBatch
+from find_agent_v2.state import (
+    EvidenceAssignment,
+    NodeRun,
+    PlanningDecision,
+    SearchAssignment,
+    SupervisorDecision,
+)
+from find_agent_v2.tools import (
+    EvaluationBatch,
+    ToolFn,
+    fetch_candidate_details_v2,
+    fetch_candidate_portraits_v2,
+    search_videos_v2,
+)
 from supply_agent.config import Settings, get_settings
 
 
@@ -81,7 +92,11 @@ def _usage(messages: list[BaseMessage]) -> dict[str, int | float]:
         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")
+        cost = (
+            response.get("cost")
+            or (response.get("token_usage") or {}).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)
@@ -171,6 +186,98 @@ class FindAgentNodeHost:
             max_retries=0,
         )
 
+    @staticmethod
+    def _json_result(raw: str) -> dict[str, Any]:
+        try:
+            value = json.loads(raw)
+        except (TypeError, ValueError):
+            return {"raw": raw}
+        return value if isinstance(value, dict) else {"result": value}
+
+    async def run_search_assignment(
+        self,
+        *,
+        round_index: int,
+        assignment: SearchAssignment,
+        system_prompt: str,
+        user_content: str,
+        slots: tuple[InputSlot, ...] = (),
+    ) -> NodeRun:
+        """Execute an already-planned search assignment without another LLM hop."""
+        with self.observer.node(node="search") as observation:
+            observation.declare(
+                fallback=user_content,
+                system_prompt=system_prompt,
+                slots=slots,
+                tools=(search_videos_v2,),
+                model="host/deterministic",
+            )
+            raw = await search_videos_v2(
+                run_id=assignment.run_id,
+                round_index=assignment.round_index,
+                searches=[item.model_dump(mode="json") for item in assignment.tasks],
+            )
+            payload = self._json_result(raw)
+            observation.set_output({"宿主直执行": payload}, ok=not bool(payload.get("error")))
+        return NodeRun("search", round_index, raw, 0, 1)
+
+    async def run_evidence_assignment(
+        self,
+        *,
+        round_index: int,
+        assignment: EvidenceAssignment,
+        system_prompt: str,
+        user_content: str,
+        slots: tuple[InputSlot, ...] = (),
+        branch_key: str = "",
+    ) -> NodeRun:
+        """Execute a host-owned evidence shard without asking a model to select its tool."""
+        tool_fn = (
+            fetch_candidate_details_v2
+            if assignment.evidence_type == "detail"
+            else fetch_candidate_portraits_v2
+        )
+        with self.observer.node(node="evidence", branch_key=branch_key) as observation:
+            observation.declare(
+                fallback=user_content,
+                system_prompt=system_prompt,
+                slots=slots,
+                tools=(tool_fn,),
+                model="host/deterministic",
+            )
+            raw = await tool_fn(
+                run_id=assignment.run_id,
+                candidate_ids=assignment.candidate_ids,
+            )
+            payload = self._json_result(raw)
+            observation.set_output({"宿主直执行": payload}, ok=not bool(payload.get("error")))
+        return NodeRun("evidence", round_index, raw, 0, 1)
+
+    async def run_host_supervision(
+        self,
+        *,
+        round_index: int,
+        decision: SupervisorDecision,
+        system_prompt: str,
+        user_content: str,
+        slots: tuple[InputSlot, ...] = (),
+        output_enricher: Callable[[SupervisorDecision], dict[str, Any]] | None = None,
+    ) -> tuple[NodeRun, SupervisorDecision]:
+        """Record a deterministic routing decision without invoking an LLM."""
+        payload = decision.model_dump(mode="json", exclude_none=True)
+        enriched = output_enricher(decision) if output_enricher else {}
+        with self.observer.node(node="supervisor") as observation:
+            observation.declare(
+                fallback=user_content,
+                system_prompt=system_prompt,
+                slots=slots,
+                tools=(),
+                model="host/deterministic",
+            )
+            observation.set_output({"宿主确定性决策": payload, **enriched}, ok=True)
+        content = json.dumps(payload, ensure_ascii=False)
+        return NodeRun("supervisor", round_index, content, 0, 0), decision
+
     def _delegate_tool(
         self,
         *,

+ 169 - 21
find_agent_v2/service.py

@@ -8,7 +8,7 @@ from dataclasses import asdict
 from decimal import Decimal
 from typing import Any
 
-from sqlalchemy import func, select
+from sqlalchemy import case, func, select
 
 from find_agent_v2.models import (
     FindAgentV2Candidate,
@@ -438,13 +438,148 @@ class FindAgentV2Service:
             raise ValueError(f"candidate_id 不属于 run: {candidate_id}")
         return items[0]
 
-    def save_details(self, run_id: str, details: list[dict[str, Any]], errors: list[dict[str, Any]]) -> None:
+    def get_search_summaries(self, run_id: str) -> list[dict[str, Any]]:
+        """Return compact search execution metadata without loading candidates."""
+        self.require_run(run_id)
+        with get_session() as session:
+            rows = list(session.scalars(select(FindAgentV2Search).where(
+                FindAgentV2Search.run_id == run_id,
+            ).order_by(FindAgentV2Search.id)))
+            return [{
+                "search_id": int(row.id),
+                "round_index": row.round_index,
+                "keyword": row.keyword,
+                "query_reason": row.query_reason,
+                "provider": row.provider,
+                "page_no": row.page_no,
+                "has_more": bool(row.has_more),
+                "next_cursor": row.next_cursor,
+                "status": row.status,
+                "result_count": row.result_count,
+            } for row in rows]
+
+    def get_candidate_progress(self, run_id: str) -> dict[str, int]:
+        """Compute full-run candidate progress with SQL aggregates, never a row limit."""
+        self.require_run(run_id)
+        pending = FindAgentV2Candidate.decision_bucket == "pending_evaluation"
+        detail_pending = FindAgentV2Candidate.detail_status == "pending"
+        portrait_pending = FindAgentV2Candidate.portrait_status == "pending"
+        with get_session() as session:
+            row = session.execute(select(
+                func.count(FindAgentV2Candidate.id),
+                func.sum(case((pending, 1), else_=0)),
+                func.sum(case((FindAgentV2Candidate.decision_bucket == "primary", 1), else_=0)),
+                func.sum(case((FindAgentV2Candidate.decision_bucket == "rejected", 1), else_=0)),
+                func.sum(case((pending & detail_pending, 1), else_=0)),
+                func.sum(case((pending & (FindAgentV2Candidate.detail_status == "success"), 1), else_=0)),
+                func.sum(case((pending & (FindAgentV2Candidate.detail_status == "failed"), 1), else_=0)),
+                func.sum(case((pending & portrait_pending, 1), else_=0)),
+                func.sum(case((pending & (FindAgentV2Candidate.portrait_status == "success"), 1), else_=0)),
+                func.sum(case((pending & (FindAgentV2Candidate.portrait_status == "failed"), 1), else_=0)),
+                func.sum(case((pending & ~detail_pending & ~portrait_pending, 1), else_=0)),
+                func.sum(case((
+                    pending
+                    & (FindAgentV2Candidate.detail_status == "success")
+                    & (FindAgentV2Candidate.portrait_status == "success"),
+                    1,
+                ), else_=0)),
+            ).where(FindAgentV2Candidate.run_id == run_id)).one()
+        values = [int(value or 0) for value in row]
+        keys = (
+            "total_count", "pending_count", "primary_count", "rejected_count",
+            "detail_pending_count", "detail_success_count", "detail_failed_count",
+            "portrait_pending_count", "portrait_success_count", "portrait_failed_count",
+            "evidence_completed_count", "evidence_success_count",
+        )
+        return dict(zip(keys, values, strict=True))
+
+    def list_pending_evidence_ids(
+        self, run_id: str, evidence_type: str, *, limit: int,
+    ) -> list[int]:
+        """Claimable evidence work projection; callers repeatedly drain this queue."""
+        if evidence_type not in {"detail", "portrait"}:
+            raise ValueError("evidence_type 只能是 detail/portrait")
+        status_column = (
+            FindAgentV2Candidate.detail_status
+            if evidence_type == "detail"
+            else FindAgentV2Candidate.portrait_status
+        )
+        with get_session() as session:
+            return [int(value) for value in session.scalars(select(
+                FindAgentV2Candidate.id,
+            ).where(
+                FindAgentV2Candidate.run_id == run_id,
+                FindAgentV2Candidate.decision_bucket == "pending_evaluation",
+                status_column == "pending",
+            ).order_by(FindAgentV2Candidate.id).limit(max(1, int(limit))))]
+
+    def list_ready_evaluation_ids(self, run_id: str, *, limit: int) -> list[int]:
+        """Return pending candidates whose detail and portrait attempts have completed."""
+        with get_session() as session:
+            return [int(value) for value in session.scalars(select(
+                FindAgentV2Candidate.id,
+            ).where(
+                FindAgentV2Candidate.run_id == run_id,
+                FindAgentV2Candidate.decision_bucket == "pending_evaluation",
+                FindAgentV2Candidate.detail_status != "pending",
+                FindAgentV2Candidate.portrait_status != "pending",
+            ).order_by(FindAgentV2Candidate.id).limit(max(1, int(limit))))]
+
+    def count_pending_candidates(self, run_id: str) -> int:
+        with get_session() as session:
+            return int(session.scalar(select(func.count()).select_from(
+                FindAgentV2Candidate,
+            ).where(
+                FindAgentV2Candidate.run_id == run_id,
+                FindAgentV2Candidate.decision_bucket == "pending_evaluation",
+            )) or 0)
+
+    def get_report_state(self, run_id: str) -> dict[str, Any]:
+        """Report-only projection: aggregate summary, primaries and rejection distribution."""
+        run = self.require_run(run_id)
+        progress = self.get_candidate_progress(run_id)
+        with get_session() as session:
+            primaries = list(session.scalars(select(FindAgentV2Candidate).where(
+                FindAgentV2Candidate.run_id == run_id,
+                FindAgentV2Candidate.decision_bucket == "primary",
+            ).order_by(
+                FindAgentV2Candidate.value_score.desc(),
+                FindAgentV2Candidate.id,
+            )))
+            rejection_rows = session.execute(select(
+                FindAgentV2Candidate.reject_reason_code,
+                func.count(FindAgentV2Candidate.id),
+            ).where(
+                FindAgentV2Candidate.run_id == run_id,
+                FindAgentV2Candidate.decision_bucket == "rejected",
+            ).group_by(FindAgentV2Candidate.reject_reason_code)).all()
+            primary_candidates = [_candidate_dict(row) for row in primaries]
+        return {
+            "run": run,
+            "summary": progress,
+            "primary_candidates": primary_candidates,
+            "rejection_reason_distribution": {
+                str(reason or "UNSPECIFIED"): int(count)
+                for reason, count in rejection_rows
+            },
+        }
+
+    def save_details(
+        self,
+        run_id: str,
+        details: list[dict[str, Any]],
+        errors: list[dict[str, Any]],
+        *,
+        requested_aweme_ids: list[str] | None = None,
+    ) -> None:
         by_id = {str(item.get("content_id") or ""): item for item in details}
         error_by_id = {str(item.get("content_id") or ""): item for item in errors}
+        requested = {str(value) for value in (requested_aweme_ids or []) if str(value)}
+        target_ids = set(by_id) | set(error_by_id) | requested
         with get_session() as session:
             rows = list(session.scalars(select(FindAgentV2Candidate).where(
                 FindAgentV2Candidate.run_id == run_id,
-                FindAgentV2Candidate.aweme_id.in_(list(by_id) + list(error_by_id)),
+                FindAgentV2Candidate.aweme_id.in_(target_ids),
             )))
             for row in rows:
                 detail = by_id.get(row.aweme_id)
@@ -466,8 +601,12 @@ class FindAgentV2Service:
                             setattr(row, key, value)
                     status, raw = "success", detail
                 else:
+                    error = error or {
+                        "error": "上游详情响应未包含已请求候选",
+                        "error_code": "UPSTREAM_RESULT_MISSING",
+                    }
                     row.detail_status = "failed"
-                    status, raw = "failed", error or {}
+                    status, raw = "failed", error
                 session.add(FindAgentV2Evidence(
                     run_id=run_id,
                     candidate_id=row.id,
@@ -478,16 +617,27 @@ class FindAgentV2Service:
                     error_message=str((error or {}).get("error") or "") or None,
                 ))
 
-    def save_portraits(self, run_id: str, results: list[dict[str, Any]]) -> None:
+    def save_portraits(
+        self,
+        run_id: str,
+        results: list[dict[str, Any]],
+        *,
+        requested_aweme_ids: list[str] | None = None,
+    ) -> None:
+        by_id = {str(item.get("aweme_id") or ""): item for item in results}
+        requested = {str(value) for value in (requested_aweme_ids or []) if str(value)}
+        target_ids = set(by_id) | requested
         with get_session() as session:
-            for item in results:
-                aweme_id = str(item.get("aweme_id") or "")
-                row = session.scalar(select(FindAgentV2Candidate).where(
-                    FindAgentV2Candidate.run_id == run_id,
-                    FindAgentV2Candidate.aweme_id == aweme_id,
-                ))
-                if row is None:
-                    continue
+            rows = list(session.scalars(select(FindAgentV2Candidate).where(
+                FindAgentV2Candidate.run_id == run_id,
+                FindAgentV2Candidate.aweme_id.in_(target_ids),
+            )))
+            for row in rows:
+                item = by_id.get(row.aweme_id) or {
+                    "aweme_id": row.aweme_id,
+                    "error": "上游画像响应未包含已请求候选",
+                    "error_code": "UPSTREAM_RESULT_MISSING",
+                }
                 normalization = item.get("age_normalization") or {}
                 content = normalization.get("content") or {}
                 account = normalization.get("account") or {}
@@ -617,18 +767,16 @@ class FindAgentV2Service:
             }
 
     def snapshot(self, run_id: str) -> DiscoverySnapshot:
-        state = self.get_full_state(run_id)
-        candidates = state["candidates"]
-        buckets = [item["decision_bucket"] for item in candidates]
-        run = state["run"]
+        run = self.require_run(run_id)
+        progress = self.get_candidate_progress(run_id)
         return DiscoverySnapshot(
             status=run["status"],
             search_count=run["search_count"],
-            candidate_count=run["candidate_count"],
-            pending_count=sum(value == "pending_evaluation" for value in buckets),
-            primary_count=sum(value == "primary" for value in buckets),
+            candidate_count=progress["total_count"],
+            pending_count=progress["pending_count"],
+            primary_count=progress["primary_count"],
             valid_primary_count=run["valid_primary_count"],
-            rejected_count=sum(value == "rejected" for value in buckets),
+            rejected_count=progress["rejected_count"],
             outcome_status=run.get("outcome_status") or "",
         )
 

+ 1 - 1
find_agent_v2/state.py

@@ -91,7 +91,7 @@ class PlannerAssignment(BaseModel):
 class SupervisorAssignment(BaseModel):
     run_id: str
     round_index: int
-    execution_plan: ExecutionPlan
+    execution_plan: dict[str, Any]
     execution_state: dict[str, Any]
 
 

+ 78 - 19
find_agent_v2/tools.py

@@ -109,13 +109,17 @@ def bound_candidate_tools(
                 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
-                    ]
+                    service = get_find_agent_v2_service()
+                    state = {
+                        "run": service.require_run(run_id),
+                        "candidates": [
+                            item
+                            for item in service.candidate_inputs(
+                                run_id, sorted(worker_allowed),
+                            )
+                            if item.get("decision_bucket") == "pending_evaluation"
+                        ][:max(1, int(limit))],
+                    }
                     return json.dumps(state, ensure_ascii=False, default=str)
 
                 return bound_query
@@ -134,6 +138,12 @@ def bound_candidate_tools(
                         "error": "candidate_ids 超出当前 Worker 分片",
                         "allowed_candidate_ids": sorted(worker_allowed),
                     }, ensure_ascii=False)
+                if set(requested) != worker_allowed:
+                    return json.dumps({
+                        "error": "必须一次处理完整 Evidence Worker 分片",
+                        "requested_candidate_ids": requested,
+                        "required_candidate_ids": sorted(worker_allowed),
+                    }, ensure_ascii=False)
                 return await fn(run_id=run_id, candidate_ids=requested)
 
             return bound_async
@@ -159,12 +169,13 @@ def bound_candidate_tools(
             def bound_sync(run_id: str, items: list[CandidateEvaluation]) -> str:
                 if run_id != worker_run_id:
                     raise ValueError("run_id 不属于当前 Worker")
-                state = get_find_agent_v2_service().get_full_state(
-                    run_id, pending_only=True,
-                )
+                service = get_find_agent_v2_service()
                 allowed_candidates = [
-                    item for item in state.get("candidates", [])
-                    if int(item["candidate_id"]) in worker_allowed
+                    item
+                    for item in service.candidate_inputs(
+                        run_id, sorted(worker_allowed),
+                    )
+                    if item.get("decision_bucket") == "pending_evaluation"
                 ]
                 normalized = normalize_evaluation_items(
                     items, allowed_candidates=allowed_candidates,
@@ -256,13 +267,34 @@ async def fetch_candidate_details_v2(run_id: str, candidate_ids: list[int]) -> s
     """批量获取候选详情并仅写入 find_agent_v2_candidate/evidence;最多 8 项。"""
     service = get_find_agent_v2_service()
     candidates = service.candidate_inputs(run_id, candidate_ids[:8])
-    payload = await fetch_details([item["aweme_id"] for item in candidates])
-    service.save_details(run_id, list(payload.get("details") or []), list(payload.get("errors") or []))
+    requested_aweme_ids = [str(item["aweme_id"]) for item in candidates]
+    payload = await fetch_details(requested_aweme_ids)
+    details = list(payload.get("details") or [])
+    errors = list(payload.get("errors") or [])
+    service.save_details(
+        run_id,
+        details,
+        errors,
+        requested_aweme_ids=requested_aweme_ids,
+    )
+    successful_ids = {
+        str(item.get("content_id") or "") for item in details
+    } & set(requested_aweme_ids)
+    error_by_id = {str(item.get("content_id") or ""): item for item in errors}
+    normalized_errors = [
+        error_by_id.get(aweme_id) or {
+            "content_id": aweme_id,
+            "error": "上游详情响应未包含已请求候选",
+            "error_code": "UPSTREAM_RESULT_MISSING",
+        }
+        for aweme_id in requested_aweme_ids
+        if aweme_id not in successful_ids
+    ]
     return json.dumps({
         "run_id": run_id,
-        "success_count": int(payload.get("success_count") or len(payload.get("details") or [])),
-        "failed_count": int(payload.get("failed_count") or len(payload.get("errors") or [])),
-        "errors": payload.get("errors") or [],
+        "success_count": len(successful_ids),
+        "failed_count": len(normalized_errors),
+        "errors": normalized_errors,
     }, ensure_ascii=False)
 
 
@@ -271,13 +303,40 @@ async def fetch_candidate_portraits_v2(run_id: str, candidate_ids: list[int]) ->
     """批量获取候选双侧年龄画像并仅写入 find_agent_v2_candidate/evidence。"""
     service = get_find_agent_v2_service()
     candidates = service.candidate_inputs(run_id, candidate_ids[:8])
+    requested_aweme_ids = [str(item["aweme_id"]) for item in candidates]
     payload = await fetch_portraits([{
             "aweme_id": item["aweme_id"],
             "author_sec_uid": item.get("author_sec_uid"),
         } for item in candidates])
     results = list(payload.get("results") or [])
-    service.save_portraits(run_id, results)
-    return json.dumps({"run_id": run_id, "count": len(results), "results": results}, ensure_ascii=False)
+    service.save_portraits(
+        run_id,
+        results,
+        requested_aweme_ids=requested_aweme_ids,
+    )
+    result_by_id = {str(item.get("aweme_id") or ""): item for item in results}
+    successful_ids = {
+        aweme_id
+        for aweme_id in requested_aweme_ids
+        if aweme_id in result_by_id and not result_by_id[aweme_id].get("error")
+    }
+    errors = [
+        result_by_id.get(aweme_id) or {
+            "aweme_id": aweme_id,
+            "error": "上游画像响应未包含已请求候选",
+            "error_code": "UPSTREAM_RESULT_MISSING",
+        }
+        for aweme_id in requested_aweme_ids
+        if aweme_id not in successful_ids
+    ]
+    return json.dumps({
+        "run_id": run_id,
+        "count": len(results),
+        "success_count": len(successful_ids),
+        "failed_count": len(errors),
+        "errors": errors,
+        "results": results,
+    }, ensure_ascii=False)
 
 
 @tool

+ 403 - 105
tests/supply_agent/test_find_agent_v2.py

@@ -10,12 +10,15 @@ from langchain_core.language_models.fake_chat_models import FakeMessagesListChat
 from langchain_core.messages import AIMessage, ToolMessage
 
 from find_agent_v2.graph import FindAgentRoundGraph
+from find_agent_v2 import tools as find_agent_tools
+from find_agent_v2 import runtime as find_agent_runtime
 from find_agent_v2.agent import decide_continued_exploration
 from find_agent_v2.runtime import (
     DelegateArgs,
     FindAgentNodeHost,
     _events,
     _message_dict,
+    _usage,
 )
 from find_agent_v2.demand_context import (
     V2DemandContext,
@@ -49,11 +52,13 @@ from find_agent_v2.state import (
     DemandBrief,
     DiscoverySnapshot,
     EvaluationBrief,
+    EvidenceAssignment,
     ExecutionPlan,
     FindAgentState,
     NodeRun,
     PlanningDecision,
     SearchTask,
+    SearchAssignment,
     SupervisorDecision,
 )
 from find_agent_v2.service import _fails_search_share_gate
@@ -63,6 +68,7 @@ from find_agent_v2.tools import (
     EVIDENCE_TOOLS,
     REPORT_TOOLS,
     SEARCH_TOOLS,
+    bound_candidate_tools,
     normalize_evaluation_items,
 )
 
@@ -87,6 +93,78 @@ def test_video_tool_error_code_is_observed_as_failed() -> None:
     assert _events([message])[0]["error_code"] == "video_understanding_timeout"
 
 
+def test_runtime_usage_reads_openrouter_token_usage_cost() -> None:
+    message = AIMessage(
+        content="done",
+        usage_metadata={"input_tokens": 10, "output_tokens": 2, "total_tokens": 12},
+        response_metadata={"token_usage": {"cost": 0.00123}},
+    )
+
+    assert _usage([message]) == {
+        "input_tokens": 10,
+        "output_tokens": 2,
+        "total_tokens": 12,
+        "cost": 0.00123,
+    }
+
+
+@pytest.mark.asyncio
+async def test_host_direct_search_and_evidence_paths_use_no_llm(monkeypatch) -> None:
+    calls: list[tuple[str, object]] = []
+
+    async def fake_search(**kwargs):
+        calls.append(("search", kwargs["searches"]))
+        return '{"run_id":"run","searches":[]}'
+
+    async def fake_details(**kwargs):
+        calls.append(("detail", kwargs["candidate_ids"]))
+        return '{"run_id":"run","success_count":2,"failed_count":0}'
+
+    monkeypatch.setattr(find_agent_runtime, "search_videos_v2", fake_search)
+    monkeypatch.setattr(find_agent_runtime, "fetch_candidate_details_v2", fake_details)
+    host = FindAgentNodeHost(observer=NullObserver())
+
+    search_run = await host.run_search_assignment(
+        round_index=1,
+        assignment=SearchAssignment(
+            run_id="run",
+            round_index=1,
+            tasks=[SearchTask(
+                task_id="task-1", keyword="日本间谍", query_reason="测试",
+            )],
+        ),
+        system_prompt="search",
+        user_content="{}",
+    )
+    evidence_run = await host.run_evidence_assignment(
+        round_index=1,
+        assignment=EvidenceAssignment(
+            run_id="run",
+            round_index=1,
+            candidate_ids=[101, 102],
+            evidence_type="detail",
+            candidates=[],
+        ),
+        system_prompt="evidence",
+        user_content="{}",
+    )
+
+    assert calls == [
+        ("search", [{
+            "task_id": "task-1",
+            "keyword": "日本间谍",
+            "query_reason": "测试",
+            "source_type": "mixed",
+            "provider": "internal_keyword",
+            "max_pages": 1,
+            "coverage_targets": [],
+        }]),
+        ("detail", [101, 102]),
+    ]
+    assert (search_run.iterations, evidence_run.iterations) == (0, 0)
+    assert (search_run.tool_calls_made, evidence_run.tool_calls_made) == (1, 1)
+
+
 def _evaluation(**overrides):
     value = {
         "candidate_id": 11,
@@ -449,6 +527,53 @@ def test_stage_tool_allowlists_are_physical_and_isolated() -> None:
     assert "query_video_discovery_state" not in all_names
 
 
+def test_bound_worker_tools_query_exact_ids_beyond_global_display_limit(monkeypatch) -> None:
+    class Service:
+        def require_run(self, run_id):
+            return {"run_id": run_id, "status": "running"}
+
+        def candidate_inputs(self, run_id, candidate_ids):
+            assert run_id == "large-run"
+            assert candidate_ids == [101, 102]
+            return [
+                {
+                    "candidate_id": candidate_id,
+                    "aweme_id": f"video-{candidate_id}",
+                    "decision_bucket": "pending_evaluation",
+                }
+                for candidate_id in candidate_ids
+            ]
+
+        def get_full_state(self, *_args, **_kwargs):
+            raise AssertionError("Worker 不应通过全局展示投影查询自己的分片")
+
+        def evaluate(self, run_id, items):
+            assert run_id == "large-run"
+            return [
+                {"candidate_id": item["candidate_id"], "decision_bucket": "primary"}
+                for item in items
+            ]
+
+    monkeypatch.setattr(find_agent_tools, "get_find_agent_v2_service", lambda: Service())
+    query_tool = bound_candidate_tools(
+        (EVIDENCE_TOOLS[2],), run_id="large-run", candidate_ids=[101, 102],
+    )[0]
+    query_result = json.loads(query_tool(run_id="large-run", limit=100))
+    assert [item["candidate_id"] for item in query_result["candidates"]] == [101, 102]
+
+    evaluation_tool = bound_candidate_tools(
+        (EVALUATION_TOOLS[1],), run_id="large-run", candidate_ids=[101, 102],
+    )[0]
+    evaluation_result = json.loads(evaluation_tool(
+        run_id="large-run",
+        items=[
+            _evaluation(candidate_id=101),
+            _evaluation(candidate_id=102),
+        ],
+    ))
+    assert [item["candidate_id"] for item in evaluation_result["updated"]] == [101, 102]
+
+
 def test_common_prompt_points_to_v2_tables_and_tools() -> None:
     assert "find_agent_v2_run" in COMMON_RULES
     assert "video_discovery_run" not in COMMON_RULES
@@ -507,9 +632,10 @@ def test_exploration_decision_uses_volume_and_pass_rate(
 
 
 def test_video_understanding_only_allows_candidates_passing_all_hard_gates(monkeypatch) -> None:
-    graph = FindAgentRoundGraph(service=_FakeService(pending_after_search=0), runner=object())
-    monkeypatch.setattr(graph, "_full_state", lambda _state: {
-        "run": {"rule_config": build_rule_snapshot()},
+    service = _FakeService(pending_after_search=0)
+    graph = FindAgentRoundGraph(service=service, runner=object())
+    monkeypatch.setattr(service, "require_run", lambda _run_id: {
+        "run_id": "run", "rule_config": build_rule_snapshot(),
     })
     common = {
         "video_url": "https://example.test/video.mp4",
@@ -537,26 +663,121 @@ class _FakeService:
         self.pending_after_search = pending_after_search
         self.stage = "start"
         self.updates: list[dict] = []
+        self.rejected_ids: set[int] = set()
 
-    def get_full_state(self, run_id: str, **_kwargs):
+    def require_run(self, run_id: str):
         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,
-                    "publish_at": build_rule_snapshot()["current_datetime"],
-                    "duration_seconds": 60,
-                    "share_count": 2000,
-                    "content_50_plus_ratio": 0.30,
-                }
-                for index in range(1, snapshot.pending_count + 1)
-            ],
+            "run_id": run_id,
+            "status": "running",
+            "outcome_status": None,
+            "current_round": 1,
+            "search_count": snapshot.search_count,
+            "candidate_count": snapshot.candidate_count,
+            "valid_primary_count": snapshot.valid_primary_count,
+            "rule_config": build_rule_snapshot(),
+        }
+
+    def get_search_summaries(self, _run_id: str):
+        if self.stage == "start":
+            return []
+        return [{
+            "search_id": 1,
+            "round_index": 1,
+            "keyword": "测试需求",
+            "query_reason": "建立候选池",
+            "provider": "internal_keyword",
+            "page_no": 1,
+            "has_more": False,
+            "next_cursor": None,
+            "status": "success",
+            "result_count": self.pending_after_search,
+        }]
+
+    def _candidate_rows(self):
+        if self.stage == "start":
+            return []
+        evidence_status = "pending" if self.stage == "searched" else "success"
+        return [{
+            "candidate_id": index,
+            "decision_bucket": (
+                "rejected"
+                if self.stage == "evaluated" or index in self.rejected_ids
+                else "pending_evaluation"
+            ),
+            "detail_status": evidence_status,
+            "portrait_status": evidence_status,
+            "publish_at": build_rule_snapshot()["current_datetime"],
+            "duration_seconds": 60,
+            "share_count": 2000,
+            "content_50_plus_ratio": 0.30,
+        } for index in range(1, self.pending_after_search + 1)]
+
+    def get_full_state(self, run_id: str, **_kwargs):
+        return {
+            "run": self.require_run(run_id),
+            "searches": self.get_search_summaries(run_id),
+            "candidates": self._candidate_rows(),
+        }
+
+    def candidate_inputs(self, _run_id: str, candidate_ids: list[int]):
+        allowed = {int(value) for value in candidate_ids}
+        return [
+            item for item in self._candidate_rows()
+            if int(item["candidate_id"]) in allowed
+        ]
+
+    def get_candidate_progress(self, _run_id: str):
+        candidates = self._candidate_rows()
+        pending = [
+            item for item in candidates
+            if item["decision_bucket"] == "pending_evaluation"
+        ]
+        return {
+            "total_count": len(candidates),
+            "pending_count": len(pending),
+            "primary_count": 0,
+            "rejected_count": len(candidates) - len(pending),
+            "detail_pending_count": sum(item["detail_status"] == "pending" for item in pending),
+            "detail_success_count": sum(item["detail_status"] == "success" for item in pending),
+            "detail_failed_count": 0,
+            "portrait_pending_count": sum(item["portrait_status"] == "pending" for item in pending),
+            "portrait_success_count": sum(item["portrait_status"] == "success" for item in pending),
+            "portrait_failed_count": 0,
+            "evidence_completed_count": sum(
+                item["detail_status"] != "pending" and item["portrait_status"] != "pending"
+                for item in pending
+            ),
+            "evidence_success_count": sum(
+                item["detail_status"] == "success" and item["portrait_status"] == "success"
+                for item in pending
+            ),
+        }
+
+    def list_pending_evidence_ids(self, _run_id: str, evidence_type: str, *, limit: int):
+        key = f"{evidence_type}_status"
+        return [
+            int(item["candidate_id"]) for item in self._candidate_rows()
+            if item["decision_bucket"] == "pending_evaluation" and item[key] == "pending"
+        ][:limit]
+
+    def list_ready_evaluation_ids(self, _run_id: str, *, limit: int):
+        return [
+            int(item["candidate_id"]) for item in self._candidate_rows()
+            if item["decision_bucket"] == "pending_evaluation"
+            and item["detail_status"] != "pending"
+            and item["portrait_status"] != "pending"
+        ][:limit]
+
+    def count_pending_candidates(self, run_id: str) -> int:
+        return int(self.get_candidate_progress(run_id)["pending_count"])
+
+    def get_report_state(self, run_id: str):
+        return {
+            "run": self.require_run(run_id),
+            "summary": self.get_candidate_progress(run_id),
+            "primary_candidates": [],
+            "rejection_reason_distribution": {},
         }
 
     def snapshot(self, _run_id: str) -> DiscoverySnapshot:
@@ -579,7 +800,9 @@ class _FakeService:
         return 0
 
     def reject_failed_gates(self, _run_id: str, failures) -> list[int]:
-        return [candidate_id for candidate_id, _gate in failures]
+        rejected = [candidate_id for candidate_id, _gate in failures]
+        self.rejected_ids.update(rejected)
+        return rejected
 
 
 class _FakeRunner:
@@ -680,39 +903,82 @@ class _WrongEvidenceProposalRunner(_FakeRunner):
         return run
 
 
+class _OptimizedRunner(_FakeRunner):
+    def __init__(self, service: _FakeService) -> None:
+        super().__init__(service)
+        self.host_calls: list[str] = []
+
+    async def run_search_assignment(self, *, round_index, user_content, **_kwargs):
+        self.host_calls.append("search")
+        self.inputs.append(("search", user_content))
+        self.service.stage = "searched"
+        return NodeRun("search", round_index, "host search", 0, 1)
+
+    async def run_evidence_assignment(
+        self, *, round_index, user_content, **_kwargs,
+    ):
+        self.host_calls.append("evidence")
+        self.inputs.append(("evidence", user_content))
+        self.service.stage = "evidenced"
+        return NodeRun("evidence", round_index, "host evidence", 0, 1)
+
+    async def run_host_supervision(
+        self, *, round_index, decision, output_enricher=None, **_kwargs,
+    ):
+        self.host_calls.append(f"supervisor:{decision.next_action}")
+        if output_enricher is not None:
+            self.supervisor_visual_outputs.append(output_enricher(decision))
+        return NodeRun("supervisor", round_index, decision.model_dump_json(), 0, 0), decision
+
+
+@pytest.mark.asyncio
+async def test_host_executes_deterministic_stages_without_redundant_llm_calls() -> None:
+    service = _FakeService(pending_after_search=2)
+    runner = _OptimizedRunner(service)
+    graph = FindAgentRoundGraph(service=service, runner=runner)
+
+    result = await graph.invoke(FindAgentState(
+        run_id="optimized-run", user_input="task", round_index=1,
+    ))
+
+    assert runner.host_calls == [
+        "search",
+        "supervisor:evidence",
+        "evidence",
+        "evidence",
+        "supervisor:evaluator",
+    ]
+    assert [node for node, _tools in runner.calls] == [
+        "supervisor", "evaluator", "supervisor",
+    ]
+    assert result.snapshot is not None and result.snapshot.pending_count == 0
+
+
 def test_supervisor_progress_counts_missing_evidence_only_for_pending_candidates() -> None:
     class ProgressService:
         @staticmethod
-        def get_full_state(_run_id: str, **_kwargs):
+        def require_run(_run_id: str):
+            return {}
+
+        @staticmethod
+        def get_search_summaries(_run_id: str):
+            return []
+
+        @staticmethod
+        def get_candidate_progress(_run_id: str):
             return {
-                "run": {},
-                "searches": [],
-                "candidates": [
-                    {
-                        "candidate_id": 1,
-                        "decision_bucket": "pending_evaluation",
-                        "detail_status": "success",
-                        "portrait_status": "pending",
-                    },
-                    {
-                        "candidate_id": 2,
-                        "decision_bucket": "rejected",
-                        "detail_status": "pending",
-                        "portrait_status": "pending",
-                    },
-                    {
-                        "candidate_id": 3,
-                        "decision_bucket": "pending_evaluation",
-                        "detail_status": "failed",
-                        "portrait_status": "success",
-                    },
-                    {
-                        "candidate_id": 4,
-                        "decision_bucket": "pending_evaluation",
-                        "detail_status": "success",
-                        "portrait_status": "success",
-                    },
-                ],
+                "total_count": 4,
+                "pending_count": 3,
+                "primary_count": 0,
+                "rejected_count": 1,
+                "detail_pending_count": 0,
+                "detail_success_count": 2,
+                "detail_failed_count": 1,
+                "portrait_pending_count": 1,
+                "portrait_success_count": 2,
+                "portrait_failed_count": 0,
+                "evidence_completed_count": 2,
+                "evidence_success_count": 1,
             }
 
     graph = FindAgentRoundGraph(service=ProgressService(), runner=object())
@@ -845,28 +1111,34 @@ async def test_evaluator_model_never_receives_hard_gate_failures() -> None:
     service.stage = "evidenced"
     rejected: list[int] = []
 
-    def state(_run_id: str, **_kwargs):
-        return {
-            "run": {"run_id": "r", "rule_config": build_rule_snapshot()},
-            "searches": [],
-            "candidates": [{
-                "candidate_id": 1,
-                "decision_bucket": "pending_evaluation",
-                "detail_status": "success",
-                "portrait_status": "success",
-                "publish_at": build_rule_snapshot()["current_datetime"],
-                "duration_seconds": 10,
-                "share_count": 2000,
-                "content_50_plus_ratio": 0.30,
-                "video_url": "https://example.test/video.mp4",
-            }],
-        }
-
-    service.get_full_state = state  # type: ignore[method-assign]
-    service.reject_failed_gates = (  # type: ignore[method-assign]
-        lambda _run_id, failures: rejected.extend(candidate_id for candidate_id, _ in failures)
-        or [candidate_id for candidate_id, _ in failures]
+    bucket = ["pending_evaluation"]
+
+    def candidate_inputs(_run_id: str, candidate_ids: list[int]):
+        if 1 not in candidate_ids:
+            return []
+        return [{
+            "candidate_id": 1,
+            "decision_bucket": bucket[0],
+            "detail_status": "success",
+            "portrait_status": "success",
+            "publish_at": build_rule_snapshot()["current_datetime"],
+            "duration_seconds": 10,
+            "share_count": 2000,
+            "content_50_plus_ratio": 0.30,
+            "video_url": "https://example.test/video.mp4",
+        }]
+
+    def reject(_run_id: str, failures):
+        ids = [candidate_id for candidate_id, _ in failures]
+        rejected.extend(ids)
+        bucket[0] = "rejected"
+        return ids
+
+    service.candidate_inputs = candidate_inputs  # type: ignore[method-assign]
+    service.list_ready_evaluation_ids = (  # type: ignore[method-assign]
+        lambda _run_id, limit: [1] if bucket[0] == "pending_evaluation" else []
     )
+    service.reject_failed_gates = reject  # type: ignore[method-assign]
     runner = _FakeRunner(service)
     graph = FindAgentRoundGraph(service=service, runner=runner)
 
@@ -880,39 +1152,67 @@ async def test_evaluator_model_never_receives_hard_gate_failures() -> None:
 
 
 @pytest.mark.asyncio
-async def test_round_graph_reenters_evaluator_until_pending_queue_is_empty() -> None:
-    service = _FakeService(pending_after_search=3)
+async def test_evaluator_drains_database_batches_until_pending_queue_is_empty() -> None:
+    class BatchedService(_FakeService):
+        def __init__(self) -> None:
+            super().__init__(pending_after_search=3)
+            self.remaining_ids = {1, 2, 3}
+
+        def _candidate_rows(self):
+            if self.stage == "start":
+                return []
+            evidence_status = "pending" if self.stage == "searched" else "success"
+            return [{
+                "candidate_id": index,
+                "decision_bucket": (
+                    "pending_evaluation" if index in self.remaining_ids else "rejected"
+                ),
+                "detail_status": evidence_status,
+                "portrait_status": evidence_status,
+                "publish_at": build_rule_snapshot()["current_datetime"],
+                "duration_seconds": 60,
+                "share_count": 2000,
+                "content_50_plus_ratio": 0.30,
+            } for index in range(1, 4)]
+
+        def list_ready_evaluation_ids(self, _run_id: str, *, limit: int):
+            del limit
+            return sorted(self.remaining_ids)[:1]
+
+        def snapshot(self, _run_id: str) -> DiscoverySnapshot:
+            if self.stage == "start":
+                return DiscoverySnapshot("running", 0, 0, 0, 0, 0, 0)
+            return DiscoverySnapshot(
+                "running", 1, 3, len(self.remaining_ids), 0, 0,
+                3 - len(self.remaining_ids),
+            )
+
+    service = BatchedService()
 
     class BatchedRunner(_FakeRunner):
         def __init__(self, fake_service: _FakeService) -> None:
             super().__init__(fake_service)
-            self.remaining = 3
-
-        async def run_node(self, *, node, round_index, tools=(), **kwargs) -> NodeRun:
-            result = await super().run_node(
-                node=node, round_index=round_index, tools=tools, **kwargs,
-            )
+        async def run_node(
+            self, *, node, round_index, tools=(), user_content="", **kwargs,
+        ) -> NodeRun:
             if node == "evaluator":
-                self.remaining -= 1
-                self.service.stage = "evaluated" if self.remaining == 0 else "batched"
-            return result
-
-    runner = BatchedRunner(service)
-    original_snapshot = service.snapshot
-
-    def batched_snapshot(run_id: str) -> DiscoverySnapshot:
-        if service.stage == "batched":
-            base = original_snapshot(run_id)
-            return replace(
-                base,
-                search_count=1,
-                candidate_count=3,
-                pending_count=runner.remaining,
-                rejected_count=3 - runner.remaining,
+                self.calls.append((node, _names(tools)))
+                self.inputs.append((node, user_content))
+                candidate_ids = json.loads(user_content)["candidate_ids"]
+                self.service.remaining_ids.difference_update(candidate_ids)
+                self.service.stage = (
+                    "evaluated" if not self.service.remaining_ids else "batched"
+                )
+                return NodeRun(node, round_index, "", 1, 0)
+            return await super().run_node(
+                node=node,
+                round_index=round_index,
+                tools=tools,
+                user_content=user_content,
+                **kwargs,
             )
-        return original_snapshot(run_id)
 
-    service.snapshot = batched_snapshot  # type: ignore[method-assign]
+    runner = BatchedRunner(service)
     graph = FindAgentRoundGraph(service=service, runner=runner)
 
     result = await graph.invoke(
@@ -948,7 +1248,7 @@ def test_supervisor_can_choose_an_extra_search_within_budget() -> None:
 
 
 @pytest.mark.asyncio
-async def test_round_graph_retries_one_stagnant_evaluator_response() -> None:
+async def test_round_graph_rejects_incomplete_evaluator_batch() -> None:
     service = _FakeService(pending_after_search=2)
 
     class RetryRunner(_FakeRunner):
@@ -958,8 +1258,6 @@ async def test_round_graph_retries_one_stagnant_evaluator_response() -> None:
             if node == "evaluator":
                 self.calls.append((node, _names(tools)))
                 self.evaluator_calls += 1
-                if self.evaluator_calls == 2:
-                    self.service.stage = "evaluated"
                 return NodeRun(node, round_index, "", 1, 0)
             return await super().run_node(
                 node=node, round_index=round_index, tools=tools, **kwargs,
@@ -968,9 +1266,9 @@ async def test_round_graph_retries_one_stagnant_evaluator_response() -> None:
     runner = RetryRunner(service)
     graph = FindAgentRoundGraph(service=service, runner=runner)
 
-    result = await graph.invoke(
-        FindAgentState(run_id="retry-run", user_input="task", round_index=1),
-    )
+    with pytest.raises(RuntimeError, match="评估分批未完整消费"):
+        await graph.invoke(
+            FindAgentState(run_id="retry-run", user_input="task", round_index=1),
+        )
 
-    assert runner.evaluator_calls == 2
-    assert result.snapshot is not None and result.snapshot.pending_count == 0
+    assert runner.evaluator_calls == 1

+ 151 - 2
tests/supply_agent/test_find_agent_v2_run_timeout.py

@@ -10,10 +10,159 @@ from sqlalchemy import create_engine, select
 from sqlalchemy.orm import Session, sessionmaker
 
 from api.services import find_agent_v2 as api_service
+from find_agent_v2 import service as find_agent_service
 from find_agent_v2.agent import FindAgentV2
-from find_agent_v2.models import FindAgentV2Run
+from find_agent_v2.models import (
+    FindAgentV2Candidate,
+    FindAgentV2Evidence,
+    FindAgentV2Run,
+    FindAgentV2Search,
+)
 from find_agent_v2.observability import NullObserver
-from find_agent_v2.service import RUN_TIMEOUT_REASON
+from find_agent_v2.service import FindAgentV2Service, RUN_TIMEOUT_REASON
+
+
+def test_service_progress_and_work_queues_are_not_limited_to_100(monkeypatch) -> None:
+    engine = create_engine("sqlite+pysqlite:///:memory:")
+    FindAgentV2Run.__table__.create(engine)
+    FindAgentV2Search.__table__.create(engine)
+    FindAgentV2Candidate.__table__.create(engine)
+    FindAgentV2Evidence.__table__.create(engine)
+    factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
+
+    @contextmanager
+    def session_scope() -> Generator[Session, None, None]:
+        session = factory()
+        try:
+            yield session
+            session.commit()
+        finally:
+            session.close()
+
+    monkeypatch.setattr(find_agent_service, "get_session", session_scope)
+    with factory.begin() as session:
+        session.add(FindAgentV2Run(
+            id=1,
+            run_id="large-run",
+            demand_word="批量测试",
+            input_json='{"user_input":"test"}',
+            rule_config_json="{}",
+            status="running",
+            candidate_count=137,
+        ))
+        candidates = []
+        for candidate_id in range(1, 138):
+            if candidate_id <= 50:
+                detail_status, portrait_status, bucket = "pending", "pending", "pending_evaluation"
+            elif candidate_id <= 90:
+                detail_status, portrait_status, bucket = "success", "success", "pending_evaluation"
+            elif candidate_id <= 120:
+                detail_status, portrait_status, bucket = "failed", "success", "pending_evaluation"
+            elif candidate_id <= 130:
+                detail_status, portrait_status, bucket = "success", "success", "primary"
+            else:
+                detail_status, portrait_status, bucket = "success", "success", "rejected"
+            candidates.append(FindAgentV2Candidate(
+                id=candidate_id,
+                run_id="large-run",
+                aweme_id=f"video-{candidate_id}",
+                detail_status=detail_status,
+                portrait_status=portrait_status,
+                decision_bucket=bucket,
+            ))
+        session.add_all(candidates)
+
+    service = FindAgentV2Service()
+    progress = service.get_candidate_progress("large-run")
+
+    assert progress == {
+        "total_count": 137,
+        "pending_count": 120,
+        "primary_count": 10,
+        "rejected_count": 7,
+        "detail_pending_count": 50,
+        "detail_success_count": 40,
+        "detail_failed_count": 30,
+        "portrait_pending_count": 50,
+        "portrait_success_count": 70,
+        "portrait_failed_count": 0,
+        "evidence_completed_count": 70,
+        "evidence_success_count": 40,
+    }
+    assert service.snapshot("large-run").candidate_count == 137
+    assert service.count_pending_candidates("large-run") == 120
+    assert len(service.get_full_state("large-run")["candidates"]) == 100
+    assert len(service.list_pending_evidence_ids("large-run", "detail", limit=16)) == 16
+    assert len(service.list_ready_evaluation_ids("large-run", limit=100)) == 70
+    report = service.get_report_state("large-run")
+    assert len(report["primary_candidates"]) == 10
+    assert report["summary"]["total_count"] == 137
+
+
+def test_missing_evidence_response_marks_requested_candidate_failed(monkeypatch) -> None:
+    engine = create_engine("sqlite+pysqlite:///:memory:")
+    FindAgentV2Candidate.__table__.create(engine)
+    FindAgentV2Evidence.__table__.create(engine)
+    factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
+    next_evidence_id = 1
+
+    @contextmanager
+    def session_scope() -> Generator[Session, None, None]:
+        nonlocal next_evidence_id
+        session = factory()
+        try:
+            yield session
+            for instance in session.new:
+                if isinstance(instance, FindAgentV2Evidence) and instance.id is None:
+                    instance.id = next_evidence_id
+                    next_evidence_id += 1
+            session.commit()
+        finally:
+            session.close()
+
+    monkeypatch.setattr(find_agent_service, "get_session", session_scope)
+    with factory.begin() as session:
+        session.add_all([
+            FindAgentV2Candidate(
+                id=candidate_id,
+                run_id="partial-response",
+                aweme_id=f"video-{candidate_id}",
+                detail_status="pending",
+                portrait_status="pending",
+                decision_bucket="pending_evaluation",
+            )
+            for candidate_id in (1, 2)
+        ])
+
+    service = FindAgentV2Service()
+    service.save_details(
+        "partial-response",
+        [{"content_id": "video-2", "title": "success"}],
+        [],
+        requested_aweme_ids=["video-1", "video-2"],
+    )
+    service.save_portraits(
+        "partial-response",
+        [{"aweme_id": "video-2", "age_normalization": {}}],
+        requested_aweme_ids=["video-1", "video-2"],
+    )
+
+    with factory() as session:
+        missing = session.get(FindAgentV2Candidate, 1)
+        successful = session.get(FindAgentV2Candidate, 2)
+        evidence = list(session.scalars(select(FindAgentV2Evidence).order_by(
+            FindAgentV2Evidence.id,
+        )))
+    assert missing is not None and successful is not None
+    assert (missing.detail_status, missing.portrait_status) == ("failed", "failed")
+    assert (successful.detail_status, successful.portrait_status) == ("success", "success")
+    assert [item.status for item in evidence].count("failed") == 2
+    assert [item.status for item in evidence].count("success") == 2
+    assert all(
+        "上游" in str(item.error_message)
+        for item in evidence
+        if item.status == "failed"
+    )
 
 
 def test_admin_list_marks_runs_older_than_60_minutes_failed(monkeypatch) -> None: