| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276 |
- """Outer round orchestrator for the isolated find agent."""
- from __future__ import annotations
- import asyncio
- import json
- from dataclasses import dataclass
- from find_agent_v2.context import build_node_slots
- from find_agent_v2.graph import FindAgentRoundGraph, NodeRunner
- from find_agent_v2.observability import ObagentObserver
- from find_agent_v2.prompts import REPORT_PROMPT
- from find_agent_v2.runtime import FindAgentNodeHost, normalize_models
- from find_agent_v2.service import (
- RUN_TIMEOUT_REASON,
- RUN_TIMEOUT_SECONDS,
- FindAgentV2Service,
- get_find_agent_v2_service,
- )
- from find_agent_v2.state import DiscoverySnapshot, FindAgentResult, FindAgentState
- from find_agent_v2.tools import REPORT_TOOLS
- from supply_agent.config import Settings
- @dataclass(frozen=True)
- class ExplorationDecision:
- continue_exploring: bool
- reason: str
- new_candidate_count: int
- new_evaluated_count: int
- round_pass_rate: float
- cumulative_pass_rate: float
- def decide_continued_exploration(
- previous: DiscoverySnapshot, current: DiscoverySnapshot,
- ) -> ExplorationDecision:
- """Decide whether to continue from discovery volume and evaluation yield."""
- new_candidates = max(0, current.candidate_count - previous.candidate_count)
- previous_evaluated = previous.primary_count + previous.rejected_count
- current_evaluated = current.primary_count + current.rejected_count
- new_evaluated = max(0, current_evaluated - previous_evaluated)
- new_passed = max(0, current.valid_primary_count - previous.valid_primary_count)
- round_rate = new_passed / new_evaluated if new_evaluated else 0.0
- cumulative_rate = (
- current.valid_primary_count / current_evaluated if current_evaluated else 0.0
- )
- if new_candidates == 0:
- keep_going = False
- reason = "本轮没有新增候选,搜索前沿已无信息增益"
- elif current_evaluated < 8:
- keep_going = True
- reason = "已评估样本不足 8 条,继续探索以形成可靠通过率"
- elif new_evaluated and round_rate == 0:
- keep_going = False
- reason = "本轮有足够评估样本但通过率为 0,继续搜索的预期收益低"
- elif new_candidates >= 2 and (round_rate >= 0.10 or cumulative_rate >= 0.10):
- keep_going = True
- reason = "本轮仍有候选增量且评估保持正向通过率,继续探索"
- else:
- keep_going = False
- reason = "候选增量或评估通过率不足,停止探索"
- return ExplorationDecision(
- continue_exploring=keep_going,
- reason=reason,
- new_candidate_count=new_candidates,
- new_evaluated_count=new_evaluated,
- round_pass_rate=round(round_rate, 4),
- cumulative_pass_rate=round(cumulative_rate, 4),
- )
- class FindAgentV2:
- """Python outer loop + guarded Supervisor graph + node-local ReAct."""
- def __init__(
- self,
- *,
- service: FindAgentV2Service | None = None,
- node_runner: NodeRunner | None = None,
- settings: Settings | None = None,
- models_by_role: dict[str, str] | None = None,
- max_rounds: int = 2,
- max_actions_per_round: int = 16,
- max_search_actions_per_round: int = 3,
- max_runtime_seconds: float = RUN_TIMEOUT_SECONDS,
- observer: ObagentObserver | None = None,
- ) -> None:
- self.service = service or get_find_agent_v2_service()
- self.observer = observer or ObagentObserver()
- self.node_runner = node_runner or FindAgentNodeHost(
- settings=settings,
- models_by_role=models_by_role,
- observer=self.observer,
- )
- self.models_by_role = dict(models_by_role or {})
- self.max_rounds = max(1, int(max_rounds))
- 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))
- self.max_runtime_seconds = max(0.01, float(max_runtime_seconds))
- 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,
- )
- 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 ""),
- model=default_model,
- models_by_role=self.models_by_role,
- ) as observation_run:
- self.service.set_obagent_run_uid(
- run_id, getattr(observation_run, "run_uid", None),
- )
- try:
- result = await asyncio.wait_for(
- self._arun_inner(
- state=state, graph=graph,
- start_round=int(run.get("current_round") or 0) + 1,
- ),
- timeout=self.max_runtime_seconds,
- )
- except TimeoutError:
- self.service.fail_run(run_id, RUN_TIMEOUT_REASON)
- final_run = self.service.require_run(run_id)
- result = FindAgentResult(
- run_id=run_id,
- status="failed",
- succeeded=False,
- business_outcome="failed",
- valid_primary_count=int(final_run.get("valid_primary_count") or 0),
- rounds=state.round_index,
- final_output=f"find_agent_v2 failed:{RUN_TIMEOUT_REASON}",
- node_runs=tuple(state.node_runs),
- stop_reason=RUN_TIMEOUT_REASON,
- )
- 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, start_round: int = 1,
- ) -> FindAgentResult:
- run_id = state.run_id
- end_reason = ""
- failed = False
- try:
- 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)
- await graph.invoke(state)
- current = state.snapshot or self.service.snapshot(run_id)
- if current.pending_count:
- failed = True
- end_reason = f"第 {round_index} 轮结束仍有 {current.pending_count} 条待评估候选"
- break
- exploration = decide_continued_exploration(
- state.previous_snapshot, current,
- )
- if not exploration.continue_exploring:
- end_reason = exploration.reason
- break
- if round_index == end_round - 1:
- end_reason = (
- f"达到最大业务轮数 {self.max_rounds};{exploration.reason};"
- f"本轮新增={exploration.new_candidate_count},"
- f"本轮通过率={exploration.round_pass_rate:.1%},"
- f"累计通过率={exploration.cumulative_pass_rate:.1%}"
- )
- except Exception as exc:
- failed = True
- end_reason = f"{type(exc).__name__}: {exc}"
- state.failures.append({"round": state.round_index, "error": end_reason})
- if state.round_index:
- try:
- self.service.update_round(
- run_id, state.round_index, status="failed", error=end_reason,
- )
- except Exception:
- pass
- final_run = self.service.finalize(
- run_id,
- failed=failed,
- reason=end_reason,
- )
- final_output = (
- f"find_agent_v2 {final_run['outcome_status']},"
- f"有效 primary={final_run['valid_primary_count']}。"
- )
- if not failed:
- try:
- report = await self.node_runner.run_node(
- node="report",
- round_index=state.round_index,
- system_prompt=REPORT_PROMPT,
- user_content=json.dumps(
- self.service.get_full_state(run_id),
- ensure_ascii=False,
- default=str,
- ),
- tools=REPORT_TOOLS,
- max_iterations=4,
- slots=build_node_slots(
- user_input=state.user_input,
- full_state=self.service.get_full_state(run_id),
- round_index=state.round_index,
- plan=state.plan,
- ),
- )
- state.node_runs.append(report)
- final_output = report.content or final_output
- except Exception as exc:
- state.failures.append({"round": state.round_index, "node": "report", "error": str(exc)})
- outcome = str(final_run.get("outcome_status") or "failed")
- return FindAgentResult(
- run_id=run_id,
- status=outcome if outcome in {"goal_met", "partial", "no_match", "failed"} else "failed", # type: ignore[arg-type]
- succeeded=not failed and final_run.get("status") == "finished",
- business_outcome=outcome,
- valid_primary_count=int(final_run.get("valid_primary_count") or 0),
- rounds=state.round_index,
- final_output=final_output,
- node_runs=tuple(state.node_runs),
- stop_reason=end_reason,
- )
- def create_find_agent_v2(
- settings: Settings | None = None,
- *,
- model: str | None = None,
- planning_model: str | None = None,
- search_model: str | None = None,
- evidence_model: str | None = None,
- 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,
- models_by_role=normalize_models(
- model=model,
- planning=planning_model,
- search=search_model,
- evidence=evidence_model,
- evaluation=evaluation_model,
- 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,
- )
|