chore(merge): sync master into dev/4.11.x

This commit is contained in:
huanghuoguoguo
2026-07-31 19:29:38 +08:00
502 changed files with 77975 additions and 12729 deletions
File diff suppressed because it is too large Load Diff
+97 -2
View File
@@ -5,7 +5,9 @@ import typing
import time
from ...core import app
from ...api.http.context import ExecutionContext
from ...entity.persistence import model as persistence_model
from ...workspace.errors import WorkspaceInvariantError
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from . import token
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -16,6 +18,20 @@ LLM_USAGE_QUERY_VARIABLE = '_llm_usage'
STREAM_USAGE_QUERY_VARIABLE = '_stream_usage'
def _ensure_same_execution_scope(
expected: ExecutionContext,
actual: ExecutionContext,
*,
resource: str,
) -> None:
if (
actual.instance_uuid != expected.instance_uuid
or actual.workspace_uuid != expected.workspace_uuid
or actual.placement_generation != expected.placement_generation
):
raise WorkspaceInvariantError(f'{resource} belongs to another Workspace execution scope')
def _store_llm_usage(query: pipeline_query.Query | None, usage_info: dict | None) -> None:
"""Store the latest provider usage on the query for upstream action handlers."""
if query is None or not usage_info:
@@ -39,24 +55,61 @@ class RuntimeProvider:
def __init__(
self,
execution_context: ExecutionContext,
provider_entity: persistence_model.ModelProvider,
token_mgr: token.TokenManager,
requester: ProviderAPIRequester,
):
if provider_entity.workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceInvariantError('Provider belongs to another Workspace')
self.execution_context = execution_context
self.provider_entity = provider_entity
self.token_mgr = token_mgr
self.requester = requester
def _validate_invocation(
self,
model: RuntimeLLMModel | RuntimeEmbeddingModel | RuntimeRerankModel,
execution_context: ExecutionContext,
) -> None:
_ensure_same_execution_scope(self.execution_context, execution_context, resource='Provider invocation')
_ensure_same_execution_scope(self.execution_context, model.execution_context, resource='Runtime model')
if model.provider is not self:
raise WorkspaceInvariantError('Runtime model is attached to another provider')
def _resolve_llm_execution_context(
self,
query: pipeline_query.Query | None,
execution_context: ExecutionContext | None,
) -> ExecutionContext:
if query is not None:
from ...pipeline.pool import get_query_execution_context
query_context = get_query_execution_context(query)
if execution_context is not None:
_ensure_same_execution_scope(
query_context,
execution_context,
resource='Explicit LLM invocation context',
)
return query_context
if execution_context is None:
raise WorkspaceInvariantError('LLM invocation requires an ExecutionContext when query is absent')
return execution_context
async def invoke_llm(
self,
query: pipeline_query.Query,
query: pipeline_query.Query | None,
model: RuntimeLLMModel,
messages: typing.List[provider_message.Message],
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
execution_context: ExecutionContext | None = None,
) -> provider_message.Message:
"""Bridge method for invoking LLM with monitoring"""
invocation_context = self._resolve_llm_execution_context(query, execution_context)
self._validate_invocation(model, invocation_context)
# Start timing for monitoring
start_time = time.time()
input_tokens = 0
@@ -130,14 +183,17 @@ class RuntimeProvider:
async def invoke_llm_stream(
self,
query: pipeline_query.Query,
query: pipeline_query.Query | None,
model: RuntimeLLMModel,
messages: typing.List[provider_message.Message],
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
execution_context: ExecutionContext | None = None,
) -> provider_message.MessageChunk:
"""Bridge method for invoking LLM stream with monitoring"""
invocation_context = self._resolve_llm_execution_context(query, execution_context)
self._validate_invocation(model, invocation_context)
# Start timing for monitoring
start_time = time.time()
status = 'success'
@@ -212,6 +268,8 @@ class RuntimeProvider:
model: RuntimeEmbeddingModel,
input_text: typing.List[str],
extra_args: dict[str, typing.Any] = {},
*,
execution_context: ExecutionContext,
knowledge_base_id: str | None = None,
query_text: str | None = None,
session_id: str | None = None,
@@ -219,6 +277,7 @@ class RuntimeProvider:
call_type: str | None = None,
) -> typing.List[typing.List[float]]:
"""Bridge method for invoking embedding with monitoring"""
self._validate_invocation(model, execution_context)
# Start timing for monitoring
start_time = time.time()
prompt_tokens = 0
@@ -254,6 +313,7 @@ class RuntimeProvider:
try:
await self.requester.ap.monitoring_service.record_embedding_call(
execution_context,
model_name=model.model_entity.name,
prompt_tokens=prompt_tokens,
total_tokens=total_tokens,
@@ -276,8 +336,11 @@ class RuntimeProvider:
query: str,
documents: typing.List[str],
extra_args: dict[str, typing.Any] = {},
*,
execution_context: ExecutionContext,
) -> typing.List[dict]:
"""Bridge method for invoking rerank with monitoring"""
self._validate_invocation(model, execution_context)
start_time = time.time()
status = 'success'
@@ -316,9 +379,16 @@ class RuntimeLLMModel:
def __init__(
self,
execution_context: ExecutionContext,
model_entity: persistence_model.LLMModel,
provider: RuntimeProvider,
):
_ensure_same_execution_scope(provider.execution_context, execution_context, resource='LLM model')
if model_entity.workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceInvariantError('LLM model belongs to another Workspace')
if model_entity.provider_uuid != provider.provider_entity.uuid:
raise WorkspaceInvariantError('LLM model references another provider')
self.execution_context = execution_context
self.model_entity = model_entity
self.provider = provider
@@ -334,9 +404,16 @@ class RuntimeEmbeddingModel:
def __init__(
self,
execution_context: ExecutionContext,
model_entity: persistence_model.EmbeddingModel,
provider: RuntimeProvider,
):
_ensure_same_execution_scope(provider.execution_context, execution_context, resource='Embedding model')
if model_entity.workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceInvariantError('Embedding model belongs to another Workspace')
if model_entity.provider_uuid != provider.provider_entity.uuid:
raise WorkspaceInvariantError('Embedding model references another provider')
self.execution_context = execution_context
self.model_entity = model_entity
self.provider = provider
@@ -352,9 +429,16 @@ class RuntimeRerankModel:
def __init__(
self,
execution_context: ExecutionContext,
model_entity: persistence_model.RerankModel,
provider: RuntimeProvider,
):
_ensure_same_execution_scope(provider.execution_context, execution_context, resource='Rerank model')
if model_entity.workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceInvariantError('Rerank model belongs to another Workspace')
if model_entity.provider_uuid != provider.provider_entity.uuid:
raise WorkspaceInvariantError('Rerank model references another provider')
self.execution_context = execution_context
self.model_entity = model_entity
self.provider = provider
@@ -379,6 +463,17 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
async def initialize(self):
pass
async def aclose(self) -> None:
"""Release requester-owned clients when its runtime provider retires.
Most built-in requesters are currently stateless, but provider
extensions may own connection pools or background resources. Keeping
the lifecycle hook on the base class lets Workspace generation changes
and application shutdown retire them deterministically.
"""
return None
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any] | list[dict[str, typing.Any]]:
"""Scan models supported by the provider.
@@ -8,6 +8,7 @@ import litellm
from litellm import acompletion, aembedding, arerank
from .. import errors, requester
from ....utils import httpclient
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
@@ -974,26 +975,34 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
request_args = dict(extra_args)
rerank_url = request_args.pop('rerank_url', None)
rerank_path = request_args.pop('rerank_path', 'rerank')
payload: dict[str, typing.Any] = {
'model': model_name,
'query': query,
'documents': documents,
'top_n': top_n,
}
if extra_args:
payload.update(extra_args)
if request_args:
payload.update(request_args)
rerank_url = f'{base_url}/rerank'
if not rerank_url:
rerank_url = f'{base_url}/{str(rerank_path).strip("/")}'
try:
async with httpx.AsyncClient(timeout=timeout) as client:
async with httpx.AsyncClient(
timeout=timeout,
event_hooks=httpclient.httpx_response_limit_hooks(),
) as client:
resp = await client.post(rerank_url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
data = await httpclient.parse_json_response(resp)
except httpx.HTTPStatusError as e:
body = ''
try:
body = e.response.text
body = await httpclient.response_text(e.response)
except Exception:
pass
raise errors.RequesterError(f'rerank 请求失败 (HTTP {e.response.status_code}): {body or str(e)}')
@@ -1029,10 +1038,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
models_url = f'{base_url}/models'
try:
async with httpx.AsyncClient(trust_env=True, timeout=timeout) as client:
async with httpx.AsyncClient(
trust_env=True,
timeout=timeout,
event_hooks=httpclient.httpx_response_limit_hooks(),
) as client:
response = await client.get(models_url, headers=headers)
response.raise_for_status()
payload = response.json()
payload = await httpclient.parse_json_response(response)
models = []
for item in payload.get('data', []):
+355 -9
View File
@@ -1,42 +1,332 @@
from __future__ import annotations
import asyncio
import dataclasses
import heapq
import time
from ...core import app
from langbot_plugin.api.entities.builtin.provider import message as provider_message, prompt as provider_prompt
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from ...api.http.context import ExecutionContext
from ...core import app
from ...pipeline.pool import (
ExecutionContextMismatchError,
ExecutionContextRequiredError,
bind_execution_context,
get_query_execution_context,
)
SessionKey = tuple[
str,
str,
int,
str,
str,
int | str,
]
SessionExpiryEntry = tuple[float, int, SessionKey]
_SESSION_EXPIRY_HEAP_MIN_LIMIT = 64
_SESSION_EXPIRY_HEAP_ACTIVE_MULTIPLIER = 4
def _query_session_key(query: pipeline_query.Query) -> tuple[SessionKey, ExecutionContext]:
execution_context = get_query_execution_context(query)
bot_uuid = getattr(query, 'bot_uuid', None)
if not isinstance(bot_uuid, str) or not bot_uuid.strip():
raise ExecutionContextRequiredError('Query.bot_uuid is required for session lookup')
execution_context = bind_execution_context(execution_context, bot_uuid=bot_uuid)
key: SessionKey = (
execution_context.instance_uuid,
execution_context.workspace_uuid,
execution_context.placement_generation,
bot_uuid,
query.launcher_type.value,
query.launcher_id,
)
return key, execution_context
class SessionManager:
"""会话管理器"""
ap: app.Application
session_list: list[provider_session.Session]
def __init__(self, ap: app.Application):
self.ap = ap
self.session_list = []
self._legacy_sessions: list[provider_session.Session] = []
self._session_index: dict[SessionKey, provider_session.Session] = {}
self._session_keys_by_workspace: dict[str, set[SessionKey]] = {}
self._session_expiry_heap: list[SessionExpiryEntry] = []
self._next_access_revision = 0
@property
def session_list(self) -> list[provider_session.Session]:
"""Compatibility view for API services that enumerate sessions."""
return [
*self._legacy_sessions,
*self._session_index.values(),
]
@session_list.setter
def session_list(self, sessions: list[provider_session.Session]) -> None:
"""Replace the cache while keeping the O(1) index consistent."""
session_values = list(sessions)
self._legacy_sessions = []
self._session_index = {}
self._session_keys_by_workspace = {}
self._session_expiry_heap = []
self._next_access_revision = 0
now = time.monotonic()
for session in session_values:
key = getattr(session, '_langbot_session_key', None)
if isinstance(key, tuple) and len(key) == 6:
self._session_index[key] = session
self._session_keys_by_workspace.setdefault(key[1], set()).add(key)
last_accessed = getattr(session, '_langbot_last_accessed', None)
if last_accessed is None:
last_accessed = now
self._touch_session(
session,
key,
float(last_accessed),
compact=False,
)
else:
self._legacy_sessions.append(session)
self._compact_session_expiry_heap(force=True)
def _retention_config(self) -> dict:
instance_config = getattr(getattr(self.ap, 'instance_config', None), 'data', {})
if not isinstance(instance_config, dict):
return {}
config = instance_config.get('system', {}).get('session_retention', {})
return config if isinstance(config, dict) else {}
def _positive_config_int(self, name: str, default: int) -> int:
try:
value = int(self._retention_config().get(name, default))
except (TypeError, ValueError):
value = default
return max(value, 1)
@staticmethod
def _session_is_idle(session: provider_session.Session) -> bool:
semaphore = getattr(session, '_semaphore', None)
concurrency = getattr(session, '_langbot_session_concurrency', None)
if semaphore is None or not isinstance(concurrency, int):
return True
return getattr(semaphore, '_value', -1) == concurrency
def _remove_session(self, session: provider_session.Session) -> None:
key = getattr(session, '_langbot_session_key', None)
if isinstance(key, tuple) and len(key) == 6 and self._session_index.get(key) is session:
self._session_index.pop(key, None)
workspace_keys = self._session_keys_by_workspace.get(key[1])
if workspace_keys is not None:
workspace_keys.discard(key)
if not workspace_keys:
self._session_keys_by_workspace.pop(key[1], None)
else:
try:
self._legacy_sessions.remove(session)
except ValueError:
pass
def _touch_session(
self,
session: provider_session.Session,
key: SessionKey,
now: float,
*,
compact: bool = True,
) -> None:
self._next_access_revision += 1
revision = self._next_access_revision
object.__setattr__(session, '_langbot_last_accessed', now)
object.__setattr__(session, '_langbot_access_revision', revision)
heapq.heappush(
self._session_expiry_heap,
(now, revision, key),
)
if compact:
self._compact_session_expiry_heap()
def _compact_session_expiry_heap(self, *, force: bool = False) -> None:
limit = max(
len(self._session_index) * _SESSION_EXPIRY_HEAP_ACTIVE_MULTIPLIER,
_SESSION_EXPIRY_HEAP_MIN_LIMIT,
)
if not force and len(self._session_expiry_heap) <= limit:
return
self._session_expiry_heap = [
(
float(getattr(session, '_langbot_last_accessed', 0.0)),
int(getattr(session, '_langbot_access_revision', 0)),
key,
)
for key, session in self._session_index.items()
]
heapq.heapify(self._session_expiry_heap)
def _pop_current_expiry_entry(
self,
) -> tuple[float, int, SessionKey, provider_session.Session] | None:
while self._session_expiry_heap:
last_accessed, revision, key = heapq.heappop(self._session_expiry_heap)
session = self._session_index.get(key)
if session is None:
continue
if getattr(session, '_langbot_access_revision', None) != revision:
continue
return last_accessed, revision, key, session
return None
def _prune_expired_sessions(self, now: float) -> None:
idle_ttl = self._positive_config_int('idle_ttl_seconds', 86400)
cutoff = now - idle_ttl
while self._session_expiry_heap:
last_accessed, _, _ = self._session_expiry_heap[0]
if last_accessed > cutoff:
break
current = self._pop_current_expiry_entry()
if current is None:
break
last_accessed, revision, key, session = current
if last_accessed > cutoff:
heapq.heappush(
self._session_expiry_heap,
(last_accessed, revision, key),
)
break
if self._session_is_idle(session):
self._remove_session(session)
continue
# The session became active without another cache lookup. Give it
# a fresh TTL instead of repeatedly examining the same expired
# entry or losing its future expiry record.
self._touch_session(session, key, now)
def _prune_workspace_capacity(
self,
workspace_uuid: str,
max_entries_per_workspace: int,
) -> None:
workspace_keys = self._session_keys_by_workspace.get(workspace_uuid, set())
overflow = len(workspace_keys) - max_entries_per_workspace + 1
if overflow <= 0:
return
idle_workspace_sessions = sorted(
(
session
for key in tuple(workspace_keys)
if (session := self._session_index.get(key)) is not None and self._session_is_idle(session)
),
key=lambda session: float(getattr(session, '_langbot_last_accessed', 0.0)),
)
for session in idle_workspace_sessions[:overflow]:
self._remove_session(session)
def _evict_oldest_idle_session(self, now: float) -> bool:
# At most one current entry per active session is examined. Stale heap
# revisions do not count and are discarded in O(log N).
current_probes = 0
max_probes = len(self._session_index)
while current_probes < max_probes:
current = self._pop_current_expiry_entry()
if current is None:
return False
_, _, key, session = current
current_probes += 1
if self._session_is_idle(session):
self._remove_session(session)
return True
self._touch_session(session, key, now)
return False
def _prune_sessions(self, now: float, workspace_uuid: str) -> None:
self._prune_expired_sessions(now)
max_entries_per_workspace = self._positive_config_int('max_entries_per_workspace', 200)
self._prune_workspace_capacity(
workspace_uuid,
max_entries_per_workspace,
)
max_entries = self._positive_config_int('max_entries', 2000)
overflow = len(self._session_index) - max_entries + 1
if overflow <= 0:
return
for _ in range(overflow):
if not self._evict_oldest_idle_session(now):
break
async def initialize(self):
pass
async def get_session(self, query: pipeline_query.Query) -> provider_session.Session:
"""获取会话"""
for session in self.session_list:
if query.launcher_type == session.launcher_type and query.launcher_id == session.launcher_id:
return session
session_key, execution_context = _query_session_key(query)
now = time.monotonic()
session = self._session_index.get(session_key)
if session is not None:
self._touch_session(session, session_key, now)
return session
self._prune_sessions(now, execution_context.workspace_uuid)
max_entries_per_workspace = self._positive_config_int('max_entries_per_workspace', 200)
workspace_entries = len(
self._session_keys_by_workspace.get(
execution_context.workspace_uuid,
(),
)
)
if workspace_entries >= max_entries_per_workspace:
raise RuntimeError(f'Workspace session cache capacity reached ({max_entries_per_workspace})')
max_entries = self._positive_config_int('max_entries', 2000)
if len(self._session_index) >= max_entries:
raise RuntimeError(f'Session cache capacity reached ({max_entries})')
session_concurrency = self.ap.instance_config.data['concurrency']['session']
session = provider_session.Session(
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
bot_uuid=query.bot_uuid,
launcher_type=query.launcher_type,
launcher_id=query.launcher_id,
sender_id=query.sender_id,
)
session_context = dataclasses.replace(
execution_context,
pipeline_uuid=None,
query_uuid=None,
)
# langbot-plugin 0.4.13 ignores Workspace fields. Preserve them until
# the Workspace-aware SDK becomes the minimum supported version.
object.__setattr__(session, 'instance_uuid', session_context.instance_uuid)
object.__setattr__(session, 'workspace_uuid', session_context.workspace_uuid)
object.__setattr__(
session,
'placement_generation',
session_context.placement_generation,
)
object.__setattr__(session, 'bot_uuid', query.bot_uuid)
object.__setattr__(session, '_execution_context', session_context)
object.__setattr__(session, '_langbot_session_key', session_key)
object.__setattr__(session, '_langbot_session_concurrency', session_concurrency)
session._semaphore = asyncio.Semaphore(session_concurrency)
self.session_list.append(session)
self._session_index[session_key] = session
self._session_keys_by_workspace.setdefault(
execution_context.workspace_uuid,
set(),
).add(session_key)
self._touch_session(session, session_key, now)
return session
async def get_conversation(
@@ -49,6 +339,17 @@ class SessionManager:
) -> provider_session.Conversation:
"""获取对话或创建对话"""
session_key, execution_context = _query_session_key(query)
if getattr(session, '_langbot_session_key', None) != session_key:
raise ExecutionContextMismatchError('Session does not belong to the Query execution scope')
execution_context = bind_execution_context(
execution_context,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
)
if execution_context.bot_uuid != getattr(session, 'bot_uuid', None):
raise ExecutionContextMismatchError('Session bot_uuid does not match the Query execution scope')
if not session.conversations:
session.conversations = []
@@ -63,7 +364,11 @@ class SessionManager:
messages=prompt_messages,
)
if session.using_conversation is None or session.using_conversation.pipeline_uuid != pipeline_uuid:
if (
session.using_conversation is None
or session.using_conversation.pipeline_uuid != pipeline_uuid
or session.using_conversation.bot_uuid != bot_uuid
):
conversation = provider_session.Conversation(
prompt=prompt,
messages=[],
@@ -71,6 +376,47 @@ class SessionManager:
bot_uuid=bot_uuid,
)
session.conversations.append(conversation)
max_conversations = self._positive_config_int('max_conversations_per_session', 20)
if len(session.conversations) > max_conversations:
del session.conversations[:-max_conversations]
session.using_conversation = conversation
return session.using_conversation
def trim_conversation_messages(
self,
conversation: provider_session.Conversation,
*,
max_rounds: int,
) -> None:
"""Bound retained process-local history after a completed turn."""
try:
max_rounds = int(max_rounds)
except (TypeError, ValueError):
max_rounds = 10
max_rounds = max(max_rounds, 1)
max_messages = self._positive_config_int('max_messages_per_conversation', 100)
kept_reversed = []
user_rounds = 0
for message in reversed(conversation.messages):
if user_rounds >= max_rounds:
break
kept_reversed.append(message)
if getattr(message, 'role', None) == 'user':
user_rounds += 1
retained = list(reversed(kept_reversed))[-max_messages:]
# Binary payloads are needed for the current model call, but retaining
# them in process-local history makes a few image/file turns consume
# hundreds of MB. Historical URL and text references remain intact.
for message in retained:
content = getattr(message, 'content', None)
if not isinstance(content, list):
continue
for element in content:
if getattr(element, 'image_base64', None) is not None:
element.image_base64 = None
if getattr(element, 'file_base64', None) is not None:
element.file_base64 = None
conversation.messages = retained
@@ -11,7 +11,7 @@ async def is_box_backend_available(ap: Any) -> bool:
if not getattr(box_service, 'available', False):
return False
try:
status = await box_service.get_status()
status = await box_service.get_backend_status()
backend_info = status.get('backend', {})
return bool(backend_info.get('available', False))
except Exception:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
from __future__ import annotations
from typing import Any
MCP_STDIO_DISABLED_CODE = 'mcp_stdio_disabled'
MCP_STDIO_DISABLED_MESSAGE = 'Stdio MCP is disabled by instance policy'
class MCPStdioDisabledError(RuntimeError):
"""Raised when an instance-level policy refuses stdio MCP execution."""
code = MCP_STDIO_DISABLED_CODE
def __init__(self) -> None:
super().__init__(MCP_STDIO_DISABLED_MESSAGE)
def stdio_mcp_enabled(ap: Any) -> bool:
"""Return the independent instance-level stdio MCP feature gate.
The open-source default remains enabled for backwards compatibility. A
deployment can disable it with ``mcp.stdio.enabled: false`` (or
``MCP__STDIO__ENABLED=false``). Invalid values fail closed instead of
accidentally enabling local process execution.
"""
instance_config = getattr(ap, 'instance_config', None)
config = getattr(instance_config, 'data', None)
if not isinstance(config, dict):
return False
mcp_config = config.get('mcp', {})
if not isinstance(mcp_config, dict):
return False
stdio_config = mcp_config.get('stdio', {})
if not isinstance(stdio_config, dict):
return False
enabled = stdio_config.get('enabled', True)
return enabled if isinstance(enabled, bool) else False
def is_stdio_server(server_config: dict[str, Any] | None) -> bool:
return isinstance(server_config, dict) and str(server_config.get('mode') or '').strip().lower() == 'stdio'
def require_stdio_mcp_enabled(ap: Any, server_config: dict[str, Any] | None) -> None:
"""Fail closed for a stdio server before choosing Box or host transport."""
if is_stdio_server(server_config) and not stdio_mcp_enabled(ap):
raise MCPStdioDisabledError()
@@ -6,10 +6,12 @@ import os
import shutil
import shlex
import threading
from contextlib import suppress, AsyncExitStack
import weakref
from contextlib import suppress, AsyncExitStack, asynccontextmanager
from typing import TYPE_CHECKING, Any
import pydantic
from ....utils import bounded_executor
from mcp import ClientSession
from mcp.client.websocket import websocket_client
from ....box.workspace import (
@@ -27,7 +29,7 @@ if TYPE_CHECKING:
from .mcp import RuntimeMCPSession
_WORKSPACE_COPY_LOCKS: dict[str, threading.Lock] = {}
_WORKSPACE_COPY_LOCKS: weakref.WeakValueDictionary[str, threading.Lock] = weakref.WeakValueDictionary()
_WORKSPACE_COPY_LOCKS_GUARD = threading.Lock()
@@ -94,6 +96,60 @@ class MCPServerBoxConfig(pydantic.BaseModel):
_HANDSHAKE_ATTEMPT_TIMEOUT_SEC = 10.0
@asynccontextmanager
async def authenticated_websocket_client(url: str, headers: dict[str, str]):
"""MCP WebSocket transport with host-only Box relay headers.
The upstream MCP helper does not expose WebSocket handshake headers. This
mirrors that transport while keeping the Box control token out of the URL,
JSON-RPC payloads, and logs.
"""
import json
import anyio
import mcp.types as mcp_types
from mcp.shared.message import SessionMessage
from pydantic import ValidationError
from websockets.asyncio.client import connect as ws_connect
from websockets.typing import Subprotocol
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
async with ws_connect(
url,
subprotocols=[Subprotocol('mcp')],
additional_headers=dict(headers),
proxy=None,
) as websocket:
async def ws_reader():
async with read_stream_writer:
async for raw_text in websocket:
try:
message = mcp_types.JSONRPCMessage.model_validate_json(raw_text)
await read_stream_writer.send(SessionMessage(message))
except ValidationError as exc: # pragma: no cover - upstream parity
await read_stream_writer.send(exc)
async def ws_writer():
async with write_stream_reader:
async for session_message in write_stream_reader:
payload = session_message.message.model_dump(
by_alias=True,
mode='json',
exclude_none=True,
)
await websocket.send(json.dumps(payload))
async with anyio.create_task_group() as task_group:
task_group.start_soon(ws_reader)
task_group.start_soon(ws_writer)
yield read_stream, write_stream
task_group.cancel_scope.cancel()
class _TransferredStack:
"""Adapts an already-populated AsyncExitStack into an async context manager
so ownership of its resources can be transferred into another exit stack.
@@ -149,6 +205,7 @@ class BoxStdioSessionRuntime:
resolved_host_path = self.resolve_host_path() if host_path is ... else host_path
return BoxWorkspaceSession(
self.ap.box_service,
self.owner.execution_context,
self.owner._build_box_session_id(),
host_path=resolved_host_path,
host_path_mode=self.config.host_path_mode,
@@ -249,7 +306,11 @@ class BoxStdioSessionRuntime:
if install_cmd:
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
payload['process_id'] = self.process_id
await workspace.box_service.start_managed_process(workspace.session_id, payload)
await workspace.box_service.start_managed_process(
workspace.execution_context,
workspace.session_id,
payload,
)
except Exception:
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
raise
@@ -259,7 +320,10 @@ class BoxStdioSessionRuntime:
f'process_id={self.process_id} (transport reconnect)'
)
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
(
websocket_url,
websocket_headers,
) = await workspace.get_managed_process_websocket_connection(self.process_id)
# Attach the WS transport + MCP session ONCE, on the owner's exit stack,
# in the same task as the serve loop that follows. websocket_client and
@@ -277,7 +341,12 @@ class BoxStdioSessionRuntime:
# attempt re-attaches to the same live process; once it has finished
# cold start the handshake succeeds and stays healthy.
try:
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
transport_context = (
authenticated_websocket_client(websocket_url, websocket_headers)
if websocket_headers
else websocket_client(websocket_url)
)
transport = await self.owner.exit_stack.enter_async_context(transport_context)
read_stream, write_stream = transport
self.owner.session = await self.owner.exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
@@ -469,7 +538,11 @@ class BoxStdioSessionRuntime:
return
try:
process_host_root = os.path.join(self._shared_workspace_host_path(), '.mcp', self.process_id)
await asyncio.to_thread(shutil.rmtree, process_host_root, True)
await bounded_executor.run_blocking_cleanup(
shutil.rmtree,
process_host_root,
True,
)
except Exception as exc:
self.ap.logger.warning(
f'MCP server {self.server_name}: failed to clean staged workspace '
@@ -487,7 +560,7 @@ class BoxStdioSessionRuntime:
)
warned = True
if asyncio.get_running_loop().time() >= deadline:
self.owner.error_phase = MCPSessionErrorPhase.SESSION_CREATE
self.owner.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE
raise Exception(f'Box runtime is not available after {int(timeout_sec)} seconds')
await asyncio.sleep(1)
File diff suppressed because it is too large Load Diff
@@ -79,6 +79,7 @@ class PluginToolLoader(loader.ToolLoader):
session=query.session,
query_id=query.query_id,
bound_plugins=[source_id] if source_id else None,
query_uuid=query.query_uuid,
)
except Exception as e:
self.ap.logger.error(f'执行函数 {name} 时发生错误: {e}')
+38 -11
View File
@@ -4,6 +4,7 @@ import re
import typing
from ....box import workspace as box_workspace
from ....api.http.context import ExecutionContext
if typing.TYPE_CHECKING:
from ....core import app
@@ -37,7 +38,15 @@ def get_visible_skills(ap: app.Application, query: pipeline_query.Query) -> dict
if skill_mgr is None:
return {}
visible_skills = getattr(skill_mgr, 'skills', {})
execution_context = ExecutionContext(
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
placement_generation=getattr(query, 'placement_generation', 0) or 0,
bot_uuid=getattr(query, 'bot_uuid', None),
pipeline_uuid=getattr(query, 'pipeline_uuid', None),
query_uuid=getattr(query, 'query_uuid', None),
)
visible_skills = skill_mgr.get_skills(execution_context)
bound_skills = get_bound_skill_names(query)
if bound_skills is None:
return visible_skills
@@ -103,6 +112,22 @@ def get_activated_skill_names(query: pipeline_query.Query) -> list[str]:
return normalize_skill_names(list(get_activated_skills(query).keys()))
def restore_activated_skills(
ap: app.Application,
query: pipeline_query.Query,
skill_names: typing.Any,
) -> list[str]:
"""Restore caller-provided names from the current visible skill set."""
restored: list[str] = []
for skill_name in normalize_skill_names(skill_names):
skill_data = get_visible_skill(ap, query, skill_name)
if skill_data is None:
continue
register_activated_skill(query, skill_data)
restored.append(skill_name)
return restored
def restore_activated_skills_from_state(
ap: app.Application,
query: pipeline_query.Query,
@@ -116,14 +141,7 @@ def restore_activated_skills_from_state(
"""
conversation_state = state.get('conversation', {}) if isinstance(state, dict) else {}
skill_names = normalize_skill_names(conversation_state.get(ACTIVATED_SKILL_NAMES_STATE_KEY))
restored: list[str] = []
for skill_name in skill_names:
skill_data = get_visible_skill(ap, query, skill_name)
if skill_data is None:
continue
register_activated_skill(query, skill_data)
restored.append(skill_name)
return restored
return restore_activated_skills(ap, query, skill_names)
async def persist_activated_skill(
@@ -260,5 +278,14 @@ def should_prepare_skill_python_env(package_root: str | None) -> bool:
return box_workspace.should_prepare_python_env(package_root)
def wrap_skill_command_with_python_env(command: str, *, mount_path: str = '/workspace') -> str:
return box_workspace.wrap_python_command_with_env(command, mount_path=mount_path).rstrip()
def wrap_skill_command_with_python_env(
command: str,
*,
mount_path: str = '/workspace',
state_path: str | None = None,
) -> str:
return box_workspace.wrap_python_command_with_env(
command,
mount_path=mount_path,
state_path=state_path,
).rstrip()
@@ -7,6 +7,7 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from .. import loader
from .availability import is_box_backend_available
from ....api.http.context import ExecutionContext
# Align with Claude Code's Skill tool design:
# - activate: Activate a skill via Tool Call, returns SKILL.md content
@@ -49,27 +50,57 @@ class SkillToolLoader(loader.ToolLoader):
return await is_box_backend_available(self.ap)
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
if not self._is_available():
if not await self._is_available():
return []
if not self._tools:
self._tools = [
self._build_activate_skill_tool(),
self._build_register_skill_tool(),
]
return list(self._tools)
async def has_tool(self, name: str) -> bool:
return self._is_available() and name in SKILL_TOOL_NAMES
return await self._is_available() and name in SKILL_TOOL_NAMES
def _is_available(self) -> bool:
async def _is_available(self) -> bool:
"""Check if skill tools should be available.
Skill tools require both a skill manager and a sandbox backend.
"""
return self._has_skill_manager() and self._sandbox_available
if not self._has_skill_manager():
return False
self._sandbox_available = await self._check_sandbox_available()
return self._sandbox_available
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
require_sandbox = getattr(
getattr(self.ap, 'box_service', None),
'require_workspace_sandbox',
None,
)
if callable(require_sandbox):
await require_sandbox(self._execution_context(query))
if name == ACTIVATE_SKILL_TOOL_NAME:
return await self._invoke_activate_skill(parameters, query)
if name == REGISTER_SKILL_TOOL_NAME:
return await self._invoke_register_skill(parameters, query)
raise ValueError(f'Unknown skill tool: {name}')
@staticmethod
def _execution_context(query) -> ExecutionContext:
attached_context = getattr(query, '_execution_context', None)
if isinstance(attached_context, ExecutionContext):
return attached_context
return ExecutionContext(
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
placement_generation=getattr(query, 'placement_generation', 0) or 0,
bot_uuid=getattr(query, 'bot_uuid', None),
pipeline_uuid=getattr(query, 'pipeline_uuid', None),
query_uuid=getattr(query, 'query_uuid', None),
entitlement_revision=getattr(query, 'entitlement_revision', 0),
)
async def shutdown(self):
pass
@@ -130,7 +161,8 @@ class SkillToolLoader(loader.ToolLoader):
raise ValueError('path is required')
# Resolve sandbox path to host path
host_path = self._resolve_workspace_directory(sandbox_path)
execution_context = self._execution_context(query)
host_path = self._resolve_workspace_directory(sandbox_path, execution_context)
# Get or create skill service
skill_service = getattr(self.ap, 'skill_service', None)
@@ -138,7 +170,7 @@ class SkillToolLoader(loader.ToolLoader):
raise ValueError('Skill service not available')
# Scan and register the skill
scanned = await skill_service.scan_directory_async(host_path)
scanned = await skill_service.scan_directory_async(execution_context, host_path)
# Override name if provided
skill_name = str(parameters.get('name') or scanned['name']).strip()
@@ -147,13 +179,14 @@ class SkillToolLoader(loader.ToolLoader):
# Create the skill
created = await skill_service.create_skill(
execution_context,
{
'name': skill_name,
'display_name': str(parameters.get('display_name') or scanned.get('display_name', '')).strip(),
'description': str(parameters.get('description') or scanned.get('description', '')).strip(),
'instructions': str(parameters.get('instructions') or scanned.get('instructions', '')),
'package_root': host_path,
}
},
)
skill_loader.register_created_skill_visibility(query, skill_name)
@@ -164,10 +197,19 @@ class SkillToolLoader(loader.ToolLoader):
'skill': created,
}
def _resolve_workspace_directory(self, sandbox_path: str) -> str:
def _resolve_workspace_directory(
self,
sandbox_path: str,
execution_context: ExecutionContext,
) -> str:
"""Resolve sandbox path to host filesystem path."""
box_service = getattr(self.ap, 'box_service', None)
workspace_root = getattr(box_service, 'default_workspace', None)
tenant_workspace = getattr(box_service, '_tenant_workspace', None)
workspace_root = (
tenant_workspace(execution_context)
if callable(tenant_workspace)
else getattr(box_service, 'default_workspace', None)
)
if not workspace_root:
raise ValueError('No default workspace configured')
+108 -23
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import typing
import time
import inspect
from typing import TYPE_CHECKING
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
@@ -9,6 +10,8 @@ from langbot_plugin.api.entities.events import pipeline_query
from . import loader as tool_loader
from .errors import ToolNotFoundError
from ...pipeline.pool import get_query_execution_context
from ...api.http.service.tenant import TenantContext
if TYPE_CHECKING:
from ...core import app
@@ -43,6 +46,36 @@ class ToolManager:
def __init__(self, ap: app.Application):
self.ap = ap
async def _bind_plugin_workspace(self, context: TenantContext) -> None:
"""Select the tenant before any plugin catalog lookup.
Tool discovery happens before invocation, so relying on ``call_tool``
to bind the Workspace is too late and can expose another task's
catalog in a shared Runtime.
"""
connector = getattr(self.ap, 'plugin_connector', None)
require_context = getattr(connector, 'require_workspace_context', None)
if require_context is None:
return
result = require_context(context)
if inspect.isawaitable(result):
await result
async def _workspace_sandbox_available(self, context: TenantContext) -> bool:
"""Resolve the Workspace capability before exposing sandbox tools."""
box_service = getattr(self.ap, 'box_service', None)
checker = getattr(box_service, 'is_workspace_sandbox_available', None)
if not callable(checker):
# Compatibility for OSS embedders and isolated manager tests. The
# BoxService execution path remains the final authority.
return True
try:
return bool(await checker(context))
except Exception:
return False
async def initialize(self):
from langbot.pkg.utils import importutil
from langbot.pkg.provider.tools import loaders
@@ -67,21 +100,24 @@ class ToolManager:
async def get_all_tools(
self,
context: TenantContext,
bound_plugins: list[str] | None = None,
bound_mcp_servers: list[str] | None = None,
include_skill_authoring: bool = False,
include_mcp_resource_tools: bool = True,
) -> list[resource_tool.LLMTool]:
await self._bind_plugin_workspace(context)
all_functions: list[resource_tool.LLMTool] = []
all_functions.extend(await self.native_tool_loader.get_tools())
# Skill tools (activate / register_skill) are exposed like native tools:
# the SkillToolLoader gates itself on sandbox + skill_mgr availability, so
# skill is just a group of authorized tools rather than a separate
# capability-gated surface.
all_functions.extend(await self.skill_tool_loader.get_tools())
sandbox_available = await self._workspace_sandbox_available(context)
if sandbox_available:
all_functions.extend(await self.native_tool_loader.get_tools())
if include_skill_authoring and sandbox_available:
all_functions.extend(await self.skill_tool_loader.get_tools())
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
all_functions.extend(
await self.mcp_tool_loader.get_tools(
context,
bound_mcp_servers,
include_resource_tools=include_mcp_resource_tools,
)
@@ -91,11 +127,13 @@ class ToolManager:
async def get_tool_catalog(
self,
context: TenantContext,
bound_plugins: list[str] | None = None,
bound_mcp_servers: list[str] | None = None,
include_skill_authoring: bool = False,
include_mcp_resource_tools: bool = False,
) -> list[dict[str, typing.Any]]:
await self._bind_plugin_workspace(context)
catalog: list[dict[str, typing.Any]] = []
def append_tools(source: str, source_name: str, tools: list[resource_tool.LLMTool]) -> None:
@@ -111,13 +149,16 @@ class ToolManager:
}
)
append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools())
if include_skill_authoring:
sandbox_available = await self._workspace_sandbox_available(context)
if sandbox_available:
append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools())
if include_skill_authoring and sandbox_available:
append_tools('skill', 'LangBot', await self.skill_tool_loader.get_tools())
catalog.extend(await self.plugin_tool_loader.get_tool_catalog(bound_plugins))
if self.mcp_tool_loader:
for item in await self.mcp_tool_loader.get_tool_catalog(
context,
bound_mcp_servers,
include_resource_tools=include_mcp_resource_tools,
):
@@ -127,6 +168,7 @@ class ToolManager:
async def get_resolved_tool_catalog(
self,
context: TenantContext,
bound_plugins: list[str] | None = None,
bound_mcp_servers: list[str] | None = None,
include_skill_authoring: bool = True,
@@ -140,6 +182,7 @@ class ToolManager:
another. Such names are therefore omitted until the scope is narrowed.
"""
catalog = await self.get_tool_catalog(
context,
bound_plugins,
bound_mcp_servers,
include_skill_authoring=include_skill_authoring,
@@ -240,22 +283,28 @@ class ToolManager:
'source_id': source_id if isinstance(source_id, str) and source_id else None,
}
async def get_tool_by_name(self, name: str) -> tool_loader.ToolLookupResult | None:
async def get_tool_by_name(self, context: TenantContext, name: str) -> tool_loader.ToolLookupResult | None:
"""Get tool by name from any active loader."""
for active_loader in (
self.native_tool_loader,
self.plugin_tool_loader,
self.mcp_tool_loader,
self.skill_tool_loader,
):
await self._bind_plugin_workspace(context)
sandbox_available = await self._workspace_sandbox_available(context)
if sandbox_available:
tool = await self.native_tool_loader.get_tool(name)
if tool:
return tool
for active_loader in (self.plugin_tool_loader,):
tool = await active_loader.get_tool(name)
if tool:
return tool
if sandbox_available:
tool = await self.skill_tool_loader.get_tool(name)
if tool:
return tool
return None
return await self.mcp_tool_loader.get_tool(context, name)
async def get_tool_schema(
self,
context: TenantContext,
name: str,
source_ref: ToolSourceRef | None = None,
) -> tuple[str | None, dict | None]:
@@ -266,13 +315,18 @@ class ToolManager:
return resource_tool.LLMTool, so no per-shape branching is needed.
Returns (None, None) when the tool is not found.
"""
tool = await self.get_tool_by_source(name, source_ref) if source_ref else await self.get_tool_by_name(name)
tool = (
await self.get_tool_by_source(context, name, source_ref)
if source_ref
else await self.get_tool_by_name(context, name)
)
if tool is None:
return None, None
return tool.description, (tool.parameters or None)
async def get_tool_detail(
self,
context: TenantContext,
name: str,
source_ref: ToolSourceRef | None = None,
) -> dict | None:
@@ -282,7 +336,11 @@ class ToolManager:
{name, description, human_desc, parameters}. Returns None when the tool
is not found.
"""
tool = await self.get_tool_by_source(name, source_ref) if source_ref else await self.get_tool_by_name(name)
tool = (
await self.get_tool_by_source(context, name, source_ref)
if source_ref
else await self.get_tool_by_name(context, name)
)
if tool is None:
return None
return {
@@ -294,6 +352,7 @@ class ToolManager:
async def get_tool_by_source(
self,
context: TenantContext,
name: str,
source_ref: ToolSourceRef,
) -> tool_loader.ToolLookupResult | None:
@@ -309,7 +368,9 @@ class ToolManager:
return None
return await self.plugin_tool_loader.get_tool(name, source_id=source_id)
if source == 'mcp':
return await self.mcp_tool_loader.get_tool(name, source_id=source_id)
return await self.mcp_tool_loader.get_tool(
context, name, source_id=source_id
)
return None
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
@@ -360,6 +421,7 @@ class ToolManager:
try:
await monitoring_service.record_tool_call(
get_query_execution_context(query),
tool_name=name,
tool_source=source,
duration=duration_ms,
@@ -424,14 +486,23 @@ class ToolManager:
source_ref = source_ref or self.get_query_tool_source(query, name)
if source_ref is not None:
execution_context = get_query_execution_context(query)
await self._bind_plugin_workspace(execution_context)
sandbox_available = await self._workspace_sandbox_available(
execution_context
)
source = source_ref['source']
source_id = source_ref.get('source_id')
uses_source_id = False
if source in {'builtin', 'native'}:
if not sandbox_available:
raise ToolNotFoundError(name)
loader = self.native_tool_loader
telemetry_source = 'native'
exists = await loader.has_tool(name)
elif source == 'skill':
if not sandbox_available:
raise ToolNotFoundError(name)
loader = self.skill_tool_loader
telemetry_source = 'skill'
exists = await loader.has_tool(name)
@@ -444,7 +515,11 @@ class ToolManager:
loader = self.mcp_tool_loader
telemetry_source = 'mcp'
uses_source_id = True
exists = await loader.has_tool(name, source_id=source_id)
exists = await loader.has_tool(
execution_context,
name,
source_id=source_id,
)
else:
raise ToolNotFoundError(name)
@@ -452,6 +527,13 @@ class ToolManager:
raise ToolNotFoundError(name)
async def invoke_selected_tool() -> typing.Any:
if source == 'mcp':
return await loader.invoke_tool(
name,
parameters,
query,
source_id=source_id,
)
if uses_source_id:
return await loader.invoke_tool(name, parameters, query, source_id=source_id)
return await loader.invoke_tool(name, parameters, query)
@@ -465,7 +547,10 @@ class ToolManager:
invoke=invoke_selected_tool,
)
if await self.native_tool_loader.has_tool(name):
execution_context = get_query_execution_context(query)
await self._bind_plugin_workspace(execution_context)
sandbox_available = await self._workspace_sandbox_available(execution_context)
if sandbox_available and await self.native_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'native')
return await self._invoke_tool_with_monitoring(
source='native',
@@ -483,7 +568,7 @@ class ToolManager:
query=query,
invoke=lambda: self.plugin_tool_loader.invoke_tool(name, parameters, query),
)
if await self.mcp_tool_loader.has_tool(name):
if await self.mcp_tool_loader.has_tool(execution_context, name):
telemetry_features.increment(query, 'tool_calls', 'mcp')
return await self._invoke_tool_with_monitoring(
source='mcp',
@@ -492,7 +577,7 @@ class ToolManager:
query=query,
invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query),
)
if await self.skill_tool_loader.has_tool(name):
if sandbox_available and await self.skill_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'skill')
return await self._invoke_tool_with_monitoring(
source='skill',