agent.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. """Outer round orchestrator for the isolated find agent."""
  2. from __future__ import annotations
  3. import asyncio
  4. from dataclasses import dataclass
  5. from find_agent_v2.context import assignment_slots, render_assignment
  6. from find_agent_v2.graph import FindAgentRoundGraph, NodeRunner
  7. from find_agent_v2.observability import ObagentObserver
  8. from find_agent_v2.prompts import REPORT_PROMPT
  9. from find_agent_v2.runtime import FindAgentNodeHost, normalize_models
  10. from find_agent_v2.service import (
  11. RUN_TIMEOUT_REASON,
  12. RUN_TIMEOUT_SECONDS,
  13. FindAgentV2Service,
  14. get_find_agent_v2_service,
  15. )
  16. from find_agent_v2.state import (
  17. DiscoverySnapshot,
  18. FindAgentResult,
  19. FindAgentState,
  20. ReportAssignment,
  21. )
  22. from find_agent_v2.tools import REPORT_TOOLS
  23. from supply_agent.config import Settings
  24. @dataclass(frozen=True)
  25. class ExplorationDecision:
  26. continue_exploring: bool
  27. reason: str
  28. new_candidate_count: int
  29. new_evaluated_count: int
  30. round_pass_rate: float
  31. cumulative_pass_rate: float
  32. def decide_continued_exploration(
  33. previous: DiscoverySnapshot, current: DiscoverySnapshot,
  34. ) -> ExplorationDecision:
  35. """Decide whether to continue from discovery volume and evaluation yield."""
  36. new_candidates = max(0, current.candidate_count - previous.candidate_count)
  37. previous_evaluated = previous.primary_count + previous.rejected_count
  38. current_evaluated = current.primary_count + current.rejected_count
  39. new_evaluated = max(0, current_evaluated - previous_evaluated)
  40. new_passed = max(0, current.valid_primary_count - previous.valid_primary_count)
  41. round_rate = new_passed / new_evaluated if new_evaluated else 0.0
  42. cumulative_rate = (
  43. current.valid_primary_count / current_evaluated if current_evaluated else 0.0
  44. )
  45. if new_candidates == 0:
  46. keep_going = False
  47. reason = "本轮没有新增候选,搜索前沿已无信息增益"
  48. elif current_evaluated < 8:
  49. keep_going = True
  50. reason = "已评估样本不足 8 条,继续探索以形成可靠通过率"
  51. elif new_evaluated and round_rate == 0:
  52. keep_going = False
  53. reason = "本轮有足够评估样本但通过率为 0,继续搜索的预期收益低"
  54. elif new_candidates >= 2 and (round_rate >= 0.10 or cumulative_rate >= 0.10):
  55. keep_going = True
  56. reason = "本轮仍有候选增量且评估保持正向通过率,继续探索"
  57. else:
  58. keep_going = False
  59. reason = "候选增量或评估通过率不足,停止探索"
  60. return ExplorationDecision(
  61. continue_exploring=keep_going,
  62. reason=reason,
  63. new_candidate_count=new_candidates,
  64. new_evaluated_count=new_evaluated,
  65. round_pass_rate=round(round_rate, 4),
  66. cumulative_pass_rate=round(cumulative_rate, 4),
  67. )
  68. class FindAgentV2:
  69. """Python outer loop + guarded Supervisor graph + node-local ReAct."""
  70. def __init__(
  71. self,
  72. *,
  73. service: FindAgentV2Service | None = None,
  74. node_runner: NodeRunner | None = None,
  75. settings: Settings | None = None,
  76. models_by_role: dict[str, str] | None = None,
  77. max_rounds: int = 2,
  78. max_actions_per_round: int = 16,
  79. max_search_actions_per_round: int = 3,
  80. max_runtime_seconds: float = RUN_TIMEOUT_SECONDS,
  81. observer: ObagentObserver | None = None,
  82. ) -> None:
  83. self.service = service or get_find_agent_v2_service()
  84. self.observer = observer or ObagentObserver()
  85. self.node_runner = node_runner or FindAgentNodeHost(
  86. settings=settings,
  87. models_by_role=models_by_role,
  88. observer=self.observer,
  89. )
  90. self.models_by_role = dict(models_by_role or {})
  91. self.max_rounds = max(1, int(max_rounds))
  92. self.max_actions_per_round = max(4, int(max_actions_per_round))
  93. self.max_search_actions_per_round = max(1, int(max_search_actions_per_round))
  94. self.max_runtime_seconds = max(0.01, float(max_runtime_seconds))
  95. async def arun(
  96. self, *, run_id: str, user_input: str, resume: bool = False,
  97. ) -> FindAgentResult:
  98. run = self.service.require_run(run_id)
  99. if resume and str(run.get("status") or "") != "running":
  100. run = self.service.prepare_resume(run_id)
  101. if str(run.get("status") or "") != "running":
  102. raise ValueError(f"run_id={run_id} 当前状态不可执行: {run.get('status')}")
  103. load_plan = getattr(self.service, "get_latest_execution_plan", None)
  104. restored_plan = load_plan(run_id) if callable(load_plan) else None
  105. state = FindAgentState(
  106. run_id=run_id, user_input=user_input, execution_plan=restored_plan,
  107. )
  108. graph = FindAgentRoundGraph(
  109. service=self.service, runner=self.node_runner, observer=self.observer,
  110. max_actions=self.max_actions_per_round,
  111. max_search_actions=self.max_search_actions_per_round,
  112. )
  113. reset_usage = getattr(self.node_runner, "reset_usage", None)
  114. if callable(reset_usage):
  115. reset_usage()
  116. default_model = self.models_by_role.get("supervisor", "google/gemini-3-flash-preview")
  117. with self.observer.run(
  118. run_id=run_id,
  119. demand_word=str(run.get("demand_word") or ""),
  120. model=default_model,
  121. models_by_role=self.models_by_role,
  122. ) as observation_run:
  123. self.service.set_obagent_run_uid(
  124. run_id, getattr(observation_run, "run_uid", None),
  125. )
  126. try:
  127. result = await asyncio.wait_for(
  128. self._arun_inner(
  129. state=state, graph=graph,
  130. start_round=int(run.get("current_round") or 0) + 1,
  131. ),
  132. timeout=self.max_runtime_seconds,
  133. )
  134. except TimeoutError:
  135. self.service.fail_run(run_id, RUN_TIMEOUT_REASON)
  136. final_run = self.service.require_run(run_id)
  137. result = FindAgentResult(
  138. run_id=run_id,
  139. status="failed",
  140. succeeded=False,
  141. business_outcome="failed",
  142. valid_primary_count=int(final_run.get("valid_primary_count") or 0),
  143. rounds=state.round_index,
  144. final_output=f"find_agent_v2 failed:{RUN_TIMEOUT_REASON}",
  145. node_runs=tuple(state.node_runs),
  146. stop_reason=RUN_TIMEOUT_REASON,
  147. )
  148. usage = getattr(self.node_runner, "usage", None)
  149. if isinstance(usage, dict):
  150. self.service.add_usage(run_id, usage)
  151. observation_run.finish(final_output=result.final_output)
  152. return result
  153. async def _arun_inner(
  154. self, *, state: FindAgentState, graph: FindAgentRoundGraph, start_round: int = 1,
  155. ) -> FindAgentResult:
  156. run_id = state.run_id
  157. end_reason = ""
  158. failed = False
  159. try:
  160. end_round = start_round + self.max_rounds
  161. for round_index in range(start_round, end_round):
  162. state.round_index = round_index
  163. state.previous_snapshot = self.service.snapshot(run_id)
  164. self.service.begin_round(run_id, round_index, state.previous_snapshot)
  165. await graph.invoke(state)
  166. current = state.snapshot or self.service.snapshot(run_id)
  167. pending_count = self.service.count_pending_candidates(run_id)
  168. if pending_count:
  169. failed = True
  170. end_reason = f"第 {round_index} 轮结束仍有 {pending_count} 条待评估候选"
  171. break
  172. exploration = decide_continued_exploration(
  173. state.previous_snapshot, current,
  174. )
  175. if not exploration.continue_exploring:
  176. end_reason = exploration.reason
  177. break
  178. if round_index == end_round - 1:
  179. end_reason = (
  180. f"达到最大业务轮数 {self.max_rounds};{exploration.reason};"
  181. f"本轮新增={exploration.new_candidate_count},"
  182. f"本轮通过率={exploration.round_pass_rate:.1%},"
  183. f"累计通过率={exploration.cumulative_pass_rate:.1%}"
  184. )
  185. except Exception as exc:
  186. failed = True
  187. end_reason = f"{type(exc).__name__}: {exc}"
  188. state.failures.append({"round": state.round_index, "error": end_reason})
  189. if state.round_index:
  190. try:
  191. self.service.update_round(
  192. run_id, state.round_index, status="failed", error=end_reason,
  193. )
  194. except Exception:
  195. pass
  196. final_run = self.service.finalize(
  197. run_id,
  198. failed=failed,
  199. reason=end_reason,
  200. )
  201. final_output = (
  202. f"find_agent_v2 {final_run['outcome_status']},"
  203. f"有效 primary={final_run['valid_primary_count']}。"
  204. )
  205. if not failed:
  206. try:
  207. report_assignment = ReportAssignment(
  208. run_id=run_id,
  209. final_state=self.service.get_report_state(run_id),
  210. )
  211. report = await self.node_runner.run_node(
  212. node="report",
  213. round_index=state.round_index,
  214. system_prompt=REPORT_PROMPT,
  215. user_content=render_assignment(report_assignment),
  216. tools=REPORT_TOOLS,
  217. max_iterations=4,
  218. slots=assignment_slots(
  219. report_assignment,
  220. source="FindAgentV2Service.get_report_state",
  221. ),
  222. )
  223. state.node_runs.append(report)
  224. final_output = report.content or final_output
  225. except Exception as exc:
  226. state.failures.append({"round": state.round_index, "node": "report", "error": str(exc)})
  227. outcome = str(final_run.get("outcome_status") or "failed")
  228. return FindAgentResult(
  229. run_id=run_id,
  230. status=outcome if outcome in {"goal_met", "partial", "no_match", "failed"} else "failed", # type: ignore[arg-type]
  231. succeeded=not failed and final_run.get("status") == "finished",
  232. business_outcome=outcome,
  233. valid_primary_count=int(final_run.get("valid_primary_count") or 0),
  234. rounds=state.round_index,
  235. final_output=final_output,
  236. node_runs=tuple(state.node_runs),
  237. stop_reason=end_reason,
  238. )
  239. def create_find_agent_v2(
  240. settings: Settings | None = None,
  241. *,
  242. model: str | None = None,
  243. planning_model: str | None = None,
  244. search_model: str | None = None,
  245. evidence_model: str | None = None,
  246. evaluation_model: str | None = None,
  247. report_model: str | None = None,
  248. max_rounds: int = 2,
  249. max_actions_per_round: int = 16,
  250. max_search_actions_per_round: int = 3,
  251. ) -> FindAgentV2:
  252. return FindAgentV2(
  253. settings=settings,
  254. models_by_role=normalize_models(
  255. model=model,
  256. planning=planning_model,
  257. search=search_model,
  258. evidence=evidence_model,
  259. evaluation=evaluation_model,
  260. report=report_model,
  261. ),
  262. max_rounds=max_rounds,
  263. max_actions_per_round=max_actions_per_round,
  264. max_search_actions_per_round=max_search_actions_per_round,
  265. )