fix(runtime): make plugin and box connectors resilient (#2347)

Preserve the contributor-authored MCP timeout commit and the follow-up configurable timeout and runtime robustness fixes.
This commit is contained in:
RockChinQ
2026-07-23 20:25:21 +08:00
committed by GitHub
35 changed files with 1555 additions and 288 deletions
+12 -4
View File
@@ -139,7 +139,8 @@ spec:
cpu: "1000m"
# Liveness probe to restart container if it becomes unresponsive
livenessProbe:
tcpSocket:
httpGet:
path: /healthz
port: 5400
initialDelaySeconds: 30
periodSeconds: 10
@@ -147,7 +148,8 @@ spec:
failureThreshold: 3
# Readiness probe to know when container is ready to accept traffic
readinessProbe:
tcpSocket:
httpGet:
path: /healthz
port: 5400
initialDelaySeconds: 10
periodSeconds: 5
@@ -265,14 +267,16 @@ spec:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
tcpSocket:
httpGet:
path: /healthz
port: 5410
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
tcpSocket:
httpGet:
path: /healthz
port: 5410
initialDelaySeconds: 10
periodSeconds: 5
@@ -319,6 +323,10 @@ metadata:
app: langbot
spec:
replicas: 1
# Plugin Runtime has a single active LangBot control owner. Recreate avoids
# two LangBot pods fighting over that connection during a rolling update.
strategy:
type: Recreate
selector:
matchLabels:
app: langbot
+1 -1
View File
@@ -70,7 +70,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.4.13",
"langbot-plugin==0.4.17",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
+134 -28
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import sys
@@ -28,6 +29,7 @@ _DOCKER_BOX_HOST = 'langbot_box'
_DEFAULT_PORT = 5410
_HEARTBEAT_INTERVAL_SEC = 20
_HEARTBEAT_FAILURE_THRESHOLD = 3
# Top-level keys under ``box`` that are LangBot-internal and should not be
# forwarded to the Box runtime.
@@ -113,6 +115,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
self._handler_task: asyncio.Task | None = None
self._ctrl_task: asyncio.Task | None = None
self._heartbeat_task: asyncio.Task | None = None
self._ctrl = None
self._generation = 0
# Parse the relay URL once for reuse.
parsed = urlparse(self.ws_relay_base_url)
@@ -145,29 +149,67 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
return self.uses_websocket()
async def initialize(self) -> None:
if self._uses_websocket():
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
await self._start_subprocess_then_ws()
else:
await self._connect_remote_ws()
else:
await self._start_local_stdio()
async with self._lifecycle_lock:
if self._closing:
raise BoxRuntimeUnavailableError('box runtime connector is shutting down')
self._generation += 1
await self._stop_transport()
try:
if self._uses_websocket():
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
await self._start_subprocess_then_ws()
else:
await self._connect_remote_ws()
else:
await self._start_local_stdio()
except BaseException:
await self._stop_transport()
await self._close_managed_subprocess()
raise
# Start heartbeat after successful connection
if self._heartbeat_task is None:
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
if self._heartbeat_task is None or self._heartbeat_task.done():
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
async def reconnect(self) -> None:
async with self._lifecycle_lock:
if self._closing:
raise BoxRuntimeUnavailableError('box runtime connector is shutting down')
self._generation += 1
await self._stop_transport()
try:
if self._uses_websocket():
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
await self._start_subprocess_then_ws()
else:
await self._connect_remote_ws()
else:
await self._start_local_stdio()
except BaseException:
await self._stop_transport()
await self._close_managed_subprocess()
raise
if self._heartbeat_task is None or self._heartbeat_task.done():
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
# -- heartbeat -----------------------------------------------------------
async def _heartbeat_loop(self) -> None:
"""Periodically ping the Box runtime to detect silent disconnections."""
while True:
failures = 0
while not self._closing:
await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC)
try:
await self.ping()
failures = 0
self.ap.logger.debug('Heartbeat to Box runtime success.')
except Exception as e:
self.ap.logger.debug(f'Failed to heartbeat to Box runtime: {e}')
failures += 1
self.ap.logger.warning(f'Box runtime heartbeat failed ({failures}/{_HEARTBEAT_FAILURE_THRESHOLD}): {e}')
if failures >= _HEARTBEAT_FAILURE_THRESHOLD:
failures = 0
if self.runtime_disconnect_callback is not None:
await self.runtime_disconnect_callback(self)
async def ping(self) -> None:
if self._handler is None:
@@ -196,9 +238,11 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
# mirroring `rt -s`.
args=['-m', 'langbot_plugin.cli.__init__', 'box', '-s', '--ws-control-port', str(self._relay_port)],
env=env,
capture_stderr=False,
)
self._ctrl = ctrl
self._ctrl_task = asyncio.create_task(
ctrl.run(self._make_connection_callback('stdio', connected, connect_error))
ctrl.run(self._make_connection_callback('stdio', connected, connect_error, self._generation))
)
try:
@@ -282,8 +326,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
await self.runtime_disconnect_callback(self)
ctrl = WebSocketClientController(ws_url=ws_url, make_connection_failed_callback=on_connect_failed)
self._ctrl = ctrl
self._ctrl_task = asyncio.create_task(
ctrl.run(self._make_connection_callback(transport_name, connected, connect_error))
ctrl.run(self._make_connection_callback(transport_name, connected, connect_error, self._generation))
)
try:
@@ -299,9 +344,29 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
transport_name: str,
connected: asyncio.Event,
connect_error: list[Exception],
generation: int,
):
async def new_connection_callback(connection: Connection) -> None:
if generation != self._generation or self._closing:
await connection.close()
return
handler = Handler(connection)
connection_ready = False
disconnect_notified = False
async def notify_disconnect() -> None:
nonlocal disconnect_notified
if (
connection_ready
and not disconnect_notified
and generation == self._generation
and not self._closing
and self.runtime_disconnect_callback is not None
):
disconnect_notified = True
self.ap.logger.error('Disconnected from Box runtime, trying to reconnect...')
await self.runtime_disconnect_callback(self)
self._handler = handler
self.client.set_handler(handler)
self._handler_task = asyncio.create_task(handler.run())
@@ -311,33 +376,74 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
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}.')
connection_ready = True
connected.set()
await self._handler_task
except asyncio.CancelledError:
raise
except Exception as exc:
if not connected.is_set():
connect_error.append(exc)
connected.set()
return
# If we reach here, handler.run() returned normally (connection
# closed) or raised after the initial handshake succeeded.
# Either way, treat it as a disconnect.
if connected.is_set():
if self._uses_websocket():
self.ap.logger.error('Disconnected from Box runtime, trying to reconnect...')
if self.runtime_disconnect_callback is not None:
await self.runtime_disconnect_callback(self)
else:
self.ap.logger.error(
'Disconnected from Box runtime via stdio. '
'Cannot automatically reconnect — please restart LangBot.'
)
finally:
if getattr(self, '_handler', None) is handler:
self._handler = None
self.client.set_handler(None)
await notify_disconnect()
return new_connection_callback
# -- lifecycle -----------------------------------------------------------
async def _stop_transport(self) -> None:
if self._handler is not None:
with contextlib.suppress(Exception):
await self._handler.close()
self.client.set_handler(None)
tasks = [
task
for task in (self._handler_task, self._ctrl_task)
if task is not None and task is not asyncio.current_task()
]
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
close_ctrl = getattr(self._ctrl, 'close', None)
if close_ctrl is not None:
with contextlib.suppress(Exception):
await close_ctrl()
self._handler = None
self._handler_task = None
self._ctrl_task = None
self._ctrl = None
async def aclose(self) -> None:
self._closing = True
self._generation += 1
if self._heartbeat_task is not None:
self._heartbeat_task.cancel()
await asyncio.gather(self._heartbeat_task, return_exceptions=True)
self._heartbeat_task = None
await self._stop_transport()
process = getattr(self, '_subprocess', None)
if process is not None and process.returncode is None:
with contextlib.suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=3)
except asyncio.TimeoutError:
with contextlib.suppress(ProcessLookupError):
process.kill()
await process.wait()
self._subprocess = None
await self._close_managed_subprocess()
def dispose(self) -> None:
"""Best-effort synchronous compatibility wrapper; prefer ``aclose``."""
self._closing = True
if self._heartbeat_task is not None:
self._heartbeat_task.cancel()
self._heartbeat_task = None
+34 -9
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import collections
import contextlib
import datetime as _dt
import enum
import json
@@ -65,6 +66,8 @@ class BoxService:
self.workspace_quota_mb = self._load_workspace_quota_mb()
self._recent_errors: collections.deque[dict] = collections.deque(maxlen=_MAX_RECENT_ERRORS)
self._shutdown_task = None
self._reconnect_task: asyncio.Task | None = None
self._closing = False
self._available = False
self._connector_error: str = ''
self._reconnecting = False
@@ -110,6 +113,8 @@ class BoxService:
self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}')
self._available = False
self._connector_error = str(exc)
if self._runtime_connector is not None:
await self._on_runtime_disconnect(self._runtime_connector)
async def _on_runtime_disconnect(self, connector: BoxRuntimeConnector) -> None:
"""Called by the connector when the Box runtime connection drops.
@@ -118,29 +123,34 @@ class BoxService:
Skipped entirely when Box is disabled by config — that path should
never have connected in the first place.
"""
if not self._enabled:
if not self._enabled or self._closing:
return
if self._reconnecting:
if self._reconnect_task is not None and not self._reconnect_task.done():
return # Another reconnect loop is already running
self._reconnecting = True
self._available = False
self._connector_error = 'Disconnected from Box runtime'
self.ap.logger.warning('Box runtime disconnected, sandbox features temporarily disabled.')
asyncio.create_task(self._reconnect_loop(connector))
self._reconnect_task = asyncio.create_task(self._reconnect_loop(connector))
async def _reconnect_loop(self, connector: BoxRuntimeConnector) -> None:
"""Retry reconnection with exponential backoff (3s → 60s max)."""
delay = 3
max_delay = 60
try:
while True:
while not self._closing:
self.ap.logger.info(f'Attempting to reconnect to Box runtime in {delay}s...')
await asyncio.sleep(delay)
try:
connector.dispose()
await connector.initialize()
await connector.reconnect()
self._ensure_default_workspace()
await self._purge_attachment_dirs()
self._available = True
self._connector_error = ''
skill_mgr = getattr(self.ap, 'skill_mgr', None)
reload_skills = getattr(skill_mgr, 'reload_skills', None)
if callable(reload_skills):
await reload_skills()
self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
return
except Exception as exc:
@@ -149,6 +159,7 @@ class BoxService:
delay = min(delay * 2, max_delay)
finally:
self._reconnecting = False
self._reconnect_task = None
@property
def available(self) -> bool:
@@ -838,14 +849,28 @@ class BoxService:
return attachments
async def shutdown(self):
await self.client.shutdown()
if self._closing:
return
self._closing = True
self._available = False
reconnect_task = self._reconnect_task
self._reconnect_task = None
if reconnect_task is not None and reconnect_task is not asyncio.current_task():
reconnect_task.cancel()
await asyncio.gather(reconnect_task, return_exceptions=True)
# The runtime may already be offline. A failed best-effort SHUTDOWN RPC
# must not prevent us from cancelling transports and reaping children.
with contextlib.suppress(Exception):
await self.client.shutdown()
if self._runtime_connector is not None:
await self._runtime_connector.aclose()
def dispose(self):
if self._runtime_connector is not None:
self._runtime_connector.dispose()
loop = getattr(self.ap, 'event_loop', None)
if loop is not None and not loop.is_closed() and (self._shutdown_task is None or self._shutdown_task.done()):
self._shutdown_task = loop.create_task(self.shutdown())
elif self._runtime_connector is not None:
self._runtime_connector.dispose()
async def get_sessions(self) -> list[dict]:
if not self._available:
+35 -1
View File
@@ -4,6 +4,7 @@ import logging
import asyncio
import traceback
import os
import contextlib
from ..platform import botmgr as im_mgr
from ..platform.webhook_pusher import WebhookPusher
@@ -166,7 +167,8 @@ class Application:
maintenance_service: maintenance_service.MaintenanceService = None
def __init__(self):
pass
self._shutdown_lock = asyncio.Lock()
self._shutdown_complete = False
async def initialize(self):
pass
@@ -318,7 +320,39 @@ class Application:
return default
return parsed
async def shutdown(self):
"""Stop application work and deterministically release runtime resources."""
async with self._shutdown_lock:
if self._shutdown_complete:
return
if self.task_mgr is not None:
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
if self.platform_mgr is not None:
with contextlib.suppress(Exception):
await self.platform_mgr.shutdown()
if self.tool_mgr is not None:
with contextlib.suppress(Exception):
await self.tool_mgr.shutdown()
if self.box_service is not None:
with contextlib.suppress(Exception):
await self.box_service.shutdown()
if self.plugin_connector is not None:
with contextlib.suppress(Exception):
await self.plugin_connector.aclose()
if self.task_mgr is not None:
tasks = [wrapper.task for wrapper in self.task_mgr.tasks if not wrapper.task.done()]
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._shutdown_complete = True
def dispose(self):
"""Compatibility wrapper for callers that cannot await shutdown."""
loop = self.event_loop
if loop is not None and not loop.is_closed():
loop.create_task(self.shutdown())
return
if self.plugin_connector is not None:
self.plugin_connector.dispose()
if self.box_service is not None:
+24 -7
View File
@@ -46,21 +46,38 @@ async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:
async def main(loop: asyncio.AbstractEventLoop):
app_inst: app.Application | None = None
runtime_loop = asyncio.get_running_loop()
shutdown_requested = asyncio.Event()
run_task: asyncio.Task | None = None
try:
# Hang system signal processing
import signal
def signal_handler(sig, frame):
if app_inst is not None:
app_inst.dispose()
print('[Signal] Program exit.')
os._exit(0)
runtime_loop.call_soon_threadsafe(shutdown_requested.set)
signal.signal(signal.SIGINT, signal_handler)
if hasattr(signal, 'SIGTERM'):
signal.signal(signal.SIGTERM, signal_handler)
app_inst = await make_app(loop)
await app_inst.run()
if app_inst is None:
return
run_task = asyncio.create_task(app_inst.run())
shutdown_task = asyncio.create_task(shutdown_requested.wait())
done, pending = await asyncio.wait((run_task, shutdown_task), return_when=asyncio.FIRST_COMPLETED)
if shutdown_task in done:
await app_inst.shutdown()
if not run_task.done():
run_task.cancel()
for task in pending:
task.cancel()
results = await asyncio.gather(run_task, shutdown_task, return_exceptions=True)
run_result = results[0]
if isinstance(run_result, BaseException) and not isinstance(run_result, asyncio.CancelledError):
raise run_result
except Exception:
if app_inst is not None:
app_inst.dispose()
traceback.print_exc()
finally:
if app_inst is not None:
await app_inst.shutdown()
+8 -5
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
import asyncio
from .. import stage, app
from ...utils import version, proxy
from ...pipeline import pool, controller, pipelinemgr
@@ -187,11 +185,16 @@ class BuildAppStage(stage.BootingStage):
ap.maintenance_service = maintenance_service_inst
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
await asyncio.sleep(3)
await plugin_connector_inst.initialize()
connector.schedule_reconnect()
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
await plugin_connector_inst.initialize()
try:
await plugin_connector_inst.initialize()
except Exception as exc:
# Keep the API/UI available while an external or managed runtime is
# starting, then recover in the background with bounded backoff.
ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
plugin_connector_inst.schedule_reconnect()
ap.plugin_connector = plugin_connector_inst
ctrl = controller.Controller(ap)
+281 -153
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import contextlib
import io
import time
import zipfile
@@ -36,6 +37,12 @@ from ..core import taskmgr
from ..entity.persistence import plugin as persistence_plugin
_CONNECT_TIMEOUT_SEC = 30.0
_HEARTBEAT_INTERVAL_SEC = 20.0
_HEARTBEAT_FAILURE_THRESHOLD = 3
_RECONNECT_MAX_DELAY_SEC = 60.0
class PluginRuntimeNotConnectedError(RuntimeError):
"""Raised when plugin runtime operations are requested before connection."""
@@ -70,128 +77,235 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
super().__init__(ap)
self.runtime_disconnect_callback = runtime_disconnect_callback
self.is_enable_plugin = self.ap.instance_config.data.get('plugin', {}).get('enable', True)
self._transport_task: asyncio.Task | None = None
self._reconnect_task: asyncio.Task | None = None
self._generation = 0
self._connected = asyncio.Event()
def _runtime_handler(self) -> handler.RuntimeConnectionHandler:
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is None:
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
return runtime_handler
def _runtime_available(self) -> bool:
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is None:
return False
# Unit-level and explicitly injected handlers don't own a transport.
# A managed transport must also have completed its handshake.
return self._transport_task is None or self._connected.is_set()
async def heartbeat_loop(self):
while True:
await asyncio.sleep(20)
failures = 0
while not self._closing:
await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC)
try:
await self.ping_plugin_runtime()
failures = 0
self.ap.logger.debug('Heartbeat to plugin runtime success.')
except Exception as e:
self.ap.logger.debug(f'Failed to heartbeat to plugin runtime: {e}')
failures += 1
self.ap.logger.warning(
f'Plugin runtime heartbeat failed ({failures}/{_HEARTBEAT_FAILURE_THRESHOLD}): {e}'
)
if failures >= _HEARTBEAT_FAILURE_THRESHOLD:
self._connected.clear()
self.schedule_reconnect()
failures = 0
async def initialize(self):
if not self.is_enable_plugin:
self.ap.logger.info('Plugin system is disabled.')
return
async def new_connection_callback(connection: base_connection.Connection):
async def disconnect_callback(
rchandler: handler.RuntimeConnectionHandler,
) -> bool:
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
self.ap.logger.error('Disconnected from plugin runtime, trying to reconnect...')
await self.runtime_disconnect_callback(self)
return False
else:
self.ap.logger.error(
'Disconnected from plugin runtime, cannot automatically reconnect while LangBot connects to plugin runtime via stdio, please restart LangBot.'
)
async with self._lifecycle_lock:
if self._closing:
raise PluginRuntimeNotConnectedError('Plugin runtime connector is shutting down')
if self._connected.is_set() and hasattr(self, 'handler'):
return
self._generation += 1
generation = self._generation
await self._stop_transport()
self._connected = asyncio.Event()
connect_errors: list[Exception] = []
async def new_connection_callback(
connection: base_connection.Connection,
):
if generation != self._generation or self._closing:
await connection.close()
return
connection_ready = False
disconnect_notified = False
async def notify_disconnect() -> None:
nonlocal disconnect_notified
if (
connection_ready
and not disconnect_notified
and generation == self._generation
and not self._closing
):
disconnect_notified = True
self._connected.clear()
await self.runtime_disconnect_callback(self)
async def disconnect_callback(
rchandler: handler.RuntimeConnectionHandler,
) -> bool:
await notify_disconnect()
return False
self.handler = handler.RuntimeConnectionHandler(connection, disconnect_callback, self.ap)
self.handler_task = asyncio.create_task(self.handler.run())
_ = await self.handler.ping()
# Push the configured marketplace (Space) URL to the runtime so it
# downloads plugins from the same Space LangBot is bound to, rather
# than relying on the runtime's own env/default.
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
if space_url:
runtime_handler = handler.RuntimeConnectionHandler(connection, disconnect_callback, self.ap)
self.handler = runtime_handler
self.handler_task = asyncio.create_task(runtime_handler.run())
try:
await self.handler.set_runtime_config(cloud_service_url=space_url)
self.ap.logger.info(f'Pushed marketplace URL to plugin runtime: {space_url}')
except Exception as e:
self.ap.logger.warning(f'Failed to push runtime config: {e}')
self.ap.logger.info('Connected to plugin runtime.')
await self.handler_task
await runtime_handler.ping()
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
if space_url:
await runtime_handler.set_runtime_config(cloud_service_url=space_url)
if generation == self._generation and not self._closing:
connection_ready = True
self._connected.set()
self.ap.logger.info('Connected to plugin runtime.')
await self.handler_task
except asyncio.CancelledError:
raise
except Exception as exc:
if not self._connected.is_set():
connect_errors.append(exc)
self._connected.set()
finally:
if generation == self._generation and not self._closing:
self._connected.clear()
if getattr(self, 'handler', None) is runtime_handler:
del self.handler
await notify_disconnect()
task: asyncio.Task | None = None
task_coro: typing.Coroutine
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
ws_url = self.ap.instance_config.data.get('plugin', {}).get(
'runtime_ws_url',
'ws://langbot_plugin_runtime:5400/control/ws',
)
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime(): # use websocket
self.ap.logger.info('use websocket to connect to plugin runtime')
ws_url = self.ap.instance_config.data.get('plugin', {}).get(
'runtime_ws_url', 'ws://langbot_plugin_runtime:5400/control/ws'
async def connection_failed(ctrl, exc=None):
error = exc or RuntimeError('WebSocket connection failed')
connect_errors.append(error)
self._connected.set()
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=connection_failed,
)
task_coro = self.ctrl.run(new_connection_callback)
elif platform.get_platform() == 'win32':
await self._start_runtime_subprocess('-m', 'langbot_plugin.cli.__init__', 'rt')
ws_url = 'ws://localhost:5400/control/ws'
async def connection_failed(ctrl, exc=None):
error = exc or RuntimeError('WebSocket connection failed')
connect_errors.append(error)
self._connected.set()
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=connection_failed,
)
task_coro = self.ctrl.run(new_connection_callback)
else:
self.ctrl = stdio_client_controller.StdioClientController(
command=sys.executable,
args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
env=os.environ.copy(),
capture_stderr=False,
)
task_coro = self.ctrl.run(new_connection_callback)
self._transport_task = asyncio.create_task(task_coro)
try:
await asyncio.wait_for(self._connected.wait(), timeout=_CONNECT_TIMEOUT_SEC)
except asyncio.TimeoutError as exc:
await self._stop_transport()
raise PluginRuntimeNotConnectedError('Plugin runtime did not become ready within 30 seconds') from exc
if connect_errors:
await self._stop_transport()
raise PluginRuntimeNotConnectedError(f'Plugin runtime connection failed: {connect_errors[-1]}')
if self.heartbeat_task is None or self.heartbeat_task.done():
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
def schedule_reconnect(self) -> None:
if self._closing or not self.is_enable_plugin:
return
if self._reconnect_task is not None and not self._reconnect_task.done():
return
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
async def _reconnect_loop(self) -> None:
delay = 1.0
try:
while not self._closing:
try:
await self.initialize()
return
except Exception as exc:
self.ap.logger.warning(f'Plugin runtime reconnection failed: {exc}; retrying in {delay:.0f}s')
await asyncio.sleep(delay)
delay = min(delay * 2, _RECONNECT_MAX_DELAY_SEC)
finally:
self._reconnect_task = None
async def _stop_transport(self) -> None:
self._connected.clear()
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is not None:
with contextlib.suppress(Exception):
await runtime_handler.close()
if getattr(self, 'handler', None) is runtime_handler:
del self.handler
tasks = [
task
for task in (
getattr(self, 'handler_task', None),
self._transport_task,
)
if task is not None and task is not asyncio.current_task()
]
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._transport_task = None
if hasattr(self, 'handler_task'):
del self.handler_task
close_ctrl = getattr(getattr(self, 'ctrl', None), 'close', None)
if close_ctrl is not None:
with contextlib.suppress(Exception):
await close_ctrl()
async def make_connection_failed_callback(
ctrl: ws_client_controller.WebSocketClientController,
exc: Exception = None,
) -> None:
if exc is not None:
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}')
else:
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}), trying to reconnect...')
await self.runtime_disconnect_callback(self)
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=make_connection_failed_callback,
)
task = self.ctrl.run(new_connection_callback)
elif platform.get_platform() == 'win32':
# Due to Windows's lack of supports for both stdio and subprocess:
# See also: https://docs.python.org/zh-cn/3.13/library/asyncio-platforms.html
# We have to launch runtime via cmd but communicate via ws.
self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')
await self._start_runtime_subprocess('-m', 'langbot_plugin.cli.__init__', 'rt')
ws_url = 'ws://localhost:5400/control/ws'
async def make_connection_failed_callback(
ctrl: ws_client_controller.WebSocketClientController,
exc: Exception = None,
) -> None:
if exc is not None:
self.ap.logger.error(f'(windows) Failed to connect to plugin runtime({ws_url}): {exc}')
else:
self.ap.logger.error(
f'(windows) Failed to connect to plugin runtime({ws_url}), trying to reconnect...'
)
await self.runtime_disconnect_callback(self)
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=make_connection_failed_callback,
)
task = self.ctrl.run(new_connection_callback)
else: # stdio
self.ap.logger.info('use stdio to connect to plugin runtime')
# cmd: lbp rt -s
python_path = sys.executable
env = os.environ.copy()
self.ctrl = stdio_client_controller.StdioClientController(
command=python_path,
args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
env=env,
)
task = self.ctrl.run(new_connection_callback)
if self.heartbeat_task is None:
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
asyncio.create_task(task)
async def aclose(self) -> None:
self._closing = True
self._generation += 1
reconnect_task = self._reconnect_task
self._reconnect_task = None
if reconnect_task is not None and reconnect_task is not asyncio.current_task():
reconnect_task.cancel()
await asyncio.gather(reconnect_task, return_exceptions=True)
if self.heartbeat_task is not None:
self.heartbeat_task.cancel()
await asyncio.gather(self.heartbeat_task, return_exceptions=True)
self.heartbeat_task = None
await self._stop_transport()
await self._close_managed_subprocess()
async def initialize_plugins(self):
pass
async def ping_plugin_runtime(self):
if not hasattr(self, 'handler'):
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
return await self.handler.ping()
return await self._runtime_handler().ping()
def _inspect_plugin_package(
self,
@@ -473,7 +587,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
file_bytes = download_resp.content
self._inspect_plugin_package(file_bytes, task_context)
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
file_key = await self._runtime_handler().send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
# Continue to install via runtime
@@ -496,7 +610,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
if task_context is not None and plugin_author and plugin_name:
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
file_key = await self._runtime_handler().send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key
del install_info['plugin_file']
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
@@ -535,14 +649,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
if task_context is not None and plugin_author and plugin_name:
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
file_key = await self._runtime_handler().send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
except Exception as e:
self.ap.logger.error(f'Failed to download file from GitHub: {e}')
raise Exception(f'Failed to download file from GitHub: {e}')
async for ret in self.handler.install_plugin(install_source.value, install_info):
async for ret in self._runtime_handler().install_plugin(install_source.value, install_info):
current_action = ret.get('current_action', None)
if current_action is not None:
if task_context is not None:
@@ -566,7 +680,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_name: str,
task_context: taskmgr.TaskContext | None = None,
) -> dict[str, Any]:
async for ret in self.handler.upgrade_plugin(plugin_author, plugin_name):
async for ret in self._runtime_handler().upgrade_plugin(plugin_author, plugin_name):
current_action = ret.get('current_action', None)
if current_action is not None:
if task_context is not None:
@@ -584,7 +698,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
delete_data: bool = False,
task_context: taskmgr.TaskContext | None = None,
) -> dict[str, Any]:
async for ret in self.handler.delete_plugin(plugin_author, plugin_name):
async for ret in self._runtime_handler().delete_plugin(plugin_author, plugin_name):
current_action = ret.get('current_action', None)
if current_action is not None:
if task_context is not None:
@@ -599,7 +713,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if delete_data:
if task_context is not None:
task_context.trace('Cleaning up plugin configuration and storage...')
await self.handler.cleanup_plugin_data(plugin_author, plugin_name)
await self._runtime_handler().cleanup_plugin_data(plugin_author, plugin_name)
async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]:
"""List plugins, optionally filtered by component kinds.
@@ -610,10 +724,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
component of the specified kinds will be returned.
E.g., ['Command', 'EventListener', 'Tool'] for pipeline-related plugins.
"""
if not self.is_enable_plugin:
if not self.is_enable_plugin or not self._runtime_available():
return []
plugins = await self.handler.list_plugins()
plugins = await self._runtime_handler().list_plugins()
# Filter plugins by component kinds if specified
if component_kinds is not None:
@@ -685,18 +799,18 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
return plugins
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
return await self.handler.get_plugin_info(author, plugin_name)
return await self._runtime_handler().get_plugin_info(author, plugin_name)
async def set_plugin_config(self, plugin_author: str, plugin_name: str, config: dict[str, Any]) -> dict[str, Any]:
return await self.handler.set_plugin_config(plugin_author, plugin_name, config)
return await self._runtime_handler().set_plugin_config(plugin_author, plugin_name, config)
@alru_cache(ttl=5 * 60) # 5 minutes
async def get_plugin_icon(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:
return await self.handler.get_plugin_icon(plugin_author, plugin_name)
return await self._runtime_handler().get_plugin_icon(plugin_author, plugin_name)
@alru_cache(ttl=5 * 60) # 5 minutes
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
return await self.handler.get_plugin_readme(plugin_author, plugin_name, language)
return await self._runtime_handler().get_plugin_readme(plugin_author, plugin_name, language)
async def get_plugin_logs(
self,
@@ -706,11 +820,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
level: str | None = None,
) -> list[dict[str, Any]]:
# Not cached: logs are live and change constantly.
return await self.handler.get_plugin_logs(plugin_author, plugin_name, limit, level)
return await self._runtime_handler().get_plugin_logs(plugin_author, plugin_name, limit, level)
@alru_cache(ttl=5 * 60)
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
return await self._runtime_handler().get_plugin_assets(plugin_author, plugin_name, filepath)
async def handle_page_api(
self,
@@ -721,13 +835,15 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
method: str,
body: Any = None,
) -> dict[str, Any]:
return await self.handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
return await self._runtime_handler().handle_page_api(
plugin_author, plugin_name, page_id, endpoint, method, body
)
async def get_debug_info(self) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
if not self.is_enable_plugin:
if not self.is_enable_plugin or not self._runtime_available():
return {}
return await self.handler.get_debug_info()
return await self._runtime_handler().get_debug_info()
async def emit_event(
self,
@@ -736,13 +852,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
) -> context.EventContext:
event_ctx = context.EventContext.from_event(event)
if not self.is_enable_plugin:
if not self.is_enable_plugin or not self._runtime_available():
event_ctx._emitted_plugins = []
event_ctx._response_sources = []
return event_ctx
# Pass include_plugins to runtime for filtering
event_ctx_result = await self.handler.emit_event(
event_ctx_result = await self._runtime_handler().emit_event(
event_ctx.model_dump(serialize_as_any=False), include_plugins=bound_plugins
)
@@ -755,19 +871,19 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
async def notify_plugin_diagnostic(self, diagnostic: dict[str, Any]) -> None:
"""Best-effort diagnostic forwarding to the plugin runtime."""
if not self.is_enable_plugin:
if not self.is_enable_plugin or not self._runtime_available():
return
try:
await self.handler.notify_plugin_diagnostic(diagnostic)
await self._runtime_handler().notify_plugin_diagnostic(diagnostic)
except Exception as e:
self.ap.logger.debug(f'Plugin diagnostic forwarding skipped: {e}')
async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
if not self.is_enable_plugin:
if not self.is_enable_plugin or not self._runtime_available():
return []
# Pass include_plugins to runtime for filtering
list_tools_data = await self.handler.list_tools(include_plugins=bound_plugins)
list_tools_data = await self._runtime_handler().list_tools(include_plugins=bound_plugins)
tools = [ComponentManifest.model_validate(tool) for tool in list_tools_data]
@@ -785,16 +901,19 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
return {'error': 'Tool not found: plugin system is disabled'}
# Pass include_plugins to runtime for validation
return await self.handler.call_tool(
if not self._runtime_available():
return {'error': 'Plugin runtime is temporarily unavailable'}
return await self._runtime_handler().call_tool(
tool_name, parameters, session.model_dump(serialize_as_any=True), query_id, include_plugins=bound_plugins
)
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
if not self.is_enable_plugin:
if not self.is_enable_plugin or not self._runtime_available():
return []
# Pass include_plugins to runtime for filtering
list_commands_data = await self.handler.list_commands(include_plugins=bound_plugins)
list_commands_data = await self._runtime_handler().list_commands(include_plugins=bound_plugins)
commands = [ComponentManifest.model_validate(command) for command in list_commands_data]
@@ -803,12 +922,15 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
async def execute_command(
self, command_ctx: command_context.ExecuteContext, bound_plugins: list[str] | None = None
) -> typing.AsyncGenerator[command_context.CommandReturn, None]:
if not self.is_enable_plugin:
if not self.is_enable_plugin or not self._runtime_available():
yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command))
return
# Pass include_plugins to runtime for validation
gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True), include_plugins=bound_plugins)
gen = self._runtime_handler().execute_command(
command_ctx.model_dump(serialize_as_any=True),
include_plugins=bound_plugins,
)
async for ret in gen:
cmd_ret = command_context.CommandReturn.model_validate(ret)
@@ -823,27 +945,33 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
retrieval_context: dict[str, Any],
) -> dict[str, Any]:
"""Retrieve knowledge using a KnowledgeEngine instance."""
if not self.is_enable_plugin:
if not self.is_enable_plugin or not self._runtime_available():
return {'results': []}
return await self.handler.retrieve_knowledge(plugin_author, plugin_name, retriever_name, retrieval_context)
return await self._runtime_handler().retrieve_knowledge(
plugin_author, plugin_name, retriever_name, retrieval_context
)
def dispose(self):
# On non-Windows stdio mode, terminate via the controller's process handle.
# On Windows, the managed subprocess is cleaned up by the base class.
if (
self.is_enable_plugin
and hasattr(self, 'ctrl')
and isinstance(self.ctrl, stdio_client_controller.StdioClientController)
):
self.ap.logger.info('Terminating plugin runtime process...')
self.ctrl.process.terminate()
self._dispose_subprocess()
"""Best-effort synchronous compatibility wrapper; prefer ``aclose``."""
self._closing = True
if self.heartbeat_task is not None:
self.heartbeat_task.cancel()
self.heartbeat_task = None
if self._reconnect_task is not None:
self._reconnect_task.cancel()
self._reconnect_task = None
for task in (
getattr(self, 'handler_task', None),
self._transport_task,
):
if task is not None:
task.cancel()
ctrl = getattr(self, 'ctrl', None)
process = getattr(ctrl, 'process', None)
if process is not None and process.returncode is None:
process.terminate()
self._dispose_subprocess()
@staticmethod
def _parse_plugin_id(plugin_id: str) -> tuple[str, str]:
@@ -873,29 +1001,29 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
context_data: IngestionContext data.
"""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self.handler.rag_ingest_document(plugin_author, plugin_name, context_data)
return await self._runtime_handler().rag_ingest_document(plugin_author, plugin_name, context_data)
async def call_rag_delete_document(self, plugin_id: str, document_id: str, kb_id: str) -> bool:
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self.handler.rag_delete_document(plugin_author, plugin_name, document_id, kb_id)
return await self._runtime_handler().rag_delete_document(plugin_author, plugin_name, document_id, kb_id)
async def get_rag_creation_schema(self, plugin_id: str) -> dict[str, Any]:
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self.handler.get_rag_creation_schema(plugin_author, plugin_name)
return await self._runtime_handler().get_rag_creation_schema(plugin_author, plugin_name)
async def get_rag_retrieval_schema(self, plugin_id: str) -> dict[str, Any]:
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self.handler.get_rag_retrieval_schema(plugin_author, plugin_name)
return await self._runtime_handler().get_rag_retrieval_schema(plugin_author, plugin_name)
async def rag_on_kb_create(self, plugin_id: str, kb_id: str, config: dict[str, Any]) -> dict[str, Any]:
"""Notify plugin about KB creation."""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self.handler.rag_on_kb_create(plugin_author, plugin_name, kb_id, config)
return await self._runtime_handler().rag_on_kb_create(plugin_author, plugin_name, kb_id, config)
async def rag_on_kb_delete(self, plugin_id: str, kb_id: str) -> dict[str, Any]:
"""Notify plugin about KB deletion."""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self.handler.rag_on_kb_delete(plugin_author, plugin_name, kb_id)
return await self._runtime_handler().rag_on_kb_delete(plugin_author, plugin_name, kb_id)
async def call_rag_retrieve(self, plugin_id: str, retrieval_context: dict[str, Any]) -> dict[str, Any]:
"""Call plugin to retrieve knowledge.
@@ -905,25 +1033,25 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
retrieval_context: RetrievalContext data.
"""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self.handler.retrieve_knowledge(plugin_author, plugin_name, '', retrieval_context)
return await self._runtime_handler().retrieve_knowledge(plugin_author, plugin_name, '', retrieval_context)
async def list_knowledge_engines(self) -> list[dict[str, Any]]:
"""List all available Knowledge Engines from plugins.
Returns a list of Knowledge Engines with their capabilities and configuration schemas.
"""
if not self.is_enable_plugin:
if not self.is_enable_plugin or not self._runtime_available():
return []
return await self.handler.list_knowledge_engines()
return await self._runtime_handler().list_knowledge_engines()
async def list_parsers(self) -> list[dict[str, Any]]:
"""List all available parsers from plugins."""
if not self.is_enable_plugin:
if not self.is_enable_plugin or not self._runtime_available():
return []
return await self.handler.list_parsers()
return await self._runtime_handler().list_parsers()
async def call_parser(self, plugin_id: str, context_data: dict[str, Any], file_bytes: bytes) -> dict[str, Any]:
"""Call plugin to parse a document."""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self.handler.parse_document(plugin_author, plugin_name, context_data, file_bytes)
return await self._runtime_handler().parse_document(plugin_author, plugin_name, context_data, file_bytes)
+126 -24
View File
@@ -3,14 +3,17 @@ from __future__ import annotations
import base64
import enum
import json
import math
import re
import time
import typing
from contextlib import AsyncExitStack, asynccontextmanager
from datetime import timedelta
import traceback
from langbot_plugin.api.entities.events import pipeline_query
import sqlalchemy
import asyncio
import hashlib
import httpx
import uuid as uuid_module
@@ -26,7 +29,13 @@ 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, _ColdStartRetry # noqa: F401
from .mcp_stdio import (
BoxStdioSessionRuntime,
MCPServerBoxConfig as MCPServerBoxConfig, # noqa: F401 - public re-export
MCPSessionErrorPhase,
_ColdStartRetry,
_get_default_memory_mb,
) # noqa: F401
# Synthesized LLM tools for MCP resources (not from server tools/list).
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
@@ -44,6 +53,7 @@ MCP_RESOURCE_CONTEXT_MAX_BYTES = 96 * 1024
MCP_RESOURCE_TRACE_QUERY_KEY = '_mcp_resource_reads'
MCP_RESOURCE_LINKS_QUERY_KEY = '_mcp_resource_links'
MCP_RESOURCE_CONTEXT_QUERY_KEY = '_mcp_resource_context'
MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS = 300.0
TEXT_LIKE_MIME_TYPES = {
'application/json',
@@ -206,6 +216,10 @@ class _CallerReconnect(Exception):
"""
class MCPToolCallTimeoutError(TimeoutError):
"""An MCP tool call exceeded its configured per-server deadline."""
class RuntimeMCPSession:
"""运行时 MCP 会话"""
@@ -255,6 +269,9 @@ class RuntimeMCPSession:
self.ap = ap
self.enable = enable
self.session = None
self.tool_call_timeout_sec = self._parse_tool_call_timeout(
server_config.get('tool_call_timeout_sec', MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS)
)
# Transient test sessions (created from the config page "test" button,
# which carry no persisted server UUID) must NOT share the live
@@ -295,21 +312,39 @@ class RuntimeMCPSession:
self._box_stdio_runtime = BoxStdioSessionRuntime(self)
self.box_config = self._box_stdio_runtime.config
def _parse_tool_call_timeout(self, value: typing.Any) -> float:
"""Return a safe tool-call timeout; zero explicitly disables it."""
try:
timeout = -1 if isinstance(value, bool) else float(value)
if timeout > 0:
# Validate the exact conversion used for each call here, so a
# finite-but-enormous manual config cannot fail at invocation.
timedelta(seconds=timeout)
except (TypeError, ValueError, OverflowError):
timeout = -1
if not math.isfinite(timeout) or timeout < 0:
self.ap.logger.warning(
f'Invalid MCP tool call timeout {value!r} for {self.server_name}; '
f'using {MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS:g} seconds'
)
return MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS
return timeout
async def _init_stdio_python_server(self):
if self._uses_box_stdio():
await self._box_stdio_runtime.initialize()
return
# Box is configured (ap.box_service exists) but currently unavailable
# (disabled by config or connection failed). Refuse stdio MCP rather
# than silently falling through to host-stdio — the operator asked
# for the sandbox and the failure mode should be visible.
# Box is configured but explicitly disabled. Refuse stdio MCP rather
# than silently falling through to host-stdio — the operator asked for
# the sandbox and the failure mode should be visible. An enabled Box
# that is reconnecting is handled above and waits for availability.
#
# Set ``error_phase = BOX_UNAVAILABLE`` BEFORE raising so the retry
# wrapper can short-circuit (retrying is pointless when Box is
# deliberately off) and the frontend can render a localized,
# actionable message instead of this raw RuntimeError. Keep the
# message itself short — the frontend ignores it for this phase.
# wrapper can distinguish a deliberately disabled Box from an enabled
# runtime that is still reconnecting. Keep the message itself short —
# the frontend ignores it for this phase.
box_service = getattr(self.ap, 'box_service', None)
if box_service is not None and not getattr(box_service, 'available', False):
self.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE
@@ -613,17 +648,29 @@ class RuntimeMCPSession:
await asyncio.sleep(2)
continue
except Exception as e:
self.retry_count = attempt + 1
if self._shutdown_event.is_set():
return # Shutdown requested, don't retry
# BOX_UNAVAILABLE is a deliberate refusal, not a transient
# failure — retrying produces log spam and a misleading
# "Failed after N attempts" message. Surface it immediately.
if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE:
box_service = getattr(self.ap, 'box_service', None)
if box_service is not None and getattr(box_service, 'enabled', True):
# Box is configured and may recover independently of
# this MCP session. Keep retrying without consuming the
# fatal budget; _wait_for_box_runtime() rate-limits the
# loop to one warning per startup timeout.
self.status = MCPSessionStatus.CONNECTING
self.error_message = None
self.error_phase = None
await asyncio.sleep(1)
continue
# Explicitly disabled Box is a deliberate refusal, not a
# transient failure. Surface it immediately without log
# spam or a misleading "Failed after N attempts" message.
self.retry_count = attempt + 1
self.status = MCPSessionStatus.ERROR
self.error_message = str(e)
self._ready_event.set()
return
self.retry_count = attempt + 1
if attempt >= self._MAX_RETRIES:
self.status = MCPSessionStatus.ERROR
self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {self._describe_exception(e)}'
@@ -950,8 +997,22 @@ class RuntimeMCPSession:
raise Exception('MCP session is not connected')
try:
result = await self.session.call_tool(tool_name, arguments)
read_timeout = timedelta(seconds=self.tool_call_timeout_sec) if self.tool_call_timeout_sec > 0 else None
result = await self.session.call_tool(
tool_name,
arguments,
read_timeout_seconds=read_timeout,
)
except Exception as e:
if self._is_tool_call_timeout(e):
self.ap.logger.warning(
f'MCP tool {tool_name} on {self.server_name} timed out after '
f'{self.tool_call_timeout_sec:g} seconds'
)
raise MCPToolCallTimeoutError(
f"MCP tool '{tool_name}' on server '{self.server_name}' timed out after "
f'{self.tool_call_timeout_sec:g} seconds'
) from e
if attempt == 0 and self._is_session_terminated(e):
self.ap.logger.warning(
f'MCP tool {tool_name} on {self.server_name} got session terminated, triggering reconnect...'
@@ -974,6 +1035,16 @@ class RuntimeMCPSession:
raise Exception('MCP session is not connected')
@staticmethod
def _is_tool_call_timeout(exc: BaseException) -> bool:
"""Recognize the MCP SDK's per-request timeout without retrying it."""
return any(
isinstance(leaf, McpError)
and leaf.error.code == httpx.codes.REQUEST_TIMEOUT
and leaf.error.message.startswith('Timed out while waiting for response')
for leaf in RuntimeMCPSession._iter_exception_leaves(exc)
)
def get_tools(self) -> list[resource_tool.LLMTool]:
return self.functions
@@ -1206,15 +1277,34 @@ class RuntimeMCPSession:
return self._box_stdio_runtime.uses_box_stdio()
def _build_box_session_id(self) -> str:
# Both live servers and transient config-page tests share ONE Box
# session ('mcp-shared'). A test therefore reuses the already-running
# container (and, for an existing server, its live managed process)
# instead of paying a full per-test session cold-start + dependency
# bootstrap. Isolation between a test and the live servers is provided
# at the *process* level: each server/test has its own process_id and a
# test only ever stops its own process_id (see cleanup_session), so it
# never disturbs another server's process or the shared session itself.
return 'mcp-shared'
# Compatible MCP servers share a session and remain isolated by
# process_id. A server with a different immutable resource profile gets
# another session; Docker/E2B cannot change memory/image/etc. after a
# session has been created.
config = self._box_stdio_runtime.config
default_memory = _get_default_memory_mb(self.ap)
profile = {
'image': config.image,
'network': config.network,
'host_path_mode': config.host_path_mode,
'cpus': config.cpus,
'memory_mb': config.memory_mb or default_memory,
'pids_limit': config.pids_limit,
'read_only_rootfs': (config.read_only_rootfs if config.read_only_rootfs is not None else False),
}
default_profile = {
'image': None,
'network': 'on',
'host_path_mode': 'ro',
'cpus': None,
'memory_mb': default_memory,
'pids_limit': None,
'read_only_rootfs': False,
}
if profile == default_profile:
return 'mcp-shared'
digest = hashlib.sha256(json.dumps(profile, sort_keys=True).encode('utf-8')).hexdigest()[:12]
return f'mcp-shared-{digest}'
def _rewrite_path(self, path: str, host_path: str | None) -> str:
return self._box_stdio_runtime.rewrite_path(path, host_path)
@@ -1797,11 +1887,23 @@ class MCPLoader(loader.ToolLoader):
async def shutdown(self):
"""关闭所有工具"""
self.ap.logger.info('Shutting down all MCP sessions...')
for server_name, session in list(self.sessions.items()):
hosted_tasks = [task for task in self._hosted_mcp_tasks if not task.done()]
for task in hosted_tasks:
task.cancel()
if hosted_tasks:
await asyncio.gather(*hosted_tasks, return_exceptions=True)
self._hosted_mcp_tasks.clear()
async def shutdown_session(server_name: str, session: RuntimeMCPSession) -> None:
try:
await session.shutdown()
self.ap.logger.debug(f'Shutdown MCP session: {server_name}')
except Exception as e:
self.ap.logger.error(f'Error shutting down MCP session {server_name}: {e}\n{traceback.format_exc()}')
await asyncio.gather(
*(shutdown_session(server_name, session) for server_name, session in list(self.sessions.items()))
)
self.sessions.clear()
self.ap.logger.info('All MCP sessions shutdown complete')
@@ -185,11 +185,10 @@ class BoxStdioSessionRuntime:
box_service = getattr(self.ap, 'box_service', None)
if box_service is None:
return False
# When Box is configured but currently unavailable (disabled or
# connection failed), do NOT silently fall through to host-stdio —
# that would bypass the sandbox the operator asked for. The caller
# is expected to refuse the stdio MCP server with a clear error.
return bool(getattr(box_service, 'available', False))
# An enabled Box service remains the required transport while it is
# reconnecting. initialize() waits for availability instead of
# permanently failing the MCP server or falling through to host stdio.
return bool(getattr(box_service, 'enabled', True))
async def initialize(self) -> None:
await self._wait_for_box_runtime()
@@ -488,7 +487,7 @@ class BoxStdioSessionRuntime:
)
warned = True
if asyncio.get_running_loop().time() >= deadline:
self.owner.error_phase = MCPSessionErrorPhase.SESSION_CREATE
self.owner.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE
raise Exception(f'Box runtime is not available after {int(timeout_sec)} seconds')
await asyncio.sleep(1)
@@ -57,7 +57,7 @@ class NativeToolLoader(loader.ToolLoader):
return await is_box_backend_available(self.ap)
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
if not self._is_sandbox_available():
if not await self._is_sandbox_available():
return []
if self._tools is None:
self._tools = [
@@ -71,7 +71,7 @@ class NativeToolLoader(loader.ToolLoader):
return list(self._tools)
async def has_tool(self, name: str) -> bool:
return name in _ALL_TOOL_NAMES and self._is_sandbox_available()
return name in _ALL_TOOL_NAMES and await self._is_sandbox_available()
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query):
if name == EXEC_TOOL_NAME:
@@ -652,12 +652,9 @@ else:
if callable(refresh_skill):
refresh_skill(selected_skill.get('name', ''))
def _is_sandbox_available(self) -> bool:
"""Check if sandbox backend is available.
This checks the cached backend availability from initialization,
not just whether the box_service process is running.
"""
async def _is_sandbox_available(self) -> bool:
"""Refresh backend availability so Box reconnects restore tool exposure."""
self._backend_available = await self._check_backend_available()
return bool(self._backend_available)
def _build_exec_tool(self) -> resource_tool.LLMTool:
@@ -49,19 +49,27 @@ class SkillToolLoader(loader.ToolLoader):
return await is_box_backend_available(self.ap)
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
if not self._is_available():
if not await self._is_available():
return []
if not self._tools:
self._tools = [
self._build_activate_skill_tool(),
self._build_register_skill_tool(),
]
return list(self._tools)
async def has_tool(self, name: str) -> bool:
return self._is_available() and name in SKILL_TOOL_NAMES
return await self._is_available() and name in SKILL_TOOL_NAMES
def _is_available(self) -> bool:
async def _is_available(self) -> bool:
"""Check if skill tools should be available.
Skill tools require both a skill manager and a sandbox backend.
"""
return self._has_skill_manager() and self._sandbox_available
if not self._has_skill_manager():
return False
self._sandbox_available = await self._check_sandbox_available()
return self._sandbox_available
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
if name == ACTIVATE_SKILL_TOOL_NAME:
+23
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import contextlib
import os
import sys
from typing import TYPE_CHECKING, Awaitable, Callable
@@ -27,6 +28,8 @@ class ManagedRuntimeConnector:
self.ap = ap
self.runtime_subprocess = None
self.runtime_subprocess_task = None
self._lifecycle_lock = asyncio.Lock()
self._closing = False
async def _start_runtime_subprocess(self, *args: str) -> None:
"""Launch a local runtime as a subprocess of the current Python interpreter.
@@ -86,3 +89,23 @@ class ManagedRuntimeConnector:
if self.runtime_subprocess_task is not None:
self.runtime_subprocess_task.cancel()
self.runtime_subprocess_task = None
async def _close_managed_subprocess(self) -> None:
"""Terminate, escalate, and reap the optional owned subprocess."""
process = self.runtime_subprocess
wait_task = self.runtime_subprocess_task
if process is not None and process.returncode is None:
with contextlib.suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=3)
except asyncio.TimeoutError:
with contextlib.suppress(ProcessLookupError):
process.kill()
await process.wait()
if wait_task is not None and wait_task is not asyncio.current_task():
if not wait_task.done():
wait_task.cancel()
await asyncio.gather(wait_task, return_exceptions=True)
self.runtime_subprocess = None
self.runtime_subprocess_task = None
+5
View File
@@ -143,6 +143,11 @@ box:
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
runtime:
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
limits:
max_sessions: 64 # Includes persistent sessions. New sessions fail explicitly when this cap is reached.
max_managed_processes: 64 # Maximum concurrently running stdio MCP / managed processes.
max_completed_processes: 256 # Global cap for retained exited-process diagnostics.
completed_process_retention_sec: 300 # Keep exited-process diagnostics before releasing memory.
local:
profile: 'default'
image: '' # Custom local sandbox image. Leave empty to use the profile default.
+136 -1
View File
@@ -1,11 +1,13 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import Mock
from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.box.client import ActionRPCBoxClient
from langbot.pkg.box import connector as connector_module
from langbot.pkg.box.connector import BoxRuntimeConnector
@@ -104,3 +106,136 @@ def test_box_runtime_connector_dispose_terminates_subprocess(monkeypatch: pytest
ctrl_task.cancel.assert_called_once()
assert connector._handler_task is None
assert connector._ctrl_task is None
@pytest.mark.asyncio
async def test_box_runtime_connector_cleans_partial_transport_on_connect_failure(
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()))
connector._start_local_stdio = AsyncMock(side_effect=RuntimeError('bind failed'))
connector._stop_transport = AsyncMock()
connector._close_managed_subprocess = AsyncMock()
with pytest.raises(RuntimeError, match='bind failed'):
await connector.initialize()
assert connector._stop_transport.await_count == 2
connector._close_managed_subprocess.assert_awaited_once()
@pytest.mark.asyncio
async def test_box_runtime_connector_starts_heartbeat_after_reconnect(
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()))
connector._start_local_stdio = AsyncMock(side_effect=[RuntimeError('bind failed'), None])
connector._stop_transport = AsyncMock()
connector._close_managed_subprocess = AsyncMock()
with pytest.raises(RuntimeError, match='bind failed'):
await connector.initialize()
assert connector._heartbeat_task is None
await connector.reconnect()
assert connector._heartbeat_task is not None
assert not connector._heartbeat_task.done()
await connector.aclose()
@pytest.mark.asyncio
async def test_box_stdio_connection_does_not_capture_unconsumed_stderr(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
created = {}
class FakeHandler:
def __init__(self, connection):
self.release = asyncio.Event()
async def call_action(self, action, data):
return None
async def run(self):
await self.release.wait()
async def close(self):
self.release.set()
class FakeController:
def __init__(self, **kwargs):
created.update(kwargs)
self.process = SimpleNamespace(returncode=0)
async def run(self, callback):
await callback(object())
async def close(self):
return None
monkeypatch.setattr(connector_module, 'Handler', FakeHandler)
monkeypatch.setattr(
'langbot_plugin.runtime.io.controllers.stdio.client.StdioClientController',
FakeController,
)
connector = BoxRuntimeConnector(make_app(Mock()))
await connector.initialize()
assert created['capture_stderr'] is False
assert connector._handler is not None
await connector.aclose()
@pytest.mark.asyncio
async def test_box_disconnect_notifies_once_and_clears_handler(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
disconnect = AsyncMock()
class FakeHandler:
def __init__(self, connection):
pass
async def call_action(self, action, data):
return None
async def run(self):
return None
async def close(self):
return None
class FakeController:
def __init__(self, **kwargs):
self.process = SimpleNamespace(returncode=0)
async def run(self, callback):
await callback(object())
async def close(self):
return None
monkeypatch.setattr(connector_module, 'Handler', FakeHandler)
monkeypatch.setattr(
'langbot_plugin.runtime.io.controllers.stdio.client.StdioClientController',
FakeController,
)
connector = BoxRuntimeConnector(make_app(Mock()), runtime_disconnect_callback=disconnect)
await connector.initialize()
await asyncio.sleep(0)
disconnect.assert_awaited_once_with(connector)
assert connector._handler is None
await connector.aclose()
+41 -1
View File
@@ -325,10 +325,50 @@ async def test_box_service_dispose_schedules_shutdown_on_event_loop(monkeypatch:
service.dispose()
await asyncio.sleep(0)
connector.dispose.assert_called_once()
connector.dispose.assert_not_called()
service.shutdown.assert_awaited_once()
@pytest.mark.asyncio
async def test_box_service_shutdown_reaps_connector_when_runtime_rpc_is_offline(
monkeypatch: pytest.MonkeyPatch,
):
connector = Mock()
connector.client = Mock()
connector.client.shutdown = AsyncMock(side_effect=RuntimeError('offline'))
connector.aclose = AsyncMock()
monkeypatch.setattr('langbot.pkg.box.service.BoxRuntimeConnector', Mock(return_value=connector))
service = BoxService(make_app(Mock()))
await service.shutdown()
connector.client.shutdown.assert_awaited_once()
connector.aclose.assert_awaited_once()
@pytest.mark.asyncio
async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
monkeypatch: pytest.MonkeyPatch,
):
app = make_app(Mock())
app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock())
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
connector = Mock()
connector.reconnect = AsyncMock()
service._ensure_default_workspace = Mock()
service._purge_attachment_dirs = AsyncMock()
monkeypatch.setattr('langbot.pkg.box.service.asyncio.sleep', AsyncMock())
await service._reconnect_loop(connector)
connector.reconnect.assert_awaited_once()
service._ensure_default_workspace.assert_called_once()
service._purge_attachment_dirs.assert_awaited_once()
app.skill_mgr.reload_skills.assert_awaited_once()
assert service.available is True
@pytest.mark.asyncio
async def test_box_runtime_reuses_request_session():
logger = Mock()
+34 -20
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import signal
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
@@ -18,47 +19,60 @@ async def test_main_signal_handler_handles_sigint_before_app_created(monkeypatch
async def fake_make_app(loop):
captured_handler[signal.SIGINT](signal.SIGINT, None)
def fake_exit(code):
raise SystemExit(code)
monkeypatch.setattr(signal, 'signal', fake_signal)
monkeypatch.setattr(boot, 'make_app', fake_make_app)
monkeypatch.setattr(boot.os, '_exit', fake_exit)
with pytest.raises(SystemExit) as exc_info:
await boot.main(SimpleNamespace())
assert exc_info.value.code == 0
await boot.main(SimpleNamespace())
@pytest.mark.asyncio
async def test_main_signal_handler_disposes_created_app(monkeypatch):
captured_handler = {}
app_inst = SimpleNamespace(disposed=False)
app_inst = SimpleNamespace(shutdown_called=False)
def fake_signal(sig, handler):
captured_handler[sig] = handler
def dispose():
app_inst.disposed = True
async def shutdown():
app_inst.shutdown_called = True
async def run():
captured_handler[signal.SIGINT](signal.SIGINT, None)
async def fake_make_app(loop):
app_inst.dispose = dispose
app_inst.shutdown = shutdown
app_inst.run = run
return app_inst
def fake_exit(code):
raise SystemExit(code)
monkeypatch.setattr(signal, 'signal', fake_signal)
monkeypatch.setattr(boot, 'make_app', fake_make_app)
monkeypatch.setattr(boot.os, '_exit', fake_exit)
with pytest.raises(SystemExit) as exc_info:
await boot.main(SimpleNamespace())
await boot.main(SimpleNamespace())
assert exc_info.value.code == 0
assert app_inst.disposed is True
assert app_inst.shutdown_called is True
@pytest.mark.asyncio
async def test_main_reports_app_run_failure_and_still_shuts_down(monkeypatch):
app_inst = SimpleNamespace(shutdown_called=False)
async def shutdown():
app_inst.shutdown_called = True
async def run():
raise RuntimeError('run failed')
async def fake_make_app(loop):
app_inst.shutdown = shutdown
app_inst.run = run
return app_inst
print_exc = Mock()
monkeypatch.setattr(signal, 'signal', lambda *_args: None)
monkeypatch.setattr(boot, 'make_app', fake_make_app)
monkeypatch.setattr(boot.traceback, 'print_exc', print_exc)
await boot.main(SimpleNamespace())
print_exc.assert_called_once()
assert app_inst.shutdown_called is True
@@ -62,6 +62,25 @@ class TestListPlugins:
assert result == []
@pytest.mark.asyncio
async def test_returns_empty_while_runtime_is_disconnected(self):
connector = create_mock_connector()
result = await connector.list_plugins()
assert result == []
@pytest.mark.asyncio
async def test_returns_empty_after_managed_transport_disconnects(self):
connector = create_mock_connector()
connector.handler = AsyncMock()
connector._transport_task = Mock()
result = await connector.list_plugins()
assert result == []
connector.handler.list_plugins.assert_not_called()
@pytest.mark.asyncio
async def test_calls_handler_list_plugins(self):
"""Test that handler.list_plugins is called."""
@@ -294,6 +313,12 @@ class TestListKnowledgeEngines:
connector.handler.list_knowledge_engines.assert_called_once()
assert result == [{'plugin_id': 'author/engine', 'name': 'Engine'}]
@pytest.mark.asyncio
async def test_returns_empty_while_runtime_is_disconnected(self):
connector = create_mock_connector()
assert await connector.list_knowledge_engines() == []
class TestListParsers:
"""Tests for list_parsers method."""
@@ -331,6 +356,12 @@ class TestListParsers:
connector.handler.list_parsers.assert_called_once()
assert result == [{'plugin_id': 'author/parser', 'supported_mime_types': ['text/plain']}]
@pytest.mark.asyncio
async def test_returns_empty_while_runtime_is_disconnected(self):
connector = create_mock_connector()
assert await connector.list_parsers() == []
class TestCallParser:
"""Tests for call_parser method."""
+137 -2
View File
@@ -1,15 +1,20 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.plugin import connector as connector_module
from langbot.pkg.plugin.connector import PluginRuntimeConnector, PluginRuntimeNotConnectedError
def make_connector() -> PluginRuntimeConnector:
app = SimpleNamespace(instance_config=SimpleNamespace(data={'plugin': {'enable': True}}))
app = SimpleNamespace(
logger=Mock(),
instance_config=SimpleNamespace(data={'plugin': {'enable': True}, 'space': {'url': ''}}),
)
return PluginRuntimeConnector(app, AsyncMock())
@@ -30,3 +35,133 @@ async def test_ping_plugin_runtime_delegates_to_connected_handler():
assert result == 'pong'
connector.handler.ping.assert_awaited_once()
@pytest.mark.asyncio
async def test_stop_transport_tolerates_handler_callback_removing_attribute():
connector = make_connector()
class Handler:
async def close(self):
del connector.handler
connector.handler = Handler()
await connector._stop_transport()
assert not hasattr(connector, 'handler')
@pytest.mark.asyncio
async def test_stdio_runtime_connection_does_not_capture_unconsumed_stderr(
monkeypatch: pytest.MonkeyPatch,
):
connector = make_connector()
created = {}
class FakeRuntimeHandler:
def __init__(self, connection, disconnect_callback, ap):
self.release = asyncio.Event()
async def ping(self):
return None
async def set_runtime_config(self, **kwargs):
return None
async def run(self):
await self.release.wait()
async def close(self):
self.release.set()
class FakeController:
def __init__(self, **kwargs):
created.update(kwargs)
async def run(self, callback):
await callback(object())
async def close(self):
return None
monkeypatch.setattr(connector_module.platform, 'get_platform', lambda: 'linux')
monkeypatch.setattr(
connector_module.platform,
'use_websocket_to_connect_plugin_runtime',
lambda: False,
)
monkeypatch.setattr(
connector_module.handler,
'RuntimeConnectionHandler',
FakeRuntimeHandler,
)
monkeypatch.setattr(
connector_module.stdio_client_controller,
'StdioClientController',
FakeController,
)
await connector.initialize()
assert created['capture_stderr'] is False
assert connector._connected.is_set()
await connector.aclose()
@pytest.mark.asyncio
async def test_runtime_disconnect_notifies_once_and_clears_handler(
monkeypatch: pytest.MonkeyPatch,
):
disconnect = AsyncMock()
connector = PluginRuntimeConnector(make_connector().ap, disconnect)
class FakeRuntimeHandler:
def __init__(self, connection, disconnect_callback, ap):
self.disconnect_callback = disconnect_callback
async def ping(self):
return None
async def set_runtime_config(self, **kwargs):
return None
async def run(self):
await self.disconnect_callback(self)
async def close(self):
return None
class FakeController:
def __init__(self, **kwargs):
pass
async def run(self, callback):
await callback(object())
async def close(self):
return None
monkeypatch.setattr(connector_module.platform, 'get_platform', lambda: 'linux')
monkeypatch.setattr(
connector_module.platform,
'use_websocket_to_connect_plugin_runtime',
lambda: False,
)
monkeypatch.setattr(
connector_module.handler,
'RuntimeConnectionHandler',
FakeRuntimeHandler,
)
monkeypatch.setattr(
connector_module.stdio_client_controller,
'StdioClientController',
FakeController,
)
await connector.initialize()
await asyncio.sleep(0)
disconnect.assert_awaited_once_with(connector)
assert not hasattr(connector, 'handler')
await connector.aclose()
@@ -417,7 +417,7 @@ class TestBuildBoxSessionPayload:
payload = s._build_box_session_payload('session-123')
assert payload['image'] == 'node:20'
assert payload['cpus'] == 2.0
assert payload["memory_mb"] == 1024
assert payload['memory_mb'] == 1024
assert payload['pids_limit'] == 256
def test_none_fields_excluded(self, mcp_module):
@@ -680,14 +680,55 @@ class TestGetRuntimeInfoDict:
# ... but are isolated by distinct process_ids within that session.
assert transient._box_stdio_runtime.process_id != live._box_stdio_runtime.process_id
def test_stdio_session_refuses_when_box_unavailable(self, mcp_module):
"""Policy: when Box is configured but unavailable (disabled in config
OR connection failed), stdio MCP servers are NOT treated as box-stdio.
``_init_stdio_python_server`` will raise a clear refusal at start
time; until then, the runtime info simply omits box_session_id so the
UI can render the disabled state cleanly."""
def test_different_resource_profiles_use_different_box_sessions(self, mcp_module):
ap = _make_ap()
ap.box_service.available = True
default = _make_session(
mcp_module,
{
'name': 'default',
'uuid': 'default-uuid',
'mode': 'stdio',
'command': 'uvx',
'args': ['mcp-server-time'],
},
ap=ap,
)
constrained = _make_session(
mcp_module,
{
'name': 'constrained',
'uuid': 'constrained-uuid',
'mode': 'stdio',
'command': 'uvx',
'args': ['mcp-server-time'],
'box': {'memory_mb': 2048},
},
ap=ap,
)
assert default._build_box_session_id() == 'mcp-shared'
assert constrained._build_box_session_id().startswith('mcp-shared-')
assert constrained._build_box_session_id() != default._build_box_session_id()
writable = _make_session(
mcp_module,
{
'name': 'writable',
'uuid': 'writable-uuid',
'mode': 'stdio',
'command': 'uvx',
'args': ['mcp-server-time'],
'box': {'host_path_mode': 'rw'},
},
ap=ap,
)
assert writable._build_box_session_id() != default._build_box_session_id()
def test_stdio_session_waits_for_enabled_box_when_temporarily_unavailable(self, mcp_module):
ap = _make_ap()
ap.box_service.available = False
ap.box_service.enabled = True
s = _make_session(
mcp_module,
{
@@ -700,9 +741,125 @@ class TestGetRuntimeInfoDict:
ap=ap,
)
info = s.get_runtime_info_dict()
assert info['box_session_id'] == 'mcp-shared'
assert info['box_enabled'] is True
def test_stdio_session_refuses_when_box_is_disabled(self, mcp_module):
ap = _make_ap()
ap.box_service.available = False
ap.box_service.enabled = False
s = _make_session(
mcp_module,
{
'name': 'test',
'uuid': 'test-uuid',
'mode': 'stdio',
'command': 'python',
'args': [],
},
ap=ap,
)
info = s.get_runtime_info_dict()
assert 'box_session_id' not in info
assert 'box_enabled' not in info
@pytest.mark.asyncio
async def test_stdio_session_waits_until_box_reconnects(self, mcp_module, monkeypatch):
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
ap = _make_ap()
ap.box_service.available = False
ap.box_service.enabled = True
session = _make_session(
mcp_module,
{
'name': 'test',
'uuid': 'test-uuid',
'mode': 'stdio',
'command': 'python',
'args': [],
'box': {'startup_timeout_sec': 1},
},
ap=ap,
)
async def reconnect_box(_delay):
ap.box_service.available = True
monkeypatch.setattr(mcp_stdio_module.asyncio, 'sleep', reconnect_box)
await session._box_stdio_runtime._wait_for_box_runtime()
assert ap.box_service.available is True
@pytest.mark.asyncio
async def test_enabled_box_timeout_does_not_exhaust_mcp_retry_budget(self, mcp_module, monkeypatch):
ap = _make_ap()
ap.box_service.available = False
ap.box_service.enabled = True
session = _make_session(
mcp_module,
{
'name': 'test',
'uuid': 'test-uuid',
'mode': 'stdio',
'command': 'python',
'args': [],
},
ap=ap,
)
attempts = 0
async def lifecycle():
nonlocal attempts
attempts += 1
if attempts == 1:
session.error_phase = mcp_module.MCPSessionErrorPhase.BOX_UNAVAILABLE
raise RuntimeError('Box runtime is not available after 1 seconds')
session._lifecycle_loop = lifecycle
sleep = AsyncMock()
monkeypatch.setattr(mcp_module.asyncio, 'sleep', sleep)
await session._lifecycle_loop_with_retry()
assert attempts == 2
assert session.retry_count == 0
sleep.assert_awaited_once_with(1)
@pytest.mark.asyncio
async def test_disabled_box_still_stops_mcp_retry_loop(self, mcp_module):
ap = _make_ap()
ap.box_service.available = False
ap.box_service.enabled = False
session = _make_session(
mcp_module,
{
'name': 'test',
'uuid': 'test-uuid',
'mode': 'stdio',
'command': 'python',
'args': [],
},
ap=ap,
)
attempts = 0
async def lifecycle():
nonlocal attempts
attempts += 1
session.error_phase = mcp_module.MCPSessionErrorPhase.BOX_UNAVAILABLE
raise RuntimeError('box_disabled_in_config')
session._lifecycle_loop = lifecycle
await session._lifecycle_loop_with_retry()
assert attempts == 1
assert session.status == mcp_module.MCPSessionStatus.ERROR
assert session.retry_count == 1
def test_stdio_session_without_box_service_uses_local_stdio(self, mcp_module):
ap = _make_ap()
del ap.box_service
@@ -12,7 +12,7 @@ import pytest
from aiohttp import web
from mcp import types as mcp_types
from langbot.pkg.provider.tools.loaders.mcp import RuntimeMCPSession
from langbot.pkg.provider.tools.loaders.mcp import MCPToolCallTimeoutError, RuntimeMCPSession
class _TransportProbe:
@@ -65,6 +65,25 @@ class _TransportProbe:
},
}
)
if method == 'tools/call':
tool_name = message.get('params', {}).get('name')
if tool_name == 'hang':
return web.Response(status=202)
return web.json_response(
{
'jsonrpc': '2.0',
'id': message['id'],
'result': {
'content': [
{
'type': 'text',
'text': 'healthy',
}
],
'isError': False,
},
}
)
return web.Response(status=202)
return web.Response(status=self.streamable_status)
@@ -126,11 +145,22 @@ async def _transport_server(streamable_status: int | None):
await runner.cleanup()
def _session(url: str, *, timeout: float = 2) -> RuntimeMCPSession:
def _session(
url: str,
*,
timeout: float = 2,
tool_call_timeout_sec: float = 300,
) -> RuntimeMCPSession:
app = cast(Any, SimpleNamespace(logger=Mock()))
return RuntimeMCPSession(
'remote-transport-test',
{'uuid': 'srv-1', 'mode': 'remote', 'url': url, 'timeout': timeout},
{
'uuid': 'srv-1',
'mode': 'remote',
'url': url,
'timeout': timeout,
'tool_call_timeout_sec': tool_call_timeout_sec,
},
True,
app,
)
@@ -164,6 +194,24 @@ async def test_remote_transport_real_streamable_http_success_keeps_session_usabl
await _close_session(session)
@pytest.mark.asyncio
async def test_remote_transport_tool_timeout_does_not_poison_session():
async with _transport_server(200) as (probe, url):
session = _session(url, tool_call_timeout_sec=0.05)
try:
await session._init_remote_server()
with pytest.raises(MCPToolCallTimeoutError, match='timed out after 0.05 seconds'):
await session.invoke_mcp_tool('hang', {})
result = await session.invoke_mcp_tool('health_check', {})
assert result[0].text == 'healthy'
assert probe.streamable_messages.count('tools/call') == 2
finally:
await _close_session(session)
@pytest.mark.asyncio
@pytest.mark.parametrize('status_code', [400, 404, 405])
async def test_remote_transport_real_streamable_http_error_falls_back_to_legacy_sse(status_code: int):
@@ -1,20 +1,25 @@
from __future__ import annotations
import asyncio
import base64
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
from mcp import types as mcp_types
from mcp.shared.exceptions import McpError
from langbot.pkg.provider.tools.loaders.mcp import (
MCP_RESOURCE_CONTEXT_QUERY_KEY,
MCP_RESOURCE_TRACE_QUERY_KEY,
MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS,
MCP_TOOL_LIST_RESOURCES,
MCP_TOOL_READ_RESOURCE,
MCPLoader,
MCPSessionStatus,
MCPToolCallTimeoutError,
RuntimeMCPSession,
)
from langbot.pkg.telemetry import features as telemetry_features
@@ -61,6 +66,111 @@ def _http_status_error(status_code: int) -> httpx.HTTPStatusError:
return httpx.HTTPStatusError(f'HTTP {status_code}', request=request, response=response)
def _tool_result(text: str = 'ok') -> mcp_types.CallToolResult:
return mcp_types.CallToolResult(
content=[mcp_types.TextContent(type='text', text=text)],
isError=False,
)
@pytest.mark.asyncio
async def test_invoke_mcp_tool_uses_configurable_request_timeout():
session = RuntimeMCPSession(
'slow-tools',
{
'uuid': 'srv-1',
'mode': 'remote',
'tool_call_timeout_sec': 900,
},
True,
_app(),
)
session.session = SimpleNamespace(call_tool=AsyncMock(return_value=_tool_result()))
result = await session.invoke_mcp_tool('render_video', {'quality': 'high'})
assert result[0].text == 'ok'
session.session.call_tool.assert_awaited_once_with(
'render_video',
{'quality': 'high'},
read_timeout_seconds=timedelta(seconds=900),
)
@pytest.mark.asyncio
async def test_invoke_mcp_tool_zero_timeout_disables_request_deadline():
session = RuntimeMCPSession(
'unbounded-tools',
{
'uuid': 'srv-1',
'mode': 'remote',
'tool_call_timeout_sec': 0,
},
True,
_app(),
)
session.session = SimpleNamespace(call_tool=AsyncMock(return_value=_tool_result()))
await session.invoke_mcp_tool('long_job', {})
session.session.call_tool.assert_awaited_once_with(
'long_job',
{},
read_timeout_seconds=None,
)
@pytest.mark.asyncio
async def test_invoke_mcp_tool_timeout_is_not_retried_and_session_remains_usable():
session = RuntimeMCPSession(
'recoverable-tools',
{
'uuid': 'srv-1',
'mode': 'remote',
'tool_call_timeout_sec': 5,
},
True,
_app(),
)
timeout = McpError(
mcp_types.ErrorData(
code=httpx.codes.REQUEST_TIMEOUT,
message='Timed out while waiting for response to ClientRequest. Waited 5 seconds.',
)
)
call_tool = AsyncMock(side_effect=[timeout, _tool_result('recovered')])
session.session = SimpleNamespace(call_tool=call_tool)
with pytest.raises(
MCPToolCallTimeoutError,
match="MCP tool 'long_job' on server 'recoverable-tools' timed out after 5 seconds",
):
await session.invoke_mcp_tool('long_job', {})
assert call_tool.await_count == 1
second_result = await session.invoke_mcp_tool('health_check', {})
assert second_result[0].text == 'recovered'
assert call_tool.await_count == 2
@pytest.mark.parametrize('invalid_timeout', [-1, float('inf'), 1e300, True, 'not-a-number'])
def test_invalid_tool_call_timeout_falls_back_to_default(invalid_timeout):
ap = _app()
session = RuntimeMCPSession(
'invalid-timeout',
{
'uuid': 'srv-1',
'mode': 'remote',
'tool_call_timeout_sec': invalid_timeout,
},
True,
ap,
)
assert session.tool_call_timeout_sec == MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS
ap.logger.warning.assert_called_once()
@pytest.mark.asyncio
async def test_remote_transport_falls_back_to_sse_for_compatible_http_status_in_exception_group():
session = RuntimeMCPSession(
@@ -321,3 +431,42 @@ async def test_build_resource_context_for_query_uses_only_bound_attached_text_re
assert query.variables[MCP_RESOURCE_CONTEXT_QUERY_KEY]['resource_count'] == 1
docs.session.read_resource.assert_awaited_once()
other.session.read_resource.assert_not_called()
@pytest.mark.asyncio
async def test_mcp_loader_shutdown_cancels_startup_tasks_and_closes_sessions_concurrently():
loader = MCPLoader(_app())
hosted_cancelled = asyncio.Event()
async def pending_host():
try:
await asyncio.Event().wait()
finally:
hosted_cancelled.set()
hosted_task = asyncio.create_task(pending_host())
await asyncio.sleep(0)
loader._hosted_mcp_tasks = [hosted_task]
started: set[str] = set()
all_started = asyncio.Event()
class Session:
def __init__(self, name: str):
self.name = name
async def shutdown(self):
started.add(self.name)
if len(started) == 2:
all_started.set()
await all_started.wait()
loader.sessions = {'one': Session('one'), 'two': Session('two')}
await asyncio.wait_for(loader.shutdown(), timeout=1)
assert hosted_cancelled.is_set()
assert hosted_task.cancelled()
assert started == {'one', 'two'}
assert loader._hosted_mcp_tasks == []
assert loader.sessions == {}
@@ -458,6 +458,25 @@ class TestSkillToolLoader:
assert await loader.has_tool('activate') is True
assert await loader.has_tool('register_skill') is True
@pytest.mark.asyncio
async def test_tools_reappear_after_box_backend_recovers(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
ap = _make_ap()
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
ap.box_service = SimpleNamespace(
available=False,
get_status=AsyncMock(return_value={'backend': {'available': True}}),
)
loader = SkillToolLoader(ap)
await loader.initialize()
assert await loader.get_tools() == []
ap.box_service.available = True
assert sorted(tool.name for tool in await loader.get_tools()) == ['activate', 'register_skill']
class TestNativeToolLoaderSkillPaths:
@pytest.mark.asyncio
@@ -151,6 +151,21 @@ async def test_native_tool_loader_exposes_all_tools_when_box_available():
assert await loader.has_tool(tool_name) is True
@pytest.mark.asyncio
async def test_native_tool_loader_refreshes_after_box_recovers():
box_service = SimpleNamespace(
available=False,
get_status=AsyncMock(return_value={'backend': {'available': True}}),
)
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
await loader.initialize()
assert await loader.get_tools() == []
box_service.available = True
assert [tool.name for tool in await loader.get_tools()] == ['exec', 'read', 'write', 'edit', 'glob', 'grep']
# ── read/write/edit file tool tests ─────────────────────────────
Generated
+4 -4
View File
@@ -2124,7 +2124,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.4.13" },
{ name = "langbot-plugin", specifier = "==0.4.17" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2189,7 +2189,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.4.13"
version = "0.4.17"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2210,9 +2210,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/a6/1eaf77c3b81e9de3390c504c5f627dc41f43bff6df9aff0e1e31d796b6f0/langbot_plugin-0.4.13.tar.gz", hash = "sha256:f936340e67679c21f1e7e7f1447339f31a0a2c965db060ecfbd9d0c51bb0d6fe", size = 334887, upload-time = "2026-07-04T05:38:59.942Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/96/336d7ac97ff5a9c413e7aaf0c329a7047ec91dec5fa4f9e0b1d0abb57d0e/langbot_plugin-0.4.17.tar.gz", hash = "sha256:b1539444f16568c0b3244f68c498ac9498c56e2c20f12e220e6ee735b8d6b5c2", size = 347080, upload-time = "2026-07-23T09:23:55.941Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/bf/fc9671a7afbd933440c38403c84d918c1022fdeed16e22a6ab3b2aec83ff/langbot_plugin-0.4.13-py3-none-any.whl", hash = "sha256:9d45ebc7a7ee0413d6db9baa009fcbf0ad07e2e1753a6f0a27f37b8b665cd1ee", size = 221884, upload-time = "2026-07-04T05:38:58.525Z" },
{ url = "https://files.pythonhosted.org/packages/e1/98/ac372a238e59894ac54189cb220a3fb75da48f319881eeb49d583712549a/langbot_plugin-0.4.17-py3-none-any.whl", hash = "sha256:6fd6c9d7e0583f04659f023a233c4201cb4c450d1ef696e79fa1e338933ee156", size = 229881, upload-time = "2026-07-23T09:23:54.582Z" },
]
[[package]]
@@ -435,6 +435,10 @@ const getFormSchema = (t: TFunction) =>
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
.positive({ message: t('mcp.timeoutMustBePositive') })
.default(30),
tool_call_timeout_sec: z
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
.nonnegative({ message: t('mcp.timeoutNonNegative') })
.default(300),
ssereadtimeout: z
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
.positive({ message: t('mcp.timeoutMustBePositive') })
@@ -474,6 +478,7 @@ const getFormSchema = (t: TFunction) =>
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
timeout: number;
tool_call_timeout_sec: number;
ssereadtimeout: number;
};
@@ -535,6 +540,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
command: '',
args: [],
timeout: 30,
tool_call_timeout_sec: 300,
ssereadtimeout: 300,
extra_args: [],
...initialDraftRef.current,
@@ -609,6 +615,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
command: '',
args: [],
timeout: 30,
tool_call_timeout_sec: 300,
ssereadtimeout: 300,
extra_args: [],
...initialDraftRef.current,
@@ -687,10 +694,16 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
command: '',
args: [],
timeout: 30,
tool_call_timeout_sec: 300,
ssereadtimeout: 300,
extra_args: [],
};
if (typeof server.extra_args.tool_call_timeout_sec === 'number') {
formValues.tool_call_timeout_sec =
server.extra_args.tool_call_timeout_sec;
}
let newExtraArgs: {
key: string;
type: 'string' | 'number' | 'boolean';
@@ -770,6 +783,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
url: value.url!,
headers,
timeout: value.timeout,
tool_call_timeout_sec: value.tool_call_timeout_sec,
},
};
} else {
@@ -786,6 +800,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
command: value.command!,
args: value.args?.map((arg) => arg.value) || [],
env,
tool_call_timeout_sec: value.tool_call_timeout_sec,
},
};
}
@@ -835,6 +850,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
extraArgsData = {
url: form.getValues('url')!,
timeout: form.getValues('timeout'),
tool_call_timeout_sec: form.getValues('tool_call_timeout_sec'),
headers: Object.fromEntries(
formExtraArgs.map((arg) => [arg.key, arg.value]),
),
@@ -846,6 +862,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
env: Object.fromEntries(
formExtraArgs.map((arg) => [arg.key, arg.value]),
),
tool_call_timeout_sec: form.getValues('tool_call_timeout_sec'),
};
}
@@ -1047,6 +1064,30 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
)}
/>
<FormField
control={form.control}
name="tool_call_timeout_sec"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.toolCallTimeout')}</FormLabel>
<FormControl>
<Input
type="number"
min={0}
step={1}
placeholder="300"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
{t('mcp.toolCallTimeoutDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{watchMode === 'remote' && (
<>
<FormField
+4
View File
@@ -519,18 +519,21 @@ export interface MCPServerExtraArgsSSE {
headers: Record<string, string>;
timeout: number;
ssereadtimeout: number;
tool_call_timeout_sec?: number;
}
export interface MCPServerExtraArgsStdio {
command: string;
args: string[];
env: Record<string, string>;
tool_call_timeout_sec?: number;
}
export interface MCPServerExtraArgsHttp {
url: string;
headers: Record<string, string>;
timeout: number;
tool_call_timeout_sec?: number;
}
// "remote" mode: the user only supplies a URL; the backend auto-detects the
@@ -540,6 +543,7 @@ export interface MCPServerExtraArgsRemote {
url: string;
headers?: Record<string, string>;
timeout?: number;
tool_call_timeout_sec?: number;
}
export enum MCPSessionStatus {
+3
View File
@@ -797,6 +797,9 @@ const enUS = {
url: 'URL',
headers: 'Headers',
timeout: 'Timeout',
toolCallTimeout: 'Tool call timeout (seconds)',
toolCallTimeoutDescription:
'Maximum wait for one tool call. Set to 0 for no timeout. Defaults to 300 seconds.',
addArgument: 'Add Argument',
addEnvVar: 'Add Environment Variable',
addHeader: 'Add Header',
+3
View File
@@ -811,6 +811,9 @@ const esES = {
url: 'URL',
headers: 'Encabezados',
timeout: 'Tiempo de espera',
toolCallTimeout: 'Tiempo de espera de herramienta (segundos)',
toolCallTimeoutDescription:
'Espera máxima para una llamada de herramienta. Use 0 para no limitar. El valor predeterminado es 300 segundos.',
addArgument: 'Añadir argumento',
addEnvVar: 'Añadir variable de entorno',
addHeader: 'Añadir encabezado',
+3
View File
@@ -803,6 +803,9 @@ const jaJP = {
url: 'URL',
headers: 'ヘッダー',
timeout: 'タイムアウト',
toolCallTimeout: 'ツール呼び出しタイムアウト(秒)',
toolCallTimeoutDescription:
'1 回のツール呼び出しの最大待機時間です。0 で無制限、既定値は 300 秒です。',
addArgument: '引数を追加',
addEnvVar: '環境変数を追加',
addHeader: 'ヘッダーを追加',
+3
View File
@@ -808,6 +808,9 @@ const ruRU = {
url: 'URL',
headers: 'Заголовки',
timeout: 'Таймаут',
toolCallTimeout: 'Таймаут вызова инструмента (секунды)',
toolCallTimeoutDescription:
'Максимальное ожидание одного вызова. 0 отключает ограничение. По умолчанию 300 секунд.',
addArgument: 'Добавить аргумент',
addEnvVar: 'Добавить переменную окружения',
addHeader: 'Добавить заголовок',
+3
View File
@@ -786,6 +786,9 @@ const thTH = {
url: 'URL',
headers: 'ส่วนหัว',
timeout: 'หมดเวลา',
toolCallTimeout: 'หมดเวลาการเรียกเครื่องมือ (วินาที)',
toolCallTimeoutDescription:
'เวลารอสูงสุดต่อการเรียกเครื่องมือหนึ่งครั้ง ตั้งเป็น 0 เพื่อไม่จำกัด ค่าเริ่มต้นคือ 300 วินาที',
addArgument: 'เพิ่มอาร์กิวเมนต์',
addEnvVar: 'เพิ่มตัวแปรสภาพแวดล้อม',
addHeader: 'เพิ่มส่วนหัว',
+3
View File
@@ -801,6 +801,9 @@ const viVN = {
url: 'URL',
headers: 'Tiêu đề',
timeout: 'Thời gian chờ',
toolCallTimeout: 'Thời gian chờ gọi công cụ (giây)',
toolCallTimeoutDescription:
'Thời gian chờ tối đa cho một lần gọi công cụ. Đặt 0 để không giới hạn. Mặc định là 300 giây.',
addArgument: 'Thêm tham số',
addEnvVar: 'Thêm biến môi trường',
addHeader: 'Thêm tiêu đề',
+3
View File
@@ -763,6 +763,9 @@ const zhHans = {
url: 'URL地址',
headers: '请求头',
timeout: '超时时间',
toolCallTimeout: '工具调用超时(秒)',
toolCallTimeoutDescription:
'单次工具调用的最长等待时间。设为 0 表示不限制,默认 300 秒。',
addArgument: '添加参数',
addEnvVar: '添加环境变量',
addHeader: '添加请求头',
+3
View File
@@ -762,6 +762,9 @@ const zhHant = {
url: 'URL位址',
headers: '請求標頭',
timeout: '逾時時間',
toolCallTimeout: '工具呼叫逾時(秒)',
toolCallTimeoutDescription:
'單次工具呼叫的最長等待時間。設為 0 表示不限制,預設 300 秒。',
addArgument: '新增參數',
addEnvVar: '新增環境變數',
addHeader: '新增請求標頭',