"""LangGraph Agent service with tool calling capabilities.""" import asyncio from typing import List, Dict, Any, Optional, AsyncGenerator from langchain_core.messages import HumanMessage, AIMessage from langchain_core.tools import tool from langchain.chat_models import init_chat_model # from langgraph.prebuilt import create_react_agent from pydantic import BaseModel, Field from .base import ToolRegistry from th_agenter.services.tools import WeatherQueryTool, TavilySearchTool, DateTimeTool from ..postgresql_tool_manager import get_postgresql_tool from ...core.config import get_settings from ..agent_config import AgentConfigService from th_agenter.services.mcp.mcp_dynamic_tools import load_mcp_tools from loguru import logger class LangGraphAgentConfig(BaseModel): """LangGraph Agent configuration.""" model_name: str = Field(default="gpt-3.5-turbo") model_provider: str = Field(default="openai") base_url: Optional[str] = Field(default=None) api_key: Optional[str] = Field(default=None) enabled_tools: List[str] = Field(default_factory=lambda: [ "calculator", "weather", "search", "file", "image" ]) max_iterations: int = Field(default=10) temperature: float = Field(default=0.7) max_tokens: int = Field(default=1000) system_message: str = Field( default="""你是一个有用的AI助手,可以使用各种工具来帮助用户解决问题。 重要规则: 1. 工具调用失败时,必须仔细分析失败原因,特别是参数格式问题 3. 在重新调用工具前,先解释上次失败的原因和改进方案 4. 确保每个工具调用的参数格式严格符合工具的要求 """ ) verbose: bool = Field(default=True) class LangGraphAgentService: """LangGraph Agent service using low-level LangGraph graph (React pattern).""" def __init__(self): self.settings = get_settings() async def initialize(self, session=None): self.tool_registry = ToolRegistry() self.config = LangGraphAgentConfig() self.tools = [] self.session = session self.config_service = AgentConfigService(session) if session else None self._initialize_tools() await self._load_config() await self._create_react_agent() def _initialize_tools(self): """Initialize available tools.""" try: dynamic_tools = load_mcp_tools() except Exception as e: logger.warning(f"加载 MCP 动态工具失败,使用本地工具回退: {e}") dynamic_tools = [] # Always keep DateTimeTool locally base_tools = [DateTimeTool()] if dynamic_tools: self.tools = dynamic_tools + base_tools logger.info(f"LangGraph 绑定 MCP 动态工具: {[t.name for t in dynamic_tools]}") else: # Fallback to local weather/search when MCP not available self.tools = [ WeatherQueryTool(), TavilySearchTool(), ] + base_tools logger.info("MCP 不可用,已回退到本地 Weather/Search 工具") async def _load_config(self): """Load configuration from database if available.""" pass # if self.config_service: # try: # db_config = self.config_service.get_active_config() # if db_config: # # Update config with database values # config_dict = db_config.config_data # for key, value in config_dict.items(): # if hasattr(self.config, key): # setattr(self.config, key, value) # logger.info("Loaded configuration from database") # except Exception as e: # logger.exception(f"Failed to load config from database: {e}") async def _create_react_agent(self): """Create LangGraph agent using low-level StateGraph with explicit nodes/edges.""" try: # Initialize the model llm_config = await get_settings().llm.get_current_config(self.db_session) self.model = init_chat_model( model=llm_config['model'], model_provider='openai', temperature=llm_config['temperature'], max_tokens=llm_config['max_tokens'], base_url= llm_config['base_url'], api_key=llm_config['api_key'] ) # Bind tools to the model so it can propose tool calls try: self.bound_model = self.model.bind_tools(self.tools) except Exception as e: logger.warning(f"Failed to bind tools to model, tool calling may not work: {e}") self.bound_model = self.model # Build low-level React graph: State -> agent -> tools -> agent ... until stop from typing import TypedDict from langgraph.graph import StateGraph, START, END from langchain_core.messages import ToolMessage, BaseMessage from typing import Annotated from langgraph.graph.message import add_messages class AgentState(TypedDict): messages: Annotated[List[BaseMessage], add_messages] # Node: call the model def agent_node(state: AgentState) -> AgentState: messages = state["messages"] # Optionally include a system instruction at the start for first turn if messages and messages[0].__class__.__name__ != 'SystemMessage': # Keep user history untouched; rely on upstream to include system if desired pass ai = self.bound_model.invoke(messages) return {"messages": [ai]} # Node: execute tools requested by the last AI message def tools_node(state: AgentState) -> AgentState: messages = state["messages"] last = messages[-1] outputs: List[ToolMessage] = [] try: tool_calls = getattr(last, 'tool_calls', []) or [] tool_map = {t.name: t for t in self.tools} for call in tool_calls: name = call.get('name') if isinstance(call, dict) else getattr(call, 'name', None) args = call.get('args') if isinstance(call, dict) else getattr(call, 'args', {}) call_id = call.get('id') if isinstance(call, dict) else getattr(call, 'id', '') if name in tool_map: try: result = tool_map[name].invoke(args) except Exception as te: result = f"Tool {name} execution error: {te}" else: result = f"Unknown tool: {name}" outputs.append(ToolMessage(content=str(result), tool_call_id=call_id)) except Exception as e: outputs.append(ToolMessage(content=f"Tool execution error: {e}", tool_call_id="")) return {"messages": outputs} # Router: decide next step after agent node def route_after_agent(state: AgentState) -> str: last = state["messages"][-1] finish_reason = None try: meta = getattr(last, 'response_metadata', {}) or {} finish_reason = meta.get('finish_reason') except Exception: finish_reason = None # If the model decided to call tools, continue to tools node if getattr(last, 'tool_calls', None): return "tools" # Otherwise, end return END graph = StateGraph(AgentState) graph.add_node("agent", agent_node) graph.add_node("tools", tools_node) graph.add_edge(START, "agent") graph.add_conditional_edges("agent", route_after_agent, {"tools": "tools", END: END}) graph.add_edge("tools", "agent") # Compile graph and store as self.agent for compatibility with existing code self.react_agent = graph.compile() logger.info("LangGraph 底层 React 智能体创建成功") except Exception as e: logger.error(f"Failed to create agent: {str(e)}") raise def _format_tools_info(self) -> str: """Format tools information for the prompt.""" tools_info = [] for tool_name in self.config.enabled_tools: tool = self.tool_registry.get_tool(tool_name) if tool: params_info = [] for param in tool.get_parameters(): params_info.append(f" - {param.name} ({param.type.value}): {param.description}") tool_info = f"**{tool.get_name()}**: {tool.get_description()}" if params_info: tool_info += "\n" + "\n".join(params_info) tools_info.append(tool_info) return "\n\n".join(tools_info) async def chat(self, message: str, chat_history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]: """Process a chat message using LangGraph.""" try: logger.info(f"Starting chat with message: {message[:100]}...") # Convert chat history to messages messages = [] if chat_history: for msg in chat_history: if msg["role"] == "user": messages.append(HumanMessage(content=msg["content"])) elif msg["role"] == "assistant": messages.append(AIMessage(content=msg["content"])) # Add current message messages.append(HumanMessage(content=message)) # Use the low-level graph directly result = await self.react_agent.ainvoke({"messages": messages}, {"recursion_limit": 6}, ) # Extract final response final_response = "" if "messages" in result and result["messages"]: last_message = result["messages"][-1] if hasattr(last_message, 'content'): final_response = last_message.content elif isinstance(last_message, dict) and "content" in last_message: final_response = last_message["content"] return { "response": final_response, "intermediate_steps": [], "success": True, "error": None } except Exception as e: logger.error(f"LangGraph chat error: {str(e)}", exc_info=True) return { "response": f"抱歉,处理您的请求时出现错误: {str(e)}", "intermediate_steps": [], "success": False, "error": str(e) } async def chat_stream(self, message: str, chat_history: Optional[List[Dict[str, str]]] = None) -> AsyncGenerator[ Dict[str, Any], None]: """Process a chat message using LangGraph with streaming.""" try: logger.info(f"Starting streaming chat with message: {message[:100]}...") # Convert chat history to messages messages = [] if chat_history: for msg in chat_history: if msg["role"] == "user": messages.append(HumanMessage(content=msg["content"])) elif msg["role"] == "assistant": messages.append(AIMessage(content=msg["content"])) # Add current message messages.append(HumanMessage(content=message)) # Track state for streaming intermediate_steps = [] final_response_started = False accumulated_response = "" final_ai_message = None # Stream the agent execution async for event in self.react_agent.astream({"messages": messages}): # Handle different event types from LangGraph print('event===', event) if isinstance(event, dict): for node_name, node_output in event.items(): logger.info(f"Processing node: {node_name}, output type: {type(node_output)}") # 处理 tools 节点 if "tools" in node_name.lower(): # 提取工具信息 tool_infos = [] if isinstance(node_output, dict) and "messages" in node_output: messages_in_output = node_output["messages"] for msg in messages_in_output: # 处理 ToolMessage 对象 if hasattr(msg, 'name') and hasattr(msg, 'content'): tool_info = { "tool_name": msg.name, "tool_output": msg.content, "tool_call_id": getattr(msg, 'tool_call_id', ''), "status": "completed" } tool_infos.append(tool_info) elif isinstance(msg, dict): if 'name' in msg and 'content' in msg: tool_info = { "tool_name": msg['name'], "tool_output": msg['content'], "tool_call_id": msg.get('tool_call_id', ''), "status": "completed" } tool_infos.append(tool_info) # 返回 tools_end 事件 for tool_info in tool_infos: yield { "type": "tools_end", "content": f"工具 {tool_info['tool_name']} 执行完成", "tool_name": tool_info["tool_name"], "tool_output": tool_info["tool_output"], "node_name": node_name, "done": False } await asyncio.sleep(0.1) # 处理 agent 节点 elif "agent" in node_name.lower(): if isinstance(node_output, dict) and "messages" in node_output: messages_in_output = node_output["messages"] if messages_in_output: last_msg = messages_in_output[-1] # 获取 finish_reason finish_reason = None if hasattr(last_msg, 'response_metadata'): finish_reason = last_msg.response_metadata.get('finish_reason') elif isinstance(last_msg, dict) and 'response_metadata' in last_msg: finish_reason = last_msg['response_metadata'].get('finish_reason') # 判断是否为 thinking 或 response if finish_reason == 'tool_calls': # thinking 状态 thinking_content = "🤔 正在思考..." if hasattr(last_msg, 'content') and last_msg.content: thinking_content = f"🤔 思考: {last_msg.content[:200]}..." elif isinstance(last_msg, dict) and "content" in last_msg: thinking_content = f"🤔 思考: {last_msg['content'][:200]}..." yield { "type": "thinking", "content": thinking_content, "node_name": node_name, "raw_output": str(node_output)[:500] if node_output else "", "done": False } await asyncio.sleep(0.1) elif finish_reason == 'stop': # response 状态 if hasattr(last_msg, 'content') and hasattr(last_msg, '__class__') and 'AI' in last_msg.__class__.__name__: current_content = last_msg.content final_ai_message = last_msg if not final_response_started and current_content: final_response_started = True yield { "type": "response_start", "content": "", "intermediate_steps": intermediate_steps, "done": False } if current_content and len(current_content) > len(accumulated_response): new_content = current_content[len(accumulated_response):] for char in new_content: accumulated_response += char yield { "type": "response", "content": accumulated_response, "intermediate_steps": intermediate_steps, "done": False } await asyncio.sleep(0.03) else: # 其他 agent 状态 yield { "type": "step", "content": f"📋 执行步骤: {node_name}", "node_name": node_name, "raw_output": str(node_output)[:500] if node_output else "", "done": False } await asyncio.sleep(0.1) # 处理其他节点 else: yield { "type": "step", "content": f"📋 执行步骤: {node_name}", "node_name": node_name, "raw_output": str(node_output)[:500] if node_output else "", "done": False } await asyncio.sleep(0.1) # 最终完成事件 yield { "type": "complete", "content": accumulated_response, "intermediate_steps": intermediate_steps, "done": True } except Exception as e: logger.error(f"Error in chat_stream: {str(e)}", exc_info=True) yield { "type": "error", "content": f"处理请求时出错: {str(e)}", "done": True } # 确保最终响应包含完整内容 final_content = accumulated_response if not final_content and final_ai_message and hasattr(final_ai_message, 'content'): final_content = final_ai_message.content or "" # Final completion signal yield { "type": "response", "content": final_content, "intermediate_steps": intermediate_steps, "done": True } except Exception as e: logger.error(f"LangGraph chat stream error: {str(e)}", exc_info=True) yield { "type": "error", "content": f"抱歉,处理您的请求时出现错误: {str(e)}", "error": str(e), "done": True } def get_available_tools(self) -> List[Dict[str, Any]]: """Get list of available tools.""" tools = [] for tool in self.tools: tools.append({ "name": tool.name, "description": tool.description, "parameters": [], "enabled": True }) return tools def get_config(self) -> Dict[str, Any]: """Get current agent configuration.""" return self.config.dict() def update_config(self, config: Dict[str, Any]): """Update agent configuration.""" for key, value in config.items(): if hasattr(self.config, key): setattr(self.config, key, value) # Recreate agent with new config self._create_react_agent() logger.info("Agent configuration updated") def _create_plan_execute_agent(self): """Create a Plan-and-Execute agent using LangGraph low-level API. 结构:START -> planner -> executor(loop) -> summarize -> END - planner:根据用户问题生成计划(JSON 数组) - executor:逐步执行计划(可调用工具),收集每步结果 - summarize:综合计划与执行结果产出最终回答 """ from typing import TypedDict, Annotated, List import json from langgraph.graph import StateGraph, START, END from langgraph.graph.message import add_messages from langchain_core.messages import HumanMessage, AIMessage, ToolMessage, BaseMessage try: self.bound_model = self.model.bind_tools(self.tools) except Exception as e: logger.warning(f"Failed to bind tools to model, tool calling may not work: {e}") self.bound_model = self.model class PlanState(TypedDict): messages: Annotated[List[BaseMessage], add_messages] plan_steps: List[str] current_step: int step_results: List[str] def planner_node(state: PlanState) -> PlanState: messages = state.get("messages", []) plan_prompt = ( "你是规划助手。基于对话内容生成可执行计划," "用 JSON 数组返回,每个元素是一条明确且可操作的步骤。" "仅输出 JSON,不要额外解释。" ) ai_plan = self.model.invoke(messages + [HumanMessage(content=plan_prompt)]) steps: List[str] = [] try: parsed = json.loads(ai_plan.content) if isinstance(parsed, list): steps = [str(s) for s in parsed] except Exception: # 回退:按行拆分 steps = [s.strip() for s in ai_plan.content.split("\n") if s.strip()] return { "messages": [ai_plan], "plan_steps": steps, "current_step": 0, "step_results": [] } def executor_node(state: PlanState) -> PlanState: idx = state.get("current_step", 0) steps = state.get("plan_steps", []) msgs = state.get("messages", []) if idx >= len(steps): return {"messages": [], "current_step": idx, "step_results": state.get("step_results", [])} step_text = steps[idx] exec_prompt = ( f"请执行计划的第{idx+1}步:{step_text}。" "需要用工具时创建工具调用;完成后给出该步的结果。" ) ai_exec = self.bound_model.invoke(msgs + [HumanMessage(content=exec_prompt)]) new_messages: List[BaseMessage] = [ai_exec] step_result_content = None # 处理工具调用 tool_map = {t.name: t for t in self.tools} tool_msgs: List[ToolMessage] = [] tool_calls = getattr(ai_exec, "tool_calls", []) or (ai_exec.additional_kwargs.get("tool_calls") if hasattr(ai_exec, "additional_kwargs") else []) if tool_calls: for call in tool_calls: name = call.get("name") args = call.get("args", {}) tool_obj = tool_map.get(name) if tool_obj: try: result = tool_obj.invoke(args) except Exception as e: result = f"工具执行失败: {e}" else: result = f"未找到工具: {name}" tool_call_id = call.get("id") or call.get("tool_call_id") or call.get("call_id") or f"tool_{name}" tool_msgs.append(ToolMessage(content=str(result), tool_call_id=tool_call_id, name=name or "tool")) new_messages.extend(tool_msgs) # 基于工具输出总结该步结果 summarize_step = "请基于上述工具输出,总结该步骤的结果,给出结构化要点与可读说明。" ai_step = self.bound_model.invoke(msgs + [ai_exec] + tool_msgs + [HumanMessage(content=summarize_step)]) step_result_content = ai_step.content new_messages.append(ai_step) else: step_result_content = ai_exec.content all_results = list(state.get("step_results", [])) if step_result_content: all_results.append(step_result_content) return { "messages": new_messages, "current_step": idx + 1, "step_results": all_results } def route_after_planner(state: PlanState) -> str: return "executor" if state.get("plan_steps") else END def route_after_executor(state: PlanState) -> str: cur = state.get("current_step", 0) total = len(state.get("plan_steps", [])) return "executor" if cur < total else "summarize" def summarize_node(state: PlanState) -> PlanState: import json as _json msgs = state.get("messages", []) steps = state.get("plan_steps", []) results = state.get("step_results", []) final_prompt = ( "请综合以上计划与各步骤结果,生成最终回答。" "要求:逻辑清晰、结论明确、可读性强;如存在不确定性请注明。" ) context_msg = HumanMessage(content=( f"计划: {_json.dumps(steps, ensure_ascii=False)}\n" f"步骤结果: {_json.dumps(results, ensure_ascii=False)}\n" f"{final_prompt}" )) ai_final = self.model.invoke(msgs + [context_msg]) return {"messages": [ai_final]} graph = StateGraph(PlanState) graph.add_node("planner", planner_node) graph.add_node("executor", executor_node) graph.add_node("summarize", summarize_node) graph.add_edge(START, "planner") graph.add_conditional_edges("planner", route_after_planner, {"executor": "executor", END: END}) graph.add_conditional_edges("executor", route_after_executor, {"executor": "executor", "summarize": "summarize"}) graph.add_edge("summarize", END) self.plan_execute_agent = graph.compile() async def chat_plan_execute(self, message: str, chat_history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]: """Single-turn Plan-and-Execute chat.""" # 确保 agent 已创建 if not hasattr(self, "plan_execute_agent"): self._create_plan_execute_agent() # 构建消息 messages = [] if chat_history: for msg in chat_history: role = msg.get("role") content = msg.get("content", "") if role == "user": messages.append(HumanMessage(content=content)) else: messages.append(AIMessage(content=content)) messages.append(HumanMessage(content=message)) try: result = await self.plan_execute_agent.ainvoke({"messages": messages}, config={"recursion_limit": self.config.max_iterations}) final_msg = None if isinstance(result, dict) and "messages" in result: ms = result["messages"] if ms: final_msg = ms[-1] final_text = getattr(final_msg, "content", "") if final_msg else "" return { "status": "success", "response": final_text, "raw": str(result) } except Exception as e: logger.error(f"Error in chat_plan_execute: {e}", exc_info=True) return { "status": "error", "error": str(e) } async def chat_stream_plan_execute(self, message: str, chat_history: Optional[List[Dict[str, str]]] = None) -> AsyncGenerator[Dict[str, Any], None]: """Streamed Plan-and-Execute chat.""" import asyncio as _asyncio if not hasattr(self, "plan_execute_agent"): self._create_plan_execute_agent() messages = [] if chat_history: for msg in chat_history: role = msg.get("role") content = msg.get("content", "") if role == "user": messages.append(HumanMessage(content=content)) else: messages.append(AIMessage(content=content)) messages.append(HumanMessage(content=message)) try: accumulated = "" async for event in self.react_agent.astream({"messages": messages}, config={"recursion_limit": self.config.max_iterations}): for key, node_output in event.items(): node_name = key[0] if isinstance(key, tuple) else key if node_name == "planner": # 规划阶段 content = "生成计划中..." if node_output and isinstance(node_output, dict): m = node_output.get("messages", []) if m: last = m[-1] if hasattr(last, "content"): content = str(last.content)[:400] yield {"type": "planning", "content": content, "done": False} await _asyncio.sleep(0.05) elif node_name == "executor": # 执行阶段(可能包含工具) yield {"type": "step", "content": "执行计划步骤", "done": False} await _asyncio.sleep(0.05) if node_output and isinstance(node_output, dict): msgs = node_output.get("messages", []) # 输出工具结束标记 tool_msgs = [m for m in msgs if hasattr(m, "__class__") and "Tool" in m.__class__.__name__] if tool_msgs: yield {"type": "tools_end", "content": f"完成 {len(tool_msgs)} 次工具执行", "done": False} await _asyncio.sleep(0.03) # 尝试输出该步总结 ai_msgs = [m for m in msgs if hasattr(m, "__class__") and "AI" in m.__class__.__name__] if ai_msgs: text = ai_msgs[-1].content if text: accumulated = text yield {"type": "response", "content": accumulated, "done": False} await _asyncio.sleep(0.02) elif node_name == "summarize": # 最终总结 if node_output and isinstance(node_output, dict): msgs = node_output.get("messages", []) if msgs: final = msgs[-1] content = getattr(final, "content", "") if content: yield {"type": "response_start", "content": "", "done": False} yield {"type": "response", "content": content, "done": False} accumulated = content await _asyncio.sleep(0.02) yield {"type": "complete", "content": accumulated, "done": True} except Exception as e: logger.error(f"Error in chat_stream_plan_execute: {e}", exc_info=True) yield {"type": "error", "content": str(e), "done": True} # Global instance _global_langgraph_agent_service: Optional[LangGraphAgentService] = None async def get_langgraph_agent_service(session=None) -> LangGraphAgentService: """Get or create LangGraph agent service instance.""" global _global_langgraph_agent_service if _global_langgraph_agent_service is None: _global_langgraph_agent_service = LangGraphAgentService() await _global_langgraph_agent_service.initialize(session) return _global_langgraph_agent_service