graph.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. """One autonomous but policy-guarded business round compiled as LangGraph."""
  2. from __future__ import annotations
  3. import asyncio
  4. import json
  5. import re
  6. from typing import Any, Protocol
  7. from langgraph.graph import END, START, StateGraph
  8. from find_agent_v2.context import build_node_slots, render_node_context
  9. from find_agent_v2.observability import InputSlot, NullObserver, graph_spec_for
  10. from find_agent_v2.prompts import EVALUATOR_PROMPT, EVIDENCE_PROMPT, SEARCH_PROMPT, SUPERVISOR_PROMPT
  11. from find_agent_v2.service import FindAgentV2Service
  12. from find_agent_v2.state import FindAgentGraphState, FindAgentState, NodeRun
  13. from find_agent_v2.tools import (
  14. EVALUATION_TOOLS,
  15. EVIDENCE_TOOLS,
  16. SEARCH_TOOLS,
  17. ToolFn,
  18. bound_candidate_tools,
  19. )
  20. class NodeRunner(Protocol):
  21. async def run_node(
  22. self,
  23. *,
  24. node: str,
  25. round_index: int,
  26. system_prompt: str,
  27. user_content: str,
  28. tools: tuple[ToolFn, ...] = (),
  29. max_iterations: int = 12,
  30. slots: tuple[InputSlot, ...] = (),
  31. branch_key: str = "",
  32. allow_delegation: bool = True,
  33. ) -> NodeRun: ...
  34. class FindAgentRoundGraph:
  35. """Supervisor-directed graph with deterministic policy approval."""
  36. def __init__(
  37. self, *, service: FindAgentV2Service, runner: NodeRunner, observer=None,
  38. max_actions: int = 16, max_search_actions: int = 3,
  39. target_primary_count: int = 5,
  40. ) -> None:
  41. self.service = service
  42. self.runner = runner
  43. self.observer = observer or NullObserver()
  44. self.max_actions = max(4, int(max_actions))
  45. self.max_search_actions = max(1, int(max_search_actions))
  46. self.target_primary_count = max(1, int(target_primary_count))
  47. self.app = self._build_graph()
  48. self.obagent_spec = graph_spec_for(self.app)
  49. def _full_state(self, state: FindAgentGraphState, *, pending_only: bool = False):
  50. return self.service.get_full_state(
  51. state["run_id"], pending_only=pending_only,
  52. )
  53. def _context(self, state: FindAgentGraphState, *, pending_only: bool = False) -> str:
  54. return render_node_context(
  55. user_input=state["user_input"],
  56. full_state=self._full_state(state, pending_only=pending_only),
  57. round_index=state["round_index"],
  58. plan=state.get("plan", ""),
  59. )
  60. def _slots(
  61. self, state: FindAgentGraphState, *, pending_only: bool = False,
  62. ) -> tuple[InputSlot, ...]:
  63. return build_node_slots(
  64. user_input=state["user_input"],
  65. full_state=self._full_state(state, pending_only=pending_only),
  66. round_index=state["round_index"],
  67. plan=state.get("plan", ""),
  68. )
  69. def _shard_state(
  70. self, state: FindAgentGraphState, items: list[dict[str, Any]],
  71. ) -> dict[str, Any]:
  72. full_state = self._full_state(state, pending_only=True)
  73. return {**full_state, "candidates": items}
  74. def _shard_context(
  75. self, state: FindAgentGraphState, items: list[dict[str, Any]],
  76. ) -> str:
  77. return render_node_context(
  78. user_input=state["user_input"],
  79. full_state=self._shard_state(state, items),
  80. round_index=state["round_index"],
  81. plan=state.get("plan", ""),
  82. )
  83. def _shard_slots(
  84. self, state: FindAgentGraphState, items: list[dict[str, Any]],
  85. ) -> tuple[InputSlot, ...]:
  86. return build_node_slots(
  87. user_input=state["user_input"],
  88. full_state=self._shard_state(state, items),
  89. round_index=state["round_index"],
  90. plan=state.get("plan", ""),
  91. )
  92. @staticmethod
  93. def _parse_supervisor(content: str) -> dict[str, Any]:
  94. text = content.strip()
  95. fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
  96. if fenced:
  97. text = fenced.group(1)
  98. else:
  99. start, end = text.find("{"), text.rfind("}")
  100. if start >= 0 and end > start:
  101. text = text[start:end + 1]
  102. try:
  103. value = json.loads(text)
  104. return value if isinstance(value, dict) else {}
  105. except (TypeError, ValueError):
  106. return {}
  107. def _approve_action(
  108. self, state: FindAgentGraphState, proposal: dict[str, Any],
  109. ) -> tuple[str, str, int, str]:
  110. """Turn an LLM proposal into a safe, executable transition."""
  111. pending = self._full_state(state, pending_only=True).get("candidates") or []
  112. missing_detail = any(item.get("detail_status") == "pending" for item in pending)
  113. missing_portrait = any(item.get("portrait_status") == "pending" for item in pending)
  114. proposed = str(proposal.get("next_action") or "").lower()
  115. reason = str(proposal.get("reason") or "")
  116. searches = int(state.get("search_actions") or 0)
  117. actions = int(state.get("action_count") or 0)
  118. try:
  119. worker_count = max(1, min(8, int(proposal.get("worker_count") or 4)))
  120. except (TypeError, ValueError):
  121. worker_count = 4
  122. scope = str(proposal.get("evidence_scope") or "both").lower()
  123. if scope not in {"detail", "portrait", "both"}:
  124. scope = "both"
  125. snapshot = self.service.snapshot(state["run_id"])
  126. if not pending and snapshot.valid_primary_count >= self.target_primary_count:
  127. return "finish", "有效 primary 已达到目标,结束本轮", worker_count, scope
  128. if actions > self.max_actions:
  129. if not pending:
  130. return "finish", "安全收敛动作已完成", worker_count, scope
  131. raise RuntimeError(
  132. f"Supervisor 安全收敛动作未产生进展:actions={actions}, pending={len(pending)}"
  133. )
  134. if actions == self.max_actions:
  135. if pending and not (missing_detail or missing_portrait):
  136. return "evaluator", "动作预算耗尽,强制消费待评估候选", worker_count, scope
  137. if pending:
  138. return "evidence", "动作预算耗尽,强制补齐缺失证据", worker_count, "both"
  139. return "finish", "达到单轮动作安全上限", worker_count, scope
  140. if pending:
  141. if missing_detail or missing_portrait:
  142. if scope == "detail" and not missing_detail:
  143. scope = "portrait"
  144. elif scope == "portrait" and not missing_portrait:
  145. scope = "detail"
  146. return "evidence", reason or "存在缺失证据,框架要求先补证", worker_count, scope
  147. return "evaluator", reason or "候选证据已处理,进入评估", worker_count, scope
  148. if searches == 0:
  149. return "search", reason or "本轮尚未搜索,框架要求先建立候选池", worker_count, scope
  150. if proposed == "search" and searches < self.max_search_actions:
  151. return "search", reason or "Supervisor 判断继续搜索仍有信息增益", worker_count, scope
  152. return "finish", reason or "没有待处理候选,结束本轮", worker_count, scope
  153. async def _supervisor(self, state: FindAgentGraphState) -> dict[str, Any]:
  154. run = await self.runner.run_node(
  155. node="supervisor",
  156. round_index=state["round_index"],
  157. system_prompt=SUPERVISOR_PROMPT,
  158. user_content=self._context(state),
  159. tools=(),
  160. max_iterations=2,
  161. slots=self._slots(state),
  162. allow_delegation=False,
  163. )
  164. proposal = self._parse_supervisor(run.content)
  165. action, reason, workers, scope = self._approve_action(state, proposal)
  166. proposed_plan = proposal.get("plan")
  167. plan = (
  168. json.dumps(proposed_plan, ensure_ascii=False)
  169. if isinstance(proposed_plan, dict)
  170. else state.get("plan", "")
  171. )
  172. decision = {
  173. "step": int(state.get("supervisor_step") or 0) + 1,
  174. "proposed_action": proposal.get("next_action"),
  175. "approved_action": action,
  176. "reason": reason,
  177. "worker_count": workers,
  178. "evidence_scope": scope,
  179. }
  180. self.service.update_round(
  181. state["run_id"], state["round_index"], phase="planning", plan=plan,
  182. )
  183. return {
  184. "phase": "planning", "plan": plan, "approved_action": action,
  185. "supervisor_step": decision["step"], "worker_count": workers,
  186. "evidence_scope": scope,
  187. "decision_history": [*state.get("decision_history", []), decision],
  188. "node_runs": [*state.get("node_runs", []), run],
  189. }
  190. async def _search(self, state: FindAgentGraphState) -> dict[str, Any]:
  191. self.service.update_round(state["run_id"], state["round_index"], phase="searching")
  192. run = await self.runner.run_node(
  193. node="search",
  194. round_index=state["round_index"],
  195. system_prompt=SEARCH_PROMPT,
  196. user_content=self._context(state),
  197. tools=SEARCH_TOOLS,
  198. max_iterations=10,
  199. slots=self._slots(state),
  200. )
  201. return {
  202. "phase": "searching",
  203. "search_actions": int(state.get("search_actions") or 0) + 1,
  204. "action_count": int(state.get("action_count") or 0) + 1,
  205. "node_runs": [*state.get("node_runs", []), run],
  206. }
  207. async def _evidence(self, state: FindAgentGraphState) -> dict[str, Any]:
  208. self.service.update_round(state["run_id"], state["round_index"], phase="evidence")
  209. pending = self._full_state(state, pending_only=True)["candidates"]
  210. detail_items = [item for item in pending if item.get("detail_status") == "pending"]
  211. portrait_items = [item for item in pending if item.get("portrait_status") == "pending"]
  212. scope = state.get("evidence_scope", "both")
  213. if scope == "detail":
  214. portrait_items = []
  215. elif scope == "portrait":
  216. detail_items = []
  217. jobs = [
  218. ("detail", detail_items[index:index + 8])
  219. for index in range(0, len(detail_items), 8)
  220. ] + [
  221. ("portrait", portrait_items[index:index + 8])
  222. for index in range(0, len(portrait_items), 8)
  223. ]
  224. semaphore = asyncio.Semaphore(max(1, min(8, int(state.get("worker_count") or 4))))
  225. async def run_shard(
  226. index: int, evidence_type: str, items: list[dict[str, Any]],
  227. ) -> NodeRun:
  228. candidate_ids = [int(item["candidate_id"]) for item in items]
  229. selected = (
  230. (EVIDENCE_TOOLS[0], EVIDENCE_TOOLS[2])
  231. if evidence_type == "detail"
  232. else (EVIDENCE_TOOLS[1], EVIDENCE_TOOLS[2])
  233. )
  234. async with semaphore:
  235. return await self.runner.run_node(
  236. node="evidence",
  237. round_index=state["round_index"],
  238. system_prompt=EVIDENCE_PROMPT,
  239. user_content=(
  240. f"你只负责当前 {evidence_type} 分片 candidate_ids={candidate_ids}。"
  241. f"必须为这些候选补齐 {evidence_type},不得访问其他候选。\n\n"
  242. + self._shard_context(state, items)
  243. ),
  244. tools=bound_candidate_tools(
  245. selected, run_id=state["run_id"], candidate_ids=candidate_ids,
  246. ),
  247. max_iterations=12,
  248. slots=self._shard_slots(state, items),
  249. branch_key=f"{evidence_type}-shard-{index}",
  250. allow_delegation=False,
  251. )
  252. runs = await asyncio.gather(*(
  253. run_shard(index, evidence_type, items)
  254. for index, (evidence_type, items) in enumerate(jobs, start=1)
  255. )) if jobs else []
  256. return {
  257. "phase": "evidence",
  258. "action_count": int(state.get("action_count") or 0) + 1,
  259. "node_runs": [*state.get("node_runs", []), *runs],
  260. }
  261. async def _evaluator(self, state: FindAgentGraphState) -> dict[str, Any]:
  262. self.service.update_round(state["run_id"], state["round_index"], phase="evaluating")
  263. node_runs = list(state.get("node_runs", []))
  264. before = self.service.snapshot(state["run_id"])
  265. pending = self._full_state(state, pending_only=True)["candidates"]
  266. shards = [pending[index:index + 8] for index in range(0, len(pending), 8)]
  267. semaphore = asyncio.Semaphore(max(1, min(8, int(state.get("worker_count") or 4))))
  268. async def run_shard(index: int, items: list[dict[str, Any]]) -> NodeRun:
  269. candidate_ids = [int(item["candidate_id"]) for item in items]
  270. async with semaphore:
  271. return await self.runner.run_node(
  272. node="evaluator",
  273. round_index=state["round_index"],
  274. system_prompt=EVALUATOR_PROMPT,
  275. user_content=(
  276. f"你只负责当前分片 candidate_ids={candidate_ids}。"
  277. "必须把这些候选全部分池,不得评估其他候选。\n\n"
  278. + self._shard_context(state, items)
  279. ),
  280. tools=bound_candidate_tools(
  281. EVALUATION_TOOLS, run_id=state["run_id"], candidate_ids=candidate_ids,
  282. ),
  283. max_iterations=12,
  284. slots=self._shard_slots(state, items),
  285. branch_key=f"step-{state.get('supervisor_step', 0)}-shard-{index}",
  286. allow_delegation=False,
  287. )
  288. runs = await asyncio.gather(*(
  289. run_shard(index, items) for index, items in enumerate(shards, start=1)
  290. )) if shards else []
  291. node_runs.extend(runs)
  292. self.service.recount_valid_primary(state["run_id"])
  293. after = self.service.snapshot(state["run_id"])
  294. stagnant = int(state.get("evaluator_stagnation") or 0)
  295. stagnant = stagnant + 1 if after.pending_count >= before.pending_count else 0
  296. if stagnant >= 3:
  297. raise RuntimeError(
  298. "评估节点连续 3 次未消费 pending_evaluation 候选:"
  299. f"remaining={after.pending_count}"
  300. )
  301. return {
  302. "phase": "evaluating", "node_runs": node_runs,
  303. "action_count": int(state.get("action_count") or 0) + 1,
  304. "evaluator_stagnation": stagnant,
  305. }
  306. @staticmethod
  307. def _route(state: FindAgentGraphState) -> str:
  308. return state.get("approved_action", "finish")
  309. def _build_graph(self):
  310. builder = StateGraph(FindAgentGraphState)
  311. builder.add_node("supervisor", self._supervisor)
  312. builder.add_node("search", self._search)
  313. builder.add_node("evidence", self._evidence)
  314. builder.add_node("evaluator", self._evaluator)
  315. builder.add_edge(START, "supervisor")
  316. builder.add_conditional_edges("supervisor", self._route, {
  317. "search": "search", "evidence": "evidence",
  318. "evaluator": "evaluator", "finish": END,
  319. })
  320. builder.add_edge("search", "supervisor")
  321. builder.add_edge("evidence", "supervisor")
  322. builder.add_edge("evaluator", "supervisor")
  323. return builder.compile()
  324. async def invoke(self, state: FindAgentState) -> FindAgentState:
  325. graph_state: FindAgentGraphState = {
  326. "run_id": state.run_id,
  327. "user_input": state.user_input,
  328. "round_index": state.round_index,
  329. "plan": state.plan,
  330. "phase": state.phase,
  331. "node_runs": [],
  332. "snapshot": state.snapshot,
  333. "supervisor_step": 0,
  334. "action_count": 0,
  335. "search_actions": 0,
  336. "worker_count": 4,
  337. "evidence_scope": "both",
  338. "decision_history": [],
  339. "evaluator_stagnation": 0,
  340. }
  341. with self.observer.round(
  342. round_index=state.round_index, spec=self.obagent_spec,
  343. ) as round_observation:
  344. output = await self.app.ainvoke(
  345. graph_state, config={"recursion_limit": max(64, self.max_actions * 4)},
  346. )
  347. state.plan = str(output.get("plan") or "")
  348. state.node_runs.extend(output.get("node_runs") or [])
  349. state.snapshot = self.service.snapshot(state.run_id)
  350. state.phase = "done"
  351. self.service.update_round(
  352. state.run_id,
  353. state.round_index,
  354. phase="done",
  355. status="done",
  356. snapshot=state.snapshot,
  357. )
  358. round_observation.set_output({
  359. "状态快照": state.snapshot.__dict__,
  360. "本轮计划": state.plan,
  361. "Supervisor决策轨迹": output.get("decision_history") or [],
  362. }, ok=True)
  363. return state