diff --git a/src/langbot/pkg/box/connector.py b/src/langbot/pkg/box/connector.py index 60408cab..18fd7877 100644 --- a/src/langbot/pkg/box/connector.py +++ b/src/langbot/pkg/box/connector.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import json import os import sys import typing @@ -13,6 +14,7 @@ from langbot_plugin.runtime.io.connection import Connection from langbot_plugin.box.client import ActionRPCBoxClient from langbot_plugin.box.errors import BoxRuntimeUnavailableError +from langbot_plugin.box.actions import LangBotToBoxAction from ..utils import platform from ..utils.managed_runtime import ManagedRuntimeConnector @@ -27,6 +29,10 @@ _DEFAULT_PORT = 5410 _HEARTBEAT_INTERVAL_SEC = 20 +# Top-level keys under ``box`` that are LangBot-internal and should not be +# forwarded to the Box runtime. +_INTERNAL_BOX_CONFIG_KEYS = frozenset({'runtime'}) + def _get_box_config(ap) -> dict: """Return the 'box' section from instance config, with safe fallbacks.""" @@ -35,6 +41,15 @@ def _get_box_config(ap) -> dict: return config_data.get('box', {}) +def _get_runtime_endpoint(box_cfg: dict) -> str: + runtime_cfg = box_cfg.get('runtime') or {} + return str(runtime_cfg.get('endpoint', '')).strip() + + +def _filter_config_for_runtime(box_cfg: dict) -> dict: + return {k: v for k, v in box_cfg.items() if k not in _INTERNAL_BOX_CONFIG_KEYS} + + def resolve_box_ws_relay_url(ap: core_app.Application) -> str: """Derive the WS relay base URL used for managed-process attach. @@ -43,10 +58,19 @@ def resolve_box_ws_relay_url(ap: core_app.Application) -> str: """ box_cfg = _get_box_config(ap) - # Explicit relay URL takes precedence. - runtime_url = str(box_cfg.get('runtime_url', '')).strip() - if runtime_url: - return runtime_url + # Explicit runtime endpoint takes precedence. The config value is a base + # URL; endpoint-specific paths are appended by the SDK client. + endpoint = _get_runtime_endpoint(box_cfg) + if endpoint: + parsed = urlparse(endpoint) + scheme = parsed.scheme or 'ws' + if scheme == 'ws': + scheme = 'http' + elif scheme == 'wss': + scheme = 'https' + host = parsed.hostname or '127.0.0.1' + port = parsed.port or _DEFAULT_PORT + return f'{scheme}://{host}:{port}' # In Docker, relay lives on the box runtime container. if platform.get_platform() == 'docker': @@ -59,7 +83,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): """Connect to the Box runtime via action RPC. Transport decision (mirrors Plugin runtime logic): - 1. Docker / --standalone-box / explicit runtime_url -> WebSocket to external Box process + 1. Docker / --standalone-box / explicit runtime.endpoint -> WebSocket to external Box process 2. Windows (non-Docker) -> subprocess + WebSocket (Windows lacks async stdio pipe) 3. Unix / macOS -> subprocess + stdio pipe """ @@ -74,7 +98,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): ): super().__init__(ap) self.runtime_disconnect_callback = runtime_disconnect_callback - self.configured_runtime_url = self._load_configured_runtime_url() + self.configured_runtime_endpoint = self._load_configured_runtime_endpoint() self.ws_relay_base_url = resolve_box_ws_relay_url(ap) self.client = ActionRPCBoxClient(logger=ap.logger) @@ -87,6 +111,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): parsed = urlparse(self.ws_relay_base_url) self._relay_host = parsed.hostname or '127.0.0.1' self._relay_port = parsed.port or _DEFAULT_PORT + self._filtered_box_config = _filter_config_for_runtime(_get_box_config(ap)) def _uses_websocket(self) -> bool: """Whether the connector should use WebSocket to reach the Box runtime. @@ -94,17 +119,17 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): True when: - Running inside Docker (Box runtime is a separate container) - The ``--standalone-box`` CLI flag was passed - - An explicit ``runtime_url`` was configured + - An explicit ``runtime.endpoint`` was configured """ return bool( - self.configured_runtime_url + self.configured_runtime_endpoint or platform.get_platform() == 'docker' or platform.use_websocket_to_connect_box_runtime() ) async def initialize(self) -> None: if self._uses_websocket(): - if platform.get_platform() == 'win32' and not self.configured_runtime_url: + if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint: await self._start_subprocess_then_ws() else: await self._connect_remote_ws() @@ -141,6 +166,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): self.ap.logger.info('Use stdio to connect to box runtime') python_path = sys.executable env = os.environ.copy() + if self._filtered_box_config: + env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config) connected = asyncio.Event() connect_error: list[Exception] = [] @@ -168,12 +195,20 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): """Launch box server as detached subprocess, then connect via WS (Windows).""" self.ap.logger.info('(windows) Use cmd to launch box runtime and communicate via ws') - await self._start_runtime_subprocess( + env = os.environ.copy() + if self._filtered_box_config: + env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config) + + python_path = sys.executable + self.runtime_subprocess = await asyncio.create_subprocess_exec( + python_path, '-m', 'langbot_plugin.box.server', '--ws-control-port', str(self._relay_port), + env=env, ) + self.runtime_subprocess_task = asyncio.create_task(self.runtime_subprocess.wait()) ws_url = f'ws://localhost:{self._relay_port}/rpc/ws' await self._connect_ws(ws_url, '(windows) WebSocket') @@ -191,8 +226,15 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): All endpoints share a single port; action RPC is at ``/rpc/ws``. """ - if self.configured_runtime_url: - return self.configured_runtime_url + if self.configured_runtime_endpoint: + base = self.configured_runtime_endpoint.rstrip('/') + parsed = urlparse(base) + scheme = parsed.scheme or 'ws' + if scheme in ('http', 'https'): + scheme = 'wss' if scheme == 'https' else 'ws' + host = parsed.hostname or '127.0.0.1' + port = parsed.port or _DEFAULT_PORT + return f'{scheme}://{host}:{port}/rpc/ws' if platform.get_platform() == 'docker': return f'ws://{_DOCKER_BOX_HOST}:{_DEFAULT_PORT}/rpc/ws' @@ -242,6 +284,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): self._handler_task = asyncio.create_task(handler.run()) try: await handler.call_action(CommonAction.PING, {}) + if self._filtered_box_config: + await handler.call_action(LangBotToBoxAction.INIT, self._filtered_box_config) + self.ap.logger.debug('Sent box configuration to Box runtime via INIT.') self.ap.logger.info(f'Connected to Box runtime via {transport_name}.') connected.set() await self._handler_task @@ -292,5 +337,5 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): # -- config helpers ------------------------------------------------------ - def _load_configured_runtime_url(self) -> str: - return str(_get_box_config(self.ap).get('runtime_url', '')).strip() + def _load_configured_runtime_endpoint(self) -> str: + return _get_runtime_endpoint(_get_box_config(self.ap)) diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index 54cbb82e..86cc0e10 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -28,11 +28,6 @@ _MAX_RECENT_ERRORS = 50 _MIB = 1024 * 1024 -def _is_path_under(path: str, root: str) -> bool: - """Check whether *path* equals *root* or is a child of *root*.""" - return path == root or path.startswith(f'{root}{os.sep}') - - def _is_path_under(path: str, root: str) -> bool: """Check whether *path* equals *root* or is a child of *root*.""" return path == root or path.startswith(f'{root}{os.sep}') @@ -57,9 +52,9 @@ class BoxService: client = self._runtime_connector.client self.client = client self.output_limit_chars = output_limit_chars - self.shared_host_root = self._load_shared_host_root() - self.allowed_host_mount_roots = self._load_allowed_host_mount_roots() - self.default_host_workspace = self._load_default_host_workspace() + self.host_root = self._load_host_root() + self.allowed_mount_roots = self._load_allowed_mount_roots() + self.default_workspace = self._load_default_workspace() self.profile = self._load_profile() self.custom_image = self._load_custom_image() self.workspace_quota_mb = self._load_workspace_quota_mb() @@ -70,7 +65,7 @@ class BoxService: self._reconnecting = False async def initialize(self): - self._ensure_default_host_workspace() + self._ensure_default_workspace() try: if self._runtime_connector is not None: await self._runtime_connector.initialize() @@ -80,7 +75,7 @@ class BoxService: self._connector_error = '' self.ap.logger.info( f'LangBot Box runtime initialized: profile={self.profile.name} ' - f'default_workspace={self.default_host_workspace or "(none)"}' + f'default_workspace={self.default_workspace or "(none)"}' ) except Exception as exc: self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}') @@ -134,7 +129,7 @@ class BoxService: skip_host_mount_validation: bool = False, ) -> dict: if not self._available: - raise BoxError('Box runtime is not available. Install and start Podman or Docker to use sandbox features.') + raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.') try: spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation) except BoxError as exc: @@ -251,8 +246,8 @@ class BoxService: def build_spec(self, spec_payload: dict, skip_host_mount_validation: bool = False) -> BoxSpec: spec_payload = dict(spec_payload) spec_payload.setdefault('env', {}) - if spec_payload.get('host_path') in (None, '') and self.default_host_workspace is not None: - spec_payload['host_path'] = self.default_host_workspace + if spec_payload.get('host_path') in (None, '') and self.default_workspace is not None: + spec_payload['host_path'] = self.default_workspace if spec_payload.get('workspace_quota_mb') in (None, '') and self.workspace_quota_mb is not None: spec_payload['workspace_quota_mb'] = self.workspace_quota_mb @@ -280,10 +275,10 @@ class BoxService: process_spec = BoxManagedProcessSpec.model_validate(process_payload) return await self.client.start_managed_process(session_id, process_spec) - async def get_managed_process(self, session_id: str) -> BoxManagedProcessInfo: - return await self.client.get_managed_process(session_id) + async def get_managed_process(self, session_id: str, process_id: str = 'default') -> BoxManagedProcessInfo: + return await self.client.get_managed_process(session_id, process_id) - def get_managed_process_websocket_url(self, session_id: str) -> str: + def get_managed_process_websocket_url(self, session_id: str, process_id: str = 'default') -> str: getter = getattr(self.client, 'get_managed_process_websocket_url', None) if getter is None: raise BoxValidationError('box runtime client does not support managed process websocket attach') @@ -292,7 +287,7 @@ class BoxService: if self._runtime_connector is not None else 'http://127.0.0.1:5410' ) - return getter(session_id, ws_relay_base_url) + return getter(session_id, ws_relay_base_url, process_id) def _serialize_result(self, result: BoxExecutionResult) -> dict: stdout, stdout_truncated = self._truncate(result.stdout) @@ -382,8 +377,11 @@ class BoxService: 'stderr_preview': stderr_preview, } - def _load_allowed_host_mount_roots(self) -> list[str]: - configured_roots = _get_box_config(self.ap).get('allowed_host_mount_roots', []) + def _local_config(self) -> dict: + return _get_box_config(self.ap).get('local') or {} + + def _load_allowed_mount_roots(self) -> list[str]: + configured_roots = self._local_config().get('allowed_mount_roots', []) normalized_roots: list[str] = [] for root in configured_roots: @@ -392,31 +390,31 @@ class BoxService: continue normalized_roots.append(os.path.realpath(os.path.abspath(root_value))) - if not normalized_roots and self.shared_host_root is not None: - normalized_roots.append(self.shared_host_root) + if not normalized_roots and self.host_root is not None: + normalized_roots.append(self.host_root) return normalized_roots - def _load_shared_host_root(self) -> str | None: - shared_host_root = str(_get_box_config(self.ap).get('shared_host_root', '')).strip() - if not shared_host_root: + def _load_host_root(self) -> str | None: + host_root = str(self._local_config().get('host_root', '')).strip() + if not host_root: return None - return os.path.realpath(os.path.abspath(shared_host_root)) + return os.path.realpath(os.path.abspath(host_root)) - def _load_default_host_workspace(self) -> str | None: - default_host_workspace = str(_get_box_config(self.ap).get('default_host_workspace', '')).strip() - if not default_host_workspace: - if self.shared_host_root is None: + def _load_default_workspace(self) -> str | None: + default_workspace = str(self._local_config().get('default_workspace', '')).strip() + if not default_workspace: + if self.host_root is None: return None - default_host_workspace = os.path.join(self.shared_host_root, 'default') - return os.path.realpath(os.path.abspath(default_host_workspace)) + default_workspace = os.path.join(self.host_root, 'default') + return os.path.realpath(os.path.abspath(default_workspace)) def _load_custom_image(self) -> str | None: - raw = str(_get_box_config(self.ap).get('image', '') or '').strip() + raw = str(self._local_config().get('image', '') or '').strip() return raw or None def _load_workspace_quota_mb(self) -> int | None: - raw_value = _get_box_config(self.ap).get('workspace_quota_mb') + raw_value = self._local_config().get('workspace_quota_mb') if raw_value in (None, ''): return None try: @@ -427,28 +425,28 @@ class BoxService: raise BoxValidationError('workspace_quota_mb must be greater than or equal to 0') return value - def _ensure_default_host_workspace(self): - if self.default_host_workspace is None: + def _ensure_default_workspace(self): + if self.default_workspace is None: return - if os.path.isdir(self.default_host_workspace): + if os.path.isdir(self.default_workspace): return - if os.path.exists(self.default_host_workspace): - raise BoxValidationError('default_host_workspace must point to a directory on the host') + if os.path.exists(self.default_workspace): + raise BoxValidationError('box.local.default_workspace must point to a directory on the host') - if not self.allowed_host_mount_roots: + if not self.allowed_mount_roots: raise BoxValidationError( - 'default_host_workspace cannot be created because no allowed_host_mount_roots are configured' + 'box.local.default_workspace cannot be created because no allowed_mount_roots are configured' ) - for allowed_root in self.allowed_host_mount_roots: - if _is_path_under(self.default_host_workspace, allowed_root): - os.makedirs(self.default_host_workspace, exist_ok=True) + for allowed_root in self.allowed_mount_roots: + if _is_path_under(self.default_workspace, allowed_root): + os.makedirs(self.default_workspace, exist_ok=True) return - allowed_roots = ', '.join(self.allowed_host_mount_roots) - raise BoxValidationError(f'default_host_workspace is outside allowed_host_mount_roots: {allowed_roots}') + allowed_roots = ', '.join(self.allowed_mount_roots) + raise BoxValidationError(f'box.local.default_workspace is outside allowed_mount_roots: {allowed_roots}') def _validate_host_mount(self, spec: BoxSpec): if spec.host_path is None: @@ -458,20 +456,18 @@ class BoxService: if not os.path.isdir(host_path): raise BoxValidationError('host_path must point to an existing directory on the host') - if not self.allowed_host_mount_roots: - raise BoxValidationError( - 'host_path mounting is disabled because no allowed_host_mount_roots are configured' - ) + if not self.allowed_mount_roots: + raise BoxValidationError('host_path mounting is disabled because no allowed_mount_roots are configured') - for allowed_root in self.allowed_host_mount_roots: + for allowed_root in self.allowed_mount_roots: if _is_path_under(host_path, allowed_root): return - allowed_roots = ', '.join(self.allowed_host_mount_roots) - raise BoxValidationError(f'host_path is outside allowed_host_mount_roots: {allowed_roots}') + allowed_roots = ', '.join(self.allowed_mount_roots) + raise BoxValidationError(f'host_path is outside allowed_mount_roots: {allowed_roots}') def _load_profile(self) -> BoxProfile: - profile_name = str(_get_box_config(self.ap).get('profile', 'default')).strip() or 'default' + profile_name = str(self._local_config().get('profile', 'default')).strip() or 'default' profile = BUILTIN_PROFILES.get(profile_name) if profile is None: @@ -592,7 +588,7 @@ class BoxService: 'and then answer from the tool result. Unless the user explicitly asks for the script, code, or implementation ' 'details, do not include the generated script in the final answer; return the result and a brief explanation only.' ) - if self.default_host_workspace: + if self.default_workspace: guidance += ( ' A default workspace is mounted at /workspace for file tasks. When the user asks to read, create, or ' 'modify local files in the working directory, use exec with /workspace paths directly; do not ask the ' diff --git a/src/langbot/pkg/box/workspace.py b/src/langbot/pkg/box/workspace.py index cd7feed1..38c52662 100644 --- a/src/langbot/pkg/box/workspace.py +++ b/src/langbot/pkg/box/workspace.py @@ -1,4 +1,3 @@ -from __future__ import annotations """Reusable workspace/session helpers built on top of Box. This module is the middle layer between the raw Box runtime primitives and @@ -14,6 +13,8 @@ Higher layers add their own semantics on top, for example: - MCP stdio chooses how to prepare dependencies and attaches to a managed process """ +from __future__ import annotations + import os import textwrap from typing import Any @@ -42,9 +43,10 @@ def rewrite_mounted_path(path: str, host_path: str | None, *, mount_path: str = if not host_path or not path: return path normalized_host = os.path.realpath(host_path) - if path.startswith(normalized_host + '/'): - return mount_path + path[len(normalized_host) :] - if path == normalized_host: + normalized_path = os.path.realpath(path) + if normalized_path.startswith(normalized_host + '/'): + return mount_path + normalized_path[len(normalized_host) :] + if normalized_path == normalized_host: return mount_path return path @@ -86,22 +88,21 @@ def rewrite_venv_command(command: str, host_path: str | None, *, mount_path: str if not host_path or not command: return command normalized_host = os.path.realpath(host_path) - if not command.startswith(normalized_host + '/'): + normalized_command = os.path.realpath(command) + if not normalized_command.startswith(normalized_host + '/'): return command - rel = command[len(normalized_host) + 1 :] + rel = normalized_command[len(normalized_host) + 1 :] parts = rel.replace('\\', '/').split('/') if len(parts) >= 3 and parts[0] in _VENV_DIRS and parts[1] in _VENV_BIN_DIRS and parts[2].startswith('python'): return 'python' - return rewrite_mounted_path(command, host_path, mount_path=mount_path) + return rewrite_mounted_path(normalized_command, host_path, mount_path=mount_path) def list_python_manifest_files(host_path: str | None) -> list[str]: normalized_root = normalize_host_path(host_path) if not normalized_root: return [] - return [ - filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename)) - ] + return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))] def classify_python_workspace(host_path: str | None) -> str | None: @@ -269,6 +270,7 @@ class BoxWorkspaceSession: cpus: float | None = None, memory_mb: int | None = None, pids_limit: int | None = None, + persistent: bool = False, ): self.box_service = box_service self.session_id = session_id @@ -283,6 +285,7 @@ class BoxWorkspaceSession: self.cpus = cpus self.memory_mb = memory_mb self.pids_limit = pids_limit + self.persistent = persistent def rewrite_path(self, path: str) -> str: return rewrite_mounted_path(path, self.host_path, mount_path=self.mount_path) @@ -297,6 +300,7 @@ class BoxWorkspaceSession: 'session_id': self.session_id, 'workdir': self.workdir, 'env': self.env, + 'persistent': self.persistent, } if self.network is not None: payload['network'] = self.network @@ -388,17 +392,19 @@ class BoxWorkspaceSession: command: str, args: list[str] | None = None, *, + process_id: str = 'default', env: dict[str, str] | None = None, cwd: str = '/workspace', ): payload = self.build_process_payload(command, args, env=env, cwd=cwd) + payload['process_id'] = process_id return await self.box_service.start_managed_process(self.session_id, payload) - async def get_managed_process(self): - return await self.box_service.get_managed_process(self.session_id) + async def get_managed_process(self, process_id: str = 'default'): + return await self.box_service.get_managed_process(self.session_id, process_id) - def get_managed_process_websocket_url(self) -> str: - return self.box_service.get_managed_process_websocket_url(self.session_id) + def get_managed_process_websocket_url(self, process_id: str = 'default') -> str: + return self.box_service.get_managed_process_websocket_url(self.session_id, process_id) async def cleanup(self) -> None: await self.box_service.client.delete_session(self.session_id) diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index cc3b4aef..658ad06b 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -20,7 +20,7 @@ from ....core import app import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.provider.message as provider_message from ....entity.persistence import mcp as persistence_mcp -from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase +from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig as MCPServerBoxConfig, MCPSessionErrorPhase class MCPSessionStatus(enum.Enum): @@ -349,7 +349,7 @@ class RuntimeMCPSession: return self._box_stdio_runtime.uses_box_stdio() def _build_box_session_id(self) -> str: - return f'mcp-{self.server_uuid}' + return 'mcp-shared' def _rewrite_path(self, path: str, host_path: str | None) -> str: return self._box_stdio_runtime.rewrite_path(path, host_path) diff --git a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py index d1f6f2ab..47a9c30b 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py @@ -81,8 +81,14 @@ class BoxStdioSessionRuntime: cpus=self.config.cpus, memory_mb=self.config.memory_mb, pids_limit=self.config.pids_limit, + persistent=True, ) + @property + def process_id(self) -> str: + """Each MCP server gets a unique process_id within the shared session.""" + return self.owner.server_uuid + def uses_box_stdio(self) -> bool: if self.server_config.get('mode') != 'stdio': return False @@ -104,7 +110,9 @@ class BoxStdioSessionRuntime: if host_path: install_cmd = self.owner._detect_install_command(host_path) if install_cmd: - self.ap.logger.info(f'MCP server {self.server_name}: installing dependencies in Box with: {install_cmd}') + self.ap.logger.info( + f'MCP server {self.server_name}: installing dependencies in Box with: {install_cmd}' + ) try: result = await workspace.execute_raw( install_cmd, @@ -122,6 +130,7 @@ class BoxStdioSessionRuntime: await workspace.start_managed_process( self.server_config['command'], self.server_config.get('args', []), + process_id=self.process_id, env=self.server_config.get('env', {}), ) except Exception: @@ -129,10 +138,12 @@ class BoxStdioSessionRuntime: raise try: - websocket_url = workspace.get_managed_process_websocket_url() + websocket_url = workspace.get_managed_process_websocket_url(self.process_id) transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url)) read_stream, write_stream = transport - self.owner.session = await self.owner.exit_stack.enter_async_context(ClientSession(read_stream, write_stream)) + self.owner.session = await self.owner.exit_stack.enter_async_context( + ClientSession(read_stream, write_stream) + ) except Exception: self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT raise @@ -150,7 +161,7 @@ class BoxStdioSessionRuntime: consecutive_errors = 0 while not self.owner._shutdown_event.is_set(): try: - info = await workspace.get_managed_process() + info = await workspace.get_managed_process(self.process_id) if isinstance(info, dict): status = info.get('status', '') else: @@ -173,10 +184,13 @@ class BoxStdioSessionRuntime: if not self.uses_box_stdio(): return - try: - await self._build_workspace().cleanup() - except Exception as exc: - self.ap.logger.warning(f'Failed to cleanup Box session for MCP server {self.server_name}: {exc}') + # In the shared-session model, we do not delete the session itself. + # The managed process exits independently; deleting the session would + # kill other MCP servers sharing the same container. + self.ap.logger.info( + f'MCP server {self.server_name}: process_id={self.process_id} cleanup complete ' + f'(shared session {self.owner._build_box_session_id()} kept alive)' + ) def rewrite_path(self, path: str, host_path: str | None) -> str: return rewrite_mounted_path(path, host_path) diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py index 6ba7640e..2c53c0bf 100644 --- a/src/langbot/pkg/provider/tools/loaders/native.py +++ b/src/langbot/pkg/provider/tools/loaders/native.py @@ -121,9 +121,7 @@ class NativeToolLoader(loader.ToolLoader): ) box_service = self.ap.box_service - host_root = ( - selected_skill.get('package_root') if selected_skill is not None else box_service.default_host_workspace - ) + host_root = selected_skill.get('package_root') if selected_skill is not None else box_service.default_workspace if not host_root: raise ValueError('No host workspace configured for file operations.') diff --git a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py index 2c54454a..9866a095 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py +++ b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py @@ -183,9 +183,9 @@ class SkillAuthoringToolLoader(loader.ToolLoader): def _resolve_workspace_directory(self, sandbox_path: str) -> str: box_service = getattr(self.ap, 'box_service', None) - workspace_root = getattr(box_service, 'default_host_workspace', None) + workspace_root = getattr(box_service, 'default_workspace', None) if not workspace_root: - raise ValueError('No default host workspace configured for importing skills') + raise ValueError('No default workspace configured for importing skills') normalized_path = str(sandbox_path).strip() or '/workspace' if not normalized_path.startswith('/workspace'): diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 4363dd2f..aca9cd95 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -105,14 +105,22 @@ monitoring: # Number of expired rows to delete per table batch delete_batch_size: 1000 box: - profile: 'default' - image: '' # Custom sandbox container image. Leave empty to use the profile default (python:3.11-slim). - runtime_url: '' # Action-RPC WebSocket URL of an external Box Runtime. Leave empty for auto-detection (stdio locally, Docker service in containers). - shared_host_root: './data/box' # For Docker deployment, use '/workspaces' - default_host_workspace: '' # Defaults to '/default' - allowed_host_mount_roots: # Defaults to [''] when left empty - - './data/box' - - '/tmp' + backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. BOX_BACKEND env var takes precedence. + runtime: + endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime. + local: + profile: 'default' + image: '' # Custom local sandbox image. Leave empty to use the profile default. + host_root: './data/box' # Base host directory for local workspace mounts. For Docker deployment, use '/workspaces'. + default_workspace: '' # Defaults to '/default'. + allowed_mount_roots: # Defaults to [''] when left empty. + - './data/box' + - '/tmp' + workspace_quota_mb: null # Optional disk quota override (>= 0). null = profile default. + e2b: + api_key: '' # Can also be set via E2B_API_KEY env var. + api_url: '' # Custom API URL for self-hosted deployments. + template: '' # Default template ID (e.g. 'base', 'python-3.11'). space: # Space service URL for OAuth and API url: 'https://space.langbot.app' diff --git a/src/langbot/templates/default-pipeline-config.json b/src/langbot/templates/default-pipeline-config.json index 8e06bce5..78e2ec95 100644 --- a/src/langbot/templates/default-pipeline-config.json +++ b/src/langbot/templates/default-pipeline-config.json @@ -54,6 +54,7 @@ } ], "knowledge-bases": [], + "box-session-id-template": "{launcher_type}_{launcher_id}", "rerank-model": "", "rerank-top-k": 5 }, diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index fd68fb47..ce25d5f1 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -124,6 +124,83 @@ stages: field: __system.is_wizard operator: neq value: true + - name: box-session-id-template + label: + en_US: Sandbox Scope + zh_Hans: 沙箱作用域 + zh_Hant: 沙箱作用域 + ja_JP: サンドボックススコープ + vi_VN: Phạm vi Sandbox + th_TH: ขอบเขต Sandbox + es_ES: Alcance del Sandbox + ru_RU: Область песочницы + description: + en_US: Determines how sandbox environments are shared across messages. + zh_Hans: 决定沙箱环境在不同消息间的共享方式。 + zh_Hant: 決定沙箱環境在不同訊息間的共享方式。 + ja_JP: メッセージ間でサンドボックス環境を共有する方法を決定します。 + vi_VN: Xác định cách chia sẻ môi trường sandbox giữa các tin nhắn. + th_TH: กำหนดวิธีแชร์สภาพแวดล้อม Sandbox ระหว่างข้อความ + es_ES: Determina cómo se comparten los entornos sandbox entre mensajes. + ru_RU: Определяет, как песочницы используются совместно между сообщениями. + type: select + required: false + default: "{launcher_type}_{launcher_id}" + options: + - name: "{global}" + label: + en_US: Global (shared by all) + zh_Hans: 全局(所有人共享) + zh_Hant: 全域(所有人共用) + ja_JP: グローバル(全員共有) + vi_VN: Toàn cục (chia sẻ cho tất cả) + th_TH: ทั่วไป (แชร์ทั้งหมด) + es_ES: Global (compartido por todos) + ru_RU: Глобальный (общий для всех) + - name: "{launcher_type}_{launcher_id}" + label: + en_US: Per chat (Recommended) + zh_Hans: 每个会话(推荐) + zh_Hant: 每個會話(推薦) + ja_JP: チャットごと(推奨) + vi_VN: Mỗi cuộc trò chuyện (Khuyến nghị) + th_TH: ต่อแชท (แนะนำ) + es_ES: Por chat (Recomendado) + ru_RU: По чату (Рекомендуется) + - name: "{launcher_type}_{launcher_id}_{sender_id}" + label: + en_US: Per user in chat + zh_Hans: 会话中每个用户 + zh_Hant: 會話中每個用戶 + ja_JP: チャット内のユーザーごと + vi_VN: Mỗi người dùng trong cuộc trò chuyện + th_TH: ต่อผู้ใช้ในแชท + es_ES: Por usuario en chat + ru_RU: По пользователю в чате + - name: "{launcher_type}_{launcher_id}_{conversation_id}" + label: + en_US: Per conversation context + zh_Hans: 每个对话上下文 + zh_Hant: 每個對話上下文 + ja_JP: 会話コンテキストごと + vi_VN: Mỗi ngữ cảnh hội thoại + th_TH: ต่อบริบทการสนทนา + es_ES: Por contexto de conversación + ru_RU: По контексту разговора + - name: "{query_id}" + label: + en_US: Per message (isolated) + zh_Hans: 每条消息(完全隔离) + zh_Hant: 每條訊息(完全隔離) + ja_JP: メッセージごと(隔離) + vi_VN: Mỗi tin nhắn (cách ly) + th_TH: ต่อข้อความ (แยกส่วน) + es_ES: Por mensaje (aislado) + ru_RU: По сообщению (изолированно) + show_if: + field: __system.is_wizard + operator: neq + value: true - name: rerank-model label: en_US: Rerank Model diff --git a/tests/integration_tests/box/test_box_integration.py b/tests/integration_tests/box/test_box_integration.py index 2754293d..33515a65 100644 --- a/tests/integration_tests/box/test_box_integration.py +++ b/tests/integration_tests/box/test_box_integration.py @@ -297,9 +297,14 @@ async def test_full_service_to_remote_runtime(tmp_path): instance_config=SimpleNamespace( data={ 'box': { - 'profile': 'default', - 'allowed_host_mount_roots': [str(tmp_path)], - 'default_host_workspace': str(host_dir), + 'backend': 'local', + 'runtime': {'endpoint': ''}, + 'local': { + 'profile': 'default', + 'allowed_mount_roots': [str(tmp_path)], + 'default_workspace': str(host_dir), + }, + 'e2b': {'api_key': '', 'api_url': '', 'template': ''}, } } ), diff --git a/tests/unit_tests/box/test_box_connector.py b/tests/unit_tests/box/test_box_connector.py index d9cc5c7c..ddd4899b 100644 --- a/tests/unit_tests/box/test_box_connector.py +++ b/tests/unit_tests/box/test_box_connector.py @@ -9,16 +9,20 @@ from langbot_plugin.box.client import ActionRPCBoxClient from langbot.pkg.box.connector import BoxRuntimeConnector -def make_app(logger: Mock, runtime_url: str = ''): +def make_app(logger: Mock, runtime_endpoint: str = ''): return SimpleNamespace( logger=logger, instance_config=SimpleNamespace( data={ 'box': { - 'runtime_url': runtime_url, - 'profile': 'default', - 'allowed_host_mount_roots': [], - 'default_host_workspace': '', + 'backend': 'local', + 'runtime': {'endpoint': runtime_endpoint}, + 'local': { + 'profile': 'default', + 'allowed_mount_roots': [], + 'default_workspace': '', + }, + 'e2b': {'api_key': '', 'api_url': '', 'template': ''}, } } ), @@ -26,7 +30,7 @@ def make_app(logger: Mock, runtime_url: str = ''): def test_box_runtime_connector_stdio_when_no_url(monkeypatch: pytest.MonkeyPatch): - """Without runtime_url, on a non-Docker Unix platform, use stdio.""" + """Without runtime.endpoint, on a non-Docker Unix platform, use stdio.""" monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux') monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) connector = BoxRuntimeConnector(make_app(Mock())) @@ -36,11 +40,11 @@ def test_box_runtime_connector_stdio_when_no_url(monkeypatch: pytest.MonkeyPatch def test_box_runtime_connector_ws_when_url_configured(monkeypatch: pytest.MonkeyPatch): - """With an explicit runtime_url, always use WebSocket.""" + """With an explicit runtime.endpoint, always use WebSocket.""" monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux') monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) logger = Mock() - connector = BoxRuntimeConnector(make_app(logger, runtime_url='http://box-runtime:5410')) + connector = BoxRuntimeConnector(make_app(logger, runtime_endpoint='http://box-runtime:5410')) assert connector._uses_websocket() is True assert isinstance(connector.client, ActionRPCBoxClient) @@ -76,7 +80,7 @@ def test_box_runtime_connector_ws_relay_url_default(monkeypatch: pytest.MonkeyPa def test_box_runtime_connector_ws_relay_url_explicit(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux') monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) - connector = BoxRuntimeConnector(make_app(Mock(), runtime_url='http://box-runtime:5410')) + connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410')) assert connector.ws_relay_base_url == 'http://box-runtime:5410' diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index 38be8301..79b8595f 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -67,12 +67,15 @@ class _InProcessBoxRuntimeClient(BoxRuntimeClient): async def start_managed_process(self, session_id: str, spec: BoxManagedProcessSpec): return await self._runtime.start_managed_process(session_id, spec) - async def get_managed_process(self, session_id: str): - return self._runtime.get_managed_process(session_id) + async def get_managed_process(self, session_id: str, process_id: str = 'default'): + return self._runtime.get_managed_process(session_id, process_id) async def get_session(self, session_id: str): return self._runtime.get_session(session_id) + async def init(self, config: dict) -> None: + self._runtime.init(config) + class FakeBackend(BaseSandboxBackend): def __init__(self, logger: Mock, available: bool = True): @@ -141,19 +144,24 @@ def make_query(query_id: int = 42) -> pipeline_query.Query: def make_app( logger: Mock, - allowed_host_mount_roots: list[str] | None = None, + allowed_mount_roots: list[str] | None = None, profile: str = 'default', - shared_host_root: str = '', + host_root: str = '', workspace_quota_mb: int | None = None, ): box_config = { - 'profile': profile, - 'shared_host_root': shared_host_root, - 'allowed_host_mount_roots': allowed_host_mount_roots or [], - 'default_host_workspace': '', + 'backend': 'local', + 'runtime': {'endpoint': ''}, + 'local': { + 'profile': profile, + 'host_root': host_root, + 'allowed_mount_roots': allowed_mount_roots or [], + 'default_workspace': '', + }, + 'e2b': {'api_key': '', 'api_url': '', 'template': ''}, } if workspace_quota_mb is not None: - box_config['workspace_quota_mb'] = workspace_quota_mb + box_config['local']['workspace_quota_mb'] = workspace_quota_mb return SimpleNamespace( logger=logger, @@ -293,14 +301,14 @@ async def test_box_service_allows_host_mount_under_configured_root(tmp_path): @pytest.mark.asyncio -async def test_box_service_uses_default_host_workspace_when_host_path_omitted(tmp_path): +async def test_box_service_uses_default_workspace_when_host_path_omitted(tmp_path): logger = Mock() backend = FakeBackend(logger) runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) host_dir = tmp_path / 'default-workspace' host_dir.mkdir() app = make_app(logger, [str(tmp_path)]) - app.instance_config.data['box']['default_host_workspace'] = str(host_dir) + app.instance_config.data['box']['local']['default_workspace'] = str(host_dir) service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) await service.initialize() @@ -313,36 +321,36 @@ async def test_box_service_uses_default_host_workspace_when_host_path_omitted(tm @pytest.mark.asyncio -async def test_box_service_creates_default_host_workspace_on_initialize(tmp_path): +async def test_box_service_creates_default_workspace_on_initialize(tmp_path): logger = Mock() backend = FakeBackend(logger) runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) allowed_root = tmp_path / 'allowed-root' allowed_root.mkdir() - default_host_workspace = allowed_root / 'default-workspace' + default_workspace = allowed_root / 'default-workspace' app = make_app(logger, [str(allowed_root)]) - app.instance_config.data['box']['default_host_workspace'] = str(default_host_workspace) + app.instance_config.data['box']['local']['default_workspace'] = str(default_workspace) service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) await service.initialize() - assert default_host_workspace.is_dir() + assert default_workspace.is_dir() @pytest.mark.asyncio -async def test_box_service_derives_workspace_and_allowed_root_from_shared_host_root(tmp_path): +async def test_box_service_derives_workspace_and_allowed_root_from_host_root(tmp_path): logger = Mock() backend = FakeBackend(logger) runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) shared_root = tmp_path / 'shared-box-root' - app = make_app(logger, shared_host_root=str(shared_root)) + app = make_app(logger, host_root=str(shared_root)) service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) await service.initialize() - assert service.shared_host_root == os.path.realpath(shared_root) - assert service.default_host_workspace == os.path.realpath(shared_root / 'default') - assert service.allowed_host_mount_roots == [os.path.realpath(shared_root)] + assert service.host_root == os.path.realpath(shared_root) + assert service.default_workspace == os.path.realpath(shared_root / 'default') + assert service.allowed_mount_roots == [os.path.realpath(shared_root)] assert (shared_root / 'default').is_dir() @@ -557,9 +565,10 @@ async def test_profile_default_provides_defaults(): assert result['ok'] is True spec = backend.start_specs[0] + profile = BUILTIN_PROFILES['default'] assert spec.network == BoxNetworkMode.OFF - assert spec.image == 'python:3.11-slim' - assert spec.timeout_sec == 30 + assert spec.image == profile.image + assert spec.timeout_sec == profile.timeout_sec @pytest.mark.asyncio @@ -698,7 +707,7 @@ async def test_box_service_applies_workspace_quota_from_config(tmp_path): host_dir = tmp_path / 'default-workspace' host_dir.mkdir() app = make_app(logger, [str(tmp_path)], workspace_quota_mb=32) - app.instance_config.data['box']['default_host_workspace'] = str(host_dir) + app.instance_config.data['box']['local']['default_workspace'] = str(host_dir) service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) await service.initialize() @@ -716,7 +725,7 @@ async def test_box_service_rejects_execution_when_workspace_already_exceeds_quot host_dir.mkdir() (host_dir / 'already-too-large.bin').write_bytes(b'x' * (2 * 1024 * 1024)) app = make_app(logger, [str(tmp_path)], workspace_quota_mb=1) - app.instance_config.data['box']['default_host_workspace'] = str(host_dir) + app.instance_config.data['box']['local']['default_workspace'] = str(host_dir) service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) await service.initialize() @@ -735,7 +744,7 @@ async def test_box_service_rejects_and_cleans_up_when_execution_exceeds_workspac host_dir = tmp_path / 'quota-workspace-post' host_dir.mkdir() app = make_app(logger, [str(tmp_path)], workspace_quota_mb=1) - app.instance_config.data['box']['default_host_workspace'] = str(host_dir) + app.instance_config.data['box']['local']['default_workspace'] = str(host_dir) service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) await service.initialize() diff --git a/tests/unit_tests/box/test_workspace.py b/tests/unit_tests/box/test_workspace.py index ea78d7fc..809347e5 100644 --- a/tests/unit_tests/box/test_workspace.py +++ b/tests/unit_tests/box/test_workspace.py @@ -79,6 +79,7 @@ async def test_workspace_session_execute_for_query_uses_session_payload(): 'session_id': 'skill-person_123-demo', 'workdir': '/workspace', 'env': {'FOO': 'bar'}, + 'persistent': False, 'host_path': '/tmp/project', 'host_path_mode': 'rw', 'cmd': 'python run.py', @@ -111,6 +112,7 @@ async def test_workspace_session_start_managed_process_rewrites_command_and_args 'args': ['/workspace/server.py', '--config', '/workspace/config.json'], 'env': {'TOKEN': '1'}, 'cwd': '/workspace', + 'process_id': 'default', } @@ -133,6 +135,7 @@ def test_workspace_session_build_session_payload_keeps_generic_workspace_shape() 'session_id': 'workspace-1', 'workdir': '/workspace', 'env': {'FOO': 'bar'}, + 'persistent': False, 'network': 'on', 'read_only_rootfs': False, 'host_path': '/tmp/project', diff --git a/tests/unit_tests/provider/test_mcp_box_integration.py b/tests/unit_tests/provider/test_mcp_box_integration.py index acb08916..273f0f79 100644 --- a/tests/unit_tests/provider/test_mcp_box_integration.py +++ b/tests/unit_tests/provider/test_mcp_box_integration.py @@ -528,7 +528,7 @@ class TestGetRuntimeInfoDict: ap=ap, ) info = s.get_runtime_info_dict() - assert info['box_session_id'] == 'mcp-test-uuid' + assert info['box_session_id'] == 'mcp-shared' assert info['box_enabled'] is True def test_stdio_session_without_box_runtime(self, mcp_module): diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py index 315c55ed..ee4b1129 100644 --- a/tests/unit_tests/provider/test_skill_tools.py +++ b/tests/unit_tests/provider/test_skill_tools.py @@ -92,12 +92,7 @@ class TestSkillManagerActivation: 'beta': _make_skill_data(name='beta'), } - response = ( - '[ACTIVATE_SKILL: alpha]\n' - '[ACTIVATE_SKILL: beta]\n' - '[ACTIVATE_SKILL: alpha]\n' - 'Let me handle this.' - ) + response = '[ACTIVATE_SKILL: alpha]\n[ACTIVATE_SKILL: beta]\n[ACTIVATE_SKILL: alpha]\nLet me handle this.' assert mgr.detect_skill_activations(response) == ['alpha', 'beta'] assert mgr.detect_skill_activation(response) == 'alpha' @@ -240,7 +235,9 @@ class TestSkillAuthoringToolLoader: ap = _make_ap() ap.skill_service = SimpleNamespace( - create_skill=AsyncMock(return_value=_make_skill_data(name='prompt-skill', package_root='/data/skills/prompt-skill')), + create_skill=AsyncMock( + return_value=_make_skill_data(name='prompt-skill', package_root='/data/skills/prompt-skill') + ), reload_skills=AsyncMock(), list_skills=AsyncMock(return_value=[]), ) @@ -329,7 +326,9 @@ class TestSkillAuthoringToolLoader: ap = _make_ap() ap.skill_service = SimpleNamespace( create_skill=AsyncMock(), - update_skill=AsyncMock(return_value=_make_skill_data(name='time-now', package_root='/data/skills/time-now')), + update_skill=AsyncMock( + return_value=_make_skill_data(name='time-now', package_root='/data/skills/time-now') + ), reload_skills=AsyncMock(), list_skills=AsyncMock(return_value=[]), ) @@ -393,7 +392,7 @@ class TestSkillAuthoringToolLoader: ) ap = _make_ap() - ap.box_service = SimpleNamespace(default_host_workspace='/tmp/langbot-workspace') + ap.box_service = SimpleNamespace(default_workspace='/tmp/langbot-workspace') ap.skill_service = SimpleNamespace( scan_directory=Mock( return_value={ @@ -413,7 +412,7 @@ class TestSkillAuthoringToolLoader: await loader.initialize() with tempfile.TemporaryDirectory() as tmpdir: - ap.box_service.default_host_workspace = tmpdir + ap.box_service.default_workspace = tmpdir repo_dir = os.path.join(tmpdir, 'repos', 'cloned-skill') os.makedirs(repo_dir) @@ -445,7 +444,7 @@ class TestSkillAuthoringToolLoader: ) ap = _make_ap() - ap.box_service = SimpleNamespace(default_host_workspace='/tmp/langbot-workspace') + ap.box_service = SimpleNamespace(default_workspace='/tmp/langbot-workspace') ap.skill_service = SimpleNamespace( scan_directory=Mock(), create_skill=AsyncMock(), @@ -501,7 +500,7 @@ class TestNativeToolLoaderSkillPaths: f.write('demo instructions') ap = _make_ap() - ap.box_service = SimpleNamespace(available=True, default_host_workspace=tmpdir) + ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir) ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)}) loader = NativeToolLoader(ap) @@ -522,7 +521,7 @@ class TestNativeToolLoaderSkillPaths: ap = _make_ap() ap.box_service = SimpleNamespace( available=True, - default_host_workspace=tmpdir, + default_workspace=tmpdir, execute_spec_payload=AsyncMock(return_value={'ok': True}), ) ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock()) @@ -555,7 +554,7 @@ class TestNativeToolLoaderSkillPaths: with tempfile.TemporaryDirectory() as tmpdir: ap = _make_ap() - ap.box_service = SimpleNamespace(available=True, default_host_workspace=tmpdir) + ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir) ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)}) loader = NativeToolLoader(ap) diff --git a/tests/unit_tests/provider/test_tool_manager_native.py b/tests/unit_tests/provider/test_tool_manager_native.py index 27f6f47d..2c6bf503 100644 --- a/tests/unit_tests/provider/test_tool_manager_native.py +++ b/tests/unit_tests/provider/test_tool_manager_native.py @@ -110,7 +110,7 @@ async def test_native_tool_loader_exposes_all_tools_when_box_available(): def _make_loader_with_workspace(tmpdir: str) -> tuple[NativeToolLoader, Mock]: logger = Mock() - box_service = SimpleNamespace(available=True, default_host_workspace=tmpdir) + box_service = SimpleNamespace(available=True, default_workspace=tmpdir) ap = SimpleNamespace(box_service=box_service, logger=logger) return NativeToolLoader(ap), logger diff --git a/web/src/app/home/add-extension/page.tsx b/web/src/app/home/add-extension/page.tsx index 220e81ac..ce4d9ea4 100644 --- a/web/src/app/home/add-extension/page.tsx +++ b/web/src/app/home/add-extension/page.tsx @@ -16,6 +16,7 @@ import { Download, PlusIcon, ChevronLeft, + ChevronRight, Server, Github, BookOpen, @@ -25,26 +26,20 @@ import { XCircle, } from 'lucide-react'; import { Input } from '@/components/ui/input'; -import { - Card, - CardContent, - CardHeader, - CardTitle, - CardDescription, -} from '@/components/ui/card'; import React, { useState, useCallback, useEffect, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; import { httpClient, systemInfo } from '@/app/infra/http/HttpClient'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { PluginV4 } from '@/app/infra/entities/plugin'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; -import { - usePluginInstallTasks, -} from '@/app/home/plugins/components/plugin-install-task'; +import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task'; import MCPForm from '@/app/home/mcp/components/mcp-form/MCPForm'; -import type { MCPFormHandle } from '@/app/home/mcp/components/mcp-form/MCPForm'; +import type { + MCPFormDraft, + MCPFormHandle, +} from '@/app/home/mcp/components/mcp-form/MCPForm'; import SkillForm from '@/app/home/skills/components/skill-form/SkillForm'; +import type { SkillFormDraft } from '@/app/home/skills/components/skill-form/SkillForm'; import { Progress } from '@/components/ui/progress'; import { cn } from '@/lib/utils'; @@ -100,7 +95,6 @@ export default function AddExtensionPage() { function AddExtensionContent() { const { t } = useTranslation(); - const navigate = useNavigate(); const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData(); const { addTask, @@ -128,11 +122,15 @@ function AddExtensionContent() { const fileInputRef = useRef(null); const mcpFormRef = useRef(null); const [mcpTesting, setMcpTesting] = useState(false); + const [mcpDraft, setMcpDraft] = useState(); + const [skillDraft, setSkillDraft] = useState(); // GitHub install state const [githubURL, setGithubURL] = useState(''); const [githubReleases, setGithubReleases] = useState([]); - const [selectedRelease, setSelectedRelease] = useState(null); + const [selectedRelease, setSelectedRelease] = useState( + null, + ); const [githubAssets, setGithubAssets] = useState([]); const [selectedAsset, setSelectedAsset] = useState(null); const [githubOwner, setGithubOwner] = useState(''); @@ -141,12 +139,14 @@ function AddExtensionContent() { const [fetchingAssets, setFetchingAssets] = useState(false); const [githubInstallStatus, setGithubInstallStatus] = useState(GithubInstallStatus.WAIT_INPUT); - const [githubInstallError, setGithubInstallError] = useState(null); + const [githubInstallError, setGithubInstallError] = useState( + null, + ); useEffect(() => { // Clear any stale completed tasks on mount clearCompletedTasks(); - }, []); + }, [clearCompletedTasks]); useEffect(() => { const onComplete = (_taskId: number, success: boolean) => { @@ -161,20 +161,17 @@ function AddExtensionContent() { }; }, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]); - const handleInstallPlugin = useCallback( - async (plugin: PluginV4) => { - setInstallInfo({ - plugin_author: plugin.author, - plugin_name: plugin.name, - plugin_version: plugin.latest_version, - }); - setInstallExtensionType(plugin.type || 'plugin'); - setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM); - setInstallError(null); - setModalOpen(true); - }, - [], - ); + const handleInstallPlugin = useCallback(async (plugin: PluginV4) => { + setInstallInfo({ + plugin_author: plugin.author, + plugin_name: plugin.name, + plugin_version: plugin.latest_version, + }); + setInstallExtensionType(plugin.type || 'plugin'); + setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM); + setInstallError(null); + setModalOpen(true); + }, []); function handleModalConfirm() { setPluginInstallStatus(PluginInstallStatus.INSTALLING); @@ -266,7 +263,7 @@ function AddExtensionContent() { }); } }, - [t, addTask, setSelectedTaskId, refreshPlugins], + [t, addTask, setSelectedTaskId, refreshPlugins, refreshSkills], ); const handleFileSelect = useCallback(() => { @@ -308,13 +305,15 @@ function AddExtensionContent() { [uploadFile], ); - function handleMCPCreated(serverName: string) { + function handleMCPCreated(_serverName: string) { + setMcpDraft(undefined); refreshMCPServers(); setPopoverView('menu'); setPopoverOpen(false); } - function handleSkillCreated(skillName: string) { + function handleSkillCreated(_skillName: string) { + setSkillDraft(undefined); refreshPlugins(); refreshSkills(); setPopoverView('menu'); @@ -467,13 +466,13 @@ function AddExtensionContent() { function getPopoverWidth(): string { switch (popoverView) { case 'mcp': - return 'w-[500px]'; + return 'w-[calc(100vw-2rem)] sm:w-[560px]'; case 'skill': - return 'w-[460px]'; + return 'w-[calc(100vw-2rem)] sm:w-[560px]'; case 'github': - return 'w-[460px]'; + return 'w-[calc(100vw-2rem)] sm:w-[480px]'; default: - return 'w-[360px]'; + return 'w-[calc(100vw-2rem)] sm:w-[380px]'; } } @@ -491,9 +490,6 @@ function AddExtensionContent() { open={popoverOpen} onOpenChange={(open) => { setPopoverOpen(open); - if (!open) { - setPopoverView('menu'); - } }} > @@ -508,12 +504,13 @@ function AddExtensionContent() { {/* ===== Menu View ===== */} {popoverView === 'menu' && ( -
+
{/* File upload area */}
- {/* Divider */} -
-
- -
-
- - {t('addExtension.orContinueWith')} +

+ {t('addExtension.orContinueWith')} +

+ +
+
-
+ + + {t('mcp.addMCPServer')} + + + {t('addExtension.addMCPServerHint')} + + + + - {/* MCP Config button */} - - - {/* Two side-by-side buttons */} -
- - + + -
- - {/* Hints for the two buttons */} -
-

- {t('addExtension.installFromGithubHint')} -

-

- {t('addExtension.createSkillHint')} -

+ + + {t('addExtension.createSkill')} + + + {t('addExtension.createSkillHint')} + + + +
)} {/* ===== MCP Form View ===== */} {popoverView === 'mcp' && ( -
-
+
+
-
+
{}} onNewServerCreated={handleMCPCreated} + onDraftChange={setMcpDraft} onTestingChange={setMcpTesting} />
-
+
-
+
{}} + onDraftChange={setSkillDraft} />
-
+
@@ -685,8 +693,8 @@ function AddExtensionContent() { {/* ===== GitHub Install View ===== */} {popoverView === 'github' && ( -
-
+
+
-
+
{githubInstallStatus === GithubInstallStatus.WAIT_INPUT && (

@@ -743,7 +751,9 @@ function AddExtensionContent() { size="sm" className="h-6 text-xs px-2" onClick={() => { - setGithubInstallStatus(GithubInstallStatus.WAIT_INPUT); + setGithubInstallStatus( + GithubInstallStatus.WAIT_INPUT, + ); setGithubReleases([]); }} > @@ -755,7 +765,7 @@ function AddExtensionContent() { {githubReleases.map((release) => (

handleReleaseSelect(release)} >
@@ -764,7 +774,9 @@ function AddExtensionContent() {
{release.tag_name} •{' '} - {new Date(release.published_at).toLocaleDateString()} + {new Date( + release.published_at, + ).toLocaleDateString()}
{release.prerelease && ( @@ -795,7 +807,9 @@ function AddExtensionContent() { size="sm" className="h-6 text-xs px-2" onClick={() => { - setGithubInstallStatus(GithubInstallStatus.SELECT_RELEASE); + setGithubInstallStatus( + GithubInstallStatus.SELECT_RELEASE, + ); setGithubAssets([]); setSelectedAsset(null); }} @@ -805,7 +819,7 @@ function AddExtensionContent() {
{selectedRelease && ( -
+
{selectedRelease.name || selectedRelease.tag_name} @@ -815,7 +829,7 @@ function AddExtensionContent() { {githubAssets.map((asset) => (
handleAssetSelect(asset)} > {asset.name} @@ -839,7 +853,9 @@ function AddExtensionContent() { size="sm" className="h-6 text-xs px-2" onClick={() => { - setGithubInstallStatus(GithubInstallStatus.SELECT_ASSET); + setGithubInstallStatus( + GithubInstallStatus.SELECT_ASSET, + ); setSelectedAsset(null); }} > @@ -848,10 +864,12 @@ function AddExtensionContent() {
{selectedRelease && selectedAsset && ( -
+
Repository: - {githubOwner}/{githubRepo} + + {githubOwner}/{githubRepo} +
Release: @@ -863,10 +881,7 @@ function AddExtensionContent() {
)} -
diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx index 99f5079d..2051081a 100644 --- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx +++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx @@ -11,13 +11,6 @@ import { Resolver, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { toast } from 'sonner'; -import { - Card, - CardContent, - CardHeader, - CardTitle, - CardDescription, -} from '@/components/ui/card'; import { Form, FormControl, @@ -94,18 +87,16 @@ function StatusDisplay({ // Tools list component function ToolsList({ tools }: { tools: MCPTool[] }) { return ( -
+
{tools.map((tool, index) => ( - - - {tool.name} - {tool.description && ( - - {tool.description} - - )} - - +
+
{tool.name}
+ {tool.description && ( +
+ {tool.description} +
+ )} +
))}
); @@ -164,10 +155,14 @@ type FormValues = z.infer> & { ssereadtimeout: number; }; +export type MCPFormDraft = Partial; + interface MCPFormProps { initServerName?: string; + initialDraft?: MCPFormDraft; onFormSubmit: () => void; onNewServerCreated: (serverName: string) => void; + onDraftChange?: (draft: MCPFormDraft) => void; onDirtyChange?: (dirty: boolean) => void; onTestingChange?: (testing: boolean) => void; } @@ -181,8 +176,10 @@ export interface MCPFormHandle { const MCPForm = forwardRef(function MCPForm( { initServerName, + initialDraft, onFormSubmit, onNewServerCreated, + onDraftChange, onDirtyChange, onTestingChange, }, @@ -191,6 +188,7 @@ const MCPForm = forwardRef(function MCPForm( const { t } = useTranslation(); const formSchema = getFormSchema(t); const isEditMode = !!initServerName; + const initialDraftRef = useRef(initialDraft); const form = useForm({ resolver: zodResolver(formSchema) as unknown as Resolver, @@ -203,6 +201,7 @@ const MCPForm = forwardRef(function MCPForm( timeout: 30, ssereadtimeout: 300, extra_args: [], + ...initialDraftRef.current, }, }); @@ -259,9 +258,10 @@ const MCPForm = forwardRef(function MCPForm( timeout: 30, ssereadtimeout: 300, extra_args: [], + ...initialDraftRef.current, }); - setExtraArgs([]); - setStdioArgs([]); + setExtraArgs(initialDraftRef.current?.extra_args ?? []); + setStdioArgs(initialDraftRef.current?.args ?? []); setRuntimeInfo(null); isInitializing.current = false; } @@ -274,6 +274,20 @@ const MCPForm = forwardRef(function MCPForm( }; }, [initServerName]); + useEffect(() => { + if (!onDraftChange || isEditMode) return; + + const subscription = form.watch((values) => { + onDraftChange({ + ...values, + extra_args: extraArgs, + args: stdioArgs, + } as MCPFormDraft); + }); + + return () => subscription.unsubscribe(); + }, [form, isEditMode, onDraftChange, extraArgs, stdioArgs]); + // Poll for updates when runtime_info status is CONNECTING useEffect(() => { if ( @@ -595,126 +609,136 @@ const MCPForm = forwardRef(function MCPForm(
{/* Runtime info: status + tools (edit mode only) */} {isEditMode && runtimeInfo && ( - - - {t('mcp.title')} - - - {(mcpTesting || - runtimeInfo.status !== MCPSessionStatus.CONNECTED) && ( -
- -
- )} +
+

{t('mcp.title')}

+ {(mcpTesting || + runtimeInfo.status !== MCPSessionStatus.CONNECTED) && ( +
+ +
+ )} - {!mcpTesting && - runtimeInfo.status === MCPSessionStatus.CONNECTED && - runtimeInfo.tools?.length > 0 && ( - <> -
- {t('mcp.toolCount', { - count: runtimeInfo.tools?.length || 0, - })} -
- - - )} - - + {!mcpTesting && + runtimeInfo.status === MCPSessionStatus.CONNECTED && + runtimeInfo.tools?.length > 0 && ( + <> +
+ {t('mcp.toolCount', { + count: runtimeInfo.tools?.length || 0, + })} +
+ + + )} +
)} {/* Server configuration */} - - - - {isEditMode ? t('mcp.editServer') : t('mcp.createServer')} - - - {t('mcp.extraParametersDescription')} - - - - ( - - - {t('mcp.name')} - * - +
+ {isEditMode && ( +

{t('mcp.editServer')}

+ )} + ( + + + {t('mcp.name')} + * + + + + + + + )} + /> + + ( + + {t('mcp.serverMode')} + + + + - - - )} - /> + + {t('mcp.http')} + {t('mcp.stdio')} + {t('mcp.sse')} + + + + + )} + /> - ( - - {t('mcp.serverMode')} - - - {t('mcp.http')} - {t('mcp.stdio')} - {t('mcp.sse')} - - - - - )} - /> + + + )} + /> - {(watchMode === 'sse' || watchMode === 'http') && ( - <> + ( + + {t('mcp.timeout')} + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + + {watchMode === 'sse' && ( ( - - {t('mcp.url')} - * - - - - - - - )} - /> - - ( - - {t('mcp.timeout')} + {t('mcp.sseTimeout')} field.onChange(Number(e.target.value)) @@ -725,133 +749,104 @@ const MCPForm = forwardRef(function MCPForm( )} /> + )} + + )} - {watchMode === 'sse' && ( - ( - - {t('mcp.sseTimeout')} - - - field.onChange(Number(e.target.value)) - } - /> - - - - )} - /> + {watchMode === 'stdio' && ( + <> + ( + + + {t('mcp.command')} + * + + + + + + )} - - )} + /> - {watchMode === 'stdio' && ( - <> - ( - - - {t('mcp.command')} - * - - - - - - - )} - /> + + {t('mcp.args')} +
+ {stdioArgs.map((arg, index) => ( +
+ updateStdioArg(index, e.target.value)} + /> + +
+ ))} + +
+
+ + )} - - {t('mcp.args')} -
- {stdioArgs.map((arg, index) => ( -
- - updateStdioArg(index, e.target.value) - } - /> - -
- ))} - -
-
- - )} - - - + + + {watchMode === 'sse' || watchMode === 'http' + ? t('mcp.headers') + : t('mcp.env')} + +
+ {extraArgs.map((arg, index) => ( +
+ + updateExtraArg(index, 'key', e.target.value) + } + /> + + updateExtraArg(index, 'value', e.target.value) + } + /> + +
+ ))} + -
- ))} - -
- - {t('mcp.extraParametersDescription')} - - - - - + ? t('mcp.addHeader') + : t('mcp.addEnvVar')} + +
+ + {t('mcp.extraParametersDescription')} + + + + ); diff --git a/web/src/app/home/skills/SkillDetailContent.tsx b/web/src/app/home/skills/SkillDetailContent.tsx index cc3ad9d6..3284d967 100644 --- a/web/src/app/home/skills/SkillDetailContent.tsx +++ b/web/src/app/home/skills/SkillDetailContent.tsx @@ -143,7 +143,7 @@ export default function SkillDetailContent({ id }: { id: string }) {
- + {t('common.confirmDelete')} diff --git a/web/src/app/home/skills/components/skill-form/SkillForm.tsx b/web/src/app/home/skills/components/skill-form/SkillForm.tsx index 6710c750..9db5d2c3 100644 --- a/web/src/app/home/skills/components/skill-form/SkillForm.tsx +++ b/web/src/app/home/skills/components/skill-form/SkillForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -12,52 +12,72 @@ import { toast } from 'sonner'; interface SkillFormProps { initSkillName?: string; + initialDraft?: SkillFormDraft; onNewSkillCreated: (skillName: string) => void; onSkillUpdated: (skillName: string) => void; + onDraftChange?: (draft: SkillFormDraft) => void; } -export default function SkillForm({ - initSkillName, - onNewSkillCreated, - onSkillUpdated, -}: SkillFormProps) { - const { t } = useTranslation(); - const [skill, setSkill] = useState>({ +export interface SkillFormDraft { + skill: Partial; + showAdvanced: boolean; +} + +const emptySkillDraft: SkillFormDraft = { + skill: { name: '', display_name: '', description: '', instructions: '', package_root: '', auto_activate: true, - }); + }, + showAdvanced: false, +}; + +export default function SkillForm({ + initSkillName, + initialDraft, + onNewSkillCreated, + onSkillUpdated, + onDraftChange, +}: SkillFormProps) { + const { t } = useTranslation(); + const initialDraftRef = useRef(initialDraft ?? emptySkillDraft); + const [skill, setSkill] = useState>( + initialDraftRef.current.skill, + ); const [scanning, setScanning] = useState(false); - const [showAdvanced, setShowAdvanced] = useState(false); + const [showAdvanced, setShowAdvanced] = useState( + initialDraftRef.current.showAdvanced, + ); + + const loadSkill = useCallback( + async (skillName: string) => { + try { + const resp = await httpClient.getSkill(skillName); + setSkill(resp.skill); + } catch (error) { + console.error('Failed to load skill:', error); + toast.error(t('skills.getSkillListError') + String(error)); + } + }, + [t], + ); useEffect(() => { if (initSkillName) { loadSkill(initSkillName); return; } - setSkill({ - name: '', - display_name: '', - description: '', - instructions: '', - package_root: '', - auto_activate: true, - }); - setShowAdvanced(false); - }, [initSkillName]); + setSkill(initialDraftRef.current.skill); + setShowAdvanced(initialDraftRef.current.showAdvanced); + }, [initSkillName, loadSkill]); - async function loadSkill(skillName: string) { - try { - const resp = await httpClient.getSkill(skillName); - setSkill(resp.skill); - } catch (error) { - console.error('Failed to load skill:', error); - toast.error(t('skills.getSkillListError') + String(error)); - } - } + useEffect(() => { + if (initSkillName) return; + onDraftChange?.({ skill, showAdvanced }); + }, [initSkillName, onDraftChange, skill, showAdvanced]); async function scanDirectory() { const path = skill.package_root?.trim(); @@ -183,10 +203,16 @@ export default function SkillForm({ />
-
- +
+
+ +

+ {t('skills.autoActivateDescription')} +

+
setSkill({ ...skill, auto_activate: checked }) @@ -194,10 +220,10 @@ export default function SkillForm({ />
-
+
{showAdvanced && ( -
+
diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 57bca7b7..2877c07c 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1347,6 +1347,8 @@ const enUS = { skillDescription: 'Skill Description', skillInstructions: 'Instructions', autoActivate: 'Auto Activate', + autoActivateDescription: + 'When enabled, the Agent may match and activate this skill based on its description during conversations.', saveSuccess: 'Saved successfully', saveError: 'Save failed: ', createSuccess: 'Created successfully', @@ -1475,6 +1477,7 @@ const enUS = { uploadExtension: 'Drag & drop or click to upload', uploadHint: 'Supports .zip (skills) and .lbpkg (plugins) files', orContinueWith: 'or choose an action below', + addMCPServerHint: 'Connect an MCP tool server extension', installFromGithub: 'Install Plugin from GitHub', installFromGithubHint: 'Install plugin extension from GitHub Release', createSkill: 'Create New Skill', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 02e3d300..d382d2b5 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1392,6 +1392,7 @@ const jaJP = { uploadExtension: 'ドラッグ&ドロップまたはクリックしてアップロード', uploadHint: '.zip(スキル)と.lbpkg(プラグイン)ファイルに対応', orContinueWith: 'または以下の操作を選択', + addMCPServerHint: 'MCPツールサーバー拡張を接続', installFromGithub: 'GitHubからプラグインをインストール', installFromGithubHint: 'GitHub Releaseからプラグイン拡張をインストール', createSkill: '新しいスキルを作成', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index a0766bef..1c6d4cd9 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1291,6 +1291,8 @@ const zhHans = { skillDescription: '技能描述', skillInstructions: '指令内容', autoActivate: '自动激活', + autoActivateDescription: + '开启后,Agent 会在对话中根据技能描述自动匹配并激活此技能。', saveSuccess: '保存成功', saveError: '保存失败:', createSuccess: '创建成功', @@ -1414,6 +1416,7 @@ const zhHans = { uploadExtension: '拖拽或点击上传扩展包', uploadHint: '支持 .zip(技能)和 .lbpkg(插件)文件', orContinueWith: '或选择以下操作', + addMCPServerHint: '连接一个 MCP 工具服务器扩展', installFromGithub: '从 GitHub 安装插件', installFromGithubHint: '从 GitHub Release 安装插件扩展', createSkill: '创建新的技能', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 7631a615..6e06d6f3 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1327,6 +1327,7 @@ const zhHant = { uploadExtension: '拖拽或點擊上傳擴充套件', uploadHint: '支援 .zip(技能)和 .lbpkg(插件)檔案', orContinueWith: '或選擇以下操作', + addMCPServerHint: '連接一個 MCP 工具伺服器擴充', installFromGithub: '從 GitHub 安裝插件', installFromGithubHint: '從 GitHub Release 安裝插件擴充', createSkill: '建立新的技能', @@ -1360,6 +1361,8 @@ const zhHant = { skillDescription: '技能描述', skillInstructions: '指令內容', autoActivate: '自動啟用', + autoActivateDescription: + '開啟後,Agent 會在對話中根據技能描述自動匹配並啟用此技能。', saveSuccess: '儲存成功', saveError: '儲存失敗:', createSuccess: '創建成功',