FinDataPilot / app /agent /graph.py
Fin-DataPilot Deploy Bot
ci: da17dfe
ab26c67
Raw
History Blame Contribute Delete
9.08 kB
"""LangGraph StateGraph assembly + streaming entry point.
Pipeline:
planner → skill_router → executor → reflector ─┐
↑ ↑ ↓ │
│ │ └── reflects: enough? │
│ │ │
│ └──── hint: skip LLM, use plan step │
│ │
└────────── plan exhausted + need_more ──────┘
synthesizer → END
The planner runs once at the start to pre-decompose the question
into a sequence of plan steps. The skill router then walks through
the plan without re-asking the LLM, with the reflector deciding
when to stop or when to trigger a re-plan.
"""
from __future__ import annotations
import logging
from typing import Any, AsyncIterator
from langgraph.graph import END, StateGraph
from app.agent.nodes.executor import executor_node
from app.agent.nodes.planner import planner_node
from app.agent.nodes.reflector import reflector_node
from app.agent.nodes.skill_router import skill_router_node
from app.agent.nodes.synthesizer import synthesize
from app.agent.state import AgentState, EV_DONE, EV_ERROR
from app.config import get_settings
from app.skills.registry import REGISTRY
logger = logging.getLogger(__name__)
def _build_graph() -> Any:
g = StateGraph(AgentState)
g.add_node("planner", planner_node)
g.add_node("skill_router", skill_router_node)
g.add_node("executor", executor_node)
g.add_node("reflector", reflector_node)
g.add_node("synthesizer", lambda s: s) # placeholder; streaming handled outside
g.set_entry_point("planner")
g.add_edge("planner", "skill_router") # planner always feeds into the router
# After router: if final_answer was set, go to synthesizer; else go to executor
def _after_router(state: AgentState) -> str:
if state.get("error"):
return "synthesizer"
if state.get("final_answer"):
return "synthesizer"
return "executor"
g.add_conditional_edges("skill_router", _after_router, {
"executor": "executor",
"synthesizer": "synthesizer",
})
# After executor: always go to reflector
g.add_edge("executor", "reflector")
# After reflector: three paths.
# need_more + plan still has steps → skill_router (advance plan)
# need_more + plan exhausted (cleared by reflector) → planner (re-plan)
# sufficient / failed / rounds cap hit → synthesizer
def _after_reflector(state: AgentState) -> str:
verdict = state.get("reflection_verdict", "sufficient")
rounds = state.get("rounds_used", 0)
max_rounds = get_settings().agent_max_reflect_rounds
if verdict == "need_more" and rounds < max_rounds:
plan = state.get("plan") or []
if not plan:
# Plan was cleared (exhausted) → re-plan
return "planner"
return "skill_router"
return "synthesizer"
g.add_conditional_edges("reflector", _after_reflector, {
"planner": "planner",
"skill_router": "skill_router",
"synthesizer": "synthesizer",
})
g.add_edge("synthesizer", END)
# Default LangGraph recursion limit is 25. With the multi-step
# plan + re-plan flow + anysearch being a slightly slower skill,
# we hit it on complex questions. Pass the limit via the config
# dict at astream time (LangGraph 0.x's .compile() doesn't accept
# a recursion_limit kwarg; the config goes on astream/ainvoke).
return g.compile()
_GRAPH = None
def get_graph() -> Any:
global _GRAPH
if _GRAPH is None:
_GRAPH = _build_graph()
return _GRAPH
# ---------- Public streaming entry point ----------
async def run_agent_stream(
user_query: str,
history: list[dict[str, Any]],
session_id: str,
) -> AsyncIterator[dict[str, Any]]:
"""Stream agent events for a single user turn."""
from app.agent.state import (
EV_MESSAGE_FINAL,
EV_REFLECTION,
EV_THINK,
EV_TOOL_CALL,
EV_TOOL_RESULT,
)
from app.utils.trace import generate_trace_id
if not REGISTRY.list_specs():
yield {"event": EV_ERROR, "data": {"message": "No skills registered"}}
yield {"event": EV_DONE, "data": {}}
return
trace_id = generate_trace_id()
init_state: AgentState = {
"user_query": user_query,
"session_id": session_id,
"history": history,
"tool_calls": [],
"rounds_used": 0,
"reflection_verdict": "need_more",
"trace_id": trace_id,
"plan": [],
"pending_step_index": 0,
"next_skill_hint": None,
"next_args_hint": None,
}
yield {"event": EV_THINK, "data": {"step": "entry", "text": f"开始处理:{user_query}", "trace_id": trace_id}}
graph = get_graph()
final_state: AgentState = dict(init_state)
try:
# Bump LangGraph's default 25 recursion limit to 50 for
# multi-step plan + re-plan flows. Most queries stay <10.
async for event in graph.astream(init_state, config={"recursion_limit": 50}):
# event is dict {node_name: node_output}
for node_name, node_out in event.items():
if not isinstance(node_out, dict):
continue
final_state.update(node_out)
# Stream per-node events
if node_name == "planner":
plan = node_out.get("plan") or []
steps = [
f"{s.get('target_skill') or 'final'} ({s.get('goal', '')[:40]})"
for s in plan
]
rationale = node_out.get("rationale", "") or ""
yield {
"event": EV_THINK,
"data": {
"step": "plan",
"text": f"已规划 {len(plan)} 步:{' → '.join(steps)}" + (
f"\n理由:{rationale}" if rationale else ""
),
},
}
if node_name == "skill_router":
tc = (node_out.get("tool_calls") or [])
if tc and tc[-1].get("result") is None:
last = tc[-1]
yield {
"event": EV_TOOL_CALL,
"data": {
"name": last["name"],
"args": last.get("args", {}),
"trace_id": last.get("trace_id", ""),
},
}
if node_out.get("final_answer"):
yield {
"event": EV_THINK,
"data": {"step": "router_final", "text": "直接生成最终答案"},
}
elif node_name == "executor":
tc = (node_out.get("tool_calls") or [])
if tc:
last = tc[-1]
yield {
"event": EV_TOOL_RESULT,
"data": {
"name": last["name"],
"ok": last.get("ok", False),
"duration_ms": last.get("duration_ms", 0),
"trace_id": last.get("trace_id", ""),
"result": last.get("result"),
"error": last.get("error"),
},
}
elif node_name == "reflector":
yield {
"event": EV_REFLECTION,
"data": {
"verdict": node_out.get("reflection_verdict", "sufficient"),
"reason": node_out.get("reflection", ""),
},
}
except Exception as exc: # noqa: BLE001
logger.exception("agent graph execution failed")
yield {"event": EV_ERROR, "data": {"message": f"Agent 执行失败: {exc}", "trace_id": trace_id}}
# If the router produced a final answer directly (no synthesizer streaming)
if final_state.get("final_answer") and not any(
True for _ in []
): # placeholder check
yield {
"event": EV_MESSAGE_FINAL,
"data": {
"content": final_state["final_answer"],
"tool_calls": final_state.get("tool_calls", []),
},
}
else:
# Stream synthesizer output
async for ev in synthesize(final_state):
yield ev
yield {"event": EV_DONE, "data": {"trace_id": trace_id}}