Files
LangBot/src/langbot/pkg/plugin/handler.py
T
Hyu d78546967c feat: expose Workspace-scoped rotating plugin debug keys (#2395)
* feat: add Workspace-scoped rotating plugin debug tokens

* chore: pin formatted Workspace debug runtime

* chore: pin merged Workspace debug runtime

* chore: pin tenant-safe debug runtime

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-04 19:20:06 +08:00

2060 lines
83 KiB
Python

from __future__ import annotations
import asyncio
import inspect
import typing
from typing import Any
import base64
import contextlib
import contextvars
import traceback
from dataclasses import dataclass
import sqlalchemy
from langbot_plugin.runtime.io import handler
from langbot_plugin.runtime.io.connection import Connection
from langbot_plugin.entities.io.context import (
ActionContext,
ApplyPluginInstallationRequest,
InstallationBinding,
PluginInstallationDesiredState,
PluginWorkerPolicy,
ReconcilePluginInstallationsRequest,
RemovePluginInstallationRequest,
RuntimeConfig,
RuntimeIdentity,
)
from langbot_plugin.entities.io.actions.enums import (
CommonAction,
RuntimeToLangBotAction,
LangBotToRuntimeAction,
PluginToRuntimeAction,
)
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from ..api.http.context import ExecutionContext
from ..entity.persistence import plugin as persistence_plugin
from ..entity.persistence import bstorage as persistence_bstorage
from ..entity.persistence import bot as persistence_bot
from ..entity.persistence import model as persistence_model
from ..core import app
from ..utils import constants
_DEFAULT_BINARY_STORAGE_VALUE_BYTES = 10 * 1024 * 1024
_HARD_MAX_BINARY_STORAGE_VALUE_BYTES = 64 * 1024 * 1024
def _binary_storage_value_limit(ap: Any) -> int:
configured = (
ap.instance_config.data.get('plugin', {})
.get('binary_storage', {})
.get('max_value_bytes', _DEFAULT_BINARY_STORAGE_VALUE_BYTES)
)
try:
configured = int(configured)
except (TypeError, ValueError):
configured = _DEFAULT_BINARY_STORAGE_VALUE_BYTES
if configured < 0:
configured = _DEFAULT_BINARY_STORAGE_VALUE_BYTES
return min(configured, _HARD_MAX_BINARY_STORAGE_VALUE_BYTES)
class _RawAction:
def __init__(self, value: str):
self.value = value
def _langbot_to_runtime_action(enum_name: str, fallback_value: str) -> Any:
return getattr(LangBotToRuntimeAction, enum_name, _RawAction(fallback_value))
def _make_rag_error_response(error: Exception, error_type: str, **extra_context) -> handler.ActionResponse:
"""Create a clean error response for RAG operations.
Args:
error: The caught exception.
error_type: A category string like 'EmbeddingError', 'VectorStoreError'.
**extra_context: Additional context fields for the error message.
"""
context_parts = [f'{k}={v}' for k, v in extra_context.items()]
context_str = f' [{", ".join(context_parts)}]' if context_parts else ''
message = f'[{error_type}/{type(error).__name__}]{context_str} {str(error)}'
return handler.ActionResponse.error(message=message)
@dataclass(frozen=True, slots=True)
class _PluginInstallationIdentity:
workspace_uuid: str
plugin_author: str
plugin_name: str
installation_uuid: str
runtime_revision: int
artifact_digest: str
_UNTRUSTED_SCOPE_FIELDS = frozenset(
{
'context',
'action_context',
'instance_uuid',
'workspace_uuid',
'placement_generation',
'installation_uuid',
'runtime_revision',
'artifact_digest',
}
)
_RUNTIME_SCOPED_ACTIONS = frozenset(
{
CommonAction.FILE_CHUNK.value,
RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value,
RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value,
}
)
class RuntimeConnectionHandler(handler.Handler):
"""Runtime connection handler"""
ap: app.Application
def validate_inbound_action_context(
self,
action: str,
action_context: ActionContext | None,
) -> ActionContext | None:
"""Require a complete installation tuple on every tenant action."""
if action == CommonAction.PING.value:
if action_context is not None:
raise ValueError('PING does not accept an installation binding')
return None
if isinstance(action_context, InstallationBinding):
return action_context
if self._allow_legacy_oss_context(action, action_context):
return action_context
raise ValueError(f'{action} requires a complete InstallationBinding context')
def _allow_legacy_oss_context(self, action: str, action_context: ActionContext | None) -> bool:
"""Keep pre-v4 local plugins usable without weakening shared Runtime."""
if not (
getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'oss'
and isinstance(action_context, ActionContext)
and not isinstance(action_context, InstallationBinding)
):
return False
if action in {
RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value,
RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value,
CommonAction.FILE_CHUNK.value,
}:
return True
# Pre-v4 OSS workers receive this capability from Core's settings
# response, but their pinned SDK cannot carry revision/digest fields.
# Shared Runtime never enters this compatibility branch.
return bool(action_context.installation_uuid)
def _require_runtime_action_context(self) -> ActionContext:
action_context = self.current_action_context
if action_context is None:
raise ValueError('Plugin Runtime action is missing a trusted Workspace context')
return action_context
async def _resolve_installation_identity(
self,
action_context: InstallationBinding,
) -> _PluginInstallationIdentity:
cached = self._installation_bindings.get(action_context.installation_uuid)
if cached is not None:
cached_binding, identity = cached
if cached_binding != action_context:
raise ValueError('Plugin installation revision or artifact is stale')
return identity
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(
persistence_plugin.PluginSetting.plugin_author,
persistence_plugin.PluginSetting.plugin_name,
persistence_plugin.PluginSetting.installation_uuid,
persistence_plugin.PluginSetting.runtime_revision,
persistence_plugin.PluginSetting.artifact_digest,
)
.where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.installation_uuid == action_context.installation_uuid)
)
setting = result.first()
if setting is None:
raise ValueError('Plugin installation is not registered in this Workspace')
if (
setting.runtime_revision != action_context.runtime_revision
or setting.artifact_digest != action_context.artifact_digest
):
raise ValueError('Plugin installation revision or artifact is stale')
identity = _PluginInstallationIdentity(
workspace_uuid=action_context.workspace_uuid,
plugin_author=setting.plugin_author,
plugin_name=setting.plugin_name,
installation_uuid=setting.installation_uuid,
runtime_revision=setting.runtime_revision,
artifact_digest=setting.artifact_digest,
)
self._installation_bindings[action_context.installation_uuid] = (action_context, identity)
return identity
async def _resolve_legacy_oss_installation_identity(
self,
action_context: ActionContext,
) -> _PluginInstallationIdentity:
if not action_context.installation_uuid:
raise ValueError('Legacy OSS plugin action is missing its installation capability')
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(
persistence_plugin.PluginSetting.plugin_author,
persistence_plugin.PluginSetting.plugin_name,
persistence_plugin.PluginSetting.installation_uuid,
persistence_plugin.PluginSetting.runtime_revision,
persistence_plugin.PluginSetting.artifact_digest,
)
.where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.installation_uuid == action_context.installation_uuid)
)
setting = result.first()
if setting is None:
raise ValueError('Plugin installation is not registered in this Workspace')
return _PluginInstallationIdentity(
workspace_uuid=action_context.workspace_uuid,
plugin_author=setting.plugin_author,
plugin_name=setting.plugin_name,
installation_uuid=setting.installation_uuid,
runtime_revision=setting.runtime_revision,
artifact_digest=setting.artifact_digest,
)
async def _require_plugin_action_context(
self,
) -> tuple[ActionContext, _PluginInstallationIdentity]:
action_context = self._require_runtime_action_context()
if isinstance(action_context, InstallationBinding):
identity = await self._resolve_installation_identity(action_context)
elif self._allow_legacy_oss_context('plugin_action', action_context):
identity = await self._resolve_legacy_oss_installation_identity(action_context)
else:
raise ValueError('Plugin action requires a complete InstallationBinding context')
return action_context, identity
async def _require_active_action_context(self, action_context: ActionContext) -> None:
"""Fence stale Runtime generations against Core's active projection."""
workspace_service = getattr(self.ap, 'workspace_service', None)
if workspace_service is None:
raise ValueError('Workspace execution service is unavailable')
binding = await workspace_service.get_execution_binding(
action_context.workspace_uuid,
expected_generation=action_context.placement_generation,
)
if (
binding.instance_uuid != action_context.instance_uuid
or binding.workspace_uuid != action_context.workspace_uuid
or binding.placement_generation != action_context.placement_generation
):
raise ValueError('Plugin Runtime action uses a stale Workspace execution binding')
@contextlib.asynccontextmanager
async def _tenant_action_scope(self, action_context: ActionContext):
"""Bind Runtime-origin work to the trusted Workspace database scope.
Cloud persistence deliberately rejects unscoped access and PostgreSQL
RLS reads the Workspace id from each short database transaction. The
wire envelope is validated before this helper is entered, so plugin
payload fields can never select the scope. A transaction-free boundary
avoids reserving one pooled connection across provider and network waits.
"""
persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
if persistence_mgr is None:
yield
return
tenant_scope_descriptor = getattr(type(persistence_mgr), 'tenant_scope', None)
if not callable(tenant_scope_descriptor):
# Lightweight test doubles and older OSS persistence managers do
# not expose the transaction-free scope API.
yield
return
async with persistence_mgr.tenant_scope(action_context.workspace_uuid):
yield
def _secure_plugin_actions(self) -> None:
"""Wrap plugin-origin actions with installation validation and payload scrubbing."""
for action_name, action_handler in list(self.actions.items()):
if action_name == CommonAction.PING.value:
continue
async def secured_action(
data: dict[str, Any],
*,
_action_handler=action_handler,
_runtime_scoped=action_name in _RUNTIME_SCOPED_ACTIONS,
) -> handler.ActionResponse:
action_context = self._require_runtime_action_context()
async with self._tenant_action_scope(action_context):
if not _runtime_scoped:
action_context, _ = await self._require_plugin_action_context()
await self._require_active_action_context(action_context)
safe_data = {key: value for key, value in data.items() if key not in _UNTRUSTED_SCOPE_FIELDS}
response = _action_handler(safe_data)
if inspect.isawaitable(response):
response = await response
return response
self.actions[action_name] = secured_action
async def _get_plugin_setting(
self,
action_context: ActionContext,
identity: _PluginInstallationIdentity,
):
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_plugin.PluginSetting)
.where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.plugin_author == identity.plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == identity.plugin_name)
)
setting = result.first()
if setting is None:
raise ValueError('Plugin installation setting was not found')
return setting
async def _get_plugin_setting_by_name(
self,
action_context: ActionContext,
plugin_author: str,
plugin_name: str,
):
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_plugin.PluginSetting)
.where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
)
return result.first()
@staticmethod
def _require_setting_binding(setting, action_context: InstallationBinding) -> None:
if setting is None:
raise ValueError('Plugin installation setting was not found')
if (
setting.installation_uuid != action_context.installation_uuid
or setting.runtime_revision != action_context.runtime_revision
or setting.artifact_digest != action_context.artifact_digest
):
raise ValueError('Plugin installation binding does not match the active setting')
async def _resolve_query(
self,
data: dict[str, Any],
action_context: ActionContext,
):
query_uuid = data.get('query_uuid')
if query_uuid is not None:
query = await self.ap.query_pool.get_query(
action_context.workspace_uuid,
query_uuid,
)
else:
query = await self.ap.query_pool.get_query_by_legacy_id(
action_context.workspace_uuid,
data['query_id'],
)
if query is None:
return None
if (
getattr(query, 'instance_uuid', None) != action_context.instance_uuid
or getattr(query, 'workspace_uuid', None) != action_context.workspace_uuid
or getattr(query, 'placement_generation', None) != action_context.placement_generation
):
return None
return query
async def _resource_exists(
self,
model,
uuid_column,
resource_uuid: str,
workspace_uuid: str,
) -> bool:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(uuid_column)
.select_from(model)
.where(model.workspace_uuid == workspace_uuid)
.where(uuid_column == resource_uuid)
.limit(1)
)
return result.first() is not None
@staticmethod
def _config_contains_file_key(value: Any, file_key: str) -> bool:
if isinstance(value, dict):
return any(RuntimeConnectionHandler._config_contains_file_key(item, file_key) for item in value.values())
if isinstance(value, list):
return any(RuntimeConnectionHandler._config_contains_file_key(item, file_key) for item in value)
return isinstance(value, str) and value == file_key
@staticmethod
def _execution_context(action_context: ActionContext) -> ExecutionContext:
"""Project the trusted wire binding into Core's runtime context."""
return ExecutionContext(
instance_uuid=action_context.instance_uuid,
workspace_uuid=action_context.workspace_uuid,
placement_generation=action_context.placement_generation,
)
@staticmethod
def _binary_storage_owner(
action_context: ActionContext,
identity: _PluginInstallationIdentity,
owner_type: str,
) -> str:
"""Resolve storage ownership exclusively from the trusted binding."""
if owner_type == 'workspace':
return action_context.workspace_uuid
if owner_type == 'plugin':
return f'{identity.plugin_author}/{identity.plugin_name}'
raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
@classmethod
def _binary_storage_key(
cls,
action_context: ActionContext,
*,
owner_type: str,
owner: str,
key: str,
) -> str:
"""Use Core's canonical key across every persistent owner dimension."""
# Import lazily: StorageMgr references the Application type, whose
# module wires the plugin connector during startup.
from ..storage.mgr import StorageMgr
return StorageMgr.canonical_binary_storage_key(
cls._execution_context(action_context),
owner_type=owner_type,
owner=owner,
key=key,
)
def __init__(
self,
connection: Connection,
disconnect_callback: typing.Callable[[], typing.Coroutine[typing.Any, typing.Any, bool]],
ap: app.Application,
):
super().__init__(connection, disconnect_callback)
self.ap = ap
self._outbound_installation_context: contextvars.ContextVar[InstallationBinding | None] = (
contextvars.ContextVar(
f'{self.__class__.__name__}_{id(self)}_outbound_installation',
default=None,
)
)
self._installation_bindings: dict[
str,
tuple[InstallationBinding, _PluginInstallationIdentity],
] = {}
@self.action(RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS)
async def initialize_plugin_settings(data: dict[str, Any]) -> handler.ActionResponse:
"""Initialize plugin settings"""
action_context = self._require_runtime_action_context()
# check if exists plugin setting
plugin_author = data['plugin_author']
plugin_name = data['plugin_name']
install_source = data['install_source']
install_info = data['install_info']
try:
setting = await self._get_plugin_setting_by_name(
action_context,
plugin_author,
plugin_name,
)
if isinstance(action_context, InstallationBinding):
self._require_setting_binding(setting, action_context)
elif setting is None:
# OSS debug and pre-v4 data/plugins are the only callers
# allowed to create settings without a desired-state row.
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_plugin.PluginSetting).values(
workspace_uuid=action_context.workspace_uuid,
plugin_author=plugin_author,
plugin_name=plugin_name,
install_source=install_source,
install_info=install_info,
enabled=True,
priority=0,
config={},
)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_plugin.PluginSetting)
.where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
.values(install_source=install_source, install_info=install_info)
)
return handler.ActionResponse.success(
data={},
)
except Exception as e:
traceback.print_exc()
return handler.ActionResponse.error(
message=f'Failed to initialize plugin settings: {e}',
)
@self.action(RuntimeToLangBotAction.GET_PLUGIN_SETTINGS)
async def get_plugin_settings(data: dict[str, Any]) -> handler.ActionResponse:
"""Get plugin settings"""
action_context = self._require_runtime_action_context()
plugin_author = data['plugin_author']
plugin_name = data['plugin_name']
setting = await self._get_plugin_setting_by_name(
action_context,
plugin_author,
plugin_name,
)
if isinstance(action_context, InstallationBinding):
self._require_setting_binding(setting, action_context)
data = {
'enabled': True,
'priority': 0,
'plugin_config': {},
'install_source': 'local',
'install_info': {},
'installation_uuid': None,
'runtime_revision': None,
'artifact_digest': None,
}
if setting is not None:
data['enabled'] = setting.enabled
data['priority'] = setting.priority
data['plugin_config'] = setting.config
data['install_source'] = setting.install_source
data['install_info'] = setting.install_info
data['installation_uuid'] = setting.installation_uuid
data['runtime_revision'] = setting.runtime_revision
data['artifact_digest'] = setting.artifact_digest
return handler.ActionResponse.success(
data=data,
)
@self.action(PluginToRuntimeAction.REPLY_MESSAGE)
async def reply_message(data: dict[str, Any]) -> handler.ActionResponse:
"""Reply message"""
action_context, _ = await self._require_plugin_action_context()
query_id = data['query_id']
message_chain = data['message_chain']
quote_origin = data['quote_origin']
query = await self._resolve_query(data, action_context)
if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
message_chain_obj = platform_message.MessageChain.model_validate(message_chain)
self.ap.logger.debug(f'Reply message: {message_chain_obj.model_dump(serialize_as_any=False)}')
await query.adapter.reply_message(
query.message_event,
message_chain_obj,
quote_origin,
)
return handler.ActionResponse.success(
data={},
)
@self.action(PluginToRuntimeAction.GET_BOT_UUID)
async def get_bot_uuid(data: dict[str, Any]) -> handler.ActionResponse:
"""Get bot uuid"""
action_context, _ = await self._require_plugin_action_context()
query_id = data['query_id']
query = await self._resolve_query(data, action_context)
if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
return handler.ActionResponse.success(
data={
'bot_uuid': query.bot_uuid,
},
)
@self.action(PluginToRuntimeAction.SET_QUERY_VAR)
async def set_query_var(data: dict[str, Any]) -> handler.ActionResponse:
"""Set query var"""
action_context, _ = await self._require_plugin_action_context()
query_id = data['query_id']
key = data['key']
value = data['value']
query = await self._resolve_query(data, action_context)
if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
query.variables[key] = value
return handler.ActionResponse.success(
data={},
)
@self.action(PluginToRuntimeAction.GET_QUERY_VAR)
async def get_query_var(data: dict[str, Any]) -> handler.ActionResponse:
"""Get query var"""
action_context, _ = await self._require_plugin_action_context()
query_id = data['query_id']
key = data['key']
query = await self._resolve_query(data, action_context)
if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
return handler.ActionResponse.success(
data={
'value': query.variables[key],
},
)
@self.action(PluginToRuntimeAction.GET_QUERY_VARS)
async def get_query_vars(data: dict[str, Any]) -> handler.ActionResponse:
"""Get query vars"""
action_context, _ = await self._require_plugin_action_context()
query_id = data['query_id']
query = await self._resolve_query(data, action_context)
if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
return handler.ActionResponse.success(
data={
'vars': query.variables,
},
)
@self.action(PluginToRuntimeAction.CREATE_NEW_CONVERSATION)
async def create_new_conversation(data: dict[str, Any]) -> handler.ActionResponse:
"""Create new conversation"""
action_context, _ = await self._require_plugin_action_context()
query_id = data['query_id']
query = await self._resolve_query(data, action_context)
if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
query.session.using_conversation = None
return handler.ActionResponse.success(
data={},
)
@self.action(PluginToRuntimeAction.GET_LANGBOT_VERSION)
async def get_langbot_version(data: dict[str, Any]) -> handler.ActionResponse:
"""Get langbot version"""
return handler.ActionResponse.success(
data={
'version': constants.semantic_version,
},
)
@self.action(PluginToRuntimeAction.GET_BOTS)
async def get_bots(data: dict[str, Any]) -> handler.ActionResponse:
"""Get bots"""
action_context, _ = await self._require_plugin_action_context()
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bot.Bot).where(
persistence_bot.Bot.workspace_uuid == action_context.workspace_uuid
)
)
bots = [
self.ap.persistence_mgr.serialize_model(
persistence_bot.Bot,
bot,
['adapter_config'],
)
for bot in result.all()
]
return handler.ActionResponse.success(
data={
'bots': bots,
},
)
@self.action(PluginToRuntimeAction.GET_BOT_INFO)
async def get_bot_info(data: dict[str, Any]) -> handler.ActionResponse:
"""Get bot info"""
action_context, _ = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
bot_uuid = data['bot_uuid']
if not await self._resource_exists(
persistence_bot.Bot,
persistence_bot.Bot.uuid,
bot_uuid,
action_context.workspace_uuid,
):
return handler.ActionResponse.error(
message=f'Bot with bot_uuid {bot_uuid} not found',
)
bot = await self.ap.bot_service.get_runtime_bot_info(
execution_context,
bot_uuid,
include_secret=False,
)
return handler.ActionResponse.success(
data={
'bot': bot,
},
)
@self.action(PluginToRuntimeAction.SEND_MESSAGE)
async def send_message(data: dict[str, Any]) -> handler.ActionResponse:
"""Send message"""
action_context, _ = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
bot_uuid = data['bot_uuid']
target_type = data['target_type']
target_id = data['target_id']
message_chain = data['message_chain']
if not await self._resource_exists(
persistence_bot.Bot,
persistence_bot.Bot.uuid,
bot_uuid,
action_context.workspace_uuid,
):
return handler.ActionResponse.error(
message=f'Bot with bot_uuid {bot_uuid} not found',
)
# Use custom deserializer that properly handles Forward messages
message_chain_obj = platform_message.MessageChain.model_validate(message_chain)
bot = await self.ap.platform_mgr.get_bot_by_uuid(
execution_context,
bot_uuid,
)
if bot is None:
return handler.ActionResponse.error(
message=f'Bot with bot_uuid {bot_uuid} not found',
)
await bot.adapter.send_message(
target_type,
target_id,
message_chain_obj,
)
return handler.ActionResponse.success(
data={},
)
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
"""Get llm models, returns list of UUID strings"""
action_context, _ = await self._require_plugin_action_context()
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel.uuid).where(
persistence_model.LLMModel.workspace_uuid == action_context.workspace_uuid
)
)
return handler.ActionResponse.success(
data={
'llm_models': list(result.scalars().all()),
},
)
@self.action(PluginToRuntimeAction.INVOKE_LLM)
async def invoke_llm(data: dict[str, Any]) -> handler.ActionResponse:
"""Invoke llm"""
action_context, _ = await self._require_plugin_action_context()
llm_model_uuid = data['llm_model_uuid']
messages = data['messages']
funcs = data.get('funcs', [])
extra_args = data.get('extra_args', {})
if not await self._resource_exists(
persistence_model.LLMModel,
persistence_model.LLMModel.uuid,
llm_model_uuid,
action_context.workspace_uuid,
):
return handler.ActionResponse.error(
message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
)
try:
execution_context = self._execution_context(action_context)
llm_model = await self.ap.model_mgr.get_model_by_uuid(
execution_context,
llm_model_uuid,
)
except ValueError:
return handler.ActionResponse.error(
message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
)
runtime_workspace_uuid = getattr(
getattr(llm_model, 'model_entity', None),
'workspace_uuid',
None,
)
if runtime_workspace_uuid not in (None, action_context.workspace_uuid):
return handler.ActionResponse.error(
message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
)
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
# The func field is excluded during model_dump() in plugin side (marked as exclude=True),
# but it's a required field for LLMTool validation. We need to provide a placeholder
# function when reconstructing the LLMTool objects from serialized data.
async def _placeholder_func(**kwargs):
pass
funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs]
result = await llm_model.provider.invoke_llm(
query=None,
model=llm_model,
messages=messages_obj,
funcs=funcs_obj,
extra_args=extra_args,
execution_context=execution_context,
)
return handler.ActionResponse.success(
data={
'message': result.model_dump(),
},
)
@self.action(RuntimeToLangBotAction.SET_BINARY_STORAGE)
async def set_binary_storage(data: dict[str, Any]) -> handler.ActionResponse:
"""Set binary storage"""
action_context, identity = await self._require_plugin_action_context()
key = data['key']
owner_type = data['owner_type']
try:
owner = self._binary_storage_owner(action_context, identity, owner_type)
unique_key = self._binary_storage_key(
action_context,
owner_type=owner_type,
owner=owner,
key=key,
)
except ValueError as e:
return handler.ActionResponse.error(
message=str(e),
)
max_value_bytes = _binary_storage_value_limit(self.ap)
encoded_value = data['value_base64']
max_encoded_chars = 4 * ((max_value_bytes + 2) // 3) + 4
if len(encoded_value) > max_encoded_chars:
return handler.ActionResponse.error(
message=f'Binary storage value exceeds the {max_value_bytes}-byte limit',
)
value = await asyncio.to_thread(base64.b64decode, encoded_value)
if len(value) > max_value_bytes:
return handler.ActionResponse.error(
message=f'Binary storage value exceeds limit ({len(value)} > {max_value_bytes} bytes)',
)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
if result.first() is not None:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
.values(value=value)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_bstorage.BinaryStorage).values(
workspace_uuid=action_context.workspace_uuid,
unique_key=unique_key,
key=key,
owner_type=owner_type,
owner=owner,
value=value,
)
)
return handler.ActionResponse.success(
data={},
)
@self.action(RuntimeToLangBotAction.GET_BINARY_STORAGE)
async def get_binary_storage(data: dict[str, Any]) -> handler.ActionResponse:
"""Get binary storage"""
action_context, identity = await self._require_plugin_action_context()
key = data['key']
owner_type = data['owner_type']
try:
owner = self._binary_storage_owner(action_context, identity, owner_type)
unique_key = self._binary_storage_key(
action_context,
owner_type=owner_type,
owner=owner,
key=key,
)
except ValueError as e:
return handler.ActionResponse.error(
message=str(e),
)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
storage = result.first()
if storage is None:
return handler.ActionResponse.error(
message=f'Storage with key {key} not found',
)
max_value_bytes = _binary_storage_value_limit(self.ap)
if len(storage.value) > max_value_bytes:
return handler.ActionResponse.error(
message=f'Binary storage value exceeds the {max_value_bytes}-byte limit',
)
return handler.ActionResponse.success(
data={
'value_base64': (await asyncio.to_thread(base64.b64encode, storage.value)).decode('utf-8'),
},
)
@self.action(RuntimeToLangBotAction.DELETE_BINARY_STORAGE)
async def delete_binary_storage(data: dict[str, Any]) -> handler.ActionResponse:
"""Delete binary storage"""
action_context, identity = await self._require_plugin_action_context()
key = data['key']
owner_type = data['owner_type']
try:
owner = self._binary_storage_owner(action_context, identity, owner_type)
unique_key = self._binary_storage_key(
action_context,
owner_type=owner_type,
owner=owner,
key=key,
)
except ValueError as e:
return handler.ActionResponse.error(
message=str(e),
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
return handler.ActionResponse.success(
data={},
)
@self.action(RuntimeToLangBotAction.GET_BINARY_STORAGE_KEYS)
async def get_binary_storage_keys(data: dict[str, Any]) -> handler.ActionResponse:
"""Get binary storage keys"""
action_context, identity = await self._require_plugin_action_context()
owner_type = data['owner_type']
try:
owner = self._binary_storage_owner(action_context, identity, owner_type)
except ValueError as e:
return handler.ActionResponse.error(
message=str(e),
)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage.key)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
)
return handler.ActionResponse.success(
data={
'keys': result.scalars().all(),
},
)
@self.action(PluginToRuntimeAction.GET_CONFIG_FILE)
async def get_config_file(data: dict[str, Any]) -> handler.ActionResponse:
"""Get a config file by file key"""
action_context, identity = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
file_key = data['file_key']
try:
setting = await self._get_plugin_setting(action_context, identity)
if not self._config_contains_file_key(setting.config, file_key):
raise ValueError('Config file does not belong to this plugin installation')
# The persisted config is user-controlled and therefore cannot
# turn an arbitrary opaque object key into authority. Validate
# every trusted scope dimension before touching the provider.
file_bytes = await self.ap.storage_mgr.load_scoped_object_key(
execution_context,
file_key,
expected_owner_type='plugin_config',
)
return handler.ActionResponse.success(
data={
'file_base64': (await asyncio.to_thread(base64.b64encode, file_bytes)).decode('utf-8'),
},
)
except Exception as e:
return handler.ActionResponse.error(
message=f'Failed to load config file {file_key}: {e}',
)
# ================= RAG Capability Handlers =================
@self.action(PluginToRuntimeAction.INVOKE_EMBEDDING)
async def invoke_embedding(data: dict[str, Any]) -> handler.ActionResponse:
action_context, _ = await self._require_plugin_action_context()
embedding_model_uuid = data['embedding_model_uuid']
texts = data['texts']
if not await self._resource_exists(
persistence_model.EmbeddingModel,
persistence_model.EmbeddingModel.uuid,
embedding_model_uuid,
action_context.workspace_uuid,
):
return handler.ActionResponse.error(
message=f'Embedding model with embedding_model_uuid {embedding_model_uuid} not found',
)
try:
execution_context = self._execution_context(action_context)
embedding_model = await self.ap.model_mgr.get_embedding_model_by_uuid(
execution_context,
embedding_model_uuid,
)
except ValueError:
return handler.ActionResponse.error(
message=f'Embedding model with embedding_model_uuid {embedding_model_uuid} not found',
)
runtime_workspace_uuid = getattr(
getattr(embedding_model, 'model_entity', None),
'workspace_uuid',
None,
)
if runtime_workspace_uuid not in (None, action_context.workspace_uuid):
return handler.ActionResponse.error(
message=f'Embedding model with embedding_model_uuid {embedding_model_uuid} not found',
)
try:
vectors = await embedding_model.provider.invoke_embedding(
embedding_model,
texts,
execution_context=execution_context,
)
return handler.ActionResponse.success(data={'vectors': vectors})
except Exception as e:
return _make_rag_error_response(e, 'EmbeddingError', embedding_model_uuid=embedding_model_uuid)
@self.action(PluginToRuntimeAction.INVOKE_RERANK)
async def invoke_rerank(data: dict[str, Any]) -> handler.ActionResponse:
action_context, _ = await self._require_plugin_action_context()
rerank_model_uuid = data['rerank_model_uuid']
query = data['query']
documents = data['documents']
top_k = data.get('top_k')
extra_args = data.get('extra_args', {})
if not await self._resource_exists(
persistence_model.RerankModel,
persistence_model.RerankModel.uuid,
rerank_model_uuid,
action_context.workspace_uuid,
):
return handler.ActionResponse.error(
message=f'Rerank model with rerank_model_uuid {rerank_model_uuid} not found',
)
try:
execution_context = self._execution_context(action_context)
rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(
execution_context,
rerank_model_uuid,
)
except ValueError:
return handler.ActionResponse.error(
message=f'Rerank model with rerank_model_uuid {rerank_model_uuid} not found',
)
runtime_workspace_uuid = getattr(
getattr(rerank_model, 'model_entity', None),
'workspace_uuid',
None,
)
if runtime_workspace_uuid not in (None, action_context.workspace_uuid):
return handler.ActionResponse.error(
message=f'Rerank model with rerank_model_uuid {rerank_model_uuid} not found',
)
try:
scores = await rerank_model.provider.invoke_rerank(
model=rerank_model,
query=query,
documents=documents[:64],
extra_args=extra_args,
execution_context=execution_context,
)
scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True)
if top_k is not None:
scored = scored[: int(top_k)]
return handler.ActionResponse.success(data={'results': scored})
except Exception as e:
return _make_rag_error_response(e, 'RerankError', rerank_model_uuid=rerank_model_uuid)
@self.action(PluginToRuntimeAction.VECTOR_UPSERT)
async def vector_upsert(data: dict[str, Any]) -> handler.ActionResponse:
action_context, _ = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
collection_id = data['collection_id']
vectors = data['vectors']
ids = data['ids']
metadata = data.get('metadata')
documents = data.get('documents')
if len(vectors) != len(ids):
return handler.ActionResponse.error(message='vectors and ids must have same length')
if metadata and len(metadata) != len(vectors):
return handler.ActionResponse.error(message='metadata must match vectors length')
if documents and len(documents) != len(vectors):
return handler.ActionResponse.error(message='documents must match vectors length')
try:
await self.ap.rag_runtime_service.vector_upsert(
execution_context,
collection_id,
vectors,
ids,
metadata,
documents,
)
return handler.ActionResponse.success(data={})
except Exception as e:
return _make_rag_error_response(
e,
'VectorStoreError',
collection_id=collection_id,
)
@self.action(PluginToRuntimeAction.VECTOR_SEARCH)
async def vector_search(data: dict[str, Any]) -> handler.ActionResponse:
action_context, _ = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
collection_id = data['collection_id']
query_vector = data['query_vector']
top_k = data['top_k']
filters = data.get('filters')
search_type = data.get('search_type', 'vector')
query_text = data.get('query_text', '')
vector_weight = data.get('vector_weight')
try:
results = await self.ap.rag_runtime_service.vector_search(
execution_context,
collection_id,
query_vector,
top_k,
filters,
search_type,
query_text,
vector_weight=vector_weight,
)
return handler.ActionResponse.success(data={'results': results})
except Exception as e:
return _make_rag_error_response(
e,
'VectorStoreError',
collection_id=collection_id,
)
@self.action(PluginToRuntimeAction.VECTOR_DELETE)
async def vector_delete(data: dict[str, Any]) -> handler.ActionResponse:
action_context, _ = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
collection_id = data['collection_id']
file_ids = data.get('file_ids')
filters = data.get('filters')
try:
count = await self.ap.rag_runtime_service.vector_delete(
execution_context,
collection_id,
file_ids,
filters,
)
return handler.ActionResponse.success(data={'count': count})
except Exception as e:
return _make_rag_error_response(
e,
'VectorStoreError',
collection_id=collection_id,
)
@self.action(PluginToRuntimeAction.VECTOR_LIST)
async def vector_list(data: dict[str, Any]) -> handler.ActionResponse:
action_context, _ = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
collection_id = data['collection_id']
filters = data.get('filters')
limit = data.get('limit', 20)
offset = data.get('offset', 0)
try:
items, total = await self.ap.rag_runtime_service.vector_list(
execution_context,
collection_id,
filters,
limit,
offset,
)
return handler.ActionResponse.success(data={'items': items, 'total': total})
except Exception as e:
return _make_rag_error_response(
e,
'VectorStoreError',
collection_id=collection_id,
)
@self.action(PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM)
async def get_knowledge_file_stream(data: dict[str, Any]) -> handler.ActionResponse:
action_context, _ = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
storage_path = data['storage_path']
try:
content_bytes = await self.ap.rag_runtime_service.get_file_stream(
execution_context,
storage_path,
)
file_key = await self.send_file(content_bytes, '')
return handler.ActionResponse.success(data={'file_key': file_key})
except Exception as e:
return _make_rag_error_response(e, 'FileServiceError', storage_path=storage_path)
@self.action(PluginToRuntimeAction.LIST_PARSERS)
async def list_parsers(data: dict[str, Any]) -> handler.ActionResponse:
"""Plugin requests host to list available parser plugins."""
action_context, _ = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
mime_type = data.get('mime_type')
try:
parsers = await self.ap.knowledge_service.list_parsers(
execution_context,
mime_type,
)
return handler.ActionResponse.success(data={'parsers': parsers})
except Exception as e:
return _make_rag_error_response(e, 'ParserDiscoveryError', mime_type=mime_type)
@self.action(PluginToRuntimeAction.INVOKE_PARSER)
async def invoke_parser(data: dict[str, Any]) -> handler.ActionResponse:
"""Plugin requests host to invoke a parser plugin."""
action_context, _ = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
plugin_author = data['plugin_author']
plugin_name = data['plugin_name']
storage_path = data['storage_path']
mime_type = data.get('mime_type', 'application/octet-stream')
filename = data.get('filename', '')
metadata = data.get('metadata', {})
try:
# Read file from storage
file_bytes = await self.ap.rag_runtime_service.get_file_stream(
execution_context,
storage_path,
)
context_data = {
'mime_type': mime_type,
'filename': filename,
'metadata': metadata,
}
await self.ap.plugin_connector.require_workspace_context(execution_context)
result = await self.ap.plugin_connector.call_parser(
f'{plugin_author}/{plugin_name}', context_data, file_bytes
)
return handler.ActionResponse.success(data=result)
except Exception as e:
return _make_rag_error_response(e, 'ParserError')
# ================= Knowledge Base Query APIs =================
@self.action(PluginToRuntimeAction.LIST_KNOWLEDGE_BASES)
async def list_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
"""List knowledge bases visible to the bound Workspace."""
action_context, _ = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
details = await self.ap.rag_mgr.get_all_knowledge_base_details(execution_context)
knowledge_bases = [
{
'uuid': kb['uuid'],
'name': kb['name'],
'description': kb.get('description') or '',
}
for kb in details
]
return handler.ActionResponse.success(data={'knowledge_bases': knowledge_bases})
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE)
async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse:
"""Retrieve documents from a knowledge base in the bound Workspace."""
action_context, _ = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
kb_id = data['kb_id']
query_text = data['query_text']
top_k = data.get('top_k', 5)
filters = data.get('filters', {})
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(
execution_context,
kb_id,
)
if not kb:
return handler.ActionResponse.error(
message=f'Knowledge base {kb_id} not found',
)
try:
entries = await kb.retrieve(
execution_context,
query_text,
settings={
'top_k': top_k,
'filters': filters,
},
)
results = [entry.model_dump(mode='json') for entry in entries]
return handler.ActionResponse.success(data={'results': results})
except Exception as e:
return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id)
@self.action(PluginToRuntimeAction.LIST_PIPELINE_KNOWLEDGE_BASES)
async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
"""List knowledge bases configured for the current query's pipeline."""
action_context, _ = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
query_id = data['query_id']
query = await self._resolve_query(data, action_context)
if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
kb_uuids = []
if query.pipeline_config:
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
kb_uuids = local_agent_config.get('knowledge-bases', [])
# Backward compatibility
if not kb_uuids:
old_kb_uuid = local_agent_config.get('knowledge-base', '')
if old_kb_uuid and old_kb_uuid != '__none__':
kb_uuids = [old_kb_uuid]
knowledge_bases = []
for kb_uuid in kb_uuids:
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(
execution_context,
kb_uuid,
)
if kb:
knowledge_bases.append(
{
'uuid': kb.get_uuid(),
'name': kb.get_name(),
'description': kb.knowledge_base_entity.description or '',
}
)
return handler.ActionResponse.success(data={'knowledge_bases': knowledge_bases})
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE)
async def retrieve_knowledge_base(data: dict[str, Any]) -> handler.ActionResponse:
"""Retrieve documents from a knowledge base within the pipeline's scope."""
action_context, _ = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
query_id = data['query_id']
kb_id = data['kb_id']
query_text = data['query_text']
top_k = data.get('top_k', 5)
filters = data.get('filters', {})
query = await self._resolve_query(data, action_context)
if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
# Validate kb_id is in pipeline's allowed list
allowed_kb_uuids = []
if query.pipeline_config:
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
allowed_kb_uuids = local_agent_config.get('knowledge-bases', [])
if not allowed_kb_uuids:
old_kb_uuid = local_agent_config.get('knowledge-base', '')
if old_kb_uuid and old_kb_uuid != '__none__':
allowed_kb_uuids = [old_kb_uuid]
if kb_id not in allowed_kb_uuids:
return handler.ActionResponse.error(
message=f'Knowledge base {kb_id} is not configured for this pipeline',
)
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(
execution_context,
kb_id,
)
if not kb:
return handler.ActionResponse.error(
message=f'Knowledge base {kb_id} not found',
)
try:
session_name = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
entries = await kb.retrieve(
execution_context,
query_text,
settings={
'top_k': top_k,
'filters': filters,
'session_name': session_name,
'bot_uuid': query.bot_uuid or '',
'sender_id': str(query.sender_id),
},
)
results = [entry.model_dump(mode='json') for entry in entries]
return handler.ActionResponse.success(data={'results': results})
except Exception as e:
return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id)
@self.action(CommonAction.PING)
async def ping(data: dict[str, Any]) -> handler.ActionResponse:
"""Ping"""
return handler.ActionResponse.success(
data={
'pong': 'pong',
},
)
self._secure_plugin_actions()
@contextlib.contextmanager
def installation_scope(self, binding: InstallationBinding | None):
"""Attach one immutable installation tuple to nested wire actions."""
token = self._outbound_installation_context.set(binding)
try:
yield
finally:
self._outbound_installation_context.reset(token)
def register_installation_binding(
self,
binding: InstallationBinding,
*,
plugin_author: str,
plugin_name: str,
) -> None:
"""Install Core's current desired-state fence for inbound actions."""
existing = self._installation_bindings.get(binding.installation_uuid)
if existing is not None:
existing_binding = existing[0]
if existing_binding.workspace_uuid != binding.workspace_uuid:
raise ValueError('Plugin installation cannot move between Workspaces')
if binding.placement_generation < existing_binding.placement_generation or (
binding.placement_generation == existing_binding.placement_generation
and binding.runtime_revision < existing_binding.runtime_revision
):
raise ValueError('Cannot register a stale plugin installation binding')
self._installation_bindings[binding.installation_uuid] = (
binding,
_PluginInstallationIdentity(
workspace_uuid=binding.workspace_uuid,
plugin_author=plugin_author,
plugin_name=plugin_name,
installation_uuid=binding.installation_uuid,
runtime_revision=binding.runtime_revision,
artifact_digest=binding.artifact_digest,
),
)
def unregister_installation_binding(self, binding: InstallationBinding) -> None:
existing = self._installation_bindings.get(binding.installation_uuid)
if existing is not None and existing[0] == binding:
self._installation_bindings.pop(binding.installation_uuid, None)
def resolve_outbound_action_context(
self,
action_context: InstallationBinding | ActionContext | dict[str, Any] | None,
) -> InstallationBinding | ActionContext | None:
if action_context is not None:
return super().resolve_outbound_action_context(action_context)
inbound_context = self.current_action_context
if inbound_context is not None:
return inbound_context
return self._outbound_installation_context.get()
def require_outbound_installation_context(self) -> InstallationBinding:
binding = self._outbound_installation_context.get()
if not isinstance(binding, InstallationBinding):
raise ValueError('Host plugin action requires an InstallationBinding scope')
return binding
async def ping(self) -> dict[str, Any]:
"""Ping the runtime"""
with self.installation_scope(None):
return await self.call_action(
CommonAction.PING,
{},
timeout=10,
)
async def set_runtime_config(
self,
*,
runtime_identity: RuntimeIdentity,
worker_policy: PluginWorkerPolicy,
runtime_profile: typing.Literal['oss_dev', 'shared'],
cloud_service_url: str | None,
) -> dict[str, Any]:
"""Push the instance-scoped, immutable Runtime handshake."""
runtime_config = RuntimeConfig(
runtime_identity=runtime_identity,
worker_policy=worker_policy,
runtime_profile=runtime_profile,
cloud_service_url=cloud_service_url,
)
with self.installation_scope(None):
return await self.call_action(
LangBotToRuntimeAction.SET_RUNTIME_CONFIG,
runtime_config.model_dump(exclude_none=True),
timeout=10,
)
async def reconcile_plugin_installations(
self,
installations: tuple[PluginInstallationDesiredState, ...],
) -> dict[str, Any]:
request = ReconcilePluginInstallationsRequest(installations=installations)
with self.installation_scope(None):
return await self.call_action(
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
request.model_dump(),
timeout=120,
)
async def apply_plugin_installation(
self,
binding: InstallationBinding,
*,
artifact_package: bytes | None,
enabled: bool,
) -> dict[str, Any]:
with self.installation_scope(binding):
artifact_file_key = None
if artifact_package is not None:
artifact_file_key = await self.send_file(artifact_package, 'lbpkg')
request = ApplyPluginInstallationRequest(
artifact_file_key=artifact_file_key,
enabled=enabled,
)
return await self.call_action(
LangBotToRuntimeAction.APPLY_PLUGIN_INSTALLATION,
request.model_dump(exclude_none=True),
timeout=120,
)
async def remove_plugin_installation(
self,
binding: InstallationBinding,
) -> dict[str, Any]:
with self.installation_scope(binding):
return await self.call_action(
LangBotToRuntimeAction.REMOVE_PLUGIN_INSTALLATION,
RemovePluginInstallationRequest().model_dump(),
timeout=120,
)
async def install_plugin(
self, install_source: str, install_info: dict[str, Any]
) -> typing.AsyncGenerator[dict[str, Any], None]:
"""Install plugin"""
gen = self.call_action_generator(
LangBotToRuntimeAction.INSTALL_PLUGIN,
{
'install_source': install_source,
'install_info': install_info,
},
timeout=120,
)
async for ret in gen:
yield ret
async def upgrade_plugin(self, plugin_author: str, plugin_name: str) -> typing.AsyncGenerator[dict[str, Any], None]:
"""Upgrade plugin"""
gen = self.call_action_generator(
LangBotToRuntimeAction.UPGRADE_PLUGIN,
{
'plugin_author': plugin_author,
'plugin_name': plugin_name,
},
timeout=120,
)
async for ret in gen:
yield ret
async def delete_plugin(self, plugin_author: str, plugin_name: str) -> typing.AsyncGenerator[dict[str, Any], None]:
"""Delete plugin"""
gen = self.call_action_generator(
LangBotToRuntimeAction.DELETE_PLUGIN,
{
'plugin_author': plugin_author,
'plugin_name': plugin_name,
},
)
async for ret in gen:
yield ret
async def list_plugins(self) -> list[dict[str, Any]]:
"""List plugins"""
result = await self.call_action(
LangBotToRuntimeAction.LIST_PLUGINS,
{},
timeout=10,
)
return result['plugins']
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
"""Get plugin"""
result = await self.call_action(
LangBotToRuntimeAction.GET_PLUGIN_INFO,
{
'author': author,
'plugin_name': plugin_name,
},
timeout=10,
)
return result['plugin']
async def set_plugin_config(self, plugin_author: str, plugin_name: str, config: dict[str, Any]) -> dict[str, Any]:
"""Set plugin config"""
action_context = self.require_outbound_installation_context()
# update plugin setting
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_plugin.PluginSetting)
.where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
.values(config=config)
)
# restart plugin
gen = self.call_action_generator(
LangBotToRuntimeAction.RESTART_PLUGIN,
{
'plugin_author': plugin_author,
'plugin_name': plugin_name,
},
)
async for ret in gen:
pass
return {}
async def emit_event(
self,
event_context: dict[str, Any],
include_plugins: list[str] | None = None,
) -> dict[str, Any]:
"""Emit event"""
result = await self.call_action(
LangBotToRuntimeAction.EMIT_EVENT,
{
'event_context': event_context,
'include_plugins': include_plugins,
},
timeout=180,
)
return result
async def notify_plugin_diagnostic(self, diagnostic: dict[str, Any]) -> dict[str, Any]:
"""Notify the plugin runtime about a best-effort plugin diagnostic.
This intentionally uses the raw protocol string instead of a SDK enum so
LangBot can keep running with older langbot-plugin versions.
"""
return await self.call_action(
_langbot_to_runtime_action('PLUGIN_DIAGNOSTIC', 'plugin_diagnostic'),
diagnostic,
timeout=5,
)
async def list_tools(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]:
"""List tools"""
result = await self.call_action(
LangBotToRuntimeAction.LIST_TOOLS,
{
'include_plugins': include_plugins,
},
timeout=20,
)
return result['tools']
async def get_plugin_icon(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:
"""Get plugin icon"""
result = await self.call_action(
LangBotToRuntimeAction.GET_PLUGIN_ICON,
{
'plugin_author': plugin_author,
'plugin_name': plugin_name,
},
)
plugin_icon_file_key = result['plugin_icon_file_key']
mime_type = result['mime_type']
plugin_icon_bytes = await self.read_local_file(plugin_icon_file_key)
await self.delete_local_file(plugin_icon_file_key)
return {
'plugin_icon_base64': (await asyncio.to_thread(base64.b64encode, plugin_icon_bytes)).decode('utf-8'),
'mime_type': mime_type,
}
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
"""Get plugin readme"""
try:
result = await self.call_action(
LangBotToRuntimeAction.GET_PLUGIN_README,
{
'plugin_author': plugin_author,
'plugin_name': plugin_name,
'language': language,
},
timeout=20,
)
except Exception:
traceback.print_exc()
return ''
readme_file_key = result.get('readme_file_key')
if not readme_file_key:
return ''
readme_bytes = await self.read_local_file(readme_file_key)
await self.delete_local_file(readme_file_key)
return readme_bytes.decode('utf-8')
async def get_plugin_logs(
self,
plugin_author: str,
plugin_name: str,
limit: int = 200,
level: str | None = None,
) -> list[dict[str, Any]]:
"""Get recent log lines captured from the plugin's stderr."""
try:
result = await self.call_action(
LangBotToRuntimeAction.GET_PLUGIN_LOGS,
{
'plugin_author': plugin_author,
'plugin_name': plugin_name,
'limit': limit,
'level': level,
},
timeout=20,
)
except Exception:
traceback.print_exc()
return []
return result.get('logs', [])
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
"""Get plugin assets"""
result = await self.call_action(
LangBotToRuntimeAction.GET_PLUGIN_ASSETS_FILE,
{
'plugin_author': plugin_author,
'plugin_name': plugin_name,
'file_path': filepath,
},
timeout=20,
)
asset_file_key = result['file_file_key']
if not asset_file_key:
return {
'asset_base64': '',
'mime_type': '',
}
mime_type = result['mime_type']
asset_bytes = await self.read_local_file(asset_file_key)
await self.delete_local_file(asset_file_key)
return {
'asset_base64': (await asyncio.to_thread(base64.b64encode, asset_bytes)).decode('utf-8'),
'mime_type': mime_type,
}
async def handle_page_api(
self,
plugin_author: str,
plugin_name: str,
page_id: str,
endpoint: str,
method: str,
body: Any = None,
) -> dict[str, Any]:
"""Forward a page API call to the plugin via runtime."""
result = await self.call_action(
LangBotToRuntimeAction.PAGE_API,
{
'plugin_author': plugin_author,
'plugin_name': plugin_name,
'page_id': page_id,
'endpoint': endpoint,
'method': method,
'body': body,
},
timeout=30,
)
return result
async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:
"""Cleanup plugin settings and binary storage"""
action_context = self.require_outbound_installation_context()
# Delete plugin settings
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_plugin.PluginSetting)
.where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
)
# Delete all binary storage for this plugin
owner = f'{plugin_author}/{plugin_name}'
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.owner_type == 'plugin')
.where(persistence_bstorage.BinaryStorage.owner == owner)
)
async def call_tool(
self,
tool_name: str,
parameters: dict[str, Any],
session: dict[str, Any],
query_id: int,
include_plugins: list[str] | None = None,
query_uuid: str | None = None,
) -> dict[str, Any]:
"""Call tool"""
query_ref: dict[str, Any] = {'query_id': query_id}
if query_uuid is not None:
query_ref['query_uuid'] = query_uuid
result = await self.call_action(
LangBotToRuntimeAction.CALL_TOOL,
{
'tool_name': tool_name,
'tool_parameters': parameters,
'session': session,
**query_ref,
'include_plugins': include_plugins,
},
timeout=180,
)
return result['tool_response']
async def list_commands(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]:
"""List commands"""
result = await self.call_action(
LangBotToRuntimeAction.LIST_COMMANDS,
{
'include_plugins': include_plugins,
},
timeout=10,
)
return result['commands']
async def execute_command(
self, command_context: dict[str, Any], include_plugins: list[str] | None = None
) -> typing.AsyncGenerator[dict[str, Any], None]:
"""Execute command"""
gen = self.call_action_generator(
LangBotToRuntimeAction.EXECUTE_COMMAND,
{
'command_context': command_context,
'include_plugins': include_plugins,
},
timeout=180,
)
async for ret in gen:
yield ret
async def retrieve_knowledge(
self,
plugin_author: str,
plugin_name: str,
retriever_name: str,
retrieval_context: dict[str, Any],
) -> dict[str, Any]:
"""Retrieve knowledge"""
result = await self.call_action(
LangBotToRuntimeAction.RETRIEVE_KNOWLEDGE,
{
'plugin_author': plugin_author,
'plugin_name': plugin_name,
'retriever_name': retriever_name,
'retrieval_context': retrieval_context,
},
timeout=30,
)
return result
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
result = await self.call_action(
LangBotToRuntimeAction.GET_DEBUG_INFO,
{},
timeout=10,
action_context=execution_context,
)
return result
# ================= RAG Capability Callers (LangBot -> Runtime) =================
async def rag_ingest_document(
self, plugin_author: str, plugin_name: str, context_data: dict[str, Any]
) -> dict[str, Any]:
"""Send INGEST_DOCUMENT action to runtime."""
result = await self.call_action(
LangBotToRuntimeAction.RAG_INGEST_DOCUMENT,
{'plugin_author': plugin_author, 'plugin_name': plugin_name, 'context': context_data},
timeout=1200, # Ingestion can be slow for large documents
)
return result
async def rag_delete_document(self, plugin_author: str, plugin_name: str, document_id: str, kb_id: str) -> bool:
result = await self.call_action(
LangBotToRuntimeAction.RAG_DELETE_DOCUMENT,
{'plugin_author': plugin_author, 'plugin_name': plugin_name, 'document_id': document_id, 'kb_id': kb_id},
timeout=30,
)
return result.get('success', False)
async def rag_on_kb_create(
self, plugin_author: str, plugin_name: str, kb_id: str, config: dict[str, Any]
) -> dict[str, Any]:
"""Notify plugin about KB creation."""
result = await self.call_action(
LangBotToRuntimeAction.RAG_ON_KB_CREATE,
{'plugin_author': plugin_author, 'plugin_name': plugin_name, 'kb_id': kb_id, 'config': config},
timeout=30,
)
return result
async def rag_on_kb_delete(self, plugin_author: str, plugin_name: str, kb_id: str) -> dict[str, Any]:
"""Notify plugin about KB deletion."""
result = await self.call_action(
LangBotToRuntimeAction.RAG_ON_KB_DELETE,
{'plugin_author': plugin_author, 'plugin_name': plugin_name, 'kb_id': kb_id},
timeout=30,
)
return result
async def get_rag_creation_schema(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:
return await self.call_action(
LangBotToRuntimeAction.GET_RAG_CREATION_SETTINGS_SCHEMA,
{'plugin_author': plugin_author, 'plugin_name': plugin_name},
timeout=10,
)
async def get_rag_retrieval_schema(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:
return await self.call_action(
LangBotToRuntimeAction.GET_RAG_RETRIEVAL_SETTINGS_SCHEMA,
{'plugin_author': plugin_author, 'plugin_name': plugin_name},
timeout=10,
)
async def list_knowledge_engines(self) -> list[dict[str, Any]]:
"""List all available Knowledge Engines from plugins."""
result = await self.call_action(LangBotToRuntimeAction.LIST_KNOWLEDGE_ENGINES, {}, timeout=60)
return result.get('engines', [])
# ================= Parser Capability Callers (LangBot -> Runtime) =================
async def list_parsers(self) -> list[dict[str, Any]]:
"""List all available parsers from plugins."""
result = await self.call_action(LangBotToRuntimeAction.LIST_PARSERS, {}, timeout=60)
return result.get('parsers', [])
async def parse_document(
self, plugin_author: str, plugin_name: str, context_data: dict[str, Any], file_bytes: bytes
) -> dict[str, Any]:
"""Send PARSE_DOCUMENT action to runtime.
Sends file content via chunked FILE_CHUNK transfer, then invokes
the PARSE_DOCUMENT action with a file_key reference.
"""
# Send file to runtime via chunked transfer
file_key = await self.send_file(file_bytes, '')
# Include file_key in context_data for the runtime to read
context_data['file_key'] = file_key
result = await self.call_action(
LangBotToRuntimeAction.PARSE_DOCUMENT,
{'plugin_author': plugin_author, 'plugin_name': plugin_name, 'context': context_data},
timeout=300,
)
return result