mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
chore(merge): sync master into dev/4.11.x
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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', []):
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user