| """agent_node — LLM bind_tools, the heart of the ReAct loop. |
| |
| The node calls the LLM with the full message history + the system prompt. |
| If the LLM emits a tool_call, the downstream ``tools_condition`` routes to |
| the ToolNode; otherwise it routes to the synthesizer. |
| |
| ``build_agent_node(llm_with_tools)`` is a factory returning a closure with |
| the bound LLM. The graph receives the chat model at compile time. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage |
|
|
| from config import settings |
| from graph.states.chat_state import ChatState |
| from nodes.chat._prompts import AGENTIC_SYSTEM_PROMPT |
|
|
|
|
| |
| |
| |
| _ERROR_MESSAGE_PREFIXES: tuple[str, ...] = ( |
| "Missing", |
| "Your API balance", |
| "You exceeded", |
| "The LLM service", |
| "Network error", |
| "Could not load PDF", |
| "The file is too large", |
| |
| "Hianyzo", |
| "Az API szamladon", |
| "Tullepted", |
| "Az LLM szolgaltatas", |
| "Halozati hiba", |
| "Nem sikerult a PDF", |
| "A fajl tul nagy", |
| ) |
|
|
|
|
| def _filter_history(messages: list[BaseMessage]) -> list[BaseMessage]: |
| """Drop error-flavored AIMessages from the history. |
| |
| Friendly-error outputs (e.g. "Your API balance is insufficient") would |
| confuse follow-up reasoning, so we exclude them when building the LLM input. |
| """ |
| cleaned: list[BaseMessage] = [] |
| for m in messages: |
| if isinstance(m, AIMessage): |
| content = m.content |
| if isinstance(content, str) and any( |
| content.startswith(prefix) for prefix in _ERROR_MESSAGE_PREFIXES |
| ): |
| continue |
| cleaned.append(m) |
| return cleaned |
|
|
|
|
| def build_agent_node(llm_with_tools, plan_to_prompt: bool = True): |
| """Factory: capture llm_with_tools in a closure. |
| |
| Args: |
| llm_with_tools: a ChatModel Runnable already bound with ``bind_tools(...)`` |
| plan_to_prompt: if True, append ``state["plan"]`` to the system prompt |
| """ |
|
|
| async def agent_node(state: ChatState) -> dict: |
| messages = state.get("messages") or [] |
| plan = state.get("plan") or [] |
| intent = state.get("intent", "chat") |
|
|
| |
| system_text = AGENTIC_SYSTEM_PROMPT |
| if plan_to_prompt and plan: |
| system_text += ( |
| f"\n\n=== CURRENT PLAN (intent: {intent}) ===" |
| f"\nSuggested tool order (hint, not mandatory): {' → '.join(plan)}" |
| ) |
|
|
| |
| iter_count = state.get("iteration_count", 0) |
| if iter_count >= settings.chat_max_iterations: |
| |
| return { |
| "messages": [HumanMessage( |
| content="Please synthesize an answer from the tool results already collected; do NOT call any more tools." |
| )], |
| "iteration_count": iter_count + 1, |
| "trace": [f"agent: max iter ({iter_count}) → forced synthesis"], |
| } |
|
|
| |
| cleaned_messages = _filter_history(messages) |
| full_messages = [SystemMessage(content=system_text)] + cleaned_messages |
| response = await llm_with_tools.ainvoke(full_messages) |
|
|
| return { |
| "messages": [response], |
| "iteration_count": iter_count + 1, |
| "trace": [f"agent: iter={iter_count + 1}, tool calls={len(getattr(response, 'tool_calls', []) or [])}"], |
| } |
|
|
| return agent_node |
|
|