| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394 |
- """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
|