chemgraph-loop / src /chemgraph /graphs /single_agent.py
rockyaaos's picture
ChemGraph Loop: guarded real-agent API (EMT/TBLite single-point energy)
c509967 verified
Raw
History Blame Contribute Delete
33.4 kB
import json
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt import ToolNode
from chemgraph.tools.ase_tools import (
run_ase,
extract_output_json,
)
from chemgraph.tools.cheminformatics_tools import (
molecule_name_to_smiles,
smiles_to_coordinate_file,
)
from chemgraph.tools.report_tools import generate_html
from chemgraph.tools.generic_tools import calculator, ask_human
from chemgraph.prompt.single_agent_prompt import (
single_agent_prompt,
formatter_prompt,
report_prompt,
)
from chemgraph.utils.tool_mapping import (
build_workflow_state,
build_validation_failure_output,
extract_query_from_messages,
format_validation_failure_message,
infer_requirement,
parse_reaction_from_context,
validate_completion,
validate_tool_call_batch,
)
from chemgraph.utils.logging_config import setup_logger
from chemgraph.utils.parsing import parse_response_formatter
from chemgraph.state.state import State
logger = setup_logger(__name__)
def _normalize_tool_call(call):
"""Normalize LangChain/OpenAI/dict tool-call payloads."""
if not isinstance(call, dict):
return None
if "function" in call:
function = call.get("function") or {}
raw_args = function.get("arguments") or {}
if isinstance(raw_args, str):
try:
args = json.loads(raw_args)
except json.JSONDecodeError:
args = {}
else:
args = raw_args
return {
"id": call.get("id"),
"name": function.get("name"),
"args": args if isinstance(args, dict) else {},
}
return {
"id": call.get("id"),
"name": call.get("name"),
"args": call.get("args") if isinstance(call.get("args"), dict) else {},
}
def _message_tool_calls(message) -> list[dict]:
"""Return normalized tool calls from a message-like object."""
if isinstance(message, dict):
raw_calls = message.get("tool_calls")
if not raw_calls:
raw_calls = (message.get("additional_kwargs") or {}).get("tool_calls")
else:
raw_calls = getattr(message, "tool_calls", None)
if not raw_calls:
raw_calls = (
getattr(message, "additional_kwargs", {}) or {}
).get("tool_calls")
calls = [_normalize_tool_call(call) for call in raw_calls or []]
return [call for call in calls if call and call.get("name")]
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 []:
normalized = _normalize_tool_call(call) if isinstance(call, dict) else None
name = normalized.get("name") if normalized else None
args = normalized.get("args", {}) if normalized else {}
# Normalize args for deterministic comparisons across repeated cycles.
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 = []
for message in messages:
if _message_tool_calls(message):
ai_with_calls.append(message)
if len(ai_with_calls) < 2:
return False
last_calls = _tool_call_signature(_message_tool_calls(ai_with_calls[-1]))
prev_calls = _tool_call_signature(_message_tool_calls(ai_with_calls[-2]))
return bool(last_calls) and last_calls == prev_calls
def _tool_message_name(message):
"""Extract tool name from a message-like object.
Parameters
----------
message : Any
Message dictionary or object.
Returns
-------
str or None
Tool name when present.
"""
if isinstance(message, dict):
return message.get("name")
return getattr(message, "name", None)
def _tool_message_content(message):
"""Extract content text from a message-like object.
Parameters
----------
message : Any
Message dictionary or object.
Returns
-------
Any
Message content, or an empty string when unavailable.
"""
if isinstance(message, dict):
return message.get("content", "")
return getattr(message, "content", "")
def _is_successful_report_message(message) -> bool:
"""Return True when a message indicates successful report generation.
Parameters
----------
message : Any
Tool message dictionary or object.
Returns
-------
bool
``True`` for non-error ``generate_html`` tool output.
"""
if _tool_message_name(message) != "generate_html":
return False
content = _tool_message_content(message)
content_text = str(content).strip().lower() if content is not None else ""
if not content_text:
return False
# ToolNode formats failures as "Error: ..."; treat only non-error output as success.
return not content_text.startswith("error")
def _message_type(message):
if isinstance(message, dict):
return message.get("type") or message.get("role")
return getattr(message, "type", None)
def _message_content(message):
if isinstance(message, dict):
return message.get("content", "")
return getattr(message, "content", "")
def _message_tool_name(message):
if isinstance(message, dict):
return message.get("name")
return getattr(message, "name", None)
def _compact_for_prompt(value, *, max_items: int = 16, max_text: int = 1200):
"""Recursively keep only prompt-useful context from large values."""
if isinstance(value, dict):
compact = {}
for key, child in value.items():
if key in {"final_structure", "simulation_input"}:
continue
compact[key] = _compact_for_prompt(
child, max_items=max_items, max_text=max_text
)
return compact
if isinstance(value, list):
if len(value) > max_items:
return {
"count": len(value),
"sample": [
_compact_for_prompt(
item, max_items=max_items, max_text=max_text
)
for item in value[:max_items]
],
}
return [
_compact_for_prompt(item, max_items=max_items, max_text=max_text)
for item in value
]
if isinstance(value, str) and len(value) > max_text:
return value[:max_text] + "...<truncated>"
return value
def _recent_text_messages(messages, max_messages: int = 6) -> list[dict]:
"""Return a short text-only conversation tail for follow-up resolution."""
recent = []
for message in messages or []:
msg_type = _message_type(message)
if msg_type == "tool":
continue
content = _message_content(message)
if not content:
continue
if hasattr(message, "tool_calls") and getattr(message, "tool_calls", None):
recent.append(
{
"role": "ai",
"content": "Tool calls requested.",
"tool_calls": [
call.get("name") for call in getattr(message, "tool_calls", [])
],
}
)
continue
recent.append(
{
"role": str(msg_type or "message"),
"content": _compact_for_prompt(str(content), max_text=500),
}
)
return recent[-max_messages:]
def _compact_state_for_llm(state: State) -> str:
"""Build a bounded workflow transcript for LLM routing/composition.
The raw LangGraph message list may contain large tool outputs such as IR
spectra. The model only needs the latest user intent, what tools ran,
where artifacts were saved, and what output is still missing.
"""
messages = state.get("messages", [])
query = extract_query_from_messages(messages)
validation = state.get("completion_validation")
try:
workflow_state = build_workflow_state(
query,
messages,
validation=validation,
)
except Exception as exc:
workflow_state = {
"intent": {"task_type": "unknown"},
"task_plan": {"next_action": "inspect_messages"},
"tool_trace": [],
"observations": {},
"artifacts": {},
"validation": {
"complete": False,
"reason": f"workflow_state_error: {exc}",
},
}
payload = {
"latest_user_query": query,
"recent_text_messages": _recent_text_messages(messages),
"intent": workflow_state.get("intent", {}),
"task_plan": workflow_state.get("task_plan", {}),
"action_plan": workflow_state.get("action_plan", {}),
"identity_resolution": workflow_state.get("identity_resolution", {}),
"clarification": workflow_state.get("clarification", {}),
"memory_refs": workflow_state.get("memory_refs", {}),
"tool_trace": workflow_state.get("tool_trace", []),
"observations": workflow_state.get("observations", {}),
"artifacts": workflow_state.get("artifacts", {}),
"validation": workflow_state.get("validation", {}),
}
compact_payload = _compact_for_prompt(payload)
return (
"Compact ChemGraph workflow state. Use only this bounded context for "
"tool routing and answer composition. Reuse listed artifacts and "
"observations when the latest query is a follow-up. Do not repeat "
"satisfied tools unless the latest query requires a different "
"property, molecule, calculator, temperature, pressure, or driver. "
"Full numerical outputs are saved in the artifact paths.\n\n"
+ json.dumps(compact_payload, indent=2, default=str)
)
def route_tools(state: State):
"""Route to the 'tools' node if the last message has tool calls; otherwise, route to 'done'.
Parameters
----------
state : State
The current state containing messages and remaining steps
Returns
-------
str
Either 'tools' or 'done' based on the state conditions
"""
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 to tool_edge: {state}")
tool_calls = _message_tool_calls(ai_message)
if tool_calls:
if not isinstance(state, list):
query = extract_query_from_messages(messages)
try:
validation = validate_completion(query, messages)
except Exception:
validation = {}
if validation.get("complete") and validation.get("task_type") != "unknown":
return "done"
try:
tool_guard = validate_tool_call_batch(
query,
messages[:-1],
tool_calls,
)
except Exception:
tool_guard = {"allowed": True}
if not tool_guard.get("allowed", True):
return "done"
if not isinstance(state, list) and _is_repeated_tool_cycle(messages):
return "done"
return "tools"
return "done"
def route_after_completion_validation(state: State):
"""Route after deterministic completion validation.
The graph can only enter the response/composer step when the mapped
tool-output requirements are satisfied. A small retry cap prevents an
endless loop when the LLM cannot repair an incomplete workflow.
"""
validation = state.get("completion_validation") or {}
if validation.get("complete"):
return "response"
if state.get("validation_attempts", 0) >= 3:
return "response"
if "balanced_reaction" in validation.get("missing", []):
return "intention"
return "agent"
def IntentionAgent(state: State, llm: ChatOpenAI):
"""Create a machine-readable scientific plan before tool execution.
This node handles chemistry intent that should be decided by the agent,
such as turning a plain-language reaction name into a balanced reaction.
Deterministic validators then parse this explicit agent decision.
"""
messages = state.get("messages", [])
query = extract_query_from_messages(messages)
requirement = infer_requirement(query)
if requirement.task_type not in {"reaction_enthalpy", "reaction_gibbs_energy"}:
return {}
if parse_reaction_from_context(query, messages) is not None:
return {}
prompt_messages = [
{
"role": "system",
"content": (
"You are a computational chemistry planning node. For the "
"user's reaction task, determine the balanced chemical reaction. "
"Return exactly one line in this format and no extra text: "
"Balanced reaction: Reactant + 2 Reactant -> Product + 2 Product. "
"Use common molecule names, not formulas, so downstream tools can "
"resolve each species.\n\n"
"Examples:\n"
"User: What is the reaction enthalpy of methane combustion?\n"
"Balanced reaction: Methane + 2 Oxygen -> Carbon dioxide + 2 Water\n"
"User: What is the Gibbs free energy for hydrogen combustion?\n"
"Balanced reaction: 2 Hydrogen + Oxygen -> 2 Water"
),
},
{"role": "user", "content": query},
]
response = llm.invoke(prompt_messages)
content = str(getattr(response, "content", response)).strip()
if not content:
return {}
if not content.lower().startswith("balanced reaction"):
content = f"Balanced reaction: {content}"
return {"messages": [AIMessage(content=content)]}
def CompletionValidator(state: State):
"""Check mapped tool requirements before final response composition."""
messages = state.get("messages", [])
query = extract_query_from_messages(messages)
validation = validate_completion(query, messages)
tool_guard = _latest_tool_guard(query, messages)
workflow_state = build_workflow_state(query, messages, validation=validation)
attempts = int(state.get("validation_attempts", 0)) + 1
update = {
**workflow_state,
"completion_validation": validation,
"tool_guard": tool_guard,
"validation_attempts": attempts,
}
deterministic_output = validation.get("structured_output")
if validation.get("complete") and deterministic_output is not None:
update["final_output"] = deterministic_output
update["messages"] = [AIMessage(content=json.dumps(deterministic_output))]
return update
if not validation.get("complete"):
if attempts >= 3:
failure_output = build_validation_failure_output(
validation,
workflow_state=workflow_state,
)
update["final_output"] = failure_output
update["messages"] = [
AIMessage(content=format_validation_failure_message(failure_output))
]
else:
missing = ", ".join(validation.get("missing", [])) or "unknown"
repair_instruction = (
tool_guard.get("repair_instruction")
if not tool_guard.get("allowed", True)
else validation.get("repair_instruction", "")
)
update["messages"] = [
HumanMessage(
content=(
"Validation failed: the requested task is not complete.\n"
f"Task type: {validation.get('task_type')}\n"
f"Missing required output: {missing}\n"
f"Tool guard: {tool_guard.get('reason', 'not evaluated')}\n"
f"Next action: {repair_instruction}\n"
"Do not provide a final answer yet. Call the required tool(s)."
)
)
]
return update
def _latest_tool_guard(query: str, messages: list) -> dict:
"""Evaluate the current AI tool-call batch against workflow state."""
if not messages:
return {"allowed": True}
latest = messages[-1]
tool_calls = _message_tool_calls(latest)
if not tool_calls:
return {"allowed": True}
try:
return validate_tool_call_batch(query, messages[:-1], tool_calls)
except Exception as exc:
return {
"allowed": True,
"blocked_tool": None,
"expected_next_action": None,
"repair_instruction": "",
"reason": f"Tool guard skipped after internal error: {exc}",
}
def route_report_tools(state: State):
"""Route report tool execution and stop if a report was already generated.
Parameters
----------
state : State
Current graph state or message list.
Returns
-------
str
``"tools"`` when ``generate_html`` should run, otherwise ``"done"``.
"""
if isinstance(state, list):
messages = state
ai_message = state[-1] if state else None
elif messages := state.get("messages", []):
ai_message = messages[-1]
else:
raise ValueError(f"No messages found in input state to tool_edge: {state}")
tool_calls = _message_tool_calls(ai_message)
if not tool_calls:
return "done"
# Only allow known report tool calls to reach ToolNode.
valid_report_tools = {"generate_html"}
requested_tools = {
call.get("name")
for call in tool_calls
if isinstance(call, dict)
}
if not requested_tools or not requested_tools.issubset(valid_report_tools):
return "done"
report_generated = any(
_is_successful_report_message(message) for message in messages
)
return "done" if report_generated else "tools"
def route_after_report_tools(state: State):
"""After report tool execution, stop on success or retry on failure.
Parameters
----------
state : State
Current graph state or message list after report tool execution.
Returns
-------
str
``"done"`` after a successful report message, otherwise ``"retry"``.
"""
if isinstance(state, list):
messages = state
elif messages := state.get("messages", []):
pass
else:
raise ValueError(f"No messages found in input state to tool_edge: {state}")
return "done" if _is_successful_report_message(messages[-1]) else "retry"
def ChemGraphAgent(
state: State,
llm: ChatOpenAI,
system_prompt: str,
tools=None,
human_supervised: bool = False,
):
"""LLM node that processes messages and decides next actions.
Parameters
----------
state : State
The current state containing messages and remaining steps
llm : ChatOpenAI
The language model to use for processing
system_prompt : str
The system prompt to guide the LLM's behavior
tools : list, optional
List of tools available to the agent, by default None
human_supervised : bool, optional
Whether to include the ``ask_human`` tool, by default False
Returns
-------
dict
Updated state containing the LLM's response
"""
# Load default tools if no tool is specified.
if tools is None:
tools = [
smiles_to_coordinate_file,
run_ase,
molecule_name_to_smiles,
extract_output_json,
calculator,
]
if human_supervised:
tools.append(ask_human)
elif human_supervised and ask_human not in tools:
# Ensure ask_human is available when custom tools are provided
# and human supervision is enabled.
tools = list(tools) + [ask_human]
compact_state = _compact_state_for_llm(state)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": compact_state},
]
llm_with_tools = llm.bind_tools(tools=tools)
return {"messages": [llm_with_tools.invoke(messages)]}
def ResponseAgent(
state: State,
llm: ChatOpenAI,
formatter_prompt: str,
max_retries: int = 1,
):
"""An LLM agent responsible for formatting final message.
When the LLM response cannot be parsed into a valid
:class:`ResponseFormatter`, the agent retries the LLM call up to
``max_retries`` times, sending the parse error back to the model so
it can correct its output.
If all attempts fail, an empty ``ResponseFormatter`` is returned
with a ``_parse_error`` key in the serialised JSON so that
downstream evaluation can detect the failure.
Parameters
----------
state : State
The current state containing messages and remaining steps.
llm : ChatOpenAI
The language model to use for formatting.
formatter_prompt : str
The prompt to guide the LLM's formatting behaviour.
max_retries : int, optional
Maximum number of retry attempts on parse failure (default 1).
Returns
-------
dict
Updated state containing the formatted response.
"""
validation = state.get("completion_validation") or {}
deterministic_output = validation.get("structured_output")
if validation.get("complete") and deterministic_output is not None:
return {
"messages": [json.dumps(deterministic_output)],
"final_output": deterministic_output,
}
if validation and not validation.get("complete") and validation.get("task_type") != "unknown":
query = extract_query_from_messages(state.get("messages", []))
workflow_state = build_workflow_state(
query,
state.get("messages", []),
validation=validation,
)
failure_output = build_validation_failure_output(
validation,
workflow_state=workflow_state,
)
return {
"messages": [json.dumps(failure_output)],
"final_output": failure_output,
}
compact_state = _compact_state_for_llm(state)
messages = [
{"role": "system", "content": formatter_prompt},
{"role": "user", "content": compact_state},
]
raw_response = llm.invoke(messages).content
formatter, parse_error = parse_response_formatter(raw_response)
# Retry loop: re-invoke the LLM with the error feedback.
retries = 0
while parse_error is not None and retries < max_retries:
retries += 1
logger.warning(
"ResponseAgent: parse attempt %d failed (%s); retrying LLM.",
retries,
parse_error,
)
retry_messages = [
{"role": "system", "content": formatter_prompt},
{"role": "user", "content": compact_state},
{
"role": "assistant",
"content": raw_response,
},
{
"role": "user",
"content": (
f"Error: {parse_error}\n\n"
"Your previous response could not be parsed. "
"Please output ONLY a valid JSON object matching the "
"ResponseFormatter schema. Do not include any text, "
"markdown fences, or explanation outside the JSON object."
),
},
]
raw_response = llm.invoke(retry_messages).content
formatter, parse_error = parse_response_formatter(raw_response)
# Serialise to JSON, injecting ``_parse_error`` when parsing failed.
result = json.loads(formatter.model_dump_json())
if parse_error is not None:
logger.error(
"ResponseAgent: all %d retries exhausted; returning empty "
"ResponseFormatter with _parse_error.",
max_retries,
)
result["_parse_error"] = parse_error
response = json.dumps(result)
return {"messages": [response], "final_output": result}
def ReportAgent(
state: State, llm: ChatOpenAI, system_prompt: str, tools=[generate_html]
):
"""LLM node that generates a report from the messages.
Parameters
----------
state : State
The current state containing messages and remaining steps
llm : ChatOpenAI
The language model to use for processing
system_prompt : str
The system prompt to guide the LLM's behavior
tools : list, optional
List of tools available to the agent, by default [generate_html]
Returns
-------
dict
Updated state containing the LLM's response
"""
# Load default tools if no tool is specified.
if tools is None:
tools = [
generate_html,
]
compact_state = _compact_state_for_llm(state)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": compact_state},
]
llm_with_tools = llm.bind_tools(
tools=tools,
tool_choice="generate_html",
parallel_tool_calls=False,
)
return {"messages": [llm_with_tools.invoke(messages)]}
def construct_single_agent_graph(
llm: ChatOpenAI,
system_prompt: str = single_agent_prompt,
structured_output: bool = False,
formatter_prompt: str = formatter_prompt,
generate_report: bool = False,
report_prompt: str = report_prompt,
tools: list = None,
max_retries: int = 1,
human_supervised: bool = False,
):
"""Construct a geometry optimization graph.
Parameters
----------
llm : ChatOpenAI
The language model to use for the graph
system_prompt : str, optional
The system prompt to guide the LLM's behavior, by default single_agent_prompt
structured_output : bool, optional
Whether to use structured output, by default False
formatter_prompt : str, optional
The prompt to guide the LLM's formatting behavior, by default formatter_prompt
generate_report: bool, optional
Whether to generate a report, by default False
report_prompt: str, optional
The prompt to guide the LLM's report generation behavior, by default report_prompt
tools : list, optional
The list of tools for the main agent, by default None
max_retries : int, optional
Maximum number of LLM retry attempts when the ResponseAgent
fails to parse the formatter output, by default 1
human_supervised : bool, optional
Whether to include the ``ask_human`` tool so the agent can
pause and request human input, by default False
Returns
-------
StateGraph
The constructed single agent graph
"""
try:
logger.info("Constructing single agent graph")
checkpointer = MemorySaver()
if tools is None:
tools = [
smiles_to_coordinate_file,
molecule_name_to_smiles,
run_ase,
extract_output_json,
calculator,
]
if human_supervised:
tools.append(ask_human)
elif human_supervised and ask_human not in tools:
# Ensure ask_human is available when custom tools are provided
# and human supervision is enabled.
tools = list(tools) + [ask_human]
tool_node = ToolNode(tools=tools)
graph_builder = StateGraph(State)
if not structured_output:
graph_builder.add_node(
"IntentionAgent",
lambda state: IntentionAgent(state, llm),
)
graph_builder.add_node(
"ChemGraphAgent",
lambda state: ChemGraphAgent(
state,
llm,
system_prompt=system_prompt,
tools=tools,
human_supervised=human_supervised,
),
)
graph_builder.add_node("tools", tool_node)
graph_builder.add_node("CompletionValidator", CompletionValidator)
graph_builder.add_edge(START, "IntentionAgent")
graph_builder.add_edge("IntentionAgent", "ChemGraphAgent")
if generate_report:
tool_node_report = ToolNode(tools=[generate_html])
graph_builder.add_node("report_tools", tool_node_report)
graph_builder.add_node(
"ReportAgent",
lambda state: ReportAgent(
state, llm, system_prompt=report_prompt, tools=[generate_html]
),
)
graph_builder.add_conditional_edges(
"ChemGraphAgent",
route_tools,
{"tools": "tools", "done": "CompletionValidator"},
)
graph_builder.add_conditional_edges(
"CompletionValidator",
route_after_completion_validation,
{
"intention": "IntentionAgent",
"agent": "ChemGraphAgent",
"response": "ReportAgent",
},
)
graph_builder.add_edge("tools", "ChemGraphAgent")
graph_builder.add_conditional_edges(
"ReportAgent",
route_report_tools,
{"tools": "report_tools", "done": END},
)
graph_builder.add_conditional_edges(
"report_tools",
route_after_report_tools,
{"retry": "ReportAgent", "done": END},
)
else:
graph_builder.add_conditional_edges(
"ChemGraphAgent",
route_tools,
{"tools": "tools", "done": "CompletionValidator"},
)
graph_builder.add_conditional_edges(
"CompletionValidator",
route_after_completion_validation,
{
"intention": "IntentionAgent",
"agent": "ChemGraphAgent",
"response": END,
},
)
graph_builder.add_edge("tools", "ChemGraphAgent")
graph = graph_builder.compile(checkpointer=checkpointer)
logger.info("Graph construction completed")
return graph
else:
graph_builder.add_node(
"IntentionAgent",
lambda state: IntentionAgent(state, llm),
)
graph_builder.add_node(
"ChemGraphAgent",
lambda state: ChemGraphAgent(
state,
llm,
system_prompt=system_prompt,
tools=tools,
human_supervised=human_supervised,
),
)
graph_builder.add_node("tools", tool_node)
graph_builder.add_node("CompletionValidator", CompletionValidator)
graph_builder.add_node(
"ResponseAgent",
lambda state: ResponseAgent(
state,
llm,
formatter_prompt=formatter_prompt,
max_retries=max_retries,
),
)
graph_builder.add_conditional_edges(
"ChemGraphAgent",
route_tools,
{"tools": "tools", "done": "CompletionValidator"},
)
graph_builder.add_conditional_edges(
"CompletionValidator",
route_after_completion_validation,
{
"intention": "IntentionAgent",
"agent": "ChemGraphAgent",
"response": "ResponseAgent",
},
)
graph_builder.add_edge("tools", "ChemGraphAgent")
graph_builder.add_edge(START, "IntentionAgent")
graph_builder.add_edge("IntentionAgent", "ChemGraphAgent")
graph_builder.add_edge("ResponseAgent", END)
graph = graph_builder.compile(checkpointer=checkpointer)
logger.info("Graph construction completed")
return graph
except Exception as e:
logger.error(f"Error constructing graph: {str(e)}")
raise