rockyaaos's picture
ChemGraph Loop: guarded real-agent API (EMT/TBLite single-point energy)
c509967 verified
Raw
History Blame Contribute Delete
16.8 kB
"""Argument parsing and main entry point for the ChemGraph CLI.
Supports three usage styles:
1. **Legacy** (no subcommand) -- ``chemgraph -q "..." -m gpt-4o``
2. **Subcommand** -- ``chemgraph run ...``, ``chemgraph eval ...``,
``chemgraph session ...``, ``chemgraph models``
3. **Standalone eval** -- ``chemgraph-eval`` via its own entry point.
"""
from __future__ import annotations
import argparse
import sys
from typing import Any, Dict
import toml
from chemgraph.models.supported_models import all_supported_models
from chemgraph.utils.config_utils import (
flatten_config,
get_argo_user_from_flat_config,
get_base_url_for_model_from_flat_config,
)
from chemgraph.cli.commands import (
ALL_WORKFLOW_TYPES,
WORKFLOW_ALIASES,
resolve_workflow,
delete_session_cmd,
initialize_agent,
interactive_mode,
list_sessions,
run_query,
save_output,
show_session,
)
from chemgraph.cli.formatting import (
check_api_keys_status,
console,
create_banner,
format_response,
list_models,
)
# ---------------------------------------------------------------------------
# Argument parser construction
# ---------------------------------------------------------------------------
# Workflow choices exposed to the user. We include common aliases
# (e.g. ``python_repl``) so that users don't have to know the
# internal ``python_relp`` name.
_WORKFLOW_CHOICES = sorted(set(ALL_WORKFLOW_TYPES) | set(WORKFLOW_ALIASES.keys()))
def _add_run_args(parser: argparse.ArgumentParser) -> None:
"""Add query/run-specific arguments to *parser*.
Used by both the ``run`` subcommand and the legacy (no subcommand)
argument parser for backward compatibility.
Parameters
----------
parser : argparse.ArgumentParser
Parser or subparser to receive query/run arguments.
"""
parser.add_argument(
"-q", "--query", type=str, help="The computational chemistry query to execute"
)
parser.add_argument(
"-m",
"--model",
type=str,
default="gpt-4o-mini",
help="LLM model to use (default: gpt-4o-mini)",
)
parser.add_argument(
"-w",
"--workflow",
type=str,
choices=_WORKFLOW_CHOICES,
default="single_agent",
help="Workflow type (default: single_agent)",
)
parser.add_argument(
"-o",
"--output",
type=str,
choices=["state", "last_message"],
default="state",
help="Output format (default: state)",
)
parser.add_argument(
"-s", "--structured", action="store_true", help="Use structured output format"
)
parser.add_argument(
"-r", "--report", action="store_true", help="Generate detailed report"
)
parser.add_argument(
"--human-supervised",
action="store_true",
help="Enable the ask_human tool for human-in-the-loop interaction",
)
parser.add_argument(
"--recursion-limit",
type=int,
default=20,
help="Recursion limit for agent workflows (default: 20)",
)
parser.add_argument(
"--interactive", action="store_true", help="Start interactive mode"
)
parser.add_argument(
"--list-models", action="store_true", help="List all available models"
)
parser.add_argument(
"--check-keys", action="store_true", help="Check API key availability"
)
parser.add_argument(
"--list-sessions",
action="store_true",
help="List recent sessions from the memory database",
)
parser.add_argument(
"--show-session",
type=str,
metavar="ID",
help="Show conversation for a session (supports prefix matching)",
)
parser.add_argument(
"--delete-session",
type=str,
metavar="ID",
help="Delete a session from the memory database",
)
parser.add_argument(
"--resume",
type=str,
metavar="ID",
help="Resume from a previous session (injects context into new query)",
)
parser.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="Increase verbosity (-v for INFO, -vv for DEBUG)",
)
parser.add_argument("--output-file", type=str, help="Save output to file")
parser.add_argument("--config", type=str, help="Load configuration from TOML file")
parser.add_argument(
"--base-url",
type=str,
default=None,
help="Base URL for the LLM API endpoint (overrides config file)",
)
parser.add_argument(
"--mcp-url",
type=str,
default=None,
help="MCP server URL for streamable_http transport (e.g. http://localhost:9003/mcp/)",
)
parser.add_argument(
"--mcp-command",
type=str,
default=None,
help="MCP server command for stdio transport (e.g. 'python -m chemgraph.mcp.mcp_tools')",
)
parser.add_argument(
"--mcp-server-name",
type=str,
default="ChemGraph General Tools",
help="Display name for the MCP server connection (default: 'ChemGraph General Tools')",
)
def create_argument_parser() -> argparse.ArgumentParser:
"""Create and configure the argument parser with subcommands."""
parser = argparse.ArgumentParser(
prog="chemgraph",
description="ChemGraph CLI - AI Agents for Computational Chemistry",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Legacy style (still works)
%(prog)s -q "What is the SMILES string for water?"
%(prog)s --interactive
%(prog)s --list-models
# Subcommand style
%(prog)s run -q "Optimize water geometry" -m gpt-4o
%(prog)s eval --profile quick --models gpt-4o-mini --config config.toml
%(prog)s eval --models gpt-4o --dataset ground_truth.json
%(prog)s session list
%(prog)s session show a3b2
%(prog)s models
""",
)
subparsers = parser.add_subparsers(dest="command")
# ---- "run" subcommand ------------------------------------------------
run_parser = subparsers.add_parser(
"run",
help="Run a single query or start interactive mode.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
_add_run_args(run_parser)
# ---- "eval" subcommand -----------------------------------------------
eval_parser = subparsers.add_parser(
"eval",
help="Run evaluation benchmarks against ground-truth datasets.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
# Import here to avoid circular imports at module level
from chemgraph.eval.cli import add_eval_args
add_eval_args(eval_parser)
# ---- "session" subcommand --------------------------------------------
session_parser = subparsers.add_parser(
"session",
help="Manage conversation sessions.",
)
session_sub = session_parser.add_subparsers(dest="session_command")
session_sub.add_parser("list", help="List recent sessions.")
show_parser = session_sub.add_parser("show", help="Show a session's conversation.")
show_parser.add_argument("id", help="Session ID (prefix matching supported).")
delete_parser = session_sub.add_parser("delete", help="Delete a session.")
delete_parser.add_argument("id", help="Session ID to delete.")
# ---- "models" subcommand ---------------------------------------------
subparsers.add_parser("models", help="List all available LLM models.")
# ---- Legacy fallback args -------------------------------------------
# Also add run args to the top-level parser so that
# `chemgraph -q "..."` keeps working without a subcommand.
_add_run_args(parser)
return parser
# ---------------------------------------------------------------------------
# Config loading
# ---------------------------------------------------------------------------
def load_config(config_file: str) -> Dict[str, Any]:
"""Load and flatten a TOML configuration file.
Merges missing keys from a sensible default so that partial config
files don't crash the CLI (addresses Bug 4 -- parity with the
Streamlit config loader).
Parameters
----------
config_file : str
Path to a TOML configuration file.
Returns
-------
dict[str, Any]
Flattened configuration dictionary with defaults filled in.
"""
try:
with open(config_file, "r") as f:
raw_config = toml.load(f)
console.print(f"[green]Configuration loaded from {config_file}[/green]")
# Merge defaults for required sections so partial configs work.
_DEFAULT_SECTIONS = {
"general": {
"model": "gpt-4o-mini",
"workflow": "single_agent",
"output": "state",
"structured": False,
"report": False,
"thread": 1,
"recursion_limit": 20,
"human_supervised": False,
"verbose": False,
},
"api": {},
"chemistry": {},
"output": {},
"mcp": {},
}
for section, defaults in _DEFAULT_SECTIONS.items():
if section not in raw_config:
raw_config[section] = defaults
elif isinstance(defaults, dict):
for key, value in defaults.items():
raw_config[section].setdefault(key, value)
flat = flatten_config(raw_config)
# Inject MCP config keys (not handled by flatten_config).
if "mcp" in raw_config and isinstance(raw_config["mcp"], dict):
for key, value in raw_config["mcp"].items():
flat[f"mcp_{key}"] = value
return flat
except FileNotFoundError:
console.print(f"[red]Configuration file not found: {config_file}[/red]")
sys.exit(1)
except toml.TomlDecodeError as e:
console.print(f"[red]Invalid TOML in configuration file: {e}[/red]")
sys.exit(1)
# ---------------------------------------------------------------------------
# Subcommand handlers
# ---------------------------------------------------------------------------
def _handle_run(args: argparse.Namespace) -> None:
"""Handle the ``run`` subcommand and legacy no-subcommand mode.
Parameters
----------
args : argparse.Namespace
Parsed CLI arguments.
"""
# Handle special commands first
if getattr(args, "list_models", False):
list_models()
return
if getattr(args, "check_keys", False):
check_api_keys_status()
return
if getattr(args, "list_sessions", False):
list_sessions()
return
if getattr(args, "show_session", None):
show_session(args.show_session)
return
if getattr(args, "delete_session", None):
delete_session_cmd(args.delete_session)
return
# Load configuration if specified
config: Dict[str, Any] = {}
if args.config:
config = load_config(args.config)
# Override args with config values (only when the user hasn't
# explicitly set them on the command line).
for key, value in config.items():
if hasattr(args, key) and getattr(args, key) is None:
setattr(args, key, value)
# Honour config recursion_limit unless user gave explicit flag.
if "recursion_limit" in config and "--recursion-limit" not in sys.argv:
args.recursion_limit = config["recursion_limit"]
# ---- Configure logging verbosity --------------------------------
import logging as _logging
from chemgraph.utils.logging_config import configure_logging
# Start from config baseline (default: WARNING = quiet).
_log_level_name = config.get("logging_level", "WARNING").upper() if config else "WARNING"
_log_level = getattr(_logging, _log_level_name, _logging.WARNING)
# CLI -v / -vv overrides the config value.
if args.verbose >= 2:
_log_level = _logging.DEBUG
elif args.verbose >= 1:
_log_level = _logging.INFO
configure_logging(_log_level)
base_url = args.base_url or (
get_base_url_for_model_from_flat_config(args.model, config) if config else None
)
argo_user = get_argo_user_from_flat_config(config) if config else None
# Resolve workflow alias (e.g. python_repl -> python_relp)
args.workflow = resolve_workflow(args.workflow)
# ---- MCP tool loading ----------------------------------------------
mcp_tools = None
mcp_url = getattr(args, "mcp_url", None) or config.get("mcp_url")
mcp_command = getattr(args, "mcp_command", None) or config.get("mcp_command")
mcp_server_name = (
getattr(args, "mcp_server_name", None)
or config.get("mcp_server_name", "ChemGraph General Tools")
)
if mcp_url or mcp_command:
from chemgraph.cli.mcp_utils import load_mcp_tools_from_config
mcp_tools = load_mcp_tools_from_config(
url=mcp_url,
command=mcp_command,
server_name=mcp_server_name,
verbose=(args.verbose > 0),
)
if mcp_tools is None:
sys.exit(1)
if getattr(args, "interactive", False):
interactive_mode(
model=args.model,
workflow=args.workflow,
structured=args.structured,
return_option=args.output,
generate_report=args.report,
human_supervised=args.human_supervised,
recursion_limit=args.recursion_limit,
base_url=base_url,
argo_user=argo_user,
verbose=(args.verbose > 0),
tools=mcp_tools,
)
return
if args.model not in all_supported_models:
console.print(
f"[yellow]Using custom model ID: {args.model} (not in curated list)[/yellow]"
)
# Require query for non-interactive mode
if not args.query:
console.print("[red]Query is required. Use -q or --query to specify.[/red]")
console.print(
"Use --help for more information or --interactive for interactive mode."
)
sys.exit(1)
# Show banner
console.print(create_banner())
# Initialize agent
agent = initialize_agent(
args.model,
args.workflow,
args.structured,
args.output,
args.report,
args.recursion_limit,
base_url=base_url,
argo_user=argo_user,
verbose=(args.verbose > 0),
human_supervised=args.human_supervised,
tools=mcp_tools,
)
if not agent:
sys.exit(1)
# Execute query
console.print(f"[bold blue]Query:[/bold blue] {args.query}")
if args.resume:
console.print(f"[bold blue]Resuming from:[/bold blue] {args.resume}")
result = run_query(
agent, args.query, verbose=(args.verbose > 0), resume_from=args.resume
)
if result:
format_response(result, verbose=(args.verbose > 0))
# Save output if requested
if args.output_file:
output_content = str(result)
save_output(output_content, args.output_file)
if hasattr(agent, "session_id") and agent.session_id:
console.print(
f"\n[dim]Session: {agent.session_id}"
f" | Resume: chemgraph -q \"<query>\" --resume {agent.session_id}[/dim]"
)
console.print("[dim]Thank you for using ChemGraph CLI![/dim]")
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def main() -> None:
"""Main CLI entry point.
Dispatches to the appropriate subcommand handler, or falls back
to the legacy behaviour when no subcommand is given.
"""
parser = create_argument_parser()
args = parser.parse_args()
if args.command == "eval":
from chemgraph.eval.cli import run_eval
run_eval(args)
elif args.command == "session":
sc = getattr(args, "session_command", None)
if sc == "list":
list_sessions()
elif sc == "show":
show_session(args.id)
elif sc == "delete":
delete_session_cmd(args.id)
else:
console.print(
"Usage: chemgraph session {list,show,delete}. Use --help for details."
)
elif args.command == "models":
list_models()
elif args.command == "run":
_handle_run(args)
else:
# No subcommand given -- legacy behaviour.
_handle_run(args)
if __name__ == "__main__":
main()