OpenSpace/openspace/core/doctor.py
2026-07-17 11:43:42 +08:00

1964 lines
66 KiB
Python

"""
System health checker that runs diagnostics on the OpenSpace environment.
Checks are grouped into Python-owned sections and streamed to the TUI as
sectioned results. The TUI remains a merge/render layer only.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import uuid
from dataclasses import dataclass
from fnmatch import fnmatch
from pathlib import Path
from typing import Any, Iterable, Optional
from openspace.protocol import CoreToTuiEvent
from openspace.communication.config import CommunicationConfig
from openspace.communication.config import load_communication_config
from openspace.config.grounding import GroundingConfig
from openspace.grounding.core.permissions import build_permission_rules_snapshot
from openspace.prompts import GroundingAgentPrompts
from openspace.skill_engine.skill_ranker import PREFILTER_THRESHOLD
from openspace.utils.logging import Logger
from pydantic import ValidationError
logger = Logger.get_logger(__name__)
_CONFIG_DIR = Path(__file__).resolve().parent.parent / "config"
_BUILTIN_SKILLS_DIR = Path(__file__).resolve().parent.parent / "skills"
_LOCK_FILE = Path.home() / ".openspace" / "lock"
_MCP_ALLOWED_TRANSPORTS = {"stdio", "sse", "streamable-http", "websocket"}
_RUNTIME_SERVICE_GETTERS = {
"execution_analyzer": "get_execution_analyzer",
"grounding_agent": "get_grounding_agent",
"grounding_client": "get_grounding_client",
"grounding_config": "get_grounding_config",
"recording_manager": "get_recording_manager",
"skill_registry": "get_skill_registry",
}
@dataclass(slots=True)
class CheckResult:
status: str # "pass", "warn", "fail"
name: str
description: str
details: Optional[str] = None
def to_dict(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"status": self.status,
"name": self.name,
"message": self.description,
}
if self.details:
payload["details"] = self.details
return payload
@dataclass(slots=True)
class DoctorSection:
key: str
title: str
checks: tuple[str, ...]
class Doctor:
"""Run a suite of environment health checks."""
def __init__(
self,
tui_bridge: Any | None = None,
openspace: Any | None = None,
) -> None:
self._tui_bridge = tui_bridge
self._openspace = openspace
def _runtime_service(self, name: str) -> Any | None:
if self._openspace is None:
return None
getter_name = _RUNTIME_SERVICE_GETTERS.get(name)
if getter_name is None:
return None
getter = getattr(self._openspace, getter_name, None)
if not callable(getter):
return None
return getter()
async def run_all(self) -> list[CheckResult]:
run_id = uuid.uuid4().hex
sections = self._build_sections()
all_results: list[CheckResult] = []
for order, section in enumerate(sections):
section_results: list[CheckResult] = []
for check_name in section.checks:
result = getattr(self, check_name)()
section_results.append(result)
all_results.append(result)
await self._stream_section(
run_id=run_id,
section=section,
order=order,
checks=[result],
section_results=section_results,
section_done=False,
run_done=False,
summary=self._section_summary(section.title, section_results),
)
await self._stream_section(
run_id=run_id,
section=section,
order=order,
checks=[],
section_results=section_results,
section_done=True,
run_done=order == len(sections) - 1,
summary=self._section_summary(section.title, section_results),
)
return all_results
def _build_sections(self) -> list[DoctorSection]:
return [
DoctorSection(
key="diagnostics",
title="Diagnostics",
checks=(
"check_python_version",
"check_openspace_version",
"check_installation_path",
"check_invoked_binary",
"check_nodejs",
"check_ripgrep",
),
),
DoctorSection(
key="updates",
title="Updates",
checks=(
"check_update_channel",
),
),
DoctorSection(
key="configuration",
title="Configuration",
checks=(
"check_config",
"check_agent_config",
"check_communication_config",
"check_env_vars",
),
),
DoctorSection(
key="invalid_settings",
title="Invalid Settings",
checks=(
"check_grounding_settings_validation",
"check_agents_settings_validation",
"check_communication_settings_validation",
"check_env_override_validation",
),
),
DoctorSection(
key="permissions",
title="Permissions",
checks=(
"check_permission_mode",
"check_permission_rules",
"check_permission_rule_precedence",
),
),
DoctorSection(
key="sandbox",
title="Sandbox",
checks=(
"check_sandbox_settings",
"check_workspace_access",
"check_temp_dir",
),
),
DoctorSection(
key="mcp",
title="MCP Diagnostics",
checks=(
"check_mcp_servers",
"check_mcp_server_definitions",
"check_mcp_env_override",
"check_mcp_sessions",
),
),
DoctorSection(
key="plugins",
title="Plugins & Skills",
checks=(
"check_plugins",
"check_skill_directories",
"check_skill_registry",
"check_skill_parse_errors",
"check_recording",
"check_execution_analyzer",
"check_tool_quality",
),
),
DoctorSection(
key="sessions",
title="Sessions",
checks=(
"check_sessions_dir",
"check_lock_file",
),
),
DoctorSection(
key="context",
title="Context",
checks=(
"check_current_project",
"check_workspace_setting",
"check_backend_scope",
),
),
DoctorSection(
key="context_usage",
title="Context Usage Warnings",
checks=(
"check_system_prompt_context_pressure",
"check_skill_selection_context_pressure",
"check_skill_injection_context_pressure",
"check_channel_context_pressure",
),
),
]
def check_python_version(self) -> CheckResult:
vi = sys.version_info
version_str = f"{vi.major}.{vi.minor}.{vi.micro}"
if vi >= (3, 12):
return CheckResult("pass", "Python", f"Python {version_str}")
if vi >= (3, 10):
return CheckResult(
"warn",
"Python",
f"Python {version_str} (>= 3.12 recommended)",
)
return CheckResult(
"fail",
"Python",
f"Python {version_str} is unsupported",
details="Upgrade to Python 3.12 or later.",
)
def check_openspace_version(self) -> CheckResult:
version = self._get_package_version()
if version is None:
return CheckResult(
"fail",
"OpenSpace",
"openspace package not importable",
)
install_type = self._detect_install_type()
return CheckResult(
"pass",
"OpenSpace",
f"Version {version}",
details=f"Install source: {install_type}",
)
def check_installation_path(self) -> CheckResult:
package_path = self._get_package_path()
if package_path is None:
return CheckResult(
"fail",
"Installation Path",
"Unable to resolve the openspace package path",
)
return CheckResult(
"pass",
"Installation Path",
str(package_path),
)
def check_invoked_binary(self) -> CheckResult:
python_executable = str(Path(sys.executable).resolve())
argv0 = (sys.argv[0] or "").strip() if sys.argv else ""
if not argv0:
return CheckResult(
"warn",
"Invoked Binary",
"Unable to determine argv[0]",
details=f"Python executable: {python_executable}",
)
invoked_path = Path(argv0).expanduser()
try:
invoked_path = invoked_path.resolve()
except OSError:
pass
return CheckResult(
"pass",
"Invoked Binary",
str(invoked_path),
details=f"Python executable: {python_executable}",
)
def check_nodejs(self) -> CheckResult:
node = shutil.which("node")
if not node:
return CheckResult(
"fail",
"Node.js",
"Node.js not found on PATH",
details="Node.js is required for the TUI.",
)
try:
out = subprocess.run(
[node, "--version"],
capture_output=True,
text=True,
timeout=10,
check=False,
)
version = out.stdout.strip() or out.stderr.strip() or "unknown"
except (subprocess.TimeoutExpired, OSError):
version = "unknown"
return CheckResult("pass", "Node.js", f"{version} at {node}")
def check_ripgrep(self) -> CheckResult:
rg = shutil.which("rg")
if rg:
return CheckResult("pass", "ripgrep", f"Found at {rg}")
return CheckResult(
"warn",
"ripgrep",
"ripgrep (rg) not found on PATH",
details="Install ripgrep for faster code search.",
)
def check_update_channel(self) -> CheckResult:
version = self._get_package_version() or "unknown"
install_type = self._detect_install_type()
package_path = self._get_package_path()
return CheckResult(
"pass",
"Installed Version",
f"{version} ({install_type})",
details=f"Package path: {package_path}" if package_path else None,
)
def check_config(self) -> CheckResult:
required = [
"config_grounding.json",
"config_security.json",
"config_agents.json",
]
optional = [
"config_mcp.json",
"config_communication.json",
]
missing: list[str] = []
invalid: list[str] = []
present_optional: list[str] = []
for name in required + optional:
path = _CONFIG_DIR / name
if not path.exists():
if name in required:
missing.append(name)
continue
if name in optional:
present_optional.append(name)
if self._load_json_path(path) is None:
invalid.append(name)
if missing or invalid:
parts: list[str] = []
if missing:
parts.append(f"missing: {', '.join(missing)}")
if invalid:
parts.append(f"invalid JSON: {', '.join(invalid)}")
return CheckResult(
"fail" if missing else "warn",
"Config Files",
"; ".join(parts),
)
optional_details = (
", ".join(present_optional) if present_optional else "none"
)
return CheckResult(
"pass",
"Config Files",
"Required config files parsed successfully",
details=f"Optional configs present: {optional_details}",
)
def check_agent_config(self) -> CheckResult:
path = _CONFIG_DIR / "config_agents.json"
data = self._load_json_path(path)
if data is None:
return CheckResult(
"warn",
"Agent Config",
"Unable to read config_agents.json",
details=str(path),
)
agents = data.get("agents")
if not isinstance(agents, list):
return CheckResult(
"warn",
"Agent Config",
"config_agents.json has no valid 'agents' array",
details=str(path),
)
target = next(
(
agent
for agent in agents
if isinstance(agent, dict)
and agent.get("name") == "GroundingAgent"
),
None,
)
if target is None:
return CheckResult(
"warn",
"Agent Config",
"GroundingAgent entry not found in config_agents.json",
details=str(path),
)
backend_scope = target.get("backend_scope") or []
max_iterations = target.get("max_iterations", "unknown")
return CheckResult(
"pass",
"Agent Config",
"GroundingAgent config loaded",
details=(
f"backend_scope={backend_scope}; "
f"max_iterations={max_iterations}"
),
)
def check_communication_config(self) -> CheckResult:
active = _CONFIG_DIR / "config_communication.json"
example = _CONFIG_DIR / "config_communication.json.example"
if active.exists():
data = self._load_json_path(active)
if data is None:
return CheckResult(
"warn",
"Communication Config",
"config_communication.json is invalid JSON",
details=str(active),
)
return CheckResult(
"pass",
"Communication Config",
"Communication config is present",
details=str(active),
)
if example.exists():
return CheckResult(
"pass",
"Communication Config",
"Optional communication config is not enabled",
details=f"Example available at {example}",
)
return CheckResult(
"pass",
"Communication Config",
"No communication config detected",
)
def check_grounding_settings_validation(self) -> CheckResult:
raw_data, file_errors = self._build_grounding_validation_payload()
if file_errors:
return CheckResult(
"fail",
"Grounding Settings",
"Unable to validate merged grounding config",
details=self._summarize_items(file_errors, limit=5),
)
try:
GroundingConfig.model_validate(raw_data)
except ValidationError as exc:
return CheckResult(
"fail",
"Grounding Settings",
"Merged grounding config failed schema validation",
details=self._format_validation_errors(exc),
)
return CheckResult(
"pass",
"Grounding Settings",
"Merged grounding config passed schema validation",
)
def check_agents_settings_validation(self) -> CheckResult:
path = _CONFIG_DIR / "config_agents.json"
data, error = self._read_json_file(path)
if data is None:
return CheckResult(
"fail",
"Agent Settings",
"Unable to load config_agents.json",
details=error or str(path),
)
issues = self._validate_agents_payload(data)
if issues:
return CheckResult(
"fail",
"Agent Settings",
f"config_agents.json has {len(issues)} validation issue(s)",
details=self._summarize_items(issues, limit=5),
)
agent_names = [
str(agent.get("name", "unknown"))
for agent in data.get("agents", [])
if isinstance(agent, dict)
]
return CheckResult(
"pass",
"Agent Settings",
f"Validated {len(agent_names)} agent definition(s)",
details=self._summarize_items(agent_names),
)
def check_communication_settings_validation(self) -> CheckResult:
path = _CONFIG_DIR / "config_communication.json"
example = _CONFIG_DIR / "config_communication.json.example"
if not path.exists():
return CheckResult(
"pass",
"Communication Settings",
"Communication config is not enabled",
details=f"Example available at {example}" if example.exists() else None,
)
data, error = self._read_json_file(path)
if data is None:
return CheckResult(
"fail",
"Communication Settings",
"Unable to parse config_communication.json",
details=error or str(path),
)
try:
CommunicationConfig.model_validate(data)
except ValidationError as exc:
return CheckResult(
"fail",
"Communication Settings",
"config_communication.json failed schema validation",
details=self._format_validation_errors(exc),
)
return CheckResult(
"pass",
"Communication Settings",
"config_communication.json passed schema validation",
)
def check_env_override_validation(self) -> CheckResult:
issues: list[str] = []
notices: list[str] = []
json_var_specs = [
("OPENSPACE_CONFIG_JSON", "dict"),
("OPENSPACE_MCP_SERVERS_JSON", "dict"),
("OPENSPACE_LLM_CONFIG", "dict"),
("OPENSPACE_LLM_EXTRA_HEADERS", "dict"),
]
for name, expected_kind in json_var_specs:
value = os.environ.get(name, "").strip()
if not value:
continue
try:
parsed = json.loads(value)
except json.JSONDecodeError as exc:
issues.append(
f"{name}: invalid JSON at line {exc.lineno} col {exc.colno}"
)
continue
if expected_kind == "dict" and not isinstance(parsed, dict):
issues.append(f"{name}: expected a JSON object")
continue
notices.append(f"{name}=JSON<{type(parsed).__name__}>")
if issues:
return CheckResult(
"fail",
"Environment Overrides",
f"{len(issues)} invalid override(s) detected",
details=self._summarize_items(issues, limit=5),
)
if notices:
return CheckResult(
"pass",
"Environment Overrides",
f"{len(notices)} JSON override(s) validated",
details=self._summarize_items(notices, limit=5),
)
return CheckResult(
"pass",
"Environment Overrides",
"No JSON env overrides set",
)
def check_env_vars(self) -> CheckResult:
text_vars = [
"OPENSPACE_MODEL",
"OPENSPACE_WORKSPACE",
"OPENSPACE_DEBUG",
"OPENSPACE_LOG_LEVEL",
]
json_vars = [
"OPENSPACE_CONFIG_JSON",
"OPENSPACE_MCP_SERVERS_JSON",
"OPENSPACE_LLM_CONFIG",
"OPENSPACE_LLM_EXTRA_HEADERS",
]
found: list[str] = []
for var in text_vars:
value = os.environ.get(var)
if value:
found.append(f"{var}={value}")
for var in json_vars:
value = os.environ.get(var)
if not value:
continue
try:
parsed = json.loads(value)
except json.JSONDecodeError:
continue
parsed_type = type(parsed).__name__
found.append(f"{var}=JSON<{parsed_type}>")
if not found:
return CheckResult(
"pass",
"Environment Variables",
"No OpenSpace env overrides set",
)
return CheckResult(
"pass",
"Environment Variables",
f"{len(found)} env override(s) detected",
details=self._summarize_items(found),
)
def check_permission_mode(self) -> CheckResult:
try:
rules = build_permission_rules_snapshot(self._resolve_workspace_dir())
mode = rules.get("mode", {}).get("current")
except Exception as exc:
return CheckResult(
"warn",
"Permission Mode",
"Permission mode unavailable",
details=str(exc),
)
if isinstance(mode, str) and mode:
return CheckResult("pass", "Permission Mode", f"Mode: {mode}")
return CheckResult(
"warn",
"Permission Mode",
"Permission mode unavailable",
)
def check_permission_rules(self) -> CheckResult:
try:
rules = build_permission_rules_snapshot(self._resolve_workspace_dir())
except Exception as exc:
return CheckResult(
"warn",
"Permission Rules",
"Unable to inspect permission rules",
details=str(exc),
)
session_rules = rules.get("session", {})
persistent_rules = rules.get("persistent", {})
if not session_rules and not persistent_rules:
return CheckResult(
"pass",
"Permission Rules",
"No custom permission rules configured",
)
return CheckResult(
"pass",
"Permission Rules",
(
f"{len(persistent_rules)} persistent and "
f"{len(session_rules)} session rule(s)"
),
)
def check_permission_rule_precedence(self) -> CheckResult:
try:
rules = build_permission_rules_snapshot(self._resolve_workspace_dir())
except Exception as exc:
return CheckResult(
"warn",
"Permission Rule Precedence",
"Unable to inspect permission rules",
details=str(exc),
)
session_rules = rules.get("session", {})
persistent_rules = rules.get("persistent", {})
overshadowed: list[str] = []
for session_pattern in session_rules:
if self._is_glob_pattern(session_pattern):
continue
for persistent_pattern in persistent_rules:
if fnmatch(session_pattern, persistent_pattern):
overshadowed.append(
f"{session_pattern} is shadowed by persistent rule {persistent_pattern}"
)
break
if overshadowed:
return CheckResult(
"warn",
"Permission Rule Precedence",
f"{len(overshadowed)} session rule(s) are shadowed by persistent rules",
details=self._summarize_items(overshadowed, limit=5),
)
return CheckResult(
"pass",
"Permission Rule Precedence",
"No shadowed exact-match session rules detected",
details="Wildcard-over-wildcard shadowing is not currently analyzed.",
)
def check_sandbox_settings(self) -> CheckResult:
try:
from openspace.services.sandbox import (
build_sandbox_status,
format_sandbox_doctor,
get_process_sandbox_manager,
)
cwd = self._resolve_workspace_dir()
manager = get_process_sandbox_manager(cwd=cwd)
payload = build_sandbox_status(manager)
except Exception as exc:
return CheckResult(
"warn",
"Sandbox Settings",
"Unable to inspect process sandbox runtime",
details=str(exc),
)
mode = str(payload.get("mode") or "unknown")
platform = str(payload.get("platform") or "unknown")
active = self._bool_label(bool(payload.get("sandboxing_enabled")))
enabled = self._bool_label(bool(payload.get("enabled_in_settings")))
description = (
f"mode={mode}, enabled={enabled}, active={active}, platform={platform}"
)
return CheckResult(
str(payload.get("status") or "warn"),
"Sandbox Settings",
description,
details=format_sandbox_doctor(payload),
)
def check_workspace_access(self) -> CheckResult:
cwd = Path.cwd()
if not cwd.exists():
return CheckResult(
"fail",
"Workspace Access",
f"Current directory missing: {cwd}",
)
if os.access(cwd, os.W_OK):
return CheckResult(
"pass",
"Workspace Access",
f"Workspace writable: {cwd}",
)
return CheckResult(
"warn",
"Workspace Access",
f"Workspace is read-only: {cwd}",
)
def check_temp_dir(self) -> CheckResult:
temp_dir = Path(os.getenv("TMPDIR") or "/tmp")
if temp_dir.exists() and os.access(temp_dir, os.W_OK):
return CheckResult(
"pass",
"Temp Directory",
f"Writable: {temp_dir}",
)
return CheckResult(
"warn",
"Temp Directory",
f"Temp dir not writable: {temp_dir}",
)
def check_mcp_servers(self) -> CheckResult:
servers, source, error = self._get_effective_mcp_servers()
if error:
return CheckResult(
"fail",
"MCP Config",
"Unable to resolve effective MCP server config",
details=error,
)
if servers is not None:
if not servers:
return CheckResult(
"warn",
"MCP Config",
f"{source} is active but defines no servers",
)
return CheckResult(
"pass",
"MCP Config",
f"{len(servers)} server(s) configured via {source}",
details=self._summarize_items(list(servers.keys())),
)
mcp_example = _CONFIG_DIR / "config_mcp.json.example"
mcp_active = _CONFIG_DIR / "config_mcp.json"
if mcp_example.exists():
return CheckResult(
"warn",
"MCP Config",
"No active MCP config (example file exists)",
details=f"Copy {mcp_example} to {mcp_active} and edit it.",
)
return CheckResult(
"warn",
"MCP Config",
"No MCP config files found",
)
def check_mcp_server_definitions(self) -> CheckResult:
servers, source, error = self._get_effective_mcp_servers()
if error:
return CheckResult(
"fail",
"MCP Server Definitions",
"Unable to validate MCP server definitions",
details=error,
)
if servers is None:
return CheckResult(
"warn",
"MCP Server Definitions",
"No active MCP server config to validate",
)
issues = self._validate_mcp_servers_payload(servers)
if issues:
return CheckResult(
"fail",
"MCP Server Definitions",
f"{len(issues)} validation issue(s) found in {source}",
details=self._summarize_items(issues, limit=5),
)
return CheckResult(
"pass",
"MCP Server Definitions",
f"All MCP server definitions in {source} look valid",
)
def check_mcp_env_override(self) -> CheckResult:
raw = os.environ.get("OPENSPACE_MCP_SERVERS_JSON", "").strip()
if not raw:
return CheckResult(
"pass",
"MCP Env Override",
"No MCP env override active",
)
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
return CheckResult(
"fail",
"MCP Env Override",
"OPENSPACE_MCP_SERVERS_JSON contains invalid JSON",
details=f"line {exc.lineno}, col {exc.colno}",
)
if not isinstance(parsed, dict):
return CheckResult(
"fail",
"MCP Env Override",
"OPENSPACE_MCP_SERVERS_JSON must be a JSON object",
)
issues = self._validate_mcp_servers_payload(parsed)
if issues:
return CheckResult(
"fail",
"MCP Env Override",
f"Env override has {len(issues)} invalid server definition(s)",
details=self._summarize_items(issues, limit=5),
)
return CheckResult(
"pass",
"MCP Env Override",
f"Using env override with {len(parsed)} server(s)",
details=self._summarize_items(list(parsed.keys())),
)
def check_mcp_sessions(self) -> CheckResult:
grounding_client = self._runtime_service("grounding_client")
if grounding_client is None:
return CheckResult(
"warn",
"MCP Sessions",
"Grounding client is not initialized",
)
try:
from openspace.grounding.core.types import BackendType
provider = grounding_client.get_provider(BackendType.MCP)
except Exception as exc:
return CheckResult(
"warn",
"MCP Sessions",
"MCP provider is not registered",
details=str(exc),
)
configured = provider.list_servers() if hasattr(provider, "list_servers") else []
if not configured:
return CheckResult(
"warn",
"MCP Sessions",
"No MCP servers configured",
)
active_sessions = [
session_name
for session_name in grounding_client.list_sessions()
if session_name.startswith("mcp-")
]
if not active_sessions:
return CheckResult(
"warn",
"MCP Sessions",
f"0 active session(s) for {len(configured)} configured server(s)",
details="Sessions are created lazily after the first MCP use.",
)
status = "pass" if len(active_sessions) == len(configured) else "warn"
return CheckResult(
status,
"MCP Sessions",
(
f"{len(active_sessions)} active session(s) for "
f"{len(configured)} configured server(s)"
),
details=self._summarize_items(active_sessions),
)
def check_plugins(self) -> CheckResult:
plugin_marketplace = Path.cwd() / ".agents" / "plugins" / "marketplace.json"
if plugin_marketplace.exists():
if self._load_json_path(plugin_marketplace) is None:
return CheckResult(
"warn",
"Plugin Marketplace",
"Plugin marketplace config is invalid JSON",
details=str(plugin_marketplace),
)
return CheckResult(
"pass",
"Plugin Marketplace",
f"Config found at {plugin_marketplace}",
)
return CheckResult(
"warn",
"Plugin Marketplace",
"No plugin marketplace config found",
details="Plugin diagnostics are limited until plugin metadata exists.",
)
def check_skill_directories(self) -> CheckResult:
skill_enabled = self._skills_enabled()
resolved, missing = self._resolve_skill_directories()
if not skill_enabled:
return CheckResult(
"warn",
"Skill Directories",
"Skill discovery is disabled in config_grounding.json",
)
if missing:
return CheckResult(
"warn",
"Skill Directories",
f"{len(missing)} configured skill dir(s) are missing",
details=self._summarize_items(missing, limit=5),
)
if not resolved:
return CheckResult(
"warn",
"Skill Directories",
"No skill directories resolved",
)
return CheckResult(
"pass",
"Skill Directories",
f"{len(resolved)} skill dir(s) resolved",
details=self._summarize_items([str(path) for path in resolved], limit=4),
)
def check_skill_registry(self) -> CheckResult:
grounding_config = self._runtime_service("grounding_config")
skills_config = getattr(grounding_config, "skills", None)
skills_enabled = bool(getattr(skills_config, "enabled", False))
registry = self._runtime_service("skill_registry")
if not skills_enabled:
return CheckResult(
"warn",
"Skill Registry",
"Skill discovery is disabled in config_grounding.json",
)
if registry is None:
return CheckResult(
"warn",
"Skill Registry",
"Skills are enabled but the registry is not initialized",
)
skills = registry.list_skills()
skill_ids = [
getattr(skill, "skill_id", None) or getattr(skill, "name", "unknown")
for skill in skills
]
return CheckResult(
"pass" if skills else "warn",
"Skill Registry",
f"{len(skills)} skill(s) discovered",
details=self._summarize_items(skill_ids) if skills else "No skills discovered",
)
def check_skill_parse_errors(self) -> CheckResult:
registry = self._runtime_service("skill_registry")
if registry is None:
if self._skills_enabled():
return CheckResult(
"warn",
"Skill Parse Errors",
"Skill registry is not initialized",
)
return CheckResult(
"warn",
"Skill Parse Errors",
"Skill discovery is disabled",
)
diagnostics = registry.get_diagnostics() if hasattr(registry, "get_diagnostics") else []
if not diagnostics:
return CheckResult(
"pass",
"Skill Parse Errors",
"No skill parse or safety issues detected",
)
fail_count = sum(1 for diagnostic in diagnostics if diagnostic.severity == "fail")
warn_count = sum(1 for diagnostic in diagnostics if diagnostic.severity == "warn")
status = "fail" if fail_count > 0 else "warn"
summary_parts = []
if fail_count > 0:
summary_parts.append(f"{fail_count} parse failure(s)")
if warn_count > 0:
summary_parts.append(f"{warn_count} safety warning(s)")
detail_items = []
for diagnostic in diagnostics:
item = f"{diagnostic.path}: {diagnostic.message}"
if diagnostic.details:
item += f" ({diagnostic.details})"
detail_items.append(item)
return CheckResult(
status,
"Skill Parse Errors",
", ".join(summary_parts),
details=self._summarize_items(detail_items, limit=5),
)
def check_recording(self) -> CheckResult:
config = getattr(self._openspace, "config", None)
recording_enabled = bool(getattr(config, "enable_recording", False))
manager = self._runtime_service("recording_manager")
if not recording_enabled:
return CheckResult(
"warn",
"Recording",
"Recording is disabled in OpenSpace config",
)
if manager is None:
return CheckResult(
"warn",
"Recording",
"Recording is enabled but the manager is not initialized",
)
backends = sorted(getattr(manager, "backends", []))
return CheckResult(
"pass",
"Recording",
f"Enabled for {len(backends)} backend(s)",
details=self._summarize_items(backends) if backends else None,
)
def check_execution_analyzer(self) -> CheckResult:
analyzer = self._runtime_service("execution_analyzer")
registry = self._runtime_service("skill_registry")
config = getattr(self._openspace, "config", None)
recording_enabled = bool(getattr(config, "enable_recording", False))
if analyzer is not None:
return CheckResult(
"pass",
"Execution Analyzer",
"Execution analysis is initialized",
)
if registry is None:
return CheckResult(
"warn",
"Execution Analyzer",
"Execution analysis unavailable because the skill registry is not initialized",
)
if not recording_enabled:
return CheckResult(
"warn",
"Execution Analyzer",
"Execution analysis unavailable because recording is disabled",
)
return CheckResult(
"warn",
"Execution Analyzer",
"Execution analysis is not initialized",
)
def check_tool_quality(self) -> CheckResult:
grounding_config = self._runtime_service("grounding_config")
quality_config = getattr(grounding_config, "tool_quality", None)
quality_enabled = bool(getattr(quality_config, "enabled", False))
grounding_client = self._runtime_service("grounding_client")
if not quality_enabled:
return CheckResult(
"warn",
"Tool Quality",
"Tool quality tracking is disabled in config_grounding.json",
)
if grounding_client is None:
return CheckResult(
"warn",
"Tool Quality",
"Grounding client is not initialized",
)
manager = getattr(grounding_client, "quality_manager", None)
if manager is None:
return CheckResult(
"warn",
"Tool Quality",
"Tool quality manager is not initialized",
)
report = grounding_client.get_quality_report()
summary = report.get("summary", {})
total_tools = summary.get("total_tools", 0)
tested_tools = summary.get("tested_tools", 0)
if total_tools == 0:
return CheckResult(
"pass",
"Tool Quality",
"Tool quality tracking is enabled (no data collected yet)",
)
return CheckResult(
"pass",
"Tool Quality",
f"{tested_tools}/{total_tools} tool(s) have quality data",
details=(
"overall success rate: "
f"{summary.get('overall_success_rate', 0):.1%}"
),
)
def check_sessions_dir(self) -> CheckResult:
sessions_dir = Path.home() / ".openspace" / "sessions"
try:
sessions_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
return CheckResult(
"warn",
"Sessions Directory",
f"Unable to prepare sessions dir: {sessions_dir}",
details=str(exc),
)
if os.access(sessions_dir, os.W_OK):
return CheckResult(
"pass",
"Sessions Directory",
f"Writable: {sessions_dir}",
)
return CheckResult(
"warn",
"Sessions Directory",
f"Sessions dir not writable: {sessions_dir}",
)
def check_lock_file(self) -> CheckResult:
if not _LOCK_FILE.exists():
return CheckResult("pass", "Lock File", "No lock file present")
try:
content = _LOCK_FILE.read_text(encoding="utf-8").strip()
pid = int(content)
except (ValueError, OSError):
_LOCK_FILE.unlink(missing_ok=True)
return CheckResult(
"warn",
"Lock File",
"Removed unreadable lock file",
)
if self._pid_alive(pid):
return CheckResult(
"warn",
"Lock File",
f"Another instance may be running (PID {pid})",
)
_LOCK_FILE.unlink(missing_ok=True)
return CheckResult(
"pass",
"Lock File",
f"Cleaned stale lock (PID {pid} no longer running)",
)
def check_current_project(self) -> CheckResult:
cwd = Path.cwd()
git_dir = cwd / ".git"
if git_dir.exists():
return CheckResult(
"pass",
"Current Project",
f"Git worktree detected: {cwd}",
)
return CheckResult(
"warn",
"Current Project",
f"No .git directory in current workspace: {cwd}",
)
def check_workspace_setting(self) -> CheckResult:
workspace_dir = getattr(getattr(self._openspace, "config", None), "workspace_dir", None)
if workspace_dir:
return CheckResult(
"pass",
"Workspace Setting",
f"Configured workspace: {workspace_dir}",
)
return CheckResult(
"warn",
"Workspace Setting",
"No explicit workspace_dir configured",
)
def check_backend_scope(self) -> CheckResult:
grounding_agent = self._runtime_service("grounding_agent")
if grounding_agent is None:
return CheckResult(
"warn",
"Backend Scope",
"GroundingAgent is not initialized",
)
backend_scope = getattr(grounding_agent, "backend_scope", None) or []
if not backend_scope:
return CheckResult(
"warn",
"Backend Scope",
"No backend scope configured",
)
return CheckResult(
"pass",
"Backend Scope",
f"{len(backend_scope)} backend(s) enabled",
details=self._summarize_items(backend_scope),
)
def check_system_prompt_context_pressure(self) -> CheckResult:
backend_scope = self._resolve_backend_scope()
prompt = GroundingAgentPrompts.build_system_prompt(
backend_scope if backend_scope else None
)
prompt_chars = len(prompt)
prompt_tokens = self._estimate_tokens(prompt_chars)
has_default_fallback = not backend_scope
if has_default_fallback or len(backend_scope) >= 4 or prompt_tokens >= 500:
reason = (
"No explicit backend scope found, so the default all-backend prompt is used"
if has_default_fallback
else "Broad backend scope increases tool guidance and decision surface"
)
return CheckResult(
"warn",
"System Prompt Breadth",
(
f"Backend scope ({len(backend_scope)} backends) produces "
f"a {prompt_chars:,}-char / ~{prompt_tokens:,} estimated-token system prompt"
),
details=(
f"{reason}: "
f"{self._summarize_items(backend_scope, limit=5)}"
),
)
return CheckResult(
"pass",
"System Prompt Breadth",
f"System prompt size is {prompt_chars:,} chars / ~{prompt_tokens:,} estimated tokens",
details=(
"Explicit backend scope keeps the prompt focused: "
f"{self._summarize_items(backend_scope, limit=5)}"
),
)
def check_skill_selection_context_pressure(self) -> CheckResult:
registry = self._runtime_service("skill_registry")
if registry is None:
return CheckResult(
"pass",
"Skill Selection Context",
"No skill registry attached",
)
skills = registry.list_skills()
skill_count = len(skills)
if skill_count == 0:
return CheckResult(
"pass",
"Skill Selection Context",
"No discovered skills",
)
descriptions_chars = sum(len(skill.description or "") for skill in skills)
descriptions_tokens = self._estimate_tokens(descriptions_chars)
skill_cfg = getattr(self._runtime_service("grounding_config"), "skills", None)
max_select = int(getattr(skill_cfg, "max_select", 2) or 2)
prefilter_top_k = max(15, max_select * 5)
if skill_count > PREFILTER_THRESHOLD:
return CheckResult(
"warn",
"Skill Selection Context",
(
f"{skill_count} discovered skills exceed the prefilter threshold "
f"({PREFILTER_THRESHOLD})"
),
details=(
"Large skill catalogs expand the selection prompt before injection. "
f"Descriptions total {descriptions_chars:,} chars / ~{descriptions_tokens:,} estimated tokens; "
f"prefilter keeps at most {prefilter_top_k} candidates."
),
)
if skill_count >= max(8, PREFILTER_THRESHOLD - 2) and descriptions_tokens >= 250:
return CheckResult(
"warn",
"Skill Selection Context",
(
f"{skill_count} discovered skills stay below the prefilter threshold "
f"but still create a medium-size selection catalog"
),
details=(
f"Descriptions total {descriptions_chars:,} chars / ~{descriptions_tokens:,} estimated tokens; "
f"selector may still inspect all {skill_count} skills."
),
)
return CheckResult(
"pass",
"Skill Selection Context",
f"{skill_count} discovered skills stay within the prefilter threshold",
details=(
f"Descriptions total {descriptions_chars:,} chars / ~{descriptions_tokens:,} estimated tokens; "
f"prefilter threshold is {PREFILTER_THRESHOLD}."
),
)
def check_skill_injection_context_pressure(self) -> CheckResult:
registry = self._runtime_service("skill_registry")
if registry is None:
return CheckResult(
"pass",
"Skill Injection Context",
"No skill registry attached",
)
skills = registry.list_skills()
if not skills:
return CheckResult(
"pass",
"Skill Injection Context",
"No skill content available for injection",
)
skill_cfg = getattr(self._runtime_service("grounding_config"), "skills", None)
max_select = int(getattr(skill_cfg, "max_select", 2) or 2)
ranked = sorted(
skills,
key=lambda skill: len(registry.load_skill_content(skill.skill_id) or ""),
reverse=True,
)
sample = ranked[:max_select]
backend_scope = self._resolve_backend_scope()
injection = registry.build_context_injection(sample, backends=backend_scope)
injection_chars = len(injection)
injection_tokens = self._estimate_tokens(injection_chars)
selected_ids = [skill.skill_id for skill in sample]
heaviest_skill_chars = max(
(len(registry.load_skill_content(skill.skill_id) or "") for skill in sample),
default=0,
)
if injection_tokens >= 1200 or injection_chars >= 5000:
return CheckResult(
"warn",
"Skill Injection Context",
(
f"Worst-case injection for {len(sample)} skill(s) is "
f"{injection_chars:,} chars / ~{injection_tokens:,} estimated tokens"
),
details=(
f"max_select={max_select}; heaviest selected skill body is "
f"{heaviest_skill_chars:,} chars; skills: "
f"{self._summarize_items(selected_ids, limit=4)}"
),
)
return CheckResult(
"pass",
"Skill Injection Context",
(
f"Worst-case injection for {len(sample)} skill(s) is "
f"{injection_chars:,} chars / ~{injection_tokens:,} estimated tokens"
),
details=(
f"max_select={max_select}; heaviest selected skill body is "
f"{heaviest_skill_chars:,} chars; skills: "
f"{self._summarize_items(selected_ids, limit=4)}"
),
)
def check_channel_context_pressure(self) -> CheckResult:
config, error = self._load_active_communication_config()
if error:
return CheckResult(
"warn",
"Channel Context",
"Unable to evaluate communication context settings",
details=error,
)
if config is None or not config.enabled_platforms:
return CheckResult(
"pass",
"Channel Context",
"No communication channels enabled",
)
turns = config.sessions.history_max_turns
platforms = config.enabled_platforms
if turns >= 10:
return CheckResult(
"warn",
"Channel Context",
(
f"Communication mode can inject channel metadata and up to "
f"{turns} history turn(s)"
),
details=(
f"Enabled platforms: {self._summarize_items(platforms)}; "
"larger history windows expand every turn's working context."
),
)
return CheckResult(
"pass",
"Channel Context",
(
f"Communication mode can inject channel metadata and up to "
f"{turns} history turn(s)"
),
details=(
f"Enabled platforms: {self._summarize_items(platforms)}; "
"history window is moderate."
),
)
def _build_grounding_validation_payload(self) -> tuple[dict[str, Any], list[str]]:
file_errors: list[str] = []
raw_data: dict[str, Any] = {}
for filename in (
"config_grounding.json",
"config_security.json",
):
path = _CONFIG_DIR / filename
data, error = self._read_json_file(path)
if data is None:
file_errors.append(f"{filename}: {error or 'missing or unreadable'}")
continue
raw_data = self._deep_merge(raw_data, data)
dev_path = _CONFIG_DIR / "config_dev.json"
if dev_path.exists():
data, error = self._read_json_file(dev_path)
if data is None:
file_errors.append(f"{dev_path.name}: {error or 'unreadable'}")
else:
raw_data = self._deep_merge(raw_data, data)
mcp_path = _CONFIG_DIR / "config_mcp.json"
if mcp_path.exists():
mcp_data, error = self._read_json_file(mcp_path)
if mcp_data is None:
file_errors.append(f"{mcp_path.name}: {error or 'unreadable'}")
elif "mcpServers" in mcp_data:
merged_mcp = dict(raw_data.get("mcp", {}))
merged_mcp["servers"] = mcp_data["mcpServers"]
raw_data["mcp"] = merged_mcp
return raw_data, file_errors
def _get_effective_mcp_servers(
self,
) -> tuple[dict[str, Any] | None, str, str | None]:
raw = os.environ.get("OPENSPACE_MCP_SERVERS_JSON", "").strip()
if raw:
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
return None, "OPENSPACE_MCP_SERVERS_JSON", (
f"OPENSPACE_MCP_SERVERS_JSON invalid JSON at "
f"line {exc.lineno}, col {exc.colno}"
)
if not isinstance(parsed, dict):
return None, "OPENSPACE_MCP_SERVERS_JSON", (
"OPENSPACE_MCP_SERVERS_JSON must be a JSON object"
)
return parsed, "OPENSPACE_MCP_SERVERS_JSON", None
mcp_path = _CONFIG_DIR / "config_mcp.json"
if not mcp_path.exists():
return None, "config_mcp.json", None
data, error = self._read_json_file(mcp_path)
if data is None:
return None, "config_mcp.json", error
servers = data.get("mcpServers")
if servers is None:
return None, "config_mcp.json", "Missing top-level 'mcpServers' key"
if not isinstance(servers, dict):
return None, "config_mcp.json", "'mcpServers' must be a JSON object"
return servers, "config_mcp.json", None
def _validate_agents_payload(self, data: dict[str, Any]) -> list[str]:
issues: list[str] = []
agents = data.get("agents")
if not isinstance(agents, list):
return ["Top-level 'agents' field must be a list"]
for index, agent in enumerate(agents):
prefix = f"agents[{index}]"
if not isinstance(agent, dict):
issues.append(f"{prefix}: entry must be an object")
continue
name = agent.get("name")
if not isinstance(name, str) or not name.strip():
issues.append(f"{prefix}.name: must be a non-empty string")
class_name = agent.get("class_name")
if not isinstance(class_name, str) or not class_name.strip():
issues.append(f"{prefix}.class_name: must be a non-empty string")
backend_scope = agent.get("backend_scope")
if backend_scope is not None:
if not isinstance(backend_scope, list) or not all(
isinstance(item, str) and item.strip() for item in backend_scope
):
issues.append(f"{prefix}.backend_scope: must be a list of strings")
max_iterations = agent.get("max_iterations")
if max_iterations is not None:
if not isinstance(max_iterations, int) or max_iterations <= 0:
issues.append(f"{prefix}.max_iterations: must be a positive integer")
return issues
def _validate_mcp_servers_payload(
self,
servers: dict[str, Any],
) -> list[str]:
issues: list[str] = []
for server_name, config in servers.items():
prefix = f"{server_name}"
if not isinstance(config, dict):
issues.append(f"{prefix}: server config must be an object")
continue
has_command = isinstance(config.get("command"), str) and bool(
config.get("command", "").strip()
)
has_url = isinstance(config.get("url"), str) and bool(
config.get("url", "").strip()
)
if not has_command and not has_url:
issues.append(f"{prefix}: expected either 'command' or 'url'")
if "args" in config:
args = config.get("args")
if not isinstance(args, list) or not all(
isinstance(arg, str) for arg in args
):
issues.append(f"{prefix}.args: must be a list of strings")
if "env" in config:
env = config.get("env")
if not isinstance(env, dict) or not all(
isinstance(key, str) and isinstance(value, str)
for key, value in env.items()
):
issues.append(f"{prefix}.env: must be an object of string pairs")
if "transport" in config:
transport = config.get("transport")
if not isinstance(transport, str) or transport not in _MCP_ALLOWED_TRANSPORTS:
issues.append(
f"{prefix}.transport: must be one of {sorted(_MCP_ALLOWED_TRANSPORTS)}"
)
return issues
def _resolve_skill_directories(self) -> tuple[list[Path], list[str]]:
resolved: list[Path] = []
missing: list[str] = []
host_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "")
if host_dirs_raw:
for raw in host_dirs_raw.split(","):
raw = raw.strip()
if not raw:
continue
path = Path(raw)
if path.exists():
resolved.append(path)
else:
missing.append(raw)
grounding_config = self._runtime_service("grounding_config")
skill_cfg = getattr(grounding_config, "skills", None)
skill_dirs = getattr(skill_cfg, "skill_dirs", []) if skill_cfg else []
for raw in skill_dirs:
path = Path(raw)
if path in resolved:
continue
if path.exists():
resolved.append(path)
else:
missing.append(raw)
if _BUILTIN_SKILLS_DIR.exists():
resolved.append(_BUILTIN_SKILLS_DIR)
else:
missing.append(str(_BUILTIN_SKILLS_DIR))
return resolved, missing
def _skills_enabled(self) -> bool:
grounding_config = self._runtime_service("grounding_config")
skills_config = getattr(grounding_config, "skills", None)
return bool(getattr(skills_config, "enabled", False))
def _resolve_backend_scope(self) -> list[str]:
grounding_agent = self._runtime_service("grounding_agent")
if grounding_agent is not None:
backend_scope = getattr(grounding_agent, "backend_scope", None) or []
if backend_scope:
return list(backend_scope)
agent_config_path = _CONFIG_DIR / "config_agents.json"
data = self._load_json_path(agent_config_path)
if isinstance(data, dict):
for agent in data.get("agents", []):
if (
isinstance(agent, dict)
and agent.get("name") == "GroundingAgent"
and isinstance(agent.get("backend_scope"), list)
):
return [str(item) for item in agent.get("backend_scope", []) if item]
return []
def _load_active_communication_config(
self,
) -> tuple[CommunicationConfig | None, str | None]:
try:
config = load_communication_config()
except FileNotFoundError:
return None, None
except Exception as exc:
return None, str(exc)
return config, None
async def _stream_section(
self,
*,
run_id: str,
section: DoctorSection,
order: int,
checks: Iterable[CheckResult],
section_results: list[CheckResult],
section_done: bool,
run_done: bool,
summary: str,
) -> None:
if self._tui_bridge is None:
return
try:
await self._tui_bridge.send(
CoreToTuiEvent.DOCTOR_RESULT.value,
{
"run_id": run_id,
"section": section.key,
"section_title": section.title,
"section_order": order,
"section_status": self._section_status(section_results),
"checks": [check.to_dict() for check in checks],
"summary": summary,
"section_done": section_done,
"run_done": run_done,
"done": section_done,
},
)
except Exception:
logger.debug("Failed to stream doctor result to TUI", exc_info=True)
@staticmethod
def _section_status(results: Iterable[CheckResult]) -> str:
statuses = {result.status for result in results}
if "fail" in statuses:
return "fail"
if "warn" in statuses:
return "warn"
if "pass" in statuses:
return "pass"
return "info"
@staticmethod
def _section_summary(title: str, results: list[CheckResult]) -> str:
if not results:
return f"{title}: no checks"
pass_count = sum(1 for result in results if result.status == "pass")
warn_count = sum(1 for result in results if result.status == "warn")
fail_count = sum(1 for result in results if result.status == "fail")
return (
f"{title}: {pass_count} pass, {warn_count} warn, {fail_count} fail"
)
@staticmethod
def _detect_install_type() -> str:
try:
import openspace as _os_pkg
pkg_file = getattr(_os_pkg, "__file__", "") or ""
except ImportError:
return "unknown"
if "site-packages" in pkg_file:
if "pipx" in pkg_file:
return "pipx"
return "pip"
return "source"
@staticmethod
def _deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
merged = dict(base)
for key, value in update.items():
if (
key in merged
and isinstance(merged[key], dict)
and isinstance(value, dict)
):
merged[key] = Doctor._deep_merge(merged[key], value)
else:
merged[key] = value
return merged
@staticmethod
def _format_validation_errors(error: ValidationError, limit: int = 5) -> str:
issues = []
for entry in error.errors():
location = ".".join(str(part) for part in entry.get("loc", ()))
message = entry.get("msg", "validation error")
issues.append(f"{location}: {message}" if location else message)
return Doctor._summarize_items(issues, limit=limit)
@staticmethod
def _read_json_file(path: Path) -> tuple[dict[str, Any] | None, str | None]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return None, f"invalid JSON at line {exc.lineno}, col {exc.colno}"
except OSError as exc:
return None, str(exc)
if not isinstance(data, dict):
return None, "top-level JSON value must be an object"
return data, None
@staticmethod
def _load_json_path(path: Path) -> dict[str, Any] | None:
data, _ = Doctor._read_json_file(path)
return data
def _resolve_workspace_dir(self) -> str:
metadata = getattr(self._openspace, "current_session_metadata", None)
if isinstance(metadata, dict):
for key in ("workspace_dir", "project_path", "worktree_path"):
value = metadata.get(key)
if isinstance(value, str) and value.strip():
return value
worktree = metadata.get("worktree")
if isinstance(worktree, dict):
value = worktree.get("workspace_dir")
if isinstance(value, str) and value.strip():
return value
config = getattr(self._openspace, "config", None)
workspace_dir = getattr(config, "workspace_dir", None)
if isinstance(workspace_dir, str) and workspace_dir.strip():
return workspace_dir
return os.getcwd()
@staticmethod
def _bool_label(value: bool) -> str:
return "on" if value else "off"
@staticmethod
def _summarize_items(items: list[str], limit: int = 4) -> str:
compact = [item for item in items if item]
if not compact:
return "none"
shown = compact[:limit]
remaining = len(compact) - len(shown)
text = ", ".join(shown)
if remaining > 0:
text += f" (+{remaining} more)"
return text
@staticmethod
def _get_package_path() -> Path | None:
try:
import openspace as _os_pkg
pkg_file = getattr(_os_pkg, "__file__", "") or ""
if not pkg_file:
return None
return Path(pkg_file).resolve()
except ImportError:
return None
@staticmethod
def _get_package_version() -> str | None:
try:
import openspace as _os_pkg
return getattr(_os_pkg, "__version__", "unknown")
except ImportError:
return None
@staticmethod
def _is_glob_pattern(pattern: str) -> bool:
return any(char in pattern for char in "*?[]")
@staticmethod
def _estimate_tokens(char_count: int) -> int:
return max(1, char_count // 4) if char_count > 0 else 0
@staticmethod
def _pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True