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

618 lines
19 KiB
Python

"""ConfigTool.
Reading a setting is auto-allowed, setting a value asks the user, keys are
constrained to a supported whitelist, and setting-source values are read from
the effective settings merge while writes land in user settings.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any, Callable, Literal, Mapping, Sequence
from openspace.grounding.core.permissions.types import PermissionAllow, PermissionAsk
from openspace.grounding.core.tool.base import BaseTool
from openspace.grounding.core.types import BackendType, ToolResult, ToolSchema, ToolStatus
from openspace.services.runtime_support.settings import (
get_effective_settings,
get_setting,
get_settings_for_source,
get_settings_path_for_source,
update_setting,
)
CONFIG_TOOL_NAME = "config"
CONFIG_TOOL_ALIAS = "Config"
CONFIG_MAX_RESULT_SIZE_CHARS = 100_000
SettingSource = Literal["global", "settings"]
SettingType = Literal["boolean", "string"]
SettingGroup = Literal["stable_engine", "stable_ui", "experimental"]
_NO_DEFAULT = object()
EDITOR_MODES = ("normal", "vim")
NOTIFICATION_CHANNELS = (
"auto",
"iterm2",
"iterm2_with_bell",
"terminal_bell",
"kitty",
"ghostty",
"notifications_disabled",
)
TEAMMATE_MODES = ("auto", "tmux", "in-process")
OS_THEME_OPTIONS = ("dark", "light")
PERMISSION_DEFAULT_MODE_OPTIONS = ("default", "plan", "acceptEdits", "dontAsk")
DESCRIPTION = "Get or set OpenSpace configuration settings."
@dataclass(frozen=True, slots=True)
class SettingConfig:
source: SettingSource
group: SettingGroup
type: SettingType
description: str
path: tuple[str, ...] | None = None
options: tuple[str, ...] | None = None
get_options: Callable[[], Sequence[str]] | None = None
app_state_key: str | None = None
validate_on_write: Callable[[Any], tuple[bool, str | None]] | None = None
format_on_read: Callable[[Any], Any] | None = None
default: Any = _NO_DEFAULT
def _validate_non_empty_string(value: Any, setting: str) -> tuple[bool, str | None]:
if not isinstance(value, str) or not value.strip():
return False, f"{setting} requires a non-empty string."
return True, None
def _validate_model(value: Any) -> tuple[bool, str | None]:
return _validate_non_empty_string(value, "model")
SUPPORTED_SETTINGS: dict[str, SettingConfig] = {
"theme": SettingConfig(
source="global",
group="stable_ui",
type="string",
description="Color theme for the UI",
options=OS_THEME_OPTIONS,
default="dark",
),
"editorMode": SettingConfig(
source="global",
group="stable_ui",
type="string",
description="Key binding mode",
options=EDITOR_MODES,
default="normal",
),
"verbose": SettingConfig(
source="global",
group="stable_ui",
type="boolean",
description="Show detailed debug output",
app_state_key="verbose",
default=False,
),
"preferredNotifChannel": SettingConfig(
source="global",
group="stable_ui",
type="string",
description="Preferred notification channel",
options=NOTIFICATION_CHANNELS,
default="auto",
),
"autoCompactEnabled": SettingConfig(
source="global",
group="stable_engine",
type="boolean",
description="Auto-compact when context is full",
default=True,
),
"autoMemoryEnabled": SettingConfig(
source="settings",
group="stable_engine",
type="boolean",
description="Enable auto-memory",
),
"autoDream.enabled": SettingConfig(
source="settings",
group="stable_engine",
type="boolean",
description="Enable background memory consolidation",
),
"fileCheckpointingEnabled": SettingConfig(
source="global",
group="stable_engine",
type="boolean",
description="Enable file checkpointing for code rewind",
default=True,
),
"showTurnDuration": SettingConfig(
source="global",
group="stable_ui",
type="boolean",
description="Show turn duration message after responses",
default=True,
),
"terminalProgressBarEnabled": SettingConfig(
source="global",
group="stable_ui",
type="boolean",
description="Show OSC 9;4 progress indicator in supported terminals",
default=True,
),
"todoFeatureEnabled": SettingConfig(
source="global",
group="stable_engine",
type="boolean",
description="Enable todo/task tracking",
default=True,
),
"model": SettingConfig(
source="settings",
group="stable_engine",
type="string",
description="Override the default model",
app_state_key="mainLoopModel",
validate_on_write=_validate_model,
format_on_read=lambda value: "default" if value is None else value,
),
"alwaysThinkingEnabled": SettingConfig(
source="settings",
group="stable_engine",
type="boolean",
description="Enable extended thinking",
app_state_key="thinkingEnabled",
),
"permissions.defaultMode": SettingConfig(
source="settings",
group="stable_engine",
type="string",
description="Default permission mode for tool usage",
options=PERMISSION_DEFAULT_MODE_OPTIONS,
),
"sandbox.enabled": SettingConfig(
source="settings",
group="stable_engine",
type="boolean",
description="Enable the process sandbox for Bash commands",
),
"sandbox.autoAllowBashIfSandboxed": SettingConfig(
source="settings",
group="stable_engine",
type="boolean",
description="Auto-allow Bash when the process sandbox is active",
),
"sandbox.allowUnsandboxedCommands": SettingConfig(
source="settings",
group="stable_engine",
type="boolean",
description="Allow explicit unsandboxed Bash bypasses",
),
"sandbox.failIfUnavailable": SettingConfig(
source="settings",
group="stable_engine",
type="boolean",
description="Fail commands when sandbox dependencies are unavailable",
),
"language": SettingConfig(
source="settings",
group="stable_engine",
type="string",
description="Preferred language for OpenSpace responses and voice dictation",
),
"teammateMode": SettingConfig(
source="global",
group="experimental",
type="string",
description="How to spawn teammates",
options=TEAMMATE_MODES,
),
"attachments.todoReminderEnabled": SettingConfig(
source="settings",
group="experimental",
type="boolean",
description="Enable the TodoWrite reminder attachment",
default=True,
),
}
if os.environ.get("USER_TYPE") == "ant":
SUPPORTED_SETTINGS["classifierPermissionsEnabled"] = SettingConfig(
source="settings",
group="experimental",
type="boolean",
description="Enable AI-based classification for Bash permission rules",
)
def is_supported(key: str) -> bool:
return key in SUPPORTED_SETTINGS
def get_config(key: str) -> SettingConfig | None:
return SUPPORTED_SETTINGS.get(key)
def get_all_keys() -> list[str]:
return list(SUPPORTED_SETTINGS)
def get_grouped_keys() -> dict[str, list[str]]:
groups: dict[str, list[str]] = {
"stable_engine": [],
"stable_ui": [],
"experimental": [],
}
for key, config in SUPPORTED_SETTINGS.items():
groups[config.group].append(key)
return groups
def get_options_for_setting(key: str) -> list[str] | None:
config = get_config(key)
if config is None:
return None
if config.options is not None:
return list(config.options)
if config.get_options is not None:
return [str(item) for item in config.get_options()]
return None
def get_path(key: str) -> list[str]:
config = get_config(key)
if config is not None and config.path is not None:
return list(config.path)
return key.split(".")
def generate_prompt() -> str:
grouped_lines: dict[str, list[str]] = {
"stable_engine": [],
"stable_ui": [],
"experimental": [],
}
for key, config in SUPPORTED_SETTINGS.items():
if key == "model":
continue
options = get_options_for_setting(key)
line = f"- {key}"
if options:
line += ": " + ", ".join(_json_stringify(item) for item in options)
elif config.type == "boolean":
line += ": true/false"
line += f" - {config.description}"
grouped_lines[config.group].append(line)
model_section = (
"## Model\n"
"- model - Override the default model. Use any non-empty OpenSpace/LiteLLM "
'model ID such as "openrouter/anthropic/claude-sonnet-4.5", '
'"openai/gpt-5", or another configured provider model.'
)
return f"""Get or set OpenSpace configuration settings.
View or change OpenSpace settings. Use when the user requests configuration changes, asks about current settings, or when adjusting a setting would benefit them.
## Usage
- **Get current value:** Omit the "value" parameter
- **Set new value:** Include the "value" parameter
## Stable engine settings
These affect model/runtime behavior and are safe to present as ordinary user configuration:
{chr(10).join(grouped_lines["stable_engine"])}
## Stable UI settings
These affect CLI/TUI presentation only:
{chr(10).join(grouped_lines["stable_ui"])}
## Experimental settings
These are implemented but tied to features that are still evolving:
{chr(10).join(grouped_lines["experimental"])}
{model_section}
## Examples
- Get theme: {{ "setting": "theme" }}
- Set dark theme: {{ "setting": "theme", "value": "dark" }}
- Enable vim mode: {{ "setting": "editorMode", "value": "vim" }}
- Enable verbose: {{ "setting": "verbose", "value": true }}
- Enable auto dream: {{ "setting": "autoDream.enabled", "value": true }}
- Change model: {{ "setting": "model", "value": "openrouter/anthropic/claude-sonnet-4.5" }}
- Change permission mode: {{ "setting": "permissions.defaultMode", "value": "plan" }}
"""
def make_input_schema() -> dict[str, Any]:
return {
"type": "object",
"properties": {
"setting": {
"type": "string",
"description": 'The setting key (e.g., "theme", "model", "permissions.defaultMode")',
},
"value": {
"type": ["string", "boolean", "number"],
"description": "The new value. Omit to get current value.",
},
},
"required": ["setting"],
"additionalProperties": False,
}
def make_output_schema() -> dict[str, Any]:
return {
"type": "object",
"properties": {
"success": {"type": "boolean"},
"operation": {"type": "string", "enum": ["get", "set"]},
"setting": {"type": "string"},
"value": {},
"previousValue": {},
"newValue": {},
"error": {"type": "string"},
},
"required": ["success"],
}
class ConfigTool(BaseTool):
_name = CONFIG_TOOL_NAME
_description = DESCRIPTION
backend_type = BackendType.META
aliases = [CONFIG_TOOL_ALIAS]
should_defer = True
search_hint = "get or set OpenSpace settings (model, permissions, UI)"
max_result_size_chars = CONFIG_MAX_RESULT_SIZE_CHARS
def __init__(self) -> None:
self._current_context: Any | None = None
super().__init__(
schema=ToolSchema(
name=self._name,
description=DESCRIPTION,
parameters=make_input_schema(),
return_schema=make_output_schema(),
backend_type=self.backend_type,
)
)
def set_context(self, context: Any) -> None:
self._current_context = context
def is_enabled(self) -> bool:
return not _is_env_truthy(os.environ.get("OPENSPACE_DISABLE_CONFIG_TOOL"))
def get_prompt(self, context: Any = None) -> str:
return generate_prompt()
def user_facing_name(self) -> str:
return "Config"
def is_read_only(self, input: Mapping[str, Any] | None = None) -> bool:
return not isinstance(input, Mapping) or "value" not in input
def is_concurrency_safe(self, input: Mapping[str, Any] | None = None) -> bool:
return True
def to_auto_classifier_input(self, input_data: Mapping[str, Any]) -> str:
setting = str(input_data.get("setting", ""))
if "value" not in input_data:
return setting
return f"{setting} = {input_data.get('value')}"
async def check_permissions(
self,
input: dict[str, Any],
context: Any = None,
) -> PermissionAllow | PermissionAsk:
if "value" not in input:
return PermissionAllow(updated_input=dict(input))
return PermissionAsk(
message=f"Set {input.get('setting')} to {_json_stringify(input.get('value'))}",
updated_input=dict(input),
)
async def _arun(self, setting: str, **kwargs: Any) -> ToolResult:
has_value = "value" in kwargs
value = kwargs.get("value")
data = await _handle_config_call(setting, value, has_value, self._current_context)
is_error = not bool(data.get("success"))
return ToolResult(
status=ToolStatus.ERROR if is_error else ToolStatus.SUCCESS,
content=_format_model_visible_result(data),
error=data.get("error") if is_error else None,
metadata={
"tool": self.name,
"data": data,
},
)
async def _handle_config_call(
setting: str,
value: Any,
has_value: bool,
context: Any | None,
) -> dict[str, Any]:
if not is_supported(setting):
return {"success": False, "error": f'Unknown setting: "{setting}"'}
config = get_config(setting)
assert config is not None
path = get_path(setting)
cwd = _context_cwd(context)
if not has_value:
current_value = get_value(config, path, cwd)
display_value = (
config.format_on_read(current_value)
if config.format_on_read is not None
else current_value
)
return {
"success": True,
"operation": "get",
"setting": setting,
"value": display_value,
}
final_value = value
if config.type == "boolean":
if isinstance(value, str):
lowered = value.lower().strip()
if lowered == "true":
final_value = True
elif lowered == "false":
final_value = False
if not isinstance(final_value, bool):
return {
"success": False,
"operation": "set",
"setting": setting,
"error": f"{setting} requires true or false.",
}
options = get_options_for_setting(setting)
if options is not None and str(final_value) not in options:
return {
"success": False,
"operation": "set",
"setting": setting,
"error": f'Invalid value "{value}". Options: {", ".join(options)}',
}
if config.validate_on_write is not None:
valid, error = config.validate_on_write(final_value)
if not valid:
return {
"success": False,
"operation": "set",
"setting": setting,
"error": error or f"Invalid value for {setting}.",
}
previous_value = get_value(config, path, cwd)
try:
set_value(config, path, final_value, cwd)
await _sync_runtime_setting(context, setting, final_value)
except Exception as exc:
return {
"success": False,
"operation": "set",
"setting": setting,
"error": str(exc),
}
return {
"success": True,
"operation": "set",
"setting": setting,
"previousValue": previous_value,
"newValue": final_value,
}
def get_value(config: SettingConfig, path: Sequence[str], cwd: str) -> Any:
value = get_setting(".".join(path), _MISSING, cwd=cwd)
if value is not _MISSING:
return value
if config.default is not _NO_DEFAULT:
return config.default
return None
def set_value(config: SettingConfig, path: Sequence[str], value: Any, cwd: str) -> None:
del config
update_setting(".".join(path), value, cwd=cwd, source="userSettings")
async def _sync_runtime_setting(context: Any | None, setting: str, value: Any) -> None:
if context is None:
return
llm_client = getattr(context, "llm_client", None)
if setting == "model" and llm_client is not None:
llm_client.model = str(value)
try:
context.model = str(value)
except Exception:
pass
elif setting == "alwaysThinkingEnabled" and llm_client is not None:
llm_client.enable_thinking = bool(value)
emit = getattr(context, "emit_event", None)
if callable(emit):
await emit("config_changed", {"setting": setting, "value": value})
def _format_model_visible_result(data: Mapping[str, Any]) -> str:
if data.get("success"):
if data.get("operation") == "get":
return f"{data.get('setting')} = {_json_stringify(data.get('value'))}"
return f"Set {data.get('setting')} to {_json_stringify(data.get('newValue'))}"
return f"Error: {data.get('error')}"
def _context_cwd(context: Any | None) -> str:
cwd = getattr(context, "cwd", None)
if cwd:
return str(cwd)
return os.getcwd()
_MISSING = object()
def _get_nested(data: Mapping[str, Any], path: Sequence[str]) -> Any:
current: Any = data
for key in path:
if isinstance(current, Mapping) and key in current:
current = current[key]
else:
return _MISSING
return current
def _json_stringify(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def _is_env_truthy(value: str | None) -> bool:
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
__all__ = [
"CONFIG_TOOL_ALIAS",
"CONFIG_MAX_RESULT_SIZE_CHARS",
"CONFIG_TOOL_NAME",
"DESCRIPTION",
"OS_THEME_OPTIONS",
"PERMISSION_DEFAULT_MODE_OPTIONS",
"SUPPORTED_SETTINGS",
"ConfigTool",
"SettingConfig",
"generate_prompt",
"get_all_keys",
"get_config",
"get_effective_settings",
"get_grouped_keys",
"get_options_for_setting",
"get_path",
"get_settings_for_source",
"get_settings_path_for_source",
"get_value",
"is_supported",
"make_input_schema",
"make_output_schema",
"set_value",
]