graph.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689
  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 assignment_slots, render_assignment
  9. from find_agent_v2.gates import evaluate_candidate_gate
  10. from find_agent_v2.observability import InputSlot, NullObserver, graph_spec_for
  11. from find_agent_v2.prompts import (
  12. EVALUATOR_PROMPT,
  13. EVIDENCE_PROMPT,
  14. PLANNER_PROMPT,
  15. SEARCH_PROMPT,
  16. SUPERVISOR_PROMPT,
  17. )
  18. from find_agent_v2.service import FindAgentV2Service
  19. from find_agent_v2.state import (
  20. EvidenceAssignment,
  21. EvaluationAssignment,
  22. FindAgentGraphState,
  23. FindAgentState,
  24. NodeRun,
  25. PlannerAssignment,
  26. PlanningDecision,
  27. SearchAssignment,
  28. SupervisorAssignment,
  29. SupervisorDecision,
  30. )
  31. from find_agent_v2.tools import (
  32. EVALUATION_TOOLS,
  33. EVIDENCE_TOOLS,
  34. SEARCH_TOOLS,
  35. ToolFn,
  36. bound_candidate_tools,
  37. normalize_evaluation_items,
  38. )
  39. class NodeRunner(Protocol):
  40. async def run_node(
  41. self,
  42. *,
  43. node: str,
  44. round_index: int,
  45. system_prompt: str,
  46. user_content: str,
  47. tools: tuple[ToolFn, ...] = (),
  48. max_iterations: int = 12,
  49. slots: tuple[InputSlot, ...] = (),
  50. branch_key: str = "",
  51. allow_delegation: bool = True,
  52. ) -> NodeRun: ...
  53. class FindAgentRoundGraph:
  54. """Supervisor-directed graph with deterministic policy approval."""
  55. def __init__(
  56. self,
  57. *,
  58. service: FindAgentV2Service,
  59. runner: NodeRunner,
  60. observer=None,
  61. max_actions: int = 16,
  62. max_search_actions: int = 3,
  63. ) -> None:
  64. self.service = service
  65. self.runner = runner
  66. self.observer = observer or NullObserver()
  67. self.max_actions = max(4, int(max_actions))
  68. self.max_search_actions = max(1, int(max_search_actions))
  69. self.app = self._build_graph()
  70. self.obagent_spec = graph_spec_for(self.app)
  71. def _full_state(self, state: FindAgentGraphState, *, pending_only: bool = False):
  72. return self.service.get_full_state(
  73. state["run_id"],
  74. pending_only=pending_only,
  75. )
  76. @staticmethod
  77. def _assignment_input(assignment, *, source: str) -> tuple[str, tuple[InputSlot, ...]]:
  78. return render_assignment(assignment), assignment_slots(assignment, source=source)
  79. def _supervisor_state(self, state: FindAgentGraphState) -> dict[str, Any]:
  80. """Compact progress projection; routing never needs full candidate payloads."""
  81. full_state = self._full_state(state)
  82. run = full_state.get("run") or {}
  83. candidates = list(full_state.get("candidates") or [])
  84. pending_candidates = [
  85. item
  86. for item in candidates
  87. if item.get("decision_bucket") == "pending_evaluation"
  88. ]
  89. return {
  90. "run": {
  91. key: run.get(key)
  92. for key in (
  93. "status", "outcome_status", "current_round", "search_count",
  94. "candidate_count", "valid_primary_count",
  95. )
  96. },
  97. "searches": full_state.get("searches") or [],
  98. "candidate_progress": {
  99. "pending_count": sum(
  100. 1 for _item in pending_candidates
  101. ),
  102. "primary_count": sum(
  103. item.get("decision_bucket") == "primary" for item in candidates
  104. ),
  105. "rejected_count": sum(
  106. item.get("decision_bucket") == "rejected" for item in candidates
  107. ),
  108. "detail_pending_count": sum(
  109. item.get("detail_status") == "pending"
  110. for item in pending_candidates
  111. ),
  112. "detail_success_count": sum(
  113. item.get("detail_status") == "success"
  114. for item in pending_candidates
  115. ),
  116. "detail_failed_count": sum(
  117. item.get("detail_status") == "failed"
  118. for item in pending_candidates
  119. ),
  120. "portrait_pending_count": sum(
  121. item.get("portrait_status") == "pending"
  122. for item in pending_candidates
  123. ),
  124. "portrait_success_count": sum(
  125. item.get("portrait_status") == "success"
  126. for item in pending_candidates
  127. ),
  128. "portrait_failed_count": sum(
  129. item.get("portrait_status") == "failed"
  130. for item in pending_candidates
  131. ),
  132. "evidence_completed_count": sum(
  133. item.get("detail_status") != "pending"
  134. and item.get("portrait_status") != "pending"
  135. for item in pending_candidates
  136. ),
  137. "evidence_success_count": sum(
  138. item.get("detail_status") == "success"
  139. and item.get("portrait_status") == "success"
  140. for item in pending_candidates
  141. ),
  142. },
  143. }
  144. def _video_understanding_ids(
  145. self,
  146. state: FindAgentGraphState,
  147. items: list[dict[str, Any]],
  148. ) -> list[int]:
  149. """Only candidates passing every deterministic hard gate may use video understanding."""
  150. run = self._full_state(state).get("run") or {}
  151. rules = run.get("rule_config") or {}
  152. selected: list[int] = []
  153. for item in items:
  154. gate = evaluate_candidate_gate(item, rules)
  155. checks = list(gate.get("checks") or [])
  156. hard_gate_passed = gate.get("status") == "pass" and all(
  157. check.get("status") == "pass" and not check.get("compensated") for check in checks
  158. )
  159. if str(item.get("video_url") or "").strip() and hard_gate_passed:
  160. selected.append(int(item["candidate_id"]))
  161. return selected
  162. @staticmethod
  163. def _parse_supervisor(content: str) -> dict[str, Any]:
  164. text = content.strip()
  165. fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
  166. if fenced:
  167. text = fenced.group(1)
  168. else:
  169. start, end = text.find("{"), text.rfind("}")
  170. if start >= 0 and end > start:
  171. text = text[start : end + 1]
  172. try:
  173. value = json.loads(text)
  174. return value if isinstance(value, dict) else {}
  175. except (TypeError, ValueError):
  176. return {}
  177. def _approve_action(
  178. self,
  179. state: FindAgentGraphState,
  180. proposal: dict[str, Any],
  181. ) -> tuple[str, str, int, str]:
  182. """Turn an LLM proposal into a safe, executable transition."""
  183. pending = self._full_state(state, pending_only=True).get("candidates") or []
  184. missing_detail = any(item.get("detail_status") == "pending" for item in pending)
  185. missing_portrait = any(item.get("portrait_status") == "pending" for item in pending)
  186. proposed = str(proposal.get("next_action") or "").lower()
  187. reason = str(proposal.get("reason") or "")
  188. searches = int(state.get("search_actions") or 0)
  189. actions = int(state.get("action_count") or 0)
  190. try:
  191. worker_count = max(1, min(8, int(proposal.get("worker_count") or 4)))
  192. except (TypeError, ValueError):
  193. worker_count = 4
  194. scope = str(proposal.get("evidence_scope") or "both").lower()
  195. if scope not in {"detail", "portrait", "both"}:
  196. scope = "both"
  197. if actions > self.max_actions:
  198. if not pending:
  199. return "finish", "安全收敛动作已完成", worker_count, scope
  200. raise RuntimeError(
  201. f"Supervisor 安全收敛动作未产生进展:actions={actions}, pending={len(pending)}"
  202. )
  203. if actions == self.max_actions:
  204. if pending and not (missing_detail or missing_portrait):
  205. return "evaluator", "动作预算耗尽,强制消费待评估候选", worker_count, scope
  206. if pending:
  207. return "evidence", "动作预算耗尽,强制补齐缺失证据", worker_count, "both"
  208. return "finish", "达到单轮动作安全上限", worker_count, scope
  209. if pending:
  210. if missing_detail or missing_portrait:
  211. if scope == "detail" and not missing_detail:
  212. scope = "portrait"
  213. elif scope == "portrait" and not missing_portrait:
  214. scope = "detail"
  215. return "evidence", reason or "存在缺失证据,框架要求先补证", worker_count, scope
  216. return "evaluator", reason or "候选证据已处理,进入评估", worker_count, scope
  217. if searches == 0:
  218. return "search", reason or "本轮尚未搜索,框架要求先建立候选池", worker_count, scope
  219. if proposed == "search" and searches < self.max_search_actions:
  220. return "search", reason or "Supervisor 判断继续搜索仍有信息增益", worker_count, scope
  221. return "finish", reason or "没有待处理候选,结束本轮", worker_count, scope
  222. async def _supervisor(self, state: FindAgentGraphState) -> dict[str, Any]:
  223. execution_plan = state.get("execution_plan")
  224. observed_decision: dict[str, Any] = {}
  225. if execution_plan is None:
  226. assignment = PlannerAssignment(
  227. run_id=state["run_id"],
  228. round_index=state["round_index"],
  229. raw_demand=state["user_input"],
  230. )
  231. user_content, slots = self._assignment_input(
  232. assignment,
  233. source="find_agent_v2_run.input_json",
  234. )
  235. structured_runner = getattr(self.runner, "run_planning", None)
  236. if callable(structured_runner):
  237. run, planning = await structured_runner(
  238. round_index=state["round_index"],
  239. system_prompt=PLANNER_PROMPT,
  240. user_content=user_content,
  241. slots=slots,
  242. )
  243. else:
  244. run = await self.runner.run_node(
  245. node="supervisor",
  246. round_index=state["round_index"],
  247. system_prompt=PLANNER_PROMPT,
  248. user_content=user_content,
  249. tools=(),
  250. max_iterations=2,
  251. slots=slots,
  252. allow_delegation=False,
  253. )
  254. planning = PlanningDecision.model_validate(self._parse_supervisor(run.content))
  255. execution_plan = planning.execution_plan
  256. proposal = planning.model_dump(mode="json", exclude={"execution_plan"})
  257. else:
  258. assignment = SupervisorAssignment(
  259. run_id=state["run_id"],
  260. round_index=state["round_index"],
  261. execution_plan=execution_plan,
  262. execution_state=self._supervisor_state(state),
  263. )
  264. user_content, slots = self._assignment_input(
  265. assignment,
  266. source="ExecutionPlan + FindAgentV2Service.get_full_state",
  267. )
  268. structured_runner = getattr(self.runner, "run_supervision", None)
  269. def approve_for_observation(supervision: SupervisorDecision) -> dict[str, Any]:
  270. proposal_payload = supervision.model_dump(mode="json")
  271. approved, approved_reason, approved_workers, approved_scope = (
  272. self._approve_action(state, proposal_payload)
  273. )
  274. decision_payload = {
  275. "step": int(state.get("supervisor_step") or 0) + 1,
  276. "proposed_action": proposal_payload.get("next_action"),
  277. "approved_action": approved,
  278. "overridden": proposal_payload.get("next_action") != approved,
  279. "reason": approved_reason,
  280. "worker_count": approved_workers,
  281. "evidence_scope": approved_scope,
  282. }
  283. observed_decision.update(decision_payload)
  284. return {"Supervisor决策校验": decision_payload}
  285. if callable(structured_runner):
  286. run, supervision = await structured_runner(
  287. round_index=state["round_index"],
  288. system_prompt=SUPERVISOR_PROMPT,
  289. user_content=user_content,
  290. slots=slots,
  291. output_enricher=approve_for_observation,
  292. )
  293. else:
  294. run = await self.runner.run_node(
  295. node="supervisor",
  296. round_index=state["round_index"],
  297. system_prompt=SUPERVISOR_PROMPT,
  298. user_content=user_content,
  299. tools=(),
  300. max_iterations=2,
  301. slots=slots,
  302. allow_delegation=False,
  303. )
  304. supervision = SupervisorDecision.model_validate(
  305. self._parse_supervisor(run.content),
  306. )
  307. proposal = supervision.model_dump(mode="json")
  308. if supervision.additional_search_tasks:
  309. known = {item.task_id for item in execution_plan.search_tasks}
  310. merged = list(execution_plan.search_tasks)
  311. for item in supervision.additional_search_tasks:
  312. if item.task_id not in known and len(merged) < 24:
  313. merged.append(item)
  314. known.add(item.task_id)
  315. if len(merged) != len(execution_plan.search_tasks):
  316. execution_plan = type(execution_plan).model_validate(
  317. {
  318. **execution_plan.model_dump(),
  319. "search_tasks": merged,
  320. }
  321. )
  322. if observed_decision:
  323. decision = observed_decision
  324. action = str(decision["approved_action"])
  325. reason = str(decision["reason"])
  326. workers = int(decision["worker_count"])
  327. scope = str(decision["evidence_scope"])
  328. else:
  329. action, reason, workers, scope = self._approve_action(state, proposal)
  330. decision = {
  331. "step": int(state.get("supervisor_step") or 0) + 1,
  332. "proposed_action": proposal.get("next_action"),
  333. "approved_action": action,
  334. "overridden": proposal.get("next_action") != action,
  335. "reason": reason,
  336. "worker_count": workers,
  337. "evidence_scope": scope,
  338. }
  339. self.service.update_round(
  340. state["run_id"],
  341. state["round_index"],
  342. phase="planning",
  343. plan=execution_plan.model_dump_json(exclude_none=True),
  344. )
  345. return {
  346. "phase": "planning",
  347. "execution_plan": execution_plan,
  348. "approved_action": action,
  349. "supervisor_step": decision["step"],
  350. "worker_count": workers,
  351. "evidence_scope": scope,
  352. "decision_history": [*state.get("decision_history", []), decision],
  353. "node_runs": [*state.get("node_runs", []), run],
  354. }
  355. async def _search(self, state: FindAgentGraphState) -> dict[str, Any]:
  356. self.service.update_round(state["run_id"], state["round_index"], phase="searching")
  357. execution_plan = state.get("execution_plan")
  358. if execution_plan is None:
  359. raise RuntimeError("Search 缺少已校验的 ExecutionPlan")
  360. existing = {
  361. (
  362. str(item.get("keyword") or ""),
  363. str(item.get("provider") or ""),
  364. str(item.get("query_reason") or ""),
  365. )
  366. for item in self._full_state(state).get("searches") or []
  367. }
  368. tasks = [
  369. item
  370. for item in execution_plan.search_tasks
  371. if (item.keyword, item.provider, item.query_reason) not in existing
  372. ][:6]
  373. assignment = SearchAssignment(
  374. run_id=state["run_id"],
  375. round_index=state["round_index"],
  376. tasks=tasks,
  377. )
  378. if not tasks:
  379. return {
  380. "phase": "searching",
  381. "search_actions": int(state.get("search_actions") or 0) + 1,
  382. "action_count": int(state.get("action_count") or 0) + 1,
  383. "node_runs": [
  384. *state.get("node_runs", []),
  385. NodeRun("search", state["round_index"], "没有未执行的搜索任务", 0, 0),
  386. ],
  387. }
  388. user_content, slots = self._assignment_input(
  389. assignment,
  390. source="ExecutionPlan.search_tasks",
  391. )
  392. run = await self.runner.run_node(
  393. node="search",
  394. round_index=state["round_index"],
  395. system_prompt=SEARCH_PROMPT,
  396. user_content=user_content,
  397. tools=SEARCH_TOOLS,
  398. max_iterations=10,
  399. slots=slots,
  400. )
  401. return {
  402. "phase": "searching",
  403. "search_actions": int(state.get("search_actions") or 0) + 1,
  404. "action_count": int(state.get("action_count") or 0) + 1,
  405. "node_runs": [*state.get("node_runs", []), run],
  406. }
  407. async def _evidence(self, state: FindAgentGraphState) -> dict[str, Any]:
  408. self.service.update_round(state["run_id"], state["round_index"], phase="evidence")
  409. pending = self._full_state(state, pending_only=True)["candidates"]
  410. detail_items = [item for item in pending if item.get("detail_status") == "pending"]
  411. portrait_items = [item for item in pending if item.get("portrait_status") == "pending"]
  412. scope = state.get("evidence_scope", "both")
  413. if scope == "detail":
  414. portrait_items = []
  415. elif scope == "portrait":
  416. detail_items = []
  417. jobs = [
  418. ("detail", detail_items[index : index + 8]) for index in range(0, len(detail_items), 8)
  419. ] + [
  420. ("portrait", portrait_items[index : index + 8])
  421. for index in range(0, len(portrait_items), 8)
  422. ]
  423. semaphore = asyncio.Semaphore(max(1, min(8, int(state.get("worker_count") or 4))))
  424. async def run_shard(
  425. index: int,
  426. evidence_type: str,
  427. items: list[dict[str, Any]],
  428. ) -> NodeRun:
  429. candidate_ids = [int(item["candidate_id"]) for item in items]
  430. assignment = EvidenceAssignment(
  431. run_id=state["run_id"],
  432. round_index=state["round_index"],
  433. candidate_ids=candidate_ids,
  434. evidence_type=evidence_type,
  435. candidates=items,
  436. )
  437. user_content, slots = self._assignment_input(
  438. assignment,
  439. source="host evidence shard",
  440. )
  441. selected = (
  442. (EVIDENCE_TOOLS[0], EVIDENCE_TOOLS[2])
  443. if evidence_type == "detail"
  444. else (EVIDENCE_TOOLS[1], EVIDENCE_TOOLS[2])
  445. )
  446. async with semaphore:
  447. return await self.runner.run_node(
  448. node="evidence",
  449. round_index=state["round_index"],
  450. system_prompt=EVIDENCE_PROMPT,
  451. user_content=user_content,
  452. tools=bound_candidate_tools(
  453. selected,
  454. run_id=state["run_id"],
  455. candidate_ids=candidate_ids,
  456. ),
  457. max_iterations=12,
  458. slots=slots,
  459. branch_key=f"{evidence_type}-shard-{index}",
  460. allow_delegation=False,
  461. )
  462. runs = (
  463. await asyncio.gather(
  464. *(
  465. run_shard(index, evidence_type, items)
  466. for index, (evidence_type, items) in enumerate(jobs, start=1)
  467. )
  468. )
  469. if jobs
  470. else []
  471. )
  472. return {
  473. "phase": "evidence",
  474. "action_count": int(state.get("action_count") or 0) + 1,
  475. "node_runs": [*state.get("node_runs", []), *runs],
  476. }
  477. async def _evaluator(self, state: FindAgentGraphState) -> dict[str, Any]:
  478. self.service.update_round(state["run_id"], state["round_index"], phase="evaluating")
  479. node_runs = list(state.get("node_runs", []))
  480. before = self.service.snapshot(state["run_id"])
  481. pending = self._full_state(state, pending_only=True)["candidates"]
  482. run = self._full_state(state).get("run") or {}
  483. rules = run.get("rule_config") or {}
  484. eligible: list[dict[str, Any]] = []
  485. gate_failures: list[tuple[int, dict[str, Any]]] = []
  486. for item in pending:
  487. gate = evaluate_candidate_gate(item, rules)
  488. if gate.get("status") == "pass":
  489. eligible.append(item)
  490. else:
  491. gate_failures.append((int(item["candidate_id"]), gate))
  492. self.service.reject_failed_gates(state["run_id"], gate_failures)
  493. shards = [eligible[index : index + 8] for index in range(0, len(eligible), 8)]
  494. semaphore = asyncio.Semaphore(max(1, min(8, int(state.get("worker_count") or 4))))
  495. async def run_shard(index: int, items: list[dict[str, Any]]) -> NodeRun:
  496. candidate_ids = [int(item["candidate_id"]) for item in items]
  497. video_understanding_ids = self._video_understanding_ids(state, items)
  498. execution_plan = state.get("execution_plan")
  499. if execution_plan is None:
  500. raise RuntimeError("Evaluator 缺少已校验的 ExecutionPlan")
  501. assignment = EvaluationAssignment(
  502. run_id=state["run_id"],
  503. round_index=state["round_index"],
  504. candidate_ids=candidate_ids,
  505. evaluation_brief=execution_plan.evaluation_brief,
  506. quality_gate_rules=rules,
  507. current_datetime=str(rules.get("current_datetime") or ""),
  508. timezone=str(rules.get("timezone") or ""),
  509. video_understanding_candidate_ids=video_understanding_ids,
  510. candidates=items,
  511. )
  512. user_content, slots = self._assignment_input(
  513. assignment,
  514. source="ExecutionPlan.evaluation_brief + host candidate shard",
  515. )
  516. video_tools = (
  517. bound_candidate_tools(
  518. (EVALUATION_TOOLS[0],),
  519. run_id=state["run_id"],
  520. candidate_ids=video_understanding_ids,
  521. )
  522. if video_understanding_ids
  523. else ()
  524. )
  525. async with semaphore:
  526. structured_runner = getattr(self.runner, "run_evaluation", None)
  527. if callable(structured_runner):
  528. run, proposed = await structured_runner(
  529. round_index=state["round_index"],
  530. system_prompt=EVALUATOR_PROMPT,
  531. user_content=user_content,
  532. slots=slots,
  533. branch_key=f"step-{state.get('supervisor_step', 0)}-shard-{index}",
  534. tools=video_tools,
  535. )
  536. normalized = normalize_evaluation_items(
  537. proposed,
  538. allowed_candidates=items,
  539. )
  540. updated = self.service.evaluate(state["run_id"], normalized)
  541. updated_ids = {int(item["candidate_id"]) for item in updated}
  542. if updated_ids != set(candidate_ids):
  543. raise RuntimeError(
  544. "评估写入结果不完整:"
  545. f"expected={candidate_ids}, updated={sorted(updated_ids)}"
  546. )
  547. return run
  548. return await self.runner.run_node(
  549. node="evaluator",
  550. round_index=state["round_index"],
  551. system_prompt=EVALUATOR_PROMPT,
  552. user_content=user_content,
  553. tools=(
  554. *video_tools,
  555. *bound_candidate_tools(
  556. EVALUATION_TOOLS[1:],
  557. run_id=state["run_id"],
  558. candidate_ids=candidate_ids,
  559. ),
  560. ),
  561. max_iterations=12,
  562. slots=slots,
  563. branch_key=f"step-{state.get('supervisor_step', 0)}-shard-{index}",
  564. allow_delegation=False,
  565. )
  566. runs = (
  567. await asyncio.gather(
  568. *(run_shard(index, items) for index, items in enumerate(shards, start=1))
  569. )
  570. if shards
  571. else []
  572. )
  573. node_runs.extend(runs)
  574. self.service.recount_valid_primary(state["run_id"])
  575. after = self.service.snapshot(state["run_id"])
  576. stagnant = int(state.get("evaluator_stagnation") or 0)
  577. stagnant = stagnant + 1 if after.pending_count >= before.pending_count else 0
  578. if stagnant >= 3:
  579. raise RuntimeError(
  580. f"评估节点连续 3 次未消费 pending_evaluation 候选:remaining={after.pending_count}"
  581. )
  582. return {
  583. "phase": "evaluating",
  584. "node_runs": node_runs,
  585. "action_count": int(state.get("action_count") or 0) + 1,
  586. "evaluator_stagnation": stagnant,
  587. }
  588. @staticmethod
  589. def _route(state: FindAgentGraphState) -> str:
  590. return state.get("approved_action", "finish")
  591. def _build_graph(self):
  592. builder = StateGraph(FindAgentGraphState)
  593. builder.add_node("supervisor", self._supervisor)
  594. builder.add_node("search", self._search)
  595. builder.add_node("evidence", self._evidence)
  596. builder.add_node("evaluator", self._evaluator)
  597. builder.add_edge(START, "supervisor")
  598. builder.add_conditional_edges(
  599. "supervisor",
  600. self._route,
  601. {
  602. "search": "search",
  603. "evidence": "evidence",
  604. "evaluator": "evaluator",
  605. "finish": END,
  606. },
  607. )
  608. builder.add_edge("search", "supervisor")
  609. builder.add_edge("evidence", "supervisor")
  610. builder.add_edge("evaluator", "supervisor")
  611. return builder.compile()
  612. async def invoke(self, state: FindAgentState) -> FindAgentState:
  613. graph_state: FindAgentGraphState = {
  614. "run_id": state.run_id,
  615. "user_input": state.user_input,
  616. "round_index": state.round_index,
  617. "execution_plan": state.execution_plan,
  618. "phase": state.phase,
  619. "node_runs": [],
  620. "snapshot": state.snapshot,
  621. "supervisor_step": 0,
  622. "action_count": 0,
  623. "search_actions": 0,
  624. "worker_count": 4,
  625. "evidence_scope": "both",
  626. "decision_history": [],
  627. "evaluator_stagnation": 0,
  628. }
  629. with self.observer.round(
  630. round_index=state.round_index,
  631. spec=self.obagent_spec,
  632. ) as round_observation:
  633. output = await self.app.ainvoke(
  634. graph_state,
  635. config={"recursion_limit": max(64, self.max_actions * 4)},
  636. )
  637. state.execution_plan = output.get("execution_plan")
  638. state.node_runs.extend(output.get("node_runs") or [])
  639. state.snapshot = self.service.snapshot(state.run_id)
  640. state.phase = "done"
  641. self.service.update_round(
  642. state.run_id,
  643. state.round_index,
  644. phase="done",
  645. status="done",
  646. snapshot=state.snapshot,
  647. )
  648. round_observation.set_output(
  649. {
  650. "状态快照": state.snapshot.__dict__,
  651. "本轮计划": (
  652. state.execution_plan.model_dump(mode="json")
  653. if state.execution_plan is not None
  654. else {}
  655. ),
  656. "Supervisor决策轨迹": output.get("decision_history") or [],
  657. },
  658. ok=True,
  659. )
  660. return state