#!/usr/bin/env python3 """LLM agent that plays Red Alert using any OpenAI-compatible model. Supports OpenRouter, Ollama, LM Studio, or any local/remote endpoint that implements the OpenAI Chat Completions API with tool calling. Usage: # With OpenRouter (cloud) export OPENROUTER_API_KEY=sk-or-... python examples/llm_agent.py --verbose # With a YAML config file python examples/llm_agent.py --config examples/config-ollama.yaml # With LM Studio (local, no API key needed) python examples/llm_agent.py --base-url http://localhost:1234/v1/chat/completions --model my-model """ import argparse import asyncio import sys from dotenv import load_dotenv load_dotenv() from openra_env.config import load_config from openra_env.agent import run_agent # Re-export for backwards compatibility from openra_env.agent import ( # noqa: F401 SYSTEM_PROMPT, load_system_prompt, compose_pregame_briefing, format_state_briefing, mcp_tools_to_openai, _sanitize_messages, chat_completion, compress_history, ) # Line-buffered stdout so output is observable in real time sys.stdout.reconfigure(line_buffering=True) def main(): parser = argparse.ArgumentParser( description="LLM agent that plays Red Alert via any OpenAI-compatible model", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Examples:\n" " %(prog)s --config examples/config-ollama.yaml --verbose\n" " %(prog)s --api-key sk-or-... --verbose\n" " %(prog)s --base-url http://localhost:1234/v1/chat/completions --model my-model\n" ), ) parser.add_argument( "--config", "-c", default=None, help="Path to YAML config file (default: auto-discover config.yaml)", ) parser.add_argument( "--url", default=None, help="OpenRA-RL server URL (overrides config agent.server_url)", ) parser.add_argument( "--base-url", default=None, help="LLM API endpoint URL (overrides config llm.base_url)", ) parser.add_argument( "--model", default=None, help="Model ID (overrides config llm.model)", ) parser.add_argument( "--api-key", default=None, help="API key for LLM endpoint (overrides config llm.api_key)", ) parser.add_argument( "--max-turns", type=int, default=None, help="Maximum LLM turns, 0 = unlimited (overrides config agent.max_turns)", ) parser.add_argument( "--max-time", type=int, default=None, help="Maximum wall-clock time in seconds (overrides config agent.max_time_s)", ) parser.add_argument( "--verbose", action="store_true", help="Print detailed LLM reasoning and tool calls", ) parser.add_argument( "--log-file", default=None, help="Write all output to this log file in addition to stdout", ) parser.add_argument( "--system-prompt", default=None, help="Path to a custom system prompt .txt file (overrides built-in default)", ) args = parser.parse_args() # Build config: YAML file + env vars + CLI overrides (CLI wins over .env) cli: dict = {} if args.url is not None: cli.setdefault("agent", {})["server_url"] = args.url if args.base_url is not None: cli.setdefault("llm", {})["base_url"] = args.base_url if args.model is not None: cli.setdefault("llm", {})["model"] = args.model if args.api_key is not None: cli.setdefault("llm", {})["api_key"] = args.api_key if args.max_turns is not None: cli.setdefault("agent", {})["max_turns"] = args.max_turns if args.max_time is not None: cli.setdefault("agent", {})["max_time_s"] = args.max_time if args.verbose: cli.setdefault("agent", {})["verbose"] = True if args.log_file is not None: cli.setdefault("agent", {})["log_file"] = args.log_file if args.system_prompt is not None: cli.setdefault("agent", {})["system_prompt_file"] = args.system_prompt config = load_config(config_path=args.config, cli_overrides=cli) verbose = config.agent.verbose # Set up logging to file if requested — tee all print() to both stdout and file if config.agent.log_file: import builtins _builtin_print = builtins.print _log_fh = open(config.agent.log_file, "w", encoding="utf-8") def _tee_print(*pargs, **kwargs): _builtin_print(*pargs, **kwargs) kwargs.pop("file", None) _builtin_print(*pargs, file=_log_fh, **kwargs) _log_fh.flush() builtins.print = _tee_print # API key validation: only required for remote endpoints is_local = any(h in config.llm.base_url for h in ("localhost", "127.0.0.1", "0.0.0.0")) if not config.llm.api_key and not is_local: print("Error: API key required for remote LLM endpoints.") print(" Set OPENROUTER_API_KEY or LLM_API_KEY environment variable, use --api-key,") print(" or use a config file with llm.api_key set.") print(" For local models (Ollama, LM Studio), use --base-url http://localhost:...") sys.exit(1) try: asyncio.run(run_agent(config, verbose)) except KeyboardInterrupt: print("\nInterrupted by user") sys.exit(0) except ConnectionRefusedError: print(f"\nCould not connect to {config.agent.server_url}") print("Is the OpenRA-RL server running?") print(" docker run -p 8000:8000 openra-rl") sys.exit(1) if __name__ == "__main__": main()