"""One autonomous but policy-guarded business round compiled as LangGraph.""" from __future__ import annotations import asyncio import json import re from typing import Any, Protocol from langgraph.graph import END, START, StateGraph from find_agent_v2.context import build_node_slots, render_node_context from find_agent_v2.observability import InputSlot, NullObserver, graph_spec_for from find_agent_v2.prompts import EVALUATOR_PROMPT, EVIDENCE_PROMPT, SEARCH_PROMPT, SUPERVISOR_PROMPT from find_agent_v2.service import FindAgentV2Service from find_agent_v2.state import FindAgentGraphState, FindAgentState, NodeRun from find_agent_v2.tools import ( EVALUATION_TOOLS, EVIDENCE_TOOLS, SEARCH_TOOLS, ToolFn, bound_candidate_tools, ) class NodeRunner(Protocol): async def run_node( self, *, node: str, round_index: int, system_prompt: str, user_content: str, tools: tuple[ToolFn, ...] = (), max_iterations: int = 12, slots: tuple[InputSlot, ...] = (), branch_key: str = "", allow_delegation: bool = True, ) -> NodeRun: ... class FindAgentRoundGraph: """Supervisor-directed graph with deterministic policy approval.""" def __init__( self, *, service: FindAgentV2Service, runner: NodeRunner, observer=None, max_actions: int = 16, max_search_actions: int = 3, target_primary_count: int = 5, ) -> None: self.service = service self.runner = runner self.observer = observer or NullObserver() self.max_actions = max(4, int(max_actions)) self.max_search_actions = max(1, int(max_search_actions)) self.target_primary_count = max(1, int(target_primary_count)) 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, ) def _context(self, state: FindAgentGraphState, *, pending_only: bool = False) -> str: return render_node_context( user_input=state["user_input"], full_state=self._full_state(state, pending_only=pending_only), round_index=state["round_index"], plan=state.get("plan", ""), ) def _slots( self, state: FindAgentGraphState, *, pending_only: bool = False, ) -> tuple[InputSlot, ...]: return build_node_slots( user_input=state["user_input"], full_state=self._full_state(state, pending_only=pending_only), round_index=state["round_index"], plan=state.get("plan", ""), ) def _shard_state( self, state: FindAgentGraphState, items: list[dict[str, Any]], ) -> dict[str, Any]: full_state = self._full_state(state, pending_only=True) return {**full_state, "candidates": items} def _shard_context( self, state: FindAgentGraphState, items: list[dict[str, Any]], ) -> str: return render_node_context( user_input=state["user_input"], full_state=self._shard_state(state, items), round_index=state["round_index"], plan=state.get("plan", ""), ) def _shard_slots( self, state: FindAgentGraphState, items: list[dict[str, Any]], ) -> tuple[InputSlot, ...]: return build_node_slots( user_input=state["user_input"], full_state=self._shard_state(state, items), round_index=state["round_index"], plan=state.get("plan", ""), ) @staticmethod def _parse_supervisor(content: str) -> dict[str, Any]: text = content.strip() fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S) if fenced: text = fenced.group(1) else: start, end = text.find("{"), text.rfind("}") if start >= 0 and end > start: text = text[start:end + 1] try: value = json.loads(text) return value if isinstance(value, dict) else {} except (TypeError, ValueError): return {} def _approve_action( self, state: FindAgentGraphState, 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) proposed = str(proposal.get("next_action") or "").lower() reason = str(proposal.get("reason") or "") searches = int(state.get("search_actions") or 0) actions = int(state.get("action_count") or 0) try: worker_count = max(1, min(8, int(proposal.get("worker_count") or 4))) except (TypeError, ValueError): worker_count = 4 scope = str(proposal.get("evidence_scope") or "both").lower() if scope not in {"detail", "portrait", "both"}: scope = "both" snapshot = self.service.snapshot(state["run_id"]) if not pending and snapshot.valid_primary_count >= self.target_primary_count: return "finish", "有效 primary 已达到目标,结束本轮", worker_count, scope if actions > self.max_actions: if not pending: return "finish", "安全收敛动作已完成", worker_count, scope raise RuntimeError( f"Supervisor 安全收敛动作未产生进展:actions={actions}, pending={len(pending)}" ) if actions == self.max_actions: if pending and not (missing_detail or missing_portrait): return "evaluator", "动作预算耗尽,强制消费待评估候选", worker_count, scope if pending: return "evidence", "动作预算耗尽,强制补齐缺失证据", worker_count, "both" return "finish", "达到单轮动作安全上限", worker_count, scope if pending: if missing_detail or missing_portrait: if scope == "detail" and not missing_detail: scope = "portrait" elif scope == "portrait" and not missing_portrait: scope = "detail" return "evidence", reason or "存在缺失证据,框架要求先补证", worker_count, scope return "evaluator", reason or "候选证据已处理,进入评估", worker_count, scope if searches == 0: return "search", reason or "本轮尚未搜索,框架要求先建立候选池", worker_count, scope if proposed == "search" and searches < self.max_search_actions: return "search", reason or "Supervisor 判断继续搜索仍有信息增益", worker_count, scope return "finish", reason or "没有待处理候选,结束本轮", worker_count, scope async def _supervisor(self, state: FindAgentGraphState) -> dict[str, Any]: run = await self.runner.run_node( node="supervisor", round_index=state["round_index"], system_prompt=SUPERVISOR_PROMPT, user_content=self._context(state), tools=(), max_iterations=2, slots=self._slots(state), allow_delegation=False, ) proposal = self._parse_supervisor(run.content) action, reason, workers, scope = self._approve_action(state, proposal) proposed_plan = proposal.get("plan") plan = ( json.dumps(proposed_plan, ensure_ascii=False) if isinstance(proposed_plan, dict) else state.get("plan", "") ) decision = { "step": int(state.get("supervisor_step") or 0) + 1, "proposed_action": proposal.get("next_action"), "approved_action": action, "reason": reason, "worker_count": workers, "evidence_scope": scope, } self.service.update_round( state["run_id"], state["round_index"], phase="planning", plan=plan, ) return { "phase": "planning", "plan": plan, "approved_action": action, "supervisor_step": decision["step"], "worker_count": workers, "evidence_scope": scope, "decision_history": [*state.get("decision_history", []), decision], "node_runs": [*state.get("node_runs", []), run], } async def _search(self, state: FindAgentGraphState) -> dict[str, Any]: self.service.update_round(state["run_id"], state["round_index"], phase="searching") run = await self.runner.run_node( node="search", round_index=state["round_index"], system_prompt=SEARCH_PROMPT, user_content=self._context(state), tools=SEARCH_TOOLS, max_iterations=10, slots=self._slots(state), ) return { "phase": "searching", "search_actions": int(state.get("search_actions") or 0) + 1, "action_count": int(state.get("action_count") or 0) + 1, "node_runs": [*state.get("node_runs", []), run], } 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)))) async def run_shard( index: int, evidence_type: str, items: list[dict[str, Any]], ) -> NodeRun: candidate_ids = [int(item["candidate_id"]) for item in items] selected = ( (EVIDENCE_TOOLS[0], EVIDENCE_TOOLS[2]) if evidence_type == "detail" else (EVIDENCE_TOOLS[1], EVIDENCE_TOOLS[2]) ) async with semaphore: return await self.runner.run_node( node="evidence", round_index=state["round_index"], system_prompt=EVIDENCE_PROMPT, user_content=( f"你只负责当前 {evidence_type} 分片 candidate_ids={candidate_ids}。" f"必须为这些候选补齐 {evidence_type},不得访问其他候选。\n\n" + self._shard_context(state, items) ), tools=bound_candidate_tools( selected, run_id=state["run_id"], candidate_ids=candidate_ids, ), max_iterations=12, slots=self._shard_slots(state, items), branch_key=f"{evidence_type}-shard-{index}", allow_delegation=False, ) runs = await asyncio.gather(*( run_shard(index, evidence_type, items) for index, (evidence_type, items) in enumerate(jobs, start=1) )) if jobs else [] return { "phase": "evidence", "action_count": int(state.get("action_count") or 0) + 1, "node_runs": [*state.get("node_runs", []), *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"] shards = [pending[index:index + 8] for index in range(0, len(pending), 8)] semaphore = asyncio.Semaphore(max(1, min(8, int(state.get("worker_count") or 4)))) async def run_shard(index: int, items: list[dict[str, Any]]) -> NodeRun: candidate_ids = [int(item["candidate_id"]) for item in items] async with semaphore: return await self.runner.run_node( node="evaluator", round_index=state["round_index"], system_prompt=EVALUATOR_PROMPT, user_content=( f"你只负责当前分片 candidate_ids={candidate_ids}。" "必须把这些候选全部分池,不得评估其他候选。\n\n" + self._shard_context(state, items) ), tools=bound_candidate_tools( EVALUATION_TOOLS, run_id=state["run_id"], candidate_ids=candidate_ids, ), max_iterations=12, slots=self._shard_slots(state, items), branch_key=f"step-{state.get('supervisor_step', 0)}-shard-{index}", allow_delegation=False, ) runs = await asyncio.gather(*( run_shard(index, items) for index, items in enumerate(shards, start=1) )) if shards else [] node_runs.extend(runs) 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( "评估节点连续 3 次未消费 pending_evaluation 候选:" f"remaining={after.pending_count}" ) return { "phase": "evaluating", "node_runs": node_runs, "action_count": int(state.get("action_count") or 0) + 1, "evaluator_stagnation": stagnant, } @staticmethod def _route(state: FindAgentGraphState) -> str: return state.get("approved_action", "finish") def _build_graph(self): builder = StateGraph(FindAgentGraphState) builder.add_node("supervisor", self._supervisor) builder.add_node("search", self._search) builder.add_node("evidence", self._evidence) builder.add_node("evaluator", self._evaluator) builder.add_edge(START, "supervisor") builder.add_conditional_edges("supervisor", self._route, { "search": "search", "evidence": "evidence", "evaluator": "evaluator", "finish": END, }) builder.add_edge("search", "supervisor") builder.add_edge("evidence", "supervisor") builder.add_edge("evaluator", "supervisor") return builder.compile() async def invoke(self, state: FindAgentState) -> FindAgentState: graph_state: FindAgentGraphState = { "run_id": state.run_id, "user_input": state.user_input, "round_index": state.round_index, "plan": state.plan, "phase": state.phase, "node_runs": [], "snapshot": state.snapshot, "supervisor_step": 0, "action_count": 0, "search_actions": 0, "worker_count": 4, "evidence_scope": "both", "decision_history": [], "evaluator_stagnation": 0, } with self.observer.round( round_index=state.round_index, spec=self.obagent_spec, ) as round_observation: output = await self.app.ainvoke( graph_state, config={"recursion_limit": max(64, self.max_actions * 4)}, ) state.plan = str(output.get("plan") or "") state.node_runs.extend(output.get("node_runs") or []) state.snapshot = self.service.snapshot(state.run_id) state.phase = "done" self.service.update_round( state.run_id, state.round_index, phase="done", status="done", snapshot=state.snapshot, ) round_observation.set_output({ "状态快照": state.snapshot.__dict__, "本轮计划": state.plan, "Supervisor决策轨迹": output.get("decision_history") or [], }, ok=True) return state