mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
491 lines
17 KiB
Python
491 lines
17 KiB
Python
import asyncio
|
|
import argparse
|
|
import sys
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from openspace.core.tui_bridge import TUIBridge
|
|
from openspace import OpenSpace, OpenSpaceConfig
|
|
from openspace.utils.logging import Logger
|
|
from openspace.utils.ui import create_ui, OpenSpaceUI
|
|
from openspace.utils.ui_integration import UIIntegration
|
|
from openspace.utils.cli_display import CLIDisplay
|
|
|
|
logger = Logger.get_logger(__name__)
|
|
|
|
from openspace.entrypoints.cli.text_loop import UIManager, interactive_mode, single_query_mode
|
|
from openspace.entrypoints.tui.controller import (
|
|
_restore_console_logs,
|
|
_suppress_console_logs_for_tui,
|
|
tui_mode,
|
|
)
|
|
|
|
|
|
|
|
def _create_argument_parser() -> argparse.ArgumentParser:
|
|
"""Create command-line argument parser"""
|
|
parser = argparse.ArgumentParser(
|
|
description='OpenSpace - Self-Evolving Skill Worker & Community',
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
|
|
# Subcommands
|
|
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
|
|
|
# refresh-cache subcommand
|
|
cache_parser = subparsers.add_parser(
|
|
'refresh-cache',
|
|
help='Refresh MCP tool cache (starts all servers once)'
|
|
)
|
|
cache_parser.add_argument(
|
|
'--config', '-c', type=str,
|
|
help='MCP configuration file path'
|
|
)
|
|
|
|
# Basic arguments (for run mode)
|
|
parser.add_argument('--config', '-c', type=str, help='Configuration file path (JSON format)')
|
|
parser.add_argument('--query', '-q', type=str, help='Single query mode: execute query directly')
|
|
|
|
# LLM arguments
|
|
parser.add_argument('--model', '-m', type=str, help='LLM model name')
|
|
|
|
# Logging arguments
|
|
parser.add_argument('--log-level', type=str, choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'], help='Log level')
|
|
|
|
# Execution arguments
|
|
parser.add_argument('--max-iterations', type=int, help='Maximum iteration count')
|
|
parser.add_argument('--timeout', type=float, help='LLM API call timeout (seconds)')
|
|
|
|
# UI arguments
|
|
parser.add_argument('--interactive', '-i', action='store_true', help='Force text interactive mode')
|
|
parser.add_argument('--tui', action='store_true', help='Use the TS TUI instead of the text input loop')
|
|
parser.add_argument('--no-tui', action='store_true', help=argparse.SUPPRESS)
|
|
parser.add_argument('--resume', action='store_true', help='Start the TUI on the resume screen')
|
|
parser.add_argument('--doctor', action='store_true', help='Start the TUI on the doctor screen')
|
|
parser.add_argument('--no-ui', action='store_true', help='Disable visualization UI')
|
|
parser.add_argument('--ui-compact', action='store_true', help='Use compact UI layout')
|
|
|
|
return parser
|
|
|
|
|
|
async def refresh_mcp_cache(config_path: Optional[str] = None):
|
|
"""Refresh MCP tool cache by starting servers one by one and saving tool metadata."""
|
|
from openspace.grounding.backends.mcp import MCPProvider, get_tool_cache
|
|
from openspace.grounding.core.types import SessionConfig, BackendType
|
|
from openspace.config import load_config, get_config
|
|
|
|
print("Refreshing MCP tool cache...")
|
|
print("Servers will be started one by one (start -> get tools -> close).")
|
|
print()
|
|
|
|
# Load config
|
|
if config_path:
|
|
config = load_config(config_path)
|
|
else:
|
|
config = get_config()
|
|
|
|
# Get MCP config
|
|
mcp_config = getattr(config, 'mcp', None) or {}
|
|
if hasattr(mcp_config, 'model_dump'):
|
|
mcp_config = mcp_config.model_dump()
|
|
|
|
# Skip dependency checks for refresh-cache (servers are pre-validated)
|
|
mcp_config["check_dependencies"] = False
|
|
|
|
# Create provider
|
|
provider = MCPProvider(config=mcp_config)
|
|
await provider.initialize()
|
|
|
|
servers = provider.list_servers()
|
|
total = len(servers)
|
|
print(f"Found {total} MCP servers configured")
|
|
print()
|
|
|
|
cache = get_tool_cache()
|
|
cache.set_server_order(servers) # Preserve config order when saving
|
|
total_tools = 0
|
|
success_count = 0
|
|
skipped_count = 0
|
|
failed_servers = []
|
|
|
|
# Load existing cache to skip already processed servers
|
|
existing_cache = cache.get_all_tools()
|
|
|
|
# Timeout for each server (in seconds)
|
|
SERVER_TIMEOUT = 60
|
|
|
|
# Process servers one by one
|
|
for i, server_name in enumerate(servers, 1):
|
|
# Skip if already cached (resume support)
|
|
if server_name in existing_cache:
|
|
cached_tools = existing_cache[server_name]
|
|
total_tools += len(cached_tools)
|
|
skipped_count += 1
|
|
print(f"[{i}/{total}] {server_name}... ⏭ cached ({len(cached_tools)} tools)")
|
|
continue
|
|
|
|
print(f"[{i}/{total}] {server_name}...", end=" ", flush=True)
|
|
session_id = f"mcp-{server_name}"
|
|
|
|
try:
|
|
# Create session and get tools with timeout protection
|
|
async with asyncio.timeout(SERVER_TIMEOUT):
|
|
# Create session for this server
|
|
cfg = SessionConfig(
|
|
session_name=session_id,
|
|
backend_type=BackendType.MCP,
|
|
connection_params={"server": server_name},
|
|
)
|
|
session = await provider.create_session(cfg)
|
|
|
|
# Get tools from this server
|
|
tools = await session.list_tools()
|
|
|
|
# Convert to metadata format
|
|
tool_metadata = []
|
|
for tool in tools:
|
|
tool_metadata.append({
|
|
"name": tool.schema.name,
|
|
"description": tool.schema.description or "",
|
|
"parameters": tool.schema.parameters or {},
|
|
})
|
|
|
|
# Save to cache (incremental)
|
|
cache.save_server(server_name, tool_metadata)
|
|
|
|
# Close session immediately to free resources
|
|
await provider.close_session(session_id)
|
|
|
|
total_tools += len(tools)
|
|
success_count += 1
|
|
print(f"✓ {len(tools)} tools")
|
|
|
|
except asyncio.TimeoutError:
|
|
error_msg = f"Timeout after {SERVER_TIMEOUT}s"
|
|
failed_servers.append((server_name, error_msg))
|
|
print(f"✗ {error_msg}")
|
|
|
|
# Save failed server info to cache
|
|
cache.save_failed_server(server_name, error_msg)
|
|
|
|
# Try to close session if it was created
|
|
try:
|
|
await provider.close_session(session_id)
|
|
except Exception:
|
|
pass
|
|
|
|
except Exception as e:
|
|
error_msg = str(e)
|
|
failed_servers.append((server_name, error_msg))
|
|
print(f"✗ {error_msg[:50]}")
|
|
|
|
# Save failed server info to cache
|
|
cache.save_failed_server(server_name, error_msg)
|
|
|
|
# Try to close session if it was created
|
|
try:
|
|
await provider.close_session(session_id)
|
|
except Exception:
|
|
pass
|
|
|
|
print()
|
|
print(f"{'='*50}")
|
|
print(f"✓ Collected {total_tools} tools from {success_count + skipped_count}/{total} servers")
|
|
if skipped_count > 0:
|
|
print(f" (skipped {skipped_count} cached, processed {success_count} new)")
|
|
print(f"✓ Cache saved to: {cache.cache_path}")
|
|
|
|
if failed_servers:
|
|
print(f"✗ Failed servers ({len(failed_servers)}):")
|
|
for name, err in failed_servers[:10]:
|
|
print(f" - {name}: {err[:60]}")
|
|
if len(failed_servers) > 10:
|
|
print(f" ... and {len(failed_servers) - 10} more (see cache file for details)")
|
|
|
|
print()
|
|
print("Done! Future list_tools() calls will use cache (no server startup).")
|
|
|
|
|
|
def _load_config(args, *, quiet: bool = False) -> OpenSpaceConfig:
|
|
"""Load configuration"""
|
|
import os
|
|
from openspace.host_detection import (
|
|
build_grounding_config_path,
|
|
build_llm_kwargs,
|
|
load_runtime_env,
|
|
)
|
|
|
|
load_runtime_env()
|
|
|
|
cli_overrides = {}
|
|
if args.max_iterations is not None:
|
|
cli_overrides['grounding_max_iterations'] = args.max_iterations
|
|
if args.timeout is not None:
|
|
cli_overrides['llm_timeout'] = args.timeout
|
|
if args.log_level:
|
|
cli_overrides['log_level'] = args.log_level
|
|
if quiet:
|
|
cli_overrides['log_to_console'] = False
|
|
cli_overrides['log_to_file'] = True
|
|
|
|
try:
|
|
from openspace.services.runtime_support.settings import get_setting
|
|
|
|
settings_model = get_setting("model", None, cwd=os.getcwd())
|
|
settings_thinking = get_setting("alwaysThinkingEnabled", None, cwd=os.getcwd())
|
|
except Exception:
|
|
settings_model = None
|
|
settings_thinking = None
|
|
|
|
# Resolve LLM model & credentials
|
|
# CLI --model > OPENSPACE_MODEL env > settings model > host-agent auto-detect > default
|
|
env_model = args.model or os.environ.get("OPENSPACE_MODEL", "") or (settings_model or "")
|
|
model, llm_kwargs = build_llm_kwargs(env_model)
|
|
cli_overrides['llm_model'] = model
|
|
cli_overrides['llm_kwargs'] = llm_kwargs
|
|
if isinstance(settings_thinking, bool):
|
|
cli_overrides["llm_enable_thinking"] = settings_thinking
|
|
|
|
max_iter = int(os.environ.get("OPENSPACE_MAX_ITERATIONS", "20"))
|
|
enable_rec = os.environ.get("OPENSPACE_ENABLE_RECORDING", "true").lower() in ("true", "1", "yes")
|
|
backend_scope_raw = os.environ.get("OPENSPACE_BACKEND_SCOPE")
|
|
backend_scope = (
|
|
[b.strip() for b in backend_scope_raw.split(",") if b.strip()]
|
|
if backend_scope_raw else None
|
|
)
|
|
config_path = build_grounding_config_path()
|
|
|
|
if 'grounding_max_iterations' not in cli_overrides:
|
|
cli_overrides['grounding_max_iterations'] = max_iter
|
|
cli_overrides['enable_recording'] = enable_rec
|
|
if backend_scope is not None:
|
|
cli_overrides['backend_scope'] = backend_scope
|
|
if config_path:
|
|
cli_overrides['grounding_config_path'] = config_path
|
|
|
|
try:
|
|
# Load from config file if provided
|
|
if args.config:
|
|
import json
|
|
with open(args.config, 'r', encoding='utf-8') as f:
|
|
config_dict = json.load(f)
|
|
|
|
# Apply CLI / env overrides
|
|
config_dict.update(cli_overrides)
|
|
config = OpenSpaceConfig(**config_dict)
|
|
|
|
if not quiet:
|
|
print(f"✓ Loaded from config file: {args.config}")
|
|
else:
|
|
config = OpenSpaceConfig(**cli_overrides)
|
|
if not quiet:
|
|
print("✓ Using default configuration")
|
|
|
|
if args.model and not quiet:
|
|
print(f"✓ CLI overrides: llm_model")
|
|
|
|
if args.log_level:
|
|
Logger.set_level(args.log_level)
|
|
|
|
return config
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to load configuration: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
def _setup_ui(args) -> tuple[Optional[OpenSpaceUI], Optional[UIIntegration]]:
|
|
if args.no_ui:
|
|
CLIDisplay.print_banner()
|
|
return None, None
|
|
|
|
ui = create_ui(enable_live=True, compact=args.ui_compact)
|
|
ui.print_banner()
|
|
ui_integration = UIIntegration(ui)
|
|
return ui, ui_integration
|
|
|
|
|
|
async def _initialize_openspace(
|
|
config: OpenSpaceConfig,
|
|
args,
|
|
*,
|
|
quiet: bool = False,
|
|
) -> OpenSpace:
|
|
openspace = OpenSpace(config)
|
|
|
|
init_steps = [("Initializing OpenSpace...", "loading")]
|
|
if not quiet:
|
|
CLIDisplay.print_initialization_progress(init_steps, show_header=False)
|
|
|
|
if not args.config:
|
|
original_log_level = Logger.get_logger("openspace").level
|
|
for log_name in ["openspace", "openspace.grounding", "openspace.agents"]:
|
|
Logger.get_logger(log_name).setLevel(logging.WARNING)
|
|
|
|
await openspace.initialize()
|
|
|
|
# Restore log level
|
|
if not args.config:
|
|
for log_name in ["openspace", "openspace.grounding", "openspace.agents"]:
|
|
Logger.get_logger(log_name).setLevel(original_log_level)
|
|
|
|
# Print initialization results
|
|
backends = openspace.list_backends()
|
|
init_steps = [
|
|
("LLM Client", "ok"),
|
|
(f"Grounding Backends ({len(backends)} available)", "ok"),
|
|
("Grounding Agent", "ok"),
|
|
]
|
|
|
|
if config.enable_recording:
|
|
init_steps.append(("Recording Manager", "ok"))
|
|
|
|
if not quiet:
|
|
CLIDisplay.print_initialization_progress(init_steps, show_header=True)
|
|
|
|
return openspace
|
|
|
|
|
|
def _should_use_tui(args) -> bool:
|
|
return bool(args.tui or args.resume or args.doctor) and not args.query
|
|
|
|
|
|
def _resolve_use_tui(args) -> bool:
|
|
if not _should_use_tui(args):
|
|
return False
|
|
|
|
if TUIBridge.default_tui_available():
|
|
if TUIBridge.interactive_terminal_available():
|
|
return True
|
|
|
|
message = (
|
|
"The TS TUI needs an interactive terminal because stdin/stdout are "
|
|
"reserved for Core/TUI IPC, but no usable terminal device was found."
|
|
)
|
|
raise RuntimeError(
|
|
f"{message} Run without --tui for the text input loop, or use "
|
|
"--query for non-interactive execution."
|
|
)
|
|
|
|
searched = "\n".join(
|
|
f" - {path}" for path in TUIBridge.default_tui_entry_candidates()
|
|
)
|
|
raise FileNotFoundError(
|
|
"The TS TUI was requested, but no TUI entry was found.\n"
|
|
"Searched default locations:\n"
|
|
f"{searched}\n{TUIBridge.default_tui_missing_hint()}"
|
|
)
|
|
|
|
|
|
async def main():
|
|
parser = _create_argument_parser()
|
|
args = parser.parse_args()
|
|
tui_options = bool(args.tui or args.resume or args.doctor)
|
|
if getattr(args, "no_tui", False) and args.tui:
|
|
parser.error("--no-tui cannot be combined with --tui")
|
|
if args.interactive and tui_options:
|
|
parser.error("--interactive cannot be combined with --tui, --resume, or --doctor")
|
|
if args.query and tui_options:
|
|
parser.error("--query cannot be combined with --tui, --resume, or --doctor")
|
|
|
|
# Handle subcommands
|
|
if args.command == 'refresh-cache':
|
|
await refresh_mcp_cache(args.config)
|
|
return 0
|
|
|
|
prefer_tui = _should_use_tui(args)
|
|
startup_console_log_handlers = (
|
|
_suppress_console_logs_for_tui() if prefer_tui else []
|
|
)
|
|
|
|
# Load configuration
|
|
try:
|
|
config = _load_config(args, quiet=prefer_tui)
|
|
|
|
try:
|
|
use_tui = _resolve_use_tui(args)
|
|
except (FileNotFoundError, RuntimeError) as e:
|
|
_restore_console_logs(startup_console_log_handlers)
|
|
startup_console_log_handlers = []
|
|
print(f"\nError: {e}")
|
|
return 1
|
|
except Exception:
|
|
_restore_console_logs(startup_console_log_handlers)
|
|
startup_console_log_handlers = []
|
|
raise
|
|
|
|
if not use_tui and startup_console_log_handlers:
|
|
_restore_console_logs(startup_console_log_handlers)
|
|
startup_console_log_handlers = []
|
|
|
|
ui = None
|
|
ui_integration = None
|
|
if not use_tui:
|
|
ui, ui_integration = _setup_ui(args)
|
|
CLIDisplay.print_configuration(config)
|
|
|
|
openspace = None
|
|
|
|
try:
|
|
# Initialize OpenSpace
|
|
openspace = await _initialize_openspace(config, args, quiet=use_tui)
|
|
|
|
# Connect UI (if enabled)
|
|
if ui_integration:
|
|
ui_integration.attach_llm_client(openspace.get_llm_client())
|
|
ui_integration.attach_grounding_client(openspace.get_grounding_client())
|
|
CLIDisplay.print_system_ready()
|
|
|
|
ui_manager = UIManager(ui, ui_integration)
|
|
|
|
# Run appropriate mode
|
|
if args.query:
|
|
await single_query_mode(openspace, args.query, ui_manager)
|
|
elif use_tui:
|
|
await tui_mode(
|
|
openspace,
|
|
args,
|
|
console_log_handlers=startup_console_log_handlers,
|
|
)
|
|
startup_console_log_handlers = []
|
|
else:
|
|
await interactive_mode(openspace, ui_manager)
|
|
|
|
except KeyboardInterrupt:
|
|
print("\n\nInterrupt signal detected")
|
|
except Exception as e:
|
|
logger.error(f"Error: {e}", exc_info=True)
|
|
print(f"\nError: {e}")
|
|
return 1
|
|
finally:
|
|
if startup_console_log_handlers:
|
|
_restore_console_logs(startup_console_log_handlers)
|
|
startup_console_log_handlers = []
|
|
if openspace:
|
|
cleanup_log_handlers = (
|
|
_suppress_console_logs_for_tui() if use_tui else []
|
|
)
|
|
try:
|
|
if not use_tui:
|
|
print("\nCleaning up resources...")
|
|
await openspace.cleanup()
|
|
finally:
|
|
_restore_console_logs(cleanup_log_handlers)
|
|
|
|
if not use_tui:
|
|
print("\nGoodbye!")
|
|
return 0
|
|
|
|
|
|
def run_main():
|
|
"""Run main function"""
|
|
try:
|
|
exit_code = asyncio.run(main())
|
|
sys.exit(exit_code)
|
|
except KeyboardInterrupt:
|
|
print("\n\nProgram interrupted")
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run_main()
|