mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-24 13:26:08 +00:00
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:
+12
-4
@@ -139,7 +139,8 @@ spec:
|
|||||||
cpu: "1000m"
|
cpu: "1000m"
|
||||||
# Liveness probe to restart container if it becomes unresponsive
|
# Liveness probe to restart container if it becomes unresponsive
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
tcpSocket:
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
port: 5400
|
port: 5400
|
||||||
initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
@@ -147,7 +148,8 @@ spec:
|
|||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
# Readiness probe to know when container is ready to accept traffic
|
# Readiness probe to know when container is ready to accept traffic
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
tcpSocket:
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
port: 5400
|
port: 5400
|
||||||
initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
@@ -265,14 +267,16 @@ spec:
|
|||||||
memory: "1Gi"
|
memory: "1Gi"
|
||||||
cpu: "1000m"
|
cpu: "1000m"
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
tcpSocket:
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
port: 5410
|
port: 5410
|
||||||
initialDelaySeconds: 20
|
initialDelaySeconds: 20
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
timeoutSeconds: 5
|
timeoutSeconds: 5
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
tcpSocket:
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
port: 5410
|
port: 5410
|
||||||
initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
@@ -319,6 +323,10 @@ metadata:
|
|||||||
app: langbot
|
app: langbot
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
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:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: langbot
|
app: langbot
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.4.13",
|
"langbot-plugin==0.4.17",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -28,6 +29,7 @@ _DOCKER_BOX_HOST = 'langbot_box'
|
|||||||
_DEFAULT_PORT = 5410
|
_DEFAULT_PORT = 5410
|
||||||
|
|
||||||
_HEARTBEAT_INTERVAL_SEC = 20
|
_HEARTBEAT_INTERVAL_SEC = 20
|
||||||
|
_HEARTBEAT_FAILURE_THRESHOLD = 3
|
||||||
|
|
||||||
# Top-level keys under ``box`` that are LangBot-internal and should not be
|
# Top-level keys under ``box`` that are LangBot-internal and should not be
|
||||||
# forwarded to the Box runtime.
|
# forwarded to the Box runtime.
|
||||||
@@ -113,6 +115,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
self._handler_task: asyncio.Task | None = None
|
self._handler_task: asyncio.Task | None = None
|
||||||
self._ctrl_task: asyncio.Task | None = None
|
self._ctrl_task: asyncio.Task | None = None
|
||||||
self._heartbeat_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.
|
# Parse the relay URL once for reuse.
|
||||||
parsed = urlparse(self.ws_relay_base_url)
|
parsed = urlparse(self.ws_relay_base_url)
|
||||||
@@ -145,29 +149,67 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
return self.uses_websocket()
|
return self.uses_websocket()
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
if self._uses_websocket():
|
async with self._lifecycle_lock:
|
||||||
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
|
if self._closing:
|
||||||
await self._start_subprocess_then_ws()
|
raise BoxRuntimeUnavailableError('box runtime connector is shutting down')
|
||||||
else:
|
self._generation += 1
|
||||||
await self._connect_remote_ws()
|
await self._stop_transport()
|
||||||
else:
|
try:
|
||||||
await self._start_local_stdio()
|
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 or self._heartbeat_task.done():
|
||||||
if self._heartbeat_task is None:
|
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||||
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 -----------------------------------------------------------
|
# -- heartbeat -----------------------------------------------------------
|
||||||
|
|
||||||
async def _heartbeat_loop(self) -> None:
|
async def _heartbeat_loop(self) -> None:
|
||||||
"""Periodically ping the Box runtime to detect silent disconnections."""
|
"""Periodically ping the Box runtime to detect silent disconnections."""
|
||||||
while True:
|
failures = 0
|
||||||
|
while not self._closing:
|
||||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC)
|
await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC)
|
||||||
try:
|
try:
|
||||||
await self.ping()
|
await self.ping()
|
||||||
|
failures = 0
|
||||||
self.ap.logger.debug('Heartbeat to Box runtime success.')
|
self.ap.logger.debug('Heartbeat to Box runtime success.')
|
||||||
except Exception as e:
|
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:
|
async def ping(self) -> None:
|
||||||
if self._handler is None:
|
if self._handler is None:
|
||||||
@@ -196,9 +238,11 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
# mirroring `rt -s`.
|
# mirroring `rt -s`.
|
||||||
args=['-m', 'langbot_plugin.cli.__init__', 'box', '-s', '--ws-control-port', str(self._relay_port)],
|
args=['-m', 'langbot_plugin.cli.__init__', 'box', '-s', '--ws-control-port', str(self._relay_port)],
|
||||||
env=env,
|
env=env,
|
||||||
|
capture_stderr=False,
|
||||||
)
|
)
|
||||||
|
self._ctrl = ctrl
|
||||||
self._ctrl_task = asyncio.create_task(
|
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:
|
try:
|
||||||
@@ -282,8 +326,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
await self.runtime_disconnect_callback(self)
|
await self.runtime_disconnect_callback(self)
|
||||||
|
|
||||||
ctrl = WebSocketClientController(ws_url=ws_url, make_connection_failed_callback=on_connect_failed)
|
ctrl = WebSocketClientController(ws_url=ws_url, make_connection_failed_callback=on_connect_failed)
|
||||||
|
self._ctrl = ctrl
|
||||||
self._ctrl_task = asyncio.create_task(
|
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:
|
try:
|
||||||
@@ -299,9 +344,29 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
transport_name: str,
|
transport_name: str,
|
||||||
connected: asyncio.Event,
|
connected: asyncio.Event,
|
||||||
connect_error: list[Exception],
|
connect_error: list[Exception],
|
||||||
|
generation: int,
|
||||||
):
|
):
|
||||||
async def new_connection_callback(connection: Connection) -> None:
|
async def new_connection_callback(connection: Connection) -> None:
|
||||||
|
if generation != self._generation or self._closing:
|
||||||
|
await connection.close()
|
||||||
|
return
|
||||||
handler = Handler(connection)
|
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._handler = handler
|
||||||
self.client.set_handler(handler)
|
self.client.set_handler(handler)
|
||||||
self._handler_task = asyncio.create_task(handler.run())
|
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)
|
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.debug('Sent box configuration to Box runtime via INIT.')
|
||||||
self.ap.logger.info(f'Connected to Box runtime via {transport_name}.')
|
self.ap.logger.info(f'Connected to Box runtime via {transport_name}.')
|
||||||
|
connection_ready = True
|
||||||
connected.set()
|
connected.set()
|
||||||
await self._handler_task
|
await self._handler_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if not connected.is_set():
|
if not connected.is_set():
|
||||||
connect_error.append(exc)
|
connect_error.append(exc)
|
||||||
connected.set()
|
connected.set()
|
||||||
return
|
return
|
||||||
|
finally:
|
||||||
# If we reach here, handler.run() returned normally (connection
|
if getattr(self, '_handler', None) is handler:
|
||||||
# closed) or raised after the initial handshake succeeded.
|
self._handler = None
|
||||||
# Either way, treat it as a disconnect.
|
self.client.set_handler(None)
|
||||||
if connected.is_set():
|
await notify_disconnect()
|
||||||
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.'
|
|
||||||
)
|
|
||||||
|
|
||||||
return new_connection_callback
|
return new_connection_callback
|
||||||
|
|
||||||
# -- lifecycle -----------------------------------------------------------
|
# -- 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:
|
def dispose(self) -> None:
|
||||||
|
"""Best-effort synchronous compatibility wrapper; prefer ``aclose``."""
|
||||||
|
self._closing = True
|
||||||
if self._heartbeat_task is not None:
|
if self._heartbeat_task is not None:
|
||||||
self._heartbeat_task.cancel()
|
self._heartbeat_task.cancel()
|
||||||
self._heartbeat_task = None
|
self._heartbeat_task = None
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import collections
|
import collections
|
||||||
|
import contextlib
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
import enum
|
import enum
|
||||||
import json
|
import json
|
||||||
@@ -65,6 +66,8 @@ class BoxService:
|
|||||||
self.workspace_quota_mb = self._load_workspace_quota_mb()
|
self.workspace_quota_mb = self._load_workspace_quota_mb()
|
||||||
self._recent_errors: collections.deque[dict] = collections.deque(maxlen=_MAX_RECENT_ERRORS)
|
self._recent_errors: collections.deque[dict] = collections.deque(maxlen=_MAX_RECENT_ERRORS)
|
||||||
self._shutdown_task = None
|
self._shutdown_task = None
|
||||||
|
self._reconnect_task: asyncio.Task | None = None
|
||||||
|
self._closing = False
|
||||||
self._available = False
|
self._available = False
|
||||||
self._connector_error: str = ''
|
self._connector_error: str = ''
|
||||||
self._reconnecting = False
|
self._reconnecting = False
|
||||||
@@ -110,6 +113,8 @@ class BoxService:
|
|||||||
self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}')
|
self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}')
|
||||||
self._available = False
|
self._available = False
|
||||||
self._connector_error = str(exc)
|
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:
|
async def _on_runtime_disconnect(self, connector: BoxRuntimeConnector) -> None:
|
||||||
"""Called by the connector when the Box runtime connection drops.
|
"""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
|
Skipped entirely when Box is disabled by config — that path should
|
||||||
never have connected in the first place.
|
never have connected in the first place.
|
||||||
"""
|
"""
|
||||||
if not self._enabled:
|
if not self._enabled or self._closing:
|
||||||
return
|
return
|
||||||
if self._reconnecting:
|
if self._reconnect_task is not None and not self._reconnect_task.done():
|
||||||
return # Another reconnect loop is already running
|
return # Another reconnect loop is already running
|
||||||
self._reconnecting = True
|
self._reconnecting = True
|
||||||
self._available = False
|
self._available = False
|
||||||
self._connector_error = 'Disconnected from Box runtime'
|
self._connector_error = 'Disconnected from Box runtime'
|
||||||
self.ap.logger.warning('Box runtime disconnected, sandbox features temporarily disabled.')
|
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:
|
async def _reconnect_loop(self, connector: BoxRuntimeConnector) -> None:
|
||||||
"""Retry reconnection with exponential backoff (3s → 60s max)."""
|
"""Retry reconnection with exponential backoff (3s → 60s max)."""
|
||||||
delay = 3
|
delay = 3
|
||||||
max_delay = 60
|
max_delay = 60
|
||||||
try:
|
try:
|
||||||
while True:
|
while not self._closing:
|
||||||
self.ap.logger.info(f'Attempting to reconnect to Box runtime in {delay}s...')
|
self.ap.logger.info(f'Attempting to reconnect to Box runtime in {delay}s...')
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
try:
|
try:
|
||||||
connector.dispose()
|
await connector.reconnect()
|
||||||
await connector.initialize()
|
self._ensure_default_workspace()
|
||||||
|
await self._purge_attachment_dirs()
|
||||||
self._available = True
|
self._available = True
|
||||||
self._connector_error = ''
|
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.')
|
self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -149,6 +159,7 @@ class BoxService:
|
|||||||
delay = min(delay * 2, max_delay)
|
delay = min(delay * 2, max_delay)
|
||||||
finally:
|
finally:
|
||||||
self._reconnecting = False
|
self._reconnecting = False
|
||||||
|
self._reconnect_task = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
@@ -838,14 +849,28 @@ class BoxService:
|
|||||||
return attachments
|
return attachments
|
||||||
|
|
||||||
async def shutdown(self):
|
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):
|
def dispose(self):
|
||||||
if self._runtime_connector is not None:
|
|
||||||
self._runtime_connector.dispose()
|
|
||||||
loop = getattr(self.ap, 'event_loop', None)
|
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()):
|
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())
|
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]:
|
async def get_sessions(self) -> list[dict]:
|
||||||
if not self._available:
|
if not self._available:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
import os
|
import os
|
||||||
|
import contextlib
|
||||||
|
|
||||||
from ..platform import botmgr as im_mgr
|
from ..platform import botmgr as im_mgr
|
||||||
from ..platform.webhook_pusher import WebhookPusher
|
from ..platform.webhook_pusher import WebhookPusher
|
||||||
@@ -166,7 +167,8 @@ class Application:
|
|||||||
maintenance_service: maintenance_service.MaintenanceService = None
|
maintenance_service: maintenance_service.MaintenanceService = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
self._shutdown_lock = asyncio.Lock()
|
||||||
|
self._shutdown_complete = False
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
pass
|
pass
|
||||||
@@ -318,7 +320,39 @@ class Application:
|
|||||||
return default
|
return default
|
||||||
return parsed
|
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):
|
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:
|
if self.plugin_connector is not None:
|
||||||
self.plugin_connector.dispose()
|
self.plugin_connector.dispose()
|
||||||
if self.box_service is not None:
|
if self.box_service is not None:
|
||||||
|
|||||||
@@ -46,21 +46,38 @@ async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:
|
|||||||
|
|
||||||
async def main(loop: asyncio.AbstractEventLoop):
|
async def main(loop: asyncio.AbstractEventLoop):
|
||||||
app_inst: app.Application | None = None
|
app_inst: app.Application | None = None
|
||||||
|
runtime_loop = asyncio.get_running_loop()
|
||||||
|
shutdown_requested = asyncio.Event()
|
||||||
|
run_task: asyncio.Task | None = None
|
||||||
try:
|
try:
|
||||||
# Hang system signal processing
|
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
if app_inst is not None:
|
|
||||||
app_inst.dispose()
|
|
||||||
print('[Signal] Program exit.')
|
print('[Signal] Program exit.')
|
||||||
os._exit(0)
|
runtime_loop.call_soon_threadsafe(shutdown_requested.set)
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
if hasattr(signal, 'SIGTERM'):
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
app_inst = await make_app(loop)
|
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:
|
except Exception:
|
||||||
if app_inst is not None:
|
|
||||||
app_inst.dispose()
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
if app_inst is not None:
|
||||||
|
await app_inst.shutdown()
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from .. import stage, app
|
from .. import stage, app
|
||||||
from ...utils import version, proxy
|
from ...utils import version, proxy
|
||||||
from ...pipeline import pool, controller, pipelinemgr
|
from ...pipeline import pool, controller, pipelinemgr
|
||||||
@@ -187,11 +185,16 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
ap.maintenance_service = maintenance_service_inst
|
ap.maintenance_service = maintenance_service_inst
|
||||||
|
|
||||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||||
await asyncio.sleep(3)
|
connector.schedule_reconnect()
|
||||||
await plugin_connector_inst.initialize()
|
|
||||||
|
|
||||||
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
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
|
ap.plugin_connector = plugin_connector_inst
|
||||||
|
|
||||||
ctrl = controller.Controller(ap)
|
ctrl = controller.Controller(ap)
|
||||||
|
|||||||
+281
-153
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import io
|
import io
|
||||||
import time
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -36,6 +37,12 @@ from ..core import taskmgr
|
|||||||
from ..entity.persistence import plugin as persistence_plugin
|
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):
|
class PluginRuntimeNotConnectedError(RuntimeError):
|
||||||
"""Raised when plugin runtime operations are requested before connection."""
|
"""Raised when plugin runtime operations are requested before connection."""
|
||||||
|
|
||||||
@@ -70,128 +77,235 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
super().__init__(ap)
|
super().__init__(ap)
|
||||||
self.runtime_disconnect_callback = runtime_disconnect_callback
|
self.runtime_disconnect_callback = runtime_disconnect_callback
|
||||||
self.is_enable_plugin = self.ap.instance_config.data.get('plugin', {}).get('enable', True)
|
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):
|
async def heartbeat_loop(self):
|
||||||
while True:
|
failures = 0
|
||||||
await asyncio.sleep(20)
|
while not self._closing:
|
||||||
|
await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC)
|
||||||
try:
|
try:
|
||||||
await self.ping_plugin_runtime()
|
await self.ping_plugin_runtime()
|
||||||
|
failures = 0
|
||||||
self.ap.logger.debug('Heartbeat to plugin runtime success.')
|
self.ap.logger.debug('Heartbeat to plugin runtime success.')
|
||||||
except Exception as e:
|
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):
|
async def initialize(self):
|
||||||
if not self.is_enable_plugin:
|
if not self.is_enable_plugin:
|
||||||
self.ap.logger.info('Plugin system is disabled.')
|
self.ap.logger.info('Plugin system is disabled.')
|
||||||
return
|
return
|
||||||
|
|
||||||
async def new_connection_callback(connection: base_connection.Connection):
|
async with self._lifecycle_lock:
|
||||||
async def disconnect_callback(
|
if self._closing:
|
||||||
rchandler: handler.RuntimeConnectionHandler,
|
raise PluginRuntimeNotConnectedError('Plugin runtime connector is shutting down')
|
||||||
) -> bool:
|
if self._connected.is_set() and hasattr(self, 'handler'):
|
||||||
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
|
return
|
||||||
self.ap.logger.error('Disconnected from plugin runtime, trying to reconnect...')
|
|
||||||
await self.runtime_disconnect_callback(self)
|
self._generation += 1
|
||||||
return False
|
generation = self._generation
|
||||||
else:
|
await self._stop_transport()
|
||||||
self.ap.logger.error(
|
self._connected = asyncio.Event()
|
||||||
'Disconnected from plugin runtime, cannot automatically reconnect while LangBot connects to plugin runtime via stdio, please restart LangBot.'
|
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
|
return False
|
||||||
|
|
||||||
self.handler = handler.RuntimeConnectionHandler(connection, disconnect_callback, self.ap)
|
runtime_handler = handler.RuntimeConnectionHandler(connection, disconnect_callback, self.ap)
|
||||||
|
self.handler = runtime_handler
|
||||||
self.handler_task = asyncio.create_task(self.handler.run())
|
self.handler_task = asyncio.create_task(runtime_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:
|
|
||||||
try:
|
try:
|
||||||
await self.handler.set_runtime_config(cloud_service_url=space_url)
|
await runtime_handler.ping()
|
||||||
self.ap.logger.info(f'Pushed marketplace URL to plugin runtime: {space_url}')
|
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
|
||||||
except Exception as e:
|
if space_url:
|
||||||
self.ap.logger.warning(f'Failed to push runtime config: {e}')
|
await runtime_handler.set_runtime_config(cloud_service_url=space_url)
|
||||||
self.ap.logger.info('Connected to plugin runtime.')
|
if generation == self._generation and not self._closing:
|
||||||
await self.handler_task
|
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
|
async def connection_failed(ctrl, exc=None):
|
||||||
self.ap.logger.info('use websocket to connect to plugin runtime')
|
error = exc or RuntimeError('WebSocket connection failed')
|
||||||
ws_url = self.ap.instance_config.data.get('plugin', {}).get(
|
connect_errors.append(error)
|
||||||
'runtime_ws_url', 'ws://langbot_plugin_runtime:5400/control/ws'
|
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(
|
async def aclose(self) -> None:
|
||||||
ctrl: ws_client_controller.WebSocketClientController,
|
self._closing = True
|
||||||
exc: Exception = None,
|
self._generation += 1
|
||||||
) -> None:
|
reconnect_task = self._reconnect_task
|
||||||
if exc is not None:
|
self._reconnect_task = None
|
||||||
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}')
|
if reconnect_task is not None and reconnect_task is not asyncio.current_task():
|
||||||
else:
|
reconnect_task.cancel()
|
||||||
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}), trying to reconnect...')
|
await asyncio.gather(reconnect_task, return_exceptions=True)
|
||||||
await self.runtime_disconnect_callback(self)
|
if self.heartbeat_task is not None:
|
||||||
|
self.heartbeat_task.cancel()
|
||||||
self.ctrl = ws_client_controller.WebSocketClientController(
|
await asyncio.gather(self.heartbeat_task, return_exceptions=True)
|
||||||
ws_url=ws_url,
|
self.heartbeat_task = None
|
||||||
make_connection_failed_callback=make_connection_failed_callback,
|
await self._stop_transport()
|
||||||
)
|
await self._close_managed_subprocess()
|
||||||
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 initialize_plugins(self):
|
async def initialize_plugins(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def ping_plugin_runtime(self):
|
async def ping_plugin_runtime(self):
|
||||||
if not hasattr(self, 'handler'):
|
return await self._runtime_handler().ping()
|
||||||
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
|
|
||||||
|
|
||||||
return await self.handler.ping()
|
|
||||||
|
|
||||||
def _inspect_plugin_package(
|
def _inspect_plugin_package(
|
||||||
self,
|
self,
|
||||||
@@ -473,7 +587,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
|
|
||||||
file_bytes = download_resp.content
|
file_bytes = download_resp.content
|
||||||
self._inspect_plugin_package(file_bytes, task_context)
|
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
|
install_info['plugin_file_key'] = file_key
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||||
# Continue to install via 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)
|
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||||
if task_context is not None and plugin_author and plugin_name:
|
if task_context is not None and plugin_author and plugin_name:
|
||||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{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
|
install_info['plugin_file_key'] = file_key
|
||||||
del install_info['plugin_file']
|
del install_info['plugin_file']
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
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)
|
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||||
if task_context is not None and plugin_author and plugin_name:
|
if task_context is not None and plugin_author and plugin_name:
|
||||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{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
|
install_info['plugin_file_key'] = file_key
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.error(f'Failed to download file from GitHub: {e}')
|
self.ap.logger.error(f'Failed to download file from GitHub: {e}')
|
||||||
raise Exception(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)
|
current_action = ret.get('current_action', None)
|
||||||
if current_action is not None:
|
if current_action is not None:
|
||||||
if task_context is not None:
|
if task_context is not None:
|
||||||
@@ -566,7 +680,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
plugin_name: str,
|
plugin_name: str,
|
||||||
task_context: taskmgr.TaskContext | None = None,
|
task_context: taskmgr.TaskContext | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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)
|
current_action = ret.get('current_action', None)
|
||||||
if current_action is not None:
|
if current_action is not None:
|
||||||
if task_context is not None:
|
if task_context is not None:
|
||||||
@@ -584,7 +698,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
delete_data: bool = False,
|
delete_data: bool = False,
|
||||||
task_context: taskmgr.TaskContext | None = None,
|
task_context: taskmgr.TaskContext | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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)
|
current_action = ret.get('current_action', None)
|
||||||
if current_action is not None:
|
if current_action is not None:
|
||||||
if task_context is not None:
|
if task_context is not None:
|
||||||
@@ -599,7 +713,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
if delete_data:
|
if delete_data:
|
||||||
if task_context is not None:
|
if task_context is not None:
|
||||||
task_context.trace('Cleaning up plugin configuration and storage...')
|
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]]:
|
async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]:
|
||||||
"""List plugins, optionally filtered by component kinds.
|
"""List plugins, optionally filtered by component kinds.
|
||||||
@@ -610,10 +724,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
component of the specified kinds will be returned.
|
component of the specified kinds will be returned.
|
||||||
E.g., ['Command', 'EventListener', 'Tool'] for pipeline-related plugins.
|
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 []
|
return []
|
||||||
|
|
||||||
plugins = await self.handler.list_plugins()
|
plugins = await self._runtime_handler().list_plugins()
|
||||||
|
|
||||||
# Filter plugins by component kinds if specified
|
# Filter plugins by component kinds if specified
|
||||||
if component_kinds is not None:
|
if component_kinds is not None:
|
||||||
@@ -685,18 +799,18 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
return plugins
|
return plugins
|
||||||
|
|
||||||
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
|
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]:
|
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
|
@alru_cache(ttl=5 * 60) # 5 minutes
|
||||||
async def get_plugin_icon(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:
|
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
|
@alru_cache(ttl=5 * 60) # 5 minutes
|
||||||
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
|
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(
|
async def get_plugin_logs(
|
||||||
self,
|
self,
|
||||||
@@ -706,11 +820,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
level: str | None = None,
|
level: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
# Not cached: logs are live and change constantly.
|
# 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)
|
@alru_cache(ttl=5 * 60)
|
||||||
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
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(
|
async def handle_page_api(
|
||||||
self,
|
self,
|
||||||
@@ -721,13 +835,15 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
method: str,
|
method: str,
|
||||||
body: Any = None,
|
body: Any = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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]:
|
async def get_debug_info(self) -> dict[str, Any]:
|
||||||
"""Get debug information including debug key and WS URL"""
|
"""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 {}
|
||||||
return await self.handler.get_debug_info()
|
return await self._runtime_handler().get_debug_info()
|
||||||
|
|
||||||
async def emit_event(
|
async def emit_event(
|
||||||
self,
|
self,
|
||||||
@@ -736,13 +852,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
) -> context.EventContext:
|
) -> context.EventContext:
|
||||||
event_ctx = context.EventContext.from_event(event)
|
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._emitted_plugins = []
|
||||||
event_ctx._response_sources = []
|
event_ctx._response_sources = []
|
||||||
return event_ctx
|
return event_ctx
|
||||||
|
|
||||||
# Pass include_plugins to runtime for filtering
|
# 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
|
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:
|
async def notify_plugin_diagnostic(self, diagnostic: dict[str, Any]) -> None:
|
||||||
"""Best-effort diagnostic forwarding to the plugin runtime."""
|
"""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
|
return
|
||||||
try:
|
try:
|
||||||
await self.handler.notify_plugin_diagnostic(diagnostic)
|
await self._runtime_handler().notify_plugin_diagnostic(diagnostic)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.debug(f'Plugin diagnostic forwarding skipped: {e}')
|
self.ap.logger.debug(f'Plugin diagnostic forwarding skipped: {e}')
|
||||||
|
|
||||||
async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
|
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 []
|
return []
|
||||||
|
|
||||||
# Pass include_plugins to runtime for filtering
|
# 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]
|
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'}
|
return {'error': 'Tool not found: plugin system is disabled'}
|
||||||
|
|
||||||
# Pass include_plugins to runtime for validation
|
# 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
|
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]:
|
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 []
|
return []
|
||||||
|
|
||||||
# Pass include_plugins to runtime for filtering
|
# 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]
|
commands = [ComponentManifest.model_validate(command) for command in list_commands_data]
|
||||||
|
|
||||||
@@ -803,12 +922,15 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
async def execute_command(
|
async def execute_command(
|
||||||
self, command_ctx: command_context.ExecuteContext, bound_plugins: list[str] | None = None
|
self, command_ctx: command_context.ExecuteContext, bound_plugins: list[str] | None = None
|
||||||
) -> typing.AsyncGenerator[command_context.CommandReturn, 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))
|
yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Pass include_plugins to runtime for validation
|
# 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:
|
async for ret in gen:
|
||||||
cmd_ret = command_context.CommandReturn.model_validate(ret)
|
cmd_ret = command_context.CommandReturn.model_validate(ret)
|
||||||
@@ -823,27 +945,33 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
retrieval_context: dict[str, Any],
|
retrieval_context: dict[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Retrieve knowledge using a KnowledgeEngine instance."""
|
"""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 {'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):
|
def dispose(self):
|
||||||
# On non-Windows stdio mode, terminate via the controller's process handle.
|
"""Best-effort synchronous compatibility wrapper; prefer ``aclose``."""
|
||||||
# On Windows, the managed subprocess is cleaned up by the base class.
|
self._closing = True
|
||||||
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()
|
|
||||||
|
|
||||||
if self.heartbeat_task is not None:
|
if self.heartbeat_task is not None:
|
||||||
self.heartbeat_task.cancel()
|
self.heartbeat_task.cancel()
|
||||||
self.heartbeat_task = None
|
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
|
@staticmethod
|
||||||
def _parse_plugin_id(plugin_id: str) -> tuple[str, str]:
|
def _parse_plugin_id(plugin_id: str) -> tuple[str, str]:
|
||||||
@@ -873,29 +1001,29 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
context_data: IngestionContext data.
|
context_data: IngestionContext data.
|
||||||
"""
|
"""
|
||||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
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:
|
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)
|
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]:
|
async def get_rag_creation_schema(self, plugin_id: str) -> dict[str, Any]:
|
||||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
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]:
|
async def get_rag_retrieval_schema(self, plugin_id: str) -> dict[str, Any]:
|
||||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
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]:
|
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."""
|
"""Notify plugin about KB creation."""
|
||||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
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]:
|
async def rag_on_kb_delete(self, plugin_id: str, kb_id: str) -> dict[str, Any]:
|
||||||
"""Notify plugin about KB deletion."""
|
"""Notify plugin about KB deletion."""
|
||||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
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]:
|
async def call_rag_retrieve(self, plugin_id: str, retrieval_context: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Call plugin to retrieve knowledge.
|
"""Call plugin to retrieve knowledge.
|
||||||
@@ -905,25 +1033,25 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
retrieval_context: RetrievalContext data.
|
retrieval_context: RetrievalContext data.
|
||||||
"""
|
"""
|
||||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
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]]:
|
async def list_knowledge_engines(self) -> list[dict[str, Any]]:
|
||||||
"""List all available Knowledge Engines from plugins.
|
"""List all available Knowledge Engines from plugins.
|
||||||
|
|
||||||
Returns a list of Knowledge Engines with their capabilities and configuration schemas.
|
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 []
|
||||||
|
|
||||||
return await self.handler.list_knowledge_engines()
|
return await self._runtime_handler().list_knowledge_engines()
|
||||||
|
|
||||||
async def list_parsers(self) -> list[dict[str, Any]]:
|
async def list_parsers(self) -> list[dict[str, Any]]:
|
||||||
"""List all available parsers from plugins."""
|
"""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 []
|
||||||
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]:
|
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."""
|
"""Call plugin to parse a document."""
|
||||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
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)
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import enum
|
import enum
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
|
from datetime import timedelta
|
||||||
import traceback
|
import traceback
|
||||||
from langbot_plugin.api.entities.events import pipeline_query
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
import uuid as uuid_module
|
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.resource.tool as resource_tool
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
from ....entity.persistence import mcp as persistence_mcp
|
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).
|
# Synthesized LLM tools for MCP resources (not from server tools/list).
|
||||||
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
|
# 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_TRACE_QUERY_KEY = '_mcp_resource_reads'
|
||||||
MCP_RESOURCE_LINKS_QUERY_KEY = '_mcp_resource_links'
|
MCP_RESOURCE_LINKS_QUERY_KEY = '_mcp_resource_links'
|
||||||
MCP_RESOURCE_CONTEXT_QUERY_KEY = '_mcp_resource_context'
|
MCP_RESOURCE_CONTEXT_QUERY_KEY = '_mcp_resource_context'
|
||||||
|
MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS = 300.0
|
||||||
|
|
||||||
TEXT_LIKE_MIME_TYPES = {
|
TEXT_LIKE_MIME_TYPES = {
|
||||||
'application/json',
|
'application/json',
|
||||||
@@ -206,6 +216,10 @@ class _CallerReconnect(Exception):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class MCPToolCallTimeoutError(TimeoutError):
|
||||||
|
"""An MCP tool call exceeded its configured per-server deadline."""
|
||||||
|
|
||||||
|
|
||||||
class RuntimeMCPSession:
|
class RuntimeMCPSession:
|
||||||
"""运行时 MCP 会话"""
|
"""运行时 MCP 会话"""
|
||||||
|
|
||||||
@@ -255,6 +269,9 @@ class RuntimeMCPSession:
|
|||||||
self.ap = ap
|
self.ap = ap
|
||||||
self.enable = enable
|
self.enable = enable
|
||||||
self.session = None
|
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,
|
# Transient test sessions (created from the config page "test" button,
|
||||||
# which carry no persisted server UUID) must NOT share the live
|
# 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_stdio_runtime = BoxStdioSessionRuntime(self)
|
||||||
self.box_config = self._box_stdio_runtime.config
|
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):
|
async def _init_stdio_python_server(self):
|
||||||
if self._uses_box_stdio():
|
if self._uses_box_stdio():
|
||||||
await self._box_stdio_runtime.initialize()
|
await self._box_stdio_runtime.initialize()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Box is configured (ap.box_service exists) but currently unavailable
|
# Box is configured but explicitly disabled. Refuse stdio MCP rather
|
||||||
# (disabled by config or connection failed). Refuse stdio MCP rather
|
# than silently falling through to host-stdio — the operator asked for
|
||||||
# than silently falling through to host-stdio — the operator asked
|
# the sandbox and the failure mode should be visible. An enabled Box
|
||||||
# for the sandbox and the failure mode should be visible.
|
# that is reconnecting is handled above and waits for availability.
|
||||||
#
|
#
|
||||||
# Set ``error_phase = BOX_UNAVAILABLE`` BEFORE raising so the retry
|
# Set ``error_phase = BOX_UNAVAILABLE`` BEFORE raising so the retry
|
||||||
# wrapper can short-circuit (retrying is pointless when Box is
|
# wrapper can distinguish a deliberately disabled Box from an enabled
|
||||||
# deliberately off) and the frontend can render a localized,
|
# runtime that is still reconnecting. Keep the message itself short —
|
||||||
# actionable message instead of this raw RuntimeError. Keep the
|
# the frontend ignores it for this phase.
|
||||||
# message itself short — the frontend ignores it for this phase.
|
|
||||||
box_service = getattr(self.ap, 'box_service', None)
|
box_service = getattr(self.ap, 'box_service', None)
|
||||||
if box_service is not None and not getattr(box_service, 'available', False):
|
if box_service is not None and not getattr(box_service, 'available', False):
|
||||||
self.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE
|
self.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE
|
||||||
@@ -613,17 +648,29 @@ class RuntimeMCPSession:
|
|||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.retry_count = attempt + 1
|
|
||||||
if self._shutdown_event.is_set():
|
if self._shutdown_event.is_set():
|
||||||
return # Shutdown requested, don't retry
|
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:
|
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.status = MCPSessionStatus.ERROR
|
||||||
self.error_message = str(e)
|
self.error_message = str(e)
|
||||||
self._ready_event.set()
|
self._ready_event.set()
|
||||||
return
|
return
|
||||||
|
self.retry_count = attempt + 1
|
||||||
if attempt >= self._MAX_RETRIES:
|
if attempt >= self._MAX_RETRIES:
|
||||||
self.status = MCPSessionStatus.ERROR
|
self.status = MCPSessionStatus.ERROR
|
||||||
self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {self._describe_exception(e)}'
|
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')
|
raise Exception('MCP session is not connected')
|
||||||
|
|
||||||
try:
|
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:
|
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):
|
if attempt == 0 and self._is_session_terminated(e):
|
||||||
self.ap.logger.warning(
|
self.ap.logger.warning(
|
||||||
f'MCP tool {tool_name} on {self.server_name} got session terminated, triggering reconnect...'
|
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')
|
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]:
|
def get_tools(self) -> list[resource_tool.LLMTool]:
|
||||||
return self.functions
|
return self.functions
|
||||||
|
|
||||||
@@ -1206,15 +1277,34 @@ class RuntimeMCPSession:
|
|||||||
return self._box_stdio_runtime.uses_box_stdio()
|
return self._box_stdio_runtime.uses_box_stdio()
|
||||||
|
|
||||||
def _build_box_session_id(self) -> str:
|
def _build_box_session_id(self) -> str:
|
||||||
# Both live servers and transient config-page tests share ONE Box
|
# Compatible MCP servers share a session and remain isolated by
|
||||||
# session ('mcp-shared'). A test therefore reuses the already-running
|
# process_id. A server with a different immutable resource profile gets
|
||||||
# container (and, for an existing server, its live managed process)
|
# another session; Docker/E2B cannot change memory/image/etc. after a
|
||||||
# instead of paying a full per-test session cold-start + dependency
|
# session has been created.
|
||||||
# bootstrap. Isolation between a test and the live servers is provided
|
config = self._box_stdio_runtime.config
|
||||||
# at the *process* level: each server/test has its own process_id and a
|
default_memory = _get_default_memory_mb(self.ap)
|
||||||
# test only ever stops its own process_id (see cleanup_session), so it
|
profile = {
|
||||||
# never disturbs another server's process or the shared session itself.
|
'image': config.image,
|
||||||
return 'mcp-shared'
|
'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:
|
def _rewrite_path(self, path: str, host_path: str | None) -> str:
|
||||||
return self._box_stdio_runtime.rewrite_path(path, host_path)
|
return self._box_stdio_runtime.rewrite_path(path, host_path)
|
||||||
@@ -1797,11 +1887,23 @@ class MCPLoader(loader.ToolLoader):
|
|||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
"""关闭所有工具"""
|
"""关闭所有工具"""
|
||||||
self.ap.logger.info('Shutting down all MCP sessions...')
|
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:
|
try:
|
||||||
await session.shutdown()
|
await session.shutdown()
|
||||||
self.ap.logger.debug(f'Shutdown MCP session: {server_name}')
|
self.ap.logger.debug(f'Shutdown MCP session: {server_name}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.error(f'Error shutting down MCP session {server_name}: {e}\n{traceback.format_exc()}')
|
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.sessions.clear()
|
||||||
self.ap.logger.info('All MCP sessions shutdown complete')
|
self.ap.logger.info('All MCP sessions shutdown complete')
|
||||||
|
|||||||
@@ -185,11 +185,10 @@ class BoxStdioSessionRuntime:
|
|||||||
box_service = getattr(self.ap, 'box_service', None)
|
box_service = getattr(self.ap, 'box_service', None)
|
||||||
if box_service is None:
|
if box_service is None:
|
||||||
return False
|
return False
|
||||||
# When Box is configured but currently unavailable (disabled or
|
# An enabled Box service remains the required transport while it is
|
||||||
# connection failed), do NOT silently fall through to host-stdio —
|
# reconnecting. initialize() waits for availability instead of
|
||||||
# that would bypass the sandbox the operator asked for. The caller
|
# permanently failing the MCP server or falling through to host stdio.
|
||||||
# is expected to refuse the stdio MCP server with a clear error.
|
return bool(getattr(box_service, 'enabled', True))
|
||||||
return bool(getattr(box_service, 'available', False))
|
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
await self._wait_for_box_runtime()
|
await self._wait_for_box_runtime()
|
||||||
@@ -488,7 +487,7 @@ class BoxStdioSessionRuntime:
|
|||||||
)
|
)
|
||||||
warned = True
|
warned = True
|
||||||
if asyncio.get_running_loop().time() >= deadline:
|
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')
|
raise Exception(f'Box runtime is not available after {int(timeout_sec)} seconds')
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class NativeToolLoader(loader.ToolLoader):
|
|||||||
return await is_box_backend_available(self.ap)
|
return await is_box_backend_available(self.ap)
|
||||||
|
|
||||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
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 []
|
return []
|
||||||
if self._tools is None:
|
if self._tools is None:
|
||||||
self._tools = [
|
self._tools = [
|
||||||
@@ -71,7 +71,7 @@ class NativeToolLoader(loader.ToolLoader):
|
|||||||
return list(self._tools)
|
return list(self._tools)
|
||||||
|
|
||||||
async def has_tool(self, name: str) -> bool:
|
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):
|
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query):
|
||||||
if name == EXEC_TOOL_NAME:
|
if name == EXEC_TOOL_NAME:
|
||||||
@@ -652,12 +652,9 @@ else:
|
|||||||
if callable(refresh_skill):
|
if callable(refresh_skill):
|
||||||
refresh_skill(selected_skill.get('name', ''))
|
refresh_skill(selected_skill.get('name', ''))
|
||||||
|
|
||||||
def _is_sandbox_available(self) -> bool:
|
async def _is_sandbox_available(self) -> bool:
|
||||||
"""Check if sandbox backend is available.
|
"""Refresh backend availability so Box reconnects restore tool exposure."""
|
||||||
|
self._backend_available = await self._check_backend_available()
|
||||||
This checks the cached backend availability from initialization,
|
|
||||||
not just whether the box_service process is running.
|
|
||||||
"""
|
|
||||||
return bool(self._backend_available)
|
return bool(self._backend_available)
|
||||||
|
|
||||||
def _build_exec_tool(self) -> resource_tool.LLMTool:
|
def _build_exec_tool(self) -> resource_tool.LLMTool:
|
||||||
|
|||||||
@@ -49,19 +49,27 @@ class SkillToolLoader(loader.ToolLoader):
|
|||||||
return await is_box_backend_available(self.ap)
|
return await is_box_backend_available(self.ap)
|
||||||
|
|
||||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
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 []
|
return []
|
||||||
|
if not self._tools:
|
||||||
|
self._tools = [
|
||||||
|
self._build_activate_skill_tool(),
|
||||||
|
self._build_register_skill_tool(),
|
||||||
|
]
|
||||||
return list(self._tools)
|
return list(self._tools)
|
||||||
|
|
||||||
async def has_tool(self, name: str) -> bool:
|
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.
|
"""Check if skill tools should be available.
|
||||||
|
|
||||||
Skill tools require both a skill manager and a sandbox backend.
|
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:
|
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
|
||||||
if name == ACTIVATE_SKILL_TOOL_NAME:
|
if name == ACTIVATE_SKILL_TOOL_NAME:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING, Awaitable, Callable
|
from typing import TYPE_CHECKING, Awaitable, Callable
|
||||||
@@ -27,6 +28,8 @@ class ManagedRuntimeConnector:
|
|||||||
self.ap = ap
|
self.ap = ap
|
||||||
self.runtime_subprocess = None
|
self.runtime_subprocess = None
|
||||||
self.runtime_subprocess_task = None
|
self.runtime_subprocess_task = None
|
||||||
|
self._lifecycle_lock = asyncio.Lock()
|
||||||
|
self._closing = False
|
||||||
|
|
||||||
async def _start_runtime_subprocess(self, *args: str) -> None:
|
async def _start_runtime_subprocess(self, *args: str) -> None:
|
||||||
"""Launch a local runtime as a subprocess of the current Python interpreter.
|
"""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:
|
if self.runtime_subprocess_task is not None:
|
||||||
self.runtime_subprocess_task.cancel()
|
self.runtime_subprocess_task.cancel()
|
||||||
self.runtime_subprocess_task = None
|
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
|
||||||
|
|||||||
@@ -143,6 +143,11 @@ box:
|
|||||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
||||||
runtime:
|
runtime:
|
||||||
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed 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:
|
local:
|
||||||
profile: 'default'
|
profile: 'default'
|
||||||
image: '' # Custom local sandbox image. Leave empty to use the profile default.
|
image: '' # Custom local sandbox image. Leave empty to use the profile default.
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from langbot_plugin.box.client import ActionRPCBoxClient
|
from langbot_plugin.box.client import ActionRPCBoxClient
|
||||||
|
from langbot.pkg.box import connector as connector_module
|
||||||
from langbot.pkg.box.connector import BoxRuntimeConnector
|
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()
|
ctrl_task.cancel.assert_called_once()
|
||||||
assert connector._handler_task is None
|
assert connector._handler_task is None
|
||||||
assert connector._ctrl_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()
|
||||||
|
|||||||
@@ -325,10 +325,50 @@ async def test_box_service_dispose_schedules_shutdown_on_event_loop(monkeypatch:
|
|||||||
service.dispose()
|
service.dispose()
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
connector.dispose.assert_called_once()
|
connector.dispose.assert_not_called()
|
||||||
service.shutdown.assert_awaited_once()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_box_runtime_reuses_request_session():
|
async def test_box_runtime_reuses_request_session():
|
||||||
logger = Mock()
|
logger = Mock()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import signal
|
import signal
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -18,47 +19,60 @@ async def test_main_signal_handler_handles_sigint_before_app_created(monkeypatch
|
|||||||
async def fake_make_app(loop):
|
async def fake_make_app(loop):
|
||||||
captured_handler[signal.SIGINT](signal.SIGINT, None)
|
captured_handler[signal.SIGINT](signal.SIGINT, None)
|
||||||
|
|
||||||
def fake_exit(code):
|
|
||||||
raise SystemExit(code)
|
|
||||||
|
|
||||||
monkeypatch.setattr(signal, 'signal', fake_signal)
|
monkeypatch.setattr(signal, 'signal', fake_signal)
|
||||||
monkeypatch.setattr(boot, 'make_app', fake_make_app)
|
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
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_main_signal_handler_disposes_created_app(monkeypatch):
|
async def test_main_signal_handler_disposes_created_app(monkeypatch):
|
||||||
captured_handler = {}
|
captured_handler = {}
|
||||||
app_inst = SimpleNamespace(disposed=False)
|
app_inst = SimpleNamespace(shutdown_called=False)
|
||||||
|
|
||||||
def fake_signal(sig, handler):
|
def fake_signal(sig, handler):
|
||||||
captured_handler[sig] = handler
|
captured_handler[sig] = handler
|
||||||
|
|
||||||
def dispose():
|
async def shutdown():
|
||||||
app_inst.disposed = True
|
app_inst.shutdown_called = True
|
||||||
|
|
||||||
async def run():
|
async def run():
|
||||||
captured_handler[signal.SIGINT](signal.SIGINT, None)
|
captured_handler[signal.SIGINT](signal.SIGINT, None)
|
||||||
|
|
||||||
async def fake_make_app(loop):
|
async def fake_make_app(loop):
|
||||||
app_inst.dispose = dispose
|
app_inst.shutdown = shutdown
|
||||||
app_inst.run = run
|
app_inst.run = run
|
||||||
return app_inst
|
return app_inst
|
||||||
|
|
||||||
def fake_exit(code):
|
|
||||||
raise SystemExit(code)
|
|
||||||
|
|
||||||
monkeypatch.setattr(signal, 'signal', fake_signal)
|
monkeypatch.setattr(signal, 'signal', fake_signal)
|
||||||
monkeypatch.setattr(boot, 'make_app', fake_make_app)
|
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.shutdown_called is True
|
||||||
assert app_inst.disposed 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 == []
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_calls_handler_list_plugins(self):
|
async def test_calls_handler_list_plugins(self):
|
||||||
"""Test that handler.list_plugins is called."""
|
"""Test that handler.list_plugins is called."""
|
||||||
@@ -294,6 +313,12 @@ class TestListKnowledgeEngines:
|
|||||||
connector.handler.list_knowledge_engines.assert_called_once()
|
connector.handler.list_knowledge_engines.assert_called_once()
|
||||||
assert result == [{'plugin_id': 'author/engine', 'name': 'Engine'}]
|
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:
|
class TestListParsers:
|
||||||
"""Tests for list_parsers method."""
|
"""Tests for list_parsers method."""
|
||||||
@@ -331,6 +356,12 @@ class TestListParsers:
|
|||||||
connector.handler.list_parsers.assert_called_once()
|
connector.handler.list_parsers.assert_called_once()
|
||||||
assert result == [{'plugin_id': 'author/parser', 'supported_mime_types': ['text/plain']}]
|
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:
|
class TestCallParser:
|
||||||
"""Tests for call_parser method."""
|
"""Tests for call_parser method."""
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from langbot.pkg.plugin import connector as connector_module
|
||||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector, PluginRuntimeNotConnectedError
|
from langbot.pkg.plugin.connector import PluginRuntimeConnector, PluginRuntimeNotConnectedError
|
||||||
|
|
||||||
|
|
||||||
def make_connector() -> PluginRuntimeConnector:
|
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())
|
return PluginRuntimeConnector(app, AsyncMock())
|
||||||
|
|
||||||
|
|
||||||
@@ -30,3 +35,133 @@ async def test_ping_plugin_runtime_delegates_to_connected_handler():
|
|||||||
|
|
||||||
assert result == 'pong'
|
assert result == 'pong'
|
||||||
connector.handler.ping.assert_awaited_once()
|
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')
|
payload = s._build_box_session_payload('session-123')
|
||||||
assert payload['image'] == 'node:20'
|
assert payload['image'] == 'node:20'
|
||||||
assert payload['cpus'] == 2.0
|
assert payload['cpus'] == 2.0
|
||||||
assert payload["memory_mb"] == 1024
|
assert payload['memory_mb'] == 1024
|
||||||
assert payload['pids_limit'] == 256
|
assert payload['pids_limit'] == 256
|
||||||
|
|
||||||
def test_none_fields_excluded(self, mcp_module):
|
def test_none_fields_excluded(self, mcp_module):
|
||||||
@@ -680,14 +680,55 @@ class TestGetRuntimeInfoDict:
|
|||||||
# ... but are isolated by distinct process_ids within that session.
|
# ... but are isolated by distinct process_ids within that session.
|
||||||
assert transient._box_stdio_runtime.process_id != live._box_stdio_runtime.process_id
|
assert transient._box_stdio_runtime.process_id != live._box_stdio_runtime.process_id
|
||||||
|
|
||||||
def test_stdio_session_refuses_when_box_unavailable(self, mcp_module):
|
def test_different_resource_profiles_use_different_box_sessions(self, mcp_module):
|
||||||
"""Policy: when Box is configured but unavailable (disabled in config
|
ap = _make_ap()
|
||||||
OR connection failed), stdio MCP servers are NOT treated as box-stdio.
|
ap.box_service.available = True
|
||||||
``_init_stdio_python_server`` will raise a clear refusal at start
|
default = _make_session(
|
||||||
time; until then, the runtime info simply omits box_session_id so the
|
mcp_module,
|
||||||
UI can render the disabled state cleanly."""
|
{
|
||||||
|
'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 = _make_ap()
|
||||||
ap.box_service.available = False
|
ap.box_service.available = False
|
||||||
|
ap.box_service.enabled = True
|
||||||
s = _make_session(
|
s = _make_session(
|
||||||
mcp_module,
|
mcp_module,
|
||||||
{
|
{
|
||||||
@@ -700,9 +741,125 @@ class TestGetRuntimeInfoDict:
|
|||||||
ap=ap,
|
ap=ap,
|
||||||
)
|
)
|
||||||
info = s.get_runtime_info_dict()
|
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_session_id' not in info
|
||||||
assert 'box_enabled' 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):
|
def test_stdio_session_without_box_service_uses_local_stdio(self, mcp_module):
|
||||||
ap = _make_ap()
|
ap = _make_ap()
|
||||||
del ap.box_service
|
del ap.box_service
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import pytest
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from mcp import types as mcp_types
|
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:
|
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=202)
|
||||||
return web.Response(status=self.streamable_status)
|
return web.Response(status=self.streamable_status)
|
||||||
|
|
||||||
@@ -126,11 +145,22 @@ async def _transport_server(streamable_status: int | None):
|
|||||||
await runner.cleanup()
|
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()))
|
app = cast(Any, SimpleNamespace(logger=Mock()))
|
||||||
return RuntimeMCPSession(
|
return RuntimeMCPSession(
|
||||||
'remote-transport-test',
|
'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,
|
True,
|
||||||
app,
|
app,
|
||||||
)
|
)
|
||||||
@@ -164,6 +194,24 @@ async def test_remote_transport_real_streamable_http_success_keeps_session_usabl
|
|||||||
await _close_session(session)
|
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.asyncio
|
||||||
@pytest.mark.parametrize('status_code', [400, 404, 405])
|
@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):
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
from datetime import timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from mcp import types as mcp_types
|
from mcp import types as mcp_types
|
||||||
|
from mcp.shared.exceptions import McpError
|
||||||
|
|
||||||
from langbot.pkg.provider.tools.loaders.mcp import (
|
from langbot.pkg.provider.tools.loaders.mcp import (
|
||||||
MCP_RESOURCE_CONTEXT_QUERY_KEY,
|
MCP_RESOURCE_CONTEXT_QUERY_KEY,
|
||||||
MCP_RESOURCE_TRACE_QUERY_KEY,
|
MCP_RESOURCE_TRACE_QUERY_KEY,
|
||||||
|
MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS,
|
||||||
MCP_TOOL_LIST_RESOURCES,
|
MCP_TOOL_LIST_RESOURCES,
|
||||||
MCP_TOOL_READ_RESOURCE,
|
MCP_TOOL_READ_RESOURCE,
|
||||||
MCPLoader,
|
MCPLoader,
|
||||||
MCPSessionStatus,
|
MCPSessionStatus,
|
||||||
|
MCPToolCallTimeoutError,
|
||||||
RuntimeMCPSession,
|
RuntimeMCPSession,
|
||||||
)
|
)
|
||||||
from langbot.pkg.telemetry import features as telemetry_features
|
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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_remote_transport_falls_back_to_sse_for_compatible_http_status_in_exception_group():
|
async def test_remote_transport_falls_back_to_sse_for_compatible_http_status_in_exception_group():
|
||||||
session = RuntimeMCPSession(
|
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
|
assert query.variables[MCP_RESOURCE_CONTEXT_QUERY_KEY]['resource_count'] == 1
|
||||||
docs.session.read_resource.assert_awaited_once()
|
docs.session.read_resource.assert_awaited_once()
|
||||||
other.session.read_resource.assert_not_called()
|
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('activate') is True
|
||||||
assert await loader.has_tool('register_skill') 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:
|
class TestNativeToolLoaderSkillPaths:
|
||||||
@pytest.mark.asyncio
|
@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
|
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 ─────────────────────────────
|
# ── read/write/edit file tool tests ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2124,7 +2124,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ 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", specifier = ">=1.3.9" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2189,7 +2189,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.4.13"
|
version = "0.4.17"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2210,9 +2210,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|||||||
@@ -435,6 +435,10 @@ const getFormSchema = (t: TFunction) =>
|
|||||||
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
||||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||||
.default(30),
|
.default(30),
|
||||||
|
tool_call_timeout_sec: z
|
||||||
|
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
||||||
|
.nonnegative({ message: t('mcp.timeoutNonNegative') })
|
||||||
|
.default(300),
|
||||||
ssereadtimeout: z
|
ssereadtimeout: z
|
||||||
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
|
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
|
||||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||||
@@ -474,6 +478,7 @@ const getFormSchema = (t: TFunction) =>
|
|||||||
|
|
||||||
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
|
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
|
||||||
timeout: number;
|
timeout: number;
|
||||||
|
tool_call_timeout_sec: number;
|
||||||
ssereadtimeout: number;
|
ssereadtimeout: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -535,6 +540,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
command: '',
|
command: '',
|
||||||
args: [],
|
args: [],
|
||||||
timeout: 30,
|
timeout: 30,
|
||||||
|
tool_call_timeout_sec: 300,
|
||||||
ssereadtimeout: 300,
|
ssereadtimeout: 300,
|
||||||
extra_args: [],
|
extra_args: [],
|
||||||
...initialDraftRef.current,
|
...initialDraftRef.current,
|
||||||
@@ -609,6 +615,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
command: '',
|
command: '',
|
||||||
args: [],
|
args: [],
|
||||||
timeout: 30,
|
timeout: 30,
|
||||||
|
tool_call_timeout_sec: 300,
|
||||||
ssereadtimeout: 300,
|
ssereadtimeout: 300,
|
||||||
extra_args: [],
|
extra_args: [],
|
||||||
...initialDraftRef.current,
|
...initialDraftRef.current,
|
||||||
@@ -687,10 +694,16 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
command: '',
|
command: '',
|
||||||
args: [],
|
args: [],
|
||||||
timeout: 30,
|
timeout: 30,
|
||||||
|
tool_call_timeout_sec: 300,
|
||||||
ssereadtimeout: 300,
|
ssereadtimeout: 300,
|
||||||
extra_args: [],
|
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: {
|
let newExtraArgs: {
|
||||||
key: string;
|
key: string;
|
||||||
type: 'string' | 'number' | 'boolean';
|
type: 'string' | 'number' | 'boolean';
|
||||||
@@ -770,6 +783,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
url: value.url!,
|
url: value.url!,
|
||||||
headers,
|
headers,
|
||||||
timeout: value.timeout,
|
timeout: value.timeout,
|
||||||
|
tool_call_timeout_sec: value.tool_call_timeout_sec,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -786,6 +800,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
command: value.command!,
|
command: value.command!,
|
||||||
args: value.args?.map((arg) => arg.value) || [],
|
args: value.args?.map((arg) => arg.value) || [],
|
||||||
env,
|
env,
|
||||||
|
tool_call_timeout_sec: value.tool_call_timeout_sec,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -835,6 +850,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
extraArgsData = {
|
extraArgsData = {
|
||||||
url: form.getValues('url')!,
|
url: form.getValues('url')!,
|
||||||
timeout: form.getValues('timeout'),
|
timeout: form.getValues('timeout'),
|
||||||
|
tool_call_timeout_sec: form.getValues('tool_call_timeout_sec'),
|
||||||
headers: Object.fromEntries(
|
headers: Object.fromEntries(
|
||||||
formExtraArgs.map((arg) => [arg.key, arg.value]),
|
formExtraArgs.map((arg) => [arg.key, arg.value]),
|
||||||
),
|
),
|
||||||
@@ -846,6 +862,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
env: Object.fromEntries(
|
env: Object.fromEntries(
|
||||||
formExtraArgs.map((arg) => [arg.key, arg.value]),
|
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' && (
|
{watchMode === 'remote' && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -519,18 +519,21 @@ export interface MCPServerExtraArgsSSE {
|
|||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
ssereadtimeout: number;
|
ssereadtimeout: number;
|
||||||
|
tool_call_timeout_sec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MCPServerExtraArgsStdio {
|
export interface MCPServerExtraArgsStdio {
|
||||||
command: string;
|
command: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
|
tool_call_timeout_sec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MCPServerExtraArgsHttp {
|
export interface MCPServerExtraArgsHttp {
|
||||||
url: string;
|
url: string;
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
|
tool_call_timeout_sec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "remote" mode: the user only supplies a URL; the backend auto-detects the
|
// "remote" mode: the user only supplies a URL; the backend auto-detects the
|
||||||
@@ -540,6 +543,7 @@ export interface MCPServerExtraArgsRemote {
|
|||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
tool_call_timeout_sec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MCPSessionStatus {
|
export enum MCPSessionStatus {
|
||||||
|
|||||||
@@ -797,6 +797,9 @@ const enUS = {
|
|||||||
url: 'URL',
|
url: 'URL',
|
||||||
headers: 'Headers',
|
headers: 'Headers',
|
||||||
timeout: 'Timeout',
|
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',
|
addArgument: 'Add Argument',
|
||||||
addEnvVar: 'Add Environment Variable',
|
addEnvVar: 'Add Environment Variable',
|
||||||
addHeader: 'Add Header',
|
addHeader: 'Add Header',
|
||||||
|
|||||||
@@ -811,6 +811,9 @@ const esES = {
|
|||||||
url: 'URL',
|
url: 'URL',
|
||||||
headers: 'Encabezados',
|
headers: 'Encabezados',
|
||||||
timeout: 'Tiempo de espera',
|
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',
|
addArgument: 'Añadir argumento',
|
||||||
addEnvVar: 'Añadir variable de entorno',
|
addEnvVar: 'Añadir variable de entorno',
|
||||||
addHeader: 'Añadir encabezado',
|
addHeader: 'Añadir encabezado',
|
||||||
|
|||||||
@@ -803,6 +803,9 @@ const jaJP = {
|
|||||||
url: 'URL',
|
url: 'URL',
|
||||||
headers: 'ヘッダー',
|
headers: 'ヘッダー',
|
||||||
timeout: 'タイムアウト',
|
timeout: 'タイムアウト',
|
||||||
|
toolCallTimeout: 'ツール呼び出しタイムアウト(秒)',
|
||||||
|
toolCallTimeoutDescription:
|
||||||
|
'1 回のツール呼び出しの最大待機時間です。0 で無制限、既定値は 300 秒です。',
|
||||||
addArgument: '引数を追加',
|
addArgument: '引数を追加',
|
||||||
addEnvVar: '環境変数を追加',
|
addEnvVar: '環境変数を追加',
|
||||||
addHeader: 'ヘッダーを追加',
|
addHeader: 'ヘッダーを追加',
|
||||||
|
|||||||
@@ -808,6 +808,9 @@ const ruRU = {
|
|||||||
url: 'URL',
|
url: 'URL',
|
||||||
headers: 'Заголовки',
|
headers: 'Заголовки',
|
||||||
timeout: 'Таймаут',
|
timeout: 'Таймаут',
|
||||||
|
toolCallTimeout: 'Таймаут вызова инструмента (секунды)',
|
||||||
|
toolCallTimeoutDescription:
|
||||||
|
'Максимальное ожидание одного вызова. 0 отключает ограничение. По умолчанию 300 секунд.',
|
||||||
addArgument: 'Добавить аргумент',
|
addArgument: 'Добавить аргумент',
|
||||||
addEnvVar: 'Добавить переменную окружения',
|
addEnvVar: 'Добавить переменную окружения',
|
||||||
addHeader: 'Добавить заголовок',
|
addHeader: 'Добавить заголовок',
|
||||||
|
|||||||
@@ -786,6 +786,9 @@ const thTH = {
|
|||||||
url: 'URL',
|
url: 'URL',
|
||||||
headers: 'ส่วนหัว',
|
headers: 'ส่วนหัว',
|
||||||
timeout: 'หมดเวลา',
|
timeout: 'หมดเวลา',
|
||||||
|
toolCallTimeout: 'หมดเวลาการเรียกเครื่องมือ (วินาที)',
|
||||||
|
toolCallTimeoutDescription:
|
||||||
|
'เวลารอสูงสุดต่อการเรียกเครื่องมือหนึ่งครั้ง ตั้งเป็น 0 เพื่อไม่จำกัด ค่าเริ่มต้นคือ 300 วินาที',
|
||||||
addArgument: 'เพิ่มอาร์กิวเมนต์',
|
addArgument: 'เพิ่มอาร์กิวเมนต์',
|
||||||
addEnvVar: 'เพิ่มตัวแปรสภาพแวดล้อม',
|
addEnvVar: 'เพิ่มตัวแปรสภาพแวดล้อม',
|
||||||
addHeader: 'เพิ่มส่วนหัว',
|
addHeader: 'เพิ่มส่วนหัว',
|
||||||
|
|||||||
@@ -801,6 +801,9 @@ const viVN = {
|
|||||||
url: 'URL',
|
url: 'URL',
|
||||||
headers: 'Tiêu đề',
|
headers: 'Tiêu đề',
|
||||||
timeout: 'Thời gian chờ',
|
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ố',
|
addArgument: 'Thêm tham số',
|
||||||
addEnvVar: 'Thêm biến môi trường',
|
addEnvVar: 'Thêm biến môi trường',
|
||||||
addHeader: 'Thêm tiêu đề',
|
addHeader: 'Thêm tiêu đề',
|
||||||
|
|||||||
@@ -763,6 +763,9 @@ const zhHans = {
|
|||||||
url: 'URL地址',
|
url: 'URL地址',
|
||||||
headers: '请求头',
|
headers: '请求头',
|
||||||
timeout: '超时时间',
|
timeout: '超时时间',
|
||||||
|
toolCallTimeout: '工具调用超时(秒)',
|
||||||
|
toolCallTimeoutDescription:
|
||||||
|
'单次工具调用的最长等待时间。设为 0 表示不限制,默认 300 秒。',
|
||||||
addArgument: '添加参数',
|
addArgument: '添加参数',
|
||||||
addEnvVar: '添加环境变量',
|
addEnvVar: '添加环境变量',
|
||||||
addHeader: '添加请求头',
|
addHeader: '添加请求头',
|
||||||
|
|||||||
@@ -762,6 +762,9 @@ const zhHant = {
|
|||||||
url: 'URL位址',
|
url: 'URL位址',
|
||||||
headers: '請求標頭',
|
headers: '請求標頭',
|
||||||
timeout: '逾時時間',
|
timeout: '逾時時間',
|
||||||
|
toolCallTimeout: '工具呼叫逾時(秒)',
|
||||||
|
toolCallTimeoutDescription:
|
||||||
|
'單次工具呼叫的最長等待時間。設為 0 表示不限制,預設 300 秒。',
|
||||||
addArgument: '新增參數',
|
addArgument: '新增參數',
|
||||||
addEnvVar: '新增環境變數',
|
addEnvVar: '新增環境變數',
|
||||||
addHeader: '新增請求標頭',
|
addHeader: '新增請求標頭',
|
||||||
|
|||||||
Reference in New Issue
Block a user