chemgraph-loop / src /chemgraph /cli /commands.py
rockyaaos's picture
ChemGraph Loop: guarded real-agent API (EMT/TBLite single-point energy)
c509967 verified
Raw
History Blame Contribute Delete
29.8 kB
"""Command implementations for the ChemGraph CLI.
Each public function corresponds to a CLI action: running a query,
starting interactive mode, managing sessions, etc.
"""
from __future__ import annotations
import os
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
from typing import Any, Dict, Optional
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.prompt import Prompt
from rich.table import Table
from chemgraph.memory.store import SessionStore
from chemgraph.models.supported_models import (
supported_alcf_models,
supported_anthropic_models,
supported_gemini_models,
supported_ollama_models,
supported_openai_models,
supported_argo_models,
)
from chemgraph.utils.async_utils import run_async_callable
from chemgraph.cli.formatting import (
console,
create_banner,
format_response,
)
# ---------------------------------------------------------------------------
# Workflow helpers
# ---------------------------------------------------------------------------
# All workflow types registered in ChemGraph.workflow_map
ALL_WORKFLOW_TYPES = [
"single_agent",
"multi_agent",
"python_relp",
"graspa",
"mock_agent",
"single_agent_mcp",
"graspa_mcp",
"rag_agent",
"single_agent_xanes",
]
# Common aliases so users can type the "obvious" name.
WORKFLOW_ALIASES: Dict[str, str] = {
"python_repl": "python_relp",
"graspa_agent": "graspa",
}
def resolve_workflow(name: str) -> str:
"""Resolve a workflow name, applying aliases.
Parameters
----------
name : str
Workflow name or supported alias.
Returns
-------
str
Canonical workflow name.
"""
return WORKFLOW_ALIASES.get(name, name)
# ---------------------------------------------------------------------------
# API-key validation
# ---------------------------------------------------------------------------
def check_api_keys(model_name: str) -> tuple[bool, str]:
"""Check if required API keys are available for *model_name*.
Parameters
----------
model_name : str
Model identifier selected for a run.
Returns
-------
tuple[bool, str]
``(is_available, error_message)``. The message is empty when the
required credentials are available or not required.
"""
model_lower = model_name.lower()
# OpenAI models (including GPT family, o-series, and Argo OpenAI)
if (
model_name in supported_openai_models
or model_name in supported_argo_models
or model_lower.startswith("gpt")
or any(prefix in model_lower for prefix in ["o1", "o3", "o4"])
):
# Argo models use a different auth mechanism; skip key check.
if model_name in supported_argo_models:
pass
elif not os.getenv("OPENAI_API_KEY"):
return (
False,
"OpenAI API key not found. Set the OPENAI_API_KEY environment variable.",
)
# Anthropic models
elif "claude" in model_lower or model_name in supported_anthropic_models:
if not os.getenv("ANTHROPIC_API_KEY"):
return (
False,
"Anthropic API key not found. Set the ANTHROPIC_API_KEY environment variable.",
)
# Google models
elif "gemini" in model_lower or model_name in supported_gemini_models:
if not os.getenv("GEMINI_API_KEY"):
return (
False,
"Gemini API key not found. Set the GEMINI_API_KEY environment variable.",
)
# GROQ models (groq: prefix)
elif model_name.startswith("groq:"):
if not os.getenv("GROQ_API_KEY"):
return (
False,
"GROQ API key not found. Set the GROQ_API_KEY environment variable.",
)
# ALCF models (Globus OAuth access token)
elif model_name in supported_alcf_models:
if not os.getenv("ALCF_ACCESS_TOKEN"):
return (
False,
"ALCF access token not found. To authenticate with ALCF:\n"
" 1. pip install globus_sdk\n"
" 2. wget https://raw.githubusercontent.com/argonne-lcf/"
"inference-endpoints/refs/heads/main/inference_auth_token.py\n"
" 3. python inference_auth_token.py authenticate\n"
" 4. export ALCF_ACCESS_TOKEN=$(python inference_auth_token.py get_access_token)\n"
"\n"
" See: https://docs.alcf.anl.gov/services/inference-endpoints/#api-access",
)
# Local models (no API key needed)
elif model_name in supported_ollama_models or any(
local in model_lower for local in ["llama", "qwen", "ollama"]
):
pass
return True, ""
# ---------------------------------------------------------------------------
# Agent initialization
# ---------------------------------------------------------------------------
_INIT_TIMEOUT_SECONDS = 30
def initialize_agent(
model_name: str,
workflow_type: str,
structured_output: bool,
return_option: str,
generate_report: bool,
recursion_limit: int,
base_url: Optional[str] = None,
argo_user: Optional[str] = None,
verbose: bool = False,
human_supervised: bool = False,
tools: Optional[list] = None,
) -> Any:
"""Initialize a ChemGraph agent with progress indication.
Uses a thread-pool executor for the timeout so it works on all
platforms.
Parameters
----------
model_name : str
LLM model identifier.
workflow_type : str
ChemGraph workflow name or alias.
structured_output : bool
Whether to request structured final output.
return_option : str
Agent return mode, such as ``"state"`` or ``"last_message"``.
generate_report : bool
Whether the agent should generate an HTML report.
recursion_limit : int
LangGraph recursion limit for the run.
base_url : str, optional
Custom model endpoint URL.
argo_user : str, optional
Argo username for Argo-hosted models.
verbose : bool, optional
Whether to print initialization details.
human_supervised : bool, optional
Whether to enable human-interrupt tooling.
tools : list, optional
Custom tool list for MCP-backed workflows.
Returns
-------
Any
Initialized ``ChemGraph`` instance, or ``None`` when initialization
fails.
"""
# Resolve workflow alias before initializing.
workflow_type = resolve_workflow(workflow_type)
if verbose:
console.print("[blue]Initializing agent with:[/blue]")
console.print(f" Model: {model_name}")
console.print(f" Workflow: {workflow_type}")
console.print(f" Structured Output: {structured_output}")
console.print(f" Return Option: {return_option}")
console.print(f" Generate Report: {generate_report}")
console.print(f" Human Supervised: {human_supervised}")
console.print(f" Recursion Limit: {recursion_limit}")
if base_url:
console.print(f" Base URL: {base_url}")
if argo_user:
console.print(f" Argo User: {argo_user}")
if tools:
console.print(f" MCP Tools: {len(tools)} loaded")
# Check API keys before attempting initialization
api_key_available, error_msg = check_api_keys(model_name)
if not api_key_available:
console.print(f"[red]{error_msg}[/red]")
console.print(
"[dim]Tip: Set environment variables in your shell or .env file[/dim]"
)
console.print(
"[dim] Example: export OPENAI_API_KEY='your_api_key_here'[/dim]"
)
return None
# Resolve API key for providers that need one passed explicitly.
api_key: Optional[str] = None
if model_name in supported_alcf_models:
api_key = os.getenv("ALCF_ACCESS_TOKEN")
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
transient=True,
) as progress:
task = progress.add_task("Initializing ChemGraph agent...", total=None)
def _create_agent() -> Any:
"""Create the ChemGraph agent inside the initialization worker.
Returns
-------
Any
Initialized ``ChemGraph`` instance.
"""
from chemgraph.agent.llm_agent import ChemGraph
return ChemGraph(
model_name=model_name,
workflow_type=workflow_type,
base_url=base_url,
api_key=api_key,
argo_user=argo_user,
generate_report=generate_report,
return_option=return_option,
recursion_limit=recursion_limit,
structured_output=structured_output,
human_supervised=human_supervised,
tools=tools,
)
try:
with ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(_create_agent)
agent = future.result(timeout=_INIT_TIMEOUT_SECONDS)
progress.update(task, description="[green]Agent initialized successfully!")
time.sleep(0.5)
return agent
except FuturesTimeoutError:
progress.update(task, description="[red]Agent initialization timed out!")
console.print(
f"[red]Agent initialization timed out after {_INIT_TIMEOUT_SECONDS}s[/red]"
)
console.print(
"[dim]This might indicate network issues or invalid API credentials[/dim]"
)
return None
except Exception as e:
progress.update(task, description="[red]Agent initialization failed!")
console.print(f"[red]Error initializing agent: {e}[/red]")
err_str = str(e).lower()
if "authentication" in err_str or "api" in err_str:
console.print(
"[dim]This looks like an API key issue. Check your credentials.[/dim]"
)
elif "connection" in err_str or "network" in err_str:
console.print(
"[dim]This looks like a network connectivity issue.[/dim]"
)
return None
# ---------------------------------------------------------------------------
# Query execution
# ---------------------------------------------------------------------------
# Thread-ID counter for interactive mode so each query gets unique state.
_thread_counter: int = 0
def _next_thread_id() -> int:
"""Return the next interactive-mode thread ID.
Returns
-------
int
Incremented thread ID.
"""
global _thread_counter
_thread_counter += 1
return _thread_counter
def run_query(
agent: Any,
query: str,
thread_id: Optional[int] = None,
verbose: bool = False,
resume_from: Optional[str] = None,
) -> Any:
"""Execute a query with the agent.
When the graph pauses for human input (``HumanInputRequired``), the
spinner is stopped, the question is shown in a Rich panel, and the
user is prompted for a response. The graph is then resumed with the
user's answer and the spinner restarts. This loop repeats until the
graph completes or a non-interrupt error occurs.
Parameters
----------
agent : Any
Initialized ChemGraph-like agent with ``run`` and ``workflow`` methods.
query : str
User query to execute.
thread_id : int, optional
LangGraph thread identifier. A new ID is allocated when omitted.
verbose : bool, optional
Whether to print execution details.
resume_from : str, optional
Previous ChemGraph session ID to load as context.
Returns
-------
Any
Agent result, resumed graph result, or ``None`` on failure.
"""
from langgraph.types import Command
from chemgraph.agent.llm_agent import HumanInputRequired
if thread_id is None:
thread_id = _next_thread_id()
if verbose:
console.print(f"[blue]Executing query:[/blue] {query}")
console.print(f"[blue]Thread ID:[/blue] {thread_id}")
if resume_from:
console.print(f"[blue]Resuming from session:[/blue] {resume_from}")
config = {"configurable": {"thread_id": thread_id}}
max_interrupts = 10 # safety guard
interrupt_count = 0
# --- First invocation: run the full agent.run() ---
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
transient=True,
) as progress:
task = progress.add_task("Processing query...", total=None)
try:
result = run_async_callable(
lambda: agent.run(query, config=config, resume_from=resume_from)
)
progress.update(task, description="[green]Query completed!")
time.sleep(0.3)
return result
except HumanInputRequired as hir:
progress.update(task, description="[yellow]Agent needs your input")
time.sleep(0.2)
question = hir.question
except Exception as e:
progress.update(task, description="[red]Query failed!")
console.print(f"[red]Error processing query: {e}[/red]")
return None
# --- Interrupt-resume loop ---
# The spinner's `with` block has exited, so the terminal is free
# for interactive user input.
while question is not None:
interrupt_count += 1
if interrupt_count > max_interrupts:
console.print(
"[red]Exceeded maximum number of human interrupts. Aborting.[/red]"
)
return None
console.print(
Panel(
question,
title="[bold yellow]Agent needs your input[/bold yellow]",
style="yellow",
)
)
human_answer = Prompt.ask("[bold cyan]Your response[/bold cyan]")
# Resume the graph, streaming messages so tool-call parameters
# are printed just like the initial invocation.
resume_config = dict(config)
resume_config["recursion_limit"] = agent.recursion_limit
async def _resume_stream():
"""Resume an interrupted graph and stream updates until completion.
Returns
-------
dict or None
Final streamed graph state.
"""
prev_msgs: list = []
last_st = None
async for s in agent.workflow.astream(
Command(resume=human_answer),
stream_mode="values",
config=resume_config,
):
if "messages" in s and s["messages"] != prev_msgs:
new_message = s["messages"][-1]
try:
new_message.pretty_print()
except Exception:
pass
prev_msgs = s["messages"]
last_st = s
return last_st
try:
result = run_async_callable(_resume_stream)
if result is None:
console.print("[red]Resume produced no output.[/red]")
return None
if agent.return_option == "last_message":
return result["messages"][-1] if result else None
elif agent.return_option == "state":
from chemgraph.agent.llm_agent import serialize_state
return serialize_state(agent.get_state(config=config))
return result
except HumanInputRequired as hir:
question = hir.question
except Exception as e:
console.print(f"[red]Error processing query: {e}[/red]")
return None
return None
# ---------------------------------------------------------------------------
# Session management
# ---------------------------------------------------------------------------
def list_sessions(limit: int = 20, db_path: Optional[str] = None) -> None:
"""Display recent sessions in a formatted table.
Parameters
----------
limit : int, optional
Maximum number of sessions to display.
db_path : str, optional
Path to the session SQLite database.
"""
store = SessionStore(db_path=db_path)
sessions = store.list_sessions(limit=limit)
if not sessions:
console.print("[dim]No sessions found.[/dim]")
return
console.print(Panel(f"Recent Sessions ({len(sessions)})", style="bold cyan"))
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Session ID", style="cyan", width=10)
table.add_column("Title", style="white", width=40)
table.add_column("Model", style="green", width=16)
table.add_column("Workflow", style="yellow", width=14)
table.add_column("Queries", style="white", justify="right", width=8)
table.add_column("Messages", style="white", justify="right", width=9)
table.add_column("Date", style="dim", width=16)
for s in sessions:
table.add_row(
s.session_id,
s.title or "[dim]Untitled[/dim]",
s.model_name,
s.workflow_type,
str(s.query_count),
str(s.message_count),
s.updated_at.strftime("%Y-%m-%d %H:%M"),
)
console.print(table)
console.print(
"\n[dim]Use 'chemgraph session show <id>' to view a session. "
"Prefix matching is supported.[/dim]"
)
def show_session(
session_id: str,
db_path: Optional[str] = None,
max_content: int = 500,
) -> None:
"""Display a session's full conversation.
Parameters
----------
session_id : str
Session ID or unique session prefix.
db_path : str, optional
Path to the session SQLite database.
max_content : int, optional
Maximum number of characters displayed for each message.
"""
store = SessionStore(db_path=db_path)
session = store.get_session(session_id)
if session is None:
console.print(
f"[red]Session '{session_id}' not found. "
f"The ID may be ambiguous or nonexistent.[/red]"
)
console.print("[dim]Use 'chemgraph session list' to see available sessions.[/dim]")
return
# Session metadata header
meta_table = Table(show_header=False, box=None, padding=(0, 2))
meta_table.add_column("Key", style="bold cyan")
meta_table.add_column("Value")
meta_table.add_row("Session ID", session.session_id)
meta_table.add_row("Title", session.title or "Untitled")
meta_table.add_row("Model", session.model_name)
meta_table.add_row("Workflow", session.workflow_type)
meta_table.add_row("Queries", str(session.query_count))
meta_table.add_row("Created", session.created_at.strftime("%Y-%m-%d %H:%M:%S"))
meta_table.add_row("Updated", session.updated_at.strftime("%Y-%m-%d %H:%M:%S"))
if session.log_dir:
meta_table.add_row("Log Dir", session.log_dir)
console.print(Panel(meta_table, title="Session Info", style="bold cyan"))
if not session.messages:
console.print("[dim]No messages in this session.[/dim]")
return
# Display conversation
console.print(f"\n[bold]Conversation ({len(session.messages)} messages):[/bold]\n")
for msg in session.messages:
if msg.role == "human":
label = "[bold cyan]User[/bold cyan]"
elif msg.role == "ai":
label = "[bold green]Assistant[/bold green]"
elif msg.role == "tool":
tool_label = f" ({msg.tool_name})" if msg.tool_name else ""
label = f"[bold yellow]Tool{tool_label}[/bold yellow]"
else:
label = f"[dim]{msg.role}[/dim]"
content = msg.content
if max_content and len(content) > max_content:
content = (
content[:max_content]
+ f"\n... [truncated, {len(msg.content)} chars total]"
)
timestamp = msg.timestamp.strftime("%H:%M:%S") if msg.timestamp else ""
console.print(f" {label} [dim]{timestamp}[/dim]")
console.print(f" {content}\n")
def delete_session_cmd(session_id: str, db_path: Optional[str] = None) -> None:
"""Delete a session from the database.
Parameters
----------
session_id : str
Session ID or unique session prefix to delete.
db_path : str, optional
Path to the session SQLite database.
"""
store = SessionStore(db_path=db_path)
# Show session info before deleting
session = store.get_session(session_id)
if session is None:
console.print(f"[red]Session '{session_id}' not found.[/red]")
return
console.print(
f"[yellow]Deleting session: {session.session_id} "
f"({session.title or 'Untitled'})[/yellow]"
)
if store.delete_session(session_id):
console.print("[green]Session deleted.[/green]")
else:
console.print("[red]Failed to delete session.[/red]")
# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------
def save_output(content: str, output_file: str) -> None:
"""Save output to a file.
Parameters
----------
content : str
Text content to write.
output_file : str
Destination file path.
"""
try:
with open(output_file, "w") as f:
f.write(content)
console.print(f"[green]Output saved to: {output_file}[/green]")
except Exception as e:
console.print(f"[red]Error saving output: {e}[/red]")
# ---------------------------------------------------------------------------
# Interactive REPL
# ---------------------------------------------------------------------------
def interactive_mode(
model: str = "gpt-4o-mini",
workflow: str = "single_agent",
structured: bool = False,
return_option: str = "state",
generate_report: bool = True,
human_supervised: bool = False,
recursion_limit: int = 20,
base_url: Optional[str] = None,
argo_user: Optional[str] = None,
verbose: bool = False,
tools: Optional[list] = None,
) -> None:
"""Start interactive REPL mode for ChemGraph CLI.
Accepts the same configuration parameters as a normal run so that
``--config`` and CLI flags are honoured when entering interactive
mode.
Parameters
----------
model : str, optional
Initial model selection.
workflow : str, optional
Initial workflow selection.
structured : bool, optional
Whether structured output is requested.
return_option : str, optional
Agent return mode.
generate_report : bool, optional
Whether report generation is enabled.
human_supervised : bool, optional
Whether human supervision tools are enabled.
recursion_limit : int, optional
LangGraph recursion limit.
base_url : str, optional
Custom model endpoint URL.
argo_user : str, optional
Argo username for Argo-hosted models.
verbose : bool, optional
Whether to print diagnostic output.
tools : list, optional
Custom tool list for MCP-backed workflows.
"""
console.print(create_banner())
console.print("[bold green]Welcome to ChemGraph Interactive Mode![/bold green]")
console.print(
"Type your queries and get AI-powered computational chemistry insights."
)
console.print(
"[dim]Type 'quit', 'exit', or 'q' to exit. Type 'help' for commands.[/dim]\n"
)
# Allow the user to override model/workflow at startup.
model = Prompt.ask(
"Select model (or type a custom model ID)", default=model
)
workflow = Prompt.ask(
"Select workflow",
choices=ALL_WORKFLOW_TYPES,
default=resolve_workflow(workflow),
)
# Initialize agent with the full config context.
agent = initialize_agent(
model,
workflow,
structured,
return_option,
generate_report,
recursion_limit,
base_url=base_url,
argo_user=argo_user,
verbose=verbose,
human_supervised=human_supervised,
tools=tools,
)
if not agent:
return
console.print(
"[green]Ready! You can now ask computational chemistry questions.[/green]\n"
)
while True:
try:
query = Prompt.ask("\n[bold cyan]ChemGraph[/bold cyan]")
if query.lower() in ("quit", "exit", "q"):
console.print("[yellow]Goodbye![/yellow]")
break
elif query.lower() == "help":
console.print(
Panel(
"""
Available commands:
quit/exit/q Exit interactive mode
help Show this help message
clear Clear screen
config Show current configuration
model <name> Change model
workflow <type> Change workflow type
Session commands:
history List recent sessions
show <id> Show a session's conversation
resume <id> Resume from a previous session
Example queries:
What is the SMILES string for water?
Optimize the geometry of methane
Calculate CO2 vibrational frequencies
Show me the structure of caffeine
""",
title="Help",
style="blue",
)
)
continue
elif query.lower() == "clear":
console.clear()
continue
elif query.lower() == "config":
console.print(f"Model: {model}")
console.print(f"Workflow: {workflow}")
if hasattr(agent, "session_id"):
console.print(f"Session ID: {agent.session_id}")
continue
elif query.lower() == "history":
list_sessions()
continue
elif query.lower().startswith("show "):
sid = query[5:].strip()
if sid:
show_session(sid)
else:
console.print("[red]Usage: show <session_id>[/red]")
continue
elif query.lower().startswith("resume "):
sid = query[7:].strip()
if not sid:
console.print("[red]Usage: resume <session_id>[/red]")
continue
resume_query = Prompt.ask(
"[bold cyan]Enter query to continue with[/bold cyan]"
)
if resume_query.strip():
result = run_query(
agent,
resume_query,
verbose=verbose,
resume_from=sid,
)
if result:
format_response(result, verbose=verbose)
continue
elif query.startswith("model "):
new_model = query[6:].strip()
model = new_model
agent = initialize_agent(
model,
workflow,
structured,
return_option,
generate_report,
recursion_limit,
base_url=base_url,
argo_user=argo_user,
human_supervised=human_supervised,
tools=tools,
)
if agent:
console.print(f"[green]Model changed to: {model}[/green]")
continue
elif query.startswith("workflow "):
new_workflow = resolve_workflow(query[9:].strip())
if new_workflow in ALL_WORKFLOW_TYPES:
workflow = new_workflow
agent = initialize_agent(
model,
workflow,
structured,
return_option,
generate_report,
recursion_limit,
base_url=base_url,
argo_user=argo_user,
human_supervised=human_supervised,
tools=tools,
)
if agent:
console.print(
f"[green]Workflow changed to: {workflow}[/green]"
)
else:
console.print(f"[red]Invalid workflow: {new_workflow}[/red]")
console.print(
f"[dim]Available: {', '.join(ALL_WORKFLOW_TYPES)}[/dim]"
)
continue
# Execute query (each query gets a unique thread ID)
result = run_query(agent, query, verbose=verbose)
if result:
format_response(result, verbose=verbose)
if hasattr(agent, "session_id") and agent.session_id:
console.print(f"[dim]Session: {agent.session_id}[/dim]")
except KeyboardInterrupt:
console.print(
"\n[yellow]Interrupted. Type 'quit' to exit.[/yellow]"
)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")