"""LangGraph workflow for the RAG (Retrieval-Augmented Generation) agent. This graph combines document retrieval tools (load_document, query_knowledge_base) with the standard chemistry tools so the agent can answer questions grounded in user-provided text documents *and* run molecular simulations when needed. Graph structure --------------- START | v RAGAgent <-------+ | | (route) | / \\ | v v | tools done-->END | | | +----------------+ The agent loops through a ReAct cycle: it can call any combination of RAG tools and chemistry tools, inspect the results, and decide whether to call more tools or produce a final answer. """ from langgraph.graph import StateGraph, START, END from langgraph.checkpoint.memory import MemorySaver from langgraph.prebuilt import ToolNode from chemgraph.tools.rag_tools import load_document, query_knowledge_base from chemgraph.tools.ase_tools import ( run_ase, save_atomsdata_to_file, file_to_atomsdata, ) from chemgraph.tools.cheminformatics_tools import ( molecule_name_to_smiles, smiles_to_coordinate_file, ) from chemgraph.tools.generic_tools import calculator from chemgraph.prompt.rag_prompt import rag_agent_prompt from chemgraph.state.state import State from chemgraph.utils.logging_config import setup_logger logger = setup_logger(__name__) # --------------------------------------------------------------------------- # Helpers (reuse the repeated-tool-call detection from single_agent) # --------------------------------------------------------------------------- def _tool_call_signature(tool_calls) -> tuple: """Create a comparable signature for a list of tool calls. Parameters ---------- tool_calls : list Tool-call dictionaries from an AI message. Returns ------- tuple Deterministic signature of tool names and arguments. """ signature = [] for call in tool_calls or []: name = call.get("name") if isinstance(call, dict) else None args = call.get("args", {}) if isinstance(call, dict) else {} if isinstance(args, dict): args_sig = tuple(sorted(args.items())) else: args_sig = str(args) signature.append((name, args_sig)) return tuple(signature) def _is_repeated_tool_cycle(messages) -> bool: """Detect if the most recent AI tool-call set repeats the previous one. Parameters ---------- messages : list Message history to inspect. Returns ------- bool ``True`` when the last two AI tool-call sets are identical. """ ai_with_calls = [ m for m in messages if hasattr(m, "tool_calls") and getattr(m, "tool_calls", None) ] if len(ai_with_calls) < 2: return False last = _tool_call_signature(ai_with_calls[-1].tool_calls) prev = _tool_call_signature(ai_with_calls[-2].tool_calls) return bool(last) and last == prev # --------------------------------------------------------------------------- # Routing # --------------------------------------------------------------------------- def route_tools(state: State): """Route to 'tools' if the last message has tool calls, else 'done'. Parameters ---------- state : State Current graph state. Returns ------- str ``"tools"`` or ``"done"``. """ if isinstance(state, list): ai_message = state[-1] elif messages := state.get("messages", []): ai_message = messages[-1] else: raise ValueError(f"No messages found in input state: {state}") if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0: if not isinstance(state, list) and _is_repeated_tool_cycle(messages): return "done" return "tools" return "done" # --------------------------------------------------------------------------- # Agent node # --------------------------------------------------------------------------- def RAGAgent(state: State, llm, system_prompt: str, tools=None): """LLM node that can retrieve from documents and run chemistry tools. Parameters ---------- state : State Current graph state with messages. llm : BaseChatModel The bound language model. system_prompt : str System prompt guiding the agent's behaviour. tools : list, optional Tools available to the agent. Uses the default RAG + chemistry tool set when ``None``. Returns ------- dict Updated state with the LLM's response appended to messages. """ if tools is None: tools = _default_tools() messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": f"{state['messages']}"}, ] llm_with_tools = llm.bind_tools(tools=tools) return {"messages": [llm_with_tools.invoke(messages)]} # --------------------------------------------------------------------------- # Default tool set # --------------------------------------------------------------------------- def _default_tools(): """Return the combined RAG + chemistry tool list.""" return [ # RAG tools load_document, query_knowledge_base, # Chemistry tools file_to_atomsdata, smiles_to_coordinate_file, run_ase, molecule_name_to_smiles, save_atomsdata_to_file, calculator, ] # --------------------------------------------------------------------------- # Graph constructor # --------------------------------------------------------------------------- def construct_rag_agent_graph( llm, system_prompt: str = rag_agent_prompt, tools: list = None, ): """Construct a RAG agent graph with document retrieval and chemistry tools. Parameters ---------- llm : BaseChatModel The language model to power the agent. system_prompt : str, optional System prompt for the RAG agent, by default ``rag_agent_prompt``. tools : list, optional Custom tool list. When ``None`` the default RAG + chemistry tools are used. Returns ------- CompiledStateGraph The compiled LangGraph workflow ready for execution. """ try: logger.info("Constructing RAG agent graph") checkpointer = MemorySaver() if tools is None: tools = _default_tools() tool_node = ToolNode(tools=tools) graph_builder = StateGraph(State) # Nodes graph_builder.add_node( "RAGAgent", lambda state: RAGAgent( state, llm, system_prompt=system_prompt, tools=tools ), ) graph_builder.add_node("tools", tool_node) # Edges graph_builder.add_edge(START, "RAGAgent") graph_builder.add_conditional_edges( "RAGAgent", route_tools, {"tools": "tools", "done": END}, ) graph_builder.add_edge("tools", "RAGAgent") graph = graph_builder.compile(checkpointer=checkpointer) logger.info("RAG agent graph construction completed") return graph except Exception as e: logger.error(f"Error constructing RAG agent graph: {e}") raise