mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-18 02:16:07 +00:00
feat(agent-runner): enforce 4.x host-owned execution
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"""Validation and fail-closed reads for Pipeline extension preferences."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
|
||||
_BOOLEAN_FIELDS = (
|
||||
'enable_all_plugins',
|
||||
'enable_all_mcp_servers',
|
||||
'enable_all_skills',
|
||||
'mcp_resource_agent_read_enabled',
|
||||
)
|
||||
|
||||
|
||||
def _valid_plugin_binding(value: typing.Any) -> bool:
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and isinstance(value.get('author'), str)
|
||||
and bool(value['author'])
|
||||
and isinstance(value.get('name'), str)
|
||||
and bool(value['name'])
|
||||
)
|
||||
|
||||
|
||||
def _valid_name(value: typing.Any) -> bool:
|
||||
return isinstance(value, str) and bool(value)
|
||||
|
||||
|
||||
def normalize_extension_preferences(value: typing.Any) -> dict[str, typing.Any]:
|
||||
"""Return a safe runtime view of persisted extension preferences.
|
||||
|
||||
Missing fields in a valid object retain the established defaults. A
|
||||
malformed root is treated as an empty allowlist with every extension
|
||||
capability disabled.
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
return {
|
||||
'enable_all_plugins': False,
|
||||
'enable_all_mcp_servers': False,
|
||||
'enable_all_skills': False,
|
||||
'mcp_resource_agent_read_enabled': False,
|
||||
'plugins': [],
|
||||
'mcp_servers': [],
|
||||
'skills': [],
|
||||
'mcp_resources': [],
|
||||
}
|
||||
|
||||
normalized = dict(value)
|
||||
normalized['enable_all_plugins'] = value.get('enable_all_plugins', True) is True
|
||||
normalized['enable_all_mcp_servers'] = value.get('enable_all_mcp_servers', True) is True
|
||||
normalized['enable_all_skills'] = value.get('enable_all_skills', True) is True
|
||||
normalized['mcp_resource_agent_read_enabled'] = (
|
||||
value.get('mcp_resource_agent_read_enabled', True) is True
|
||||
)
|
||||
|
||||
plugins = value.get('plugins', [])
|
||||
plugins_are_valid = isinstance(plugins, list) and all(
|
||||
_valid_plugin_binding(plugin) for plugin in plugins
|
||||
)
|
||||
normalized['plugins'] = list(plugins) if plugins_are_valid else []
|
||||
if not plugins_are_valid:
|
||||
normalized['enable_all_plugins'] = False
|
||||
|
||||
mcp_servers = value.get('mcp_servers', [])
|
||||
mcp_servers_are_valid = isinstance(mcp_servers, list) and all(
|
||||
_valid_name(server) for server in mcp_servers
|
||||
)
|
||||
normalized['mcp_servers'] = list(mcp_servers) if mcp_servers_are_valid else []
|
||||
if not mcp_servers_are_valid:
|
||||
normalized['enable_all_mcp_servers'] = False
|
||||
|
||||
skills = value.get('skills', [])
|
||||
skills_are_valid = isinstance(skills, list) and all(_valid_name(skill) for skill in skills)
|
||||
normalized['skills'] = list(skills) if skills_are_valid else []
|
||||
if not skills_are_valid:
|
||||
normalized['enable_all_skills'] = False
|
||||
|
||||
mcp_resources = value.get('mcp_resources', [])
|
||||
mcp_resources_are_valid = isinstance(mcp_resources, list) and all(
|
||||
isinstance(resource, dict) for resource in mcp_resources
|
||||
)
|
||||
normalized['mcp_resources'] = list(mcp_resources) if mcp_resources_are_valid else []
|
||||
if not mcp_resources_are_valid:
|
||||
normalized['mcp_resource_agent_read_enabled'] = False
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_extension_preferences(
|
||||
value: typing.Any,
|
||||
*,
|
||||
context: str = 'Pipeline extensions_preferences',
|
||||
field_aliases: typing.Mapping[str, str] | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Reject extension preferences that cannot be consumed unambiguously."""
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f'{context} must be an object')
|
||||
|
||||
for field_name in _BOOLEAN_FIELDS:
|
||||
if field_name in value and not isinstance(value[field_name], bool):
|
||||
raise ValueError(f"{context} field '{field_name}' must be a boolean")
|
||||
|
||||
aliases = field_aliases or {}
|
||||
_validate_list_field(
|
||||
value,
|
||||
'plugins',
|
||||
aliases.get('plugins', 'plugins'),
|
||||
_valid_plugin_binding,
|
||||
'a plugin object with non-empty author and name',
|
||||
context,
|
||||
)
|
||||
_validate_list_field(
|
||||
value,
|
||||
'mcp_servers',
|
||||
aliases.get('mcp_servers', 'mcp_servers'),
|
||||
_valid_name,
|
||||
'a non-empty string',
|
||||
context,
|
||||
)
|
||||
_validate_list_field(
|
||||
value,
|
||||
'skills',
|
||||
aliases.get('skills', 'skills'),
|
||||
_valid_name,
|
||||
'a non-empty string',
|
||||
context,
|
||||
)
|
||||
_validate_list_field(
|
||||
value,
|
||||
'mcp_resources',
|
||||
aliases.get('mcp_resources', 'mcp_resources'),
|
||||
lambda item: isinstance(item, dict),
|
||||
'an object',
|
||||
context,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_list_field(
|
||||
value: dict[str, typing.Any],
|
||||
field_name: str,
|
||||
field_label: str,
|
||||
item_validator: typing.Callable[[typing.Any], bool],
|
||||
item_description: str,
|
||||
context: str,
|
||||
) -> None:
|
||||
if field_name not in value:
|
||||
return
|
||||
items = value[field_name]
|
||||
if not isinstance(items, list):
|
||||
raise ValueError(f"{context} field '{field_label}' must be a list")
|
||||
for index, item in enumerate(items):
|
||||
if not item_validator(item):
|
||||
raise ValueError(
|
||||
f"{context} field '{field_label}[{index}]' must be {item_description}"
|
||||
)
|
||||
@@ -14,7 +14,8 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.events as events
|
||||
from ..utils import importutil
|
||||
from .config_coercion import coerce_pipeline_config
|
||||
from ..agent.runner.config_migration import ConfigMigration
|
||||
from ..agent.runner.config_resolver import RunnerConfigResolver
|
||||
from .extension_preferences import normalize_extension_preferences
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
@@ -92,14 +93,16 @@ class RuntimePipeline:
|
||||
self.stage_containers = stage_containers
|
||||
|
||||
# Extract bound plugins and MCP servers from extensions_preferences
|
||||
extensions_prefs = pipeline_entity.extensions_preferences or {}
|
||||
self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True)
|
||||
self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True)
|
||||
extensions_prefs = normalize_extension_preferences(pipeline_entity.extensions_preferences)
|
||||
self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True) is True
|
||||
self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True) is True
|
||||
pipeline_config = pipeline_entity.config or {}
|
||||
runner_config: dict[str, typing.Any] = {}
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config) if isinstance(pipeline_config, dict) else None
|
||||
runner_id = (
|
||||
RunnerConfigResolver.resolve_runner_id(pipeline_config) if isinstance(pipeline_config, dict) else None
|
||||
)
|
||||
if runner_id:
|
||||
resolved_runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
|
||||
resolved_runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
|
||||
if isinstance(resolved_runner_config, dict):
|
||||
runner_config = resolved_runner_config
|
||||
|
||||
@@ -107,24 +110,26 @@ class RuntimePipeline:
|
||||
'mcp-resources',
|
||||
extensions_prefs.get('mcp_resources', []),
|
||||
)
|
||||
self.mcp_resource_agent_read_enabled = runner_config.get(
|
||||
'mcp-resource-agent-read-enabled',
|
||||
extensions_prefs.get('mcp_resource_agent_read_enabled', True),
|
||||
self.mcp_resource_agent_read_enabled = (
|
||||
runner_config.get(
|
||||
'mcp-resource-agent-read-enabled',
|
||||
extensions_prefs.get('mcp_resource_agent_read_enabled', True),
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
if self.enable_all_plugins:
|
||||
# None indicates to use all available plugins
|
||||
self.bound_plugins = None
|
||||
else:
|
||||
plugin_list = extensions_prefs.get('plugins', [])
|
||||
self.bound_plugins = [f'{p["author"]}/{p["name"]}' for p in plugin_list] if plugin_list else []
|
||||
plugin_list = extensions_prefs['plugins']
|
||||
self.bound_plugins = [f'{p["author"]}/{p["name"]}' for p in plugin_list]
|
||||
|
||||
if self.enable_all_mcp_servers:
|
||||
# None indicates to use all available MCP servers
|
||||
self.bound_mcp_servers = None
|
||||
else:
|
||||
mcp_server_list = extensions_prefs.get('mcp_servers', [])
|
||||
self.bound_mcp_servers = mcp_server_list if mcp_server_list else []
|
||||
self.bound_mcp_servers = extensions_prefs['mcp_servers']
|
||||
|
||||
async def run(self, query: pipeline_query.Query):
|
||||
query.pipeline_config = self.pipeline_entity.config
|
||||
@@ -296,9 +301,9 @@ class RuntimePipeline:
|
||||
# Get runner name from pipeline config
|
||||
runner_name = None
|
||||
if query.pipeline_config:
|
||||
from ..agent.runner.config_migration import ConfigMigration
|
||||
from ..agent.runner.config_resolver import RunnerConfigResolver
|
||||
|
||||
runner_name = ConfigMigration.resolve_runner_id(query.pipeline_config)
|
||||
runner_name = RunnerConfigResolver.resolve_runner_id(query.pipeline_config)
|
||||
|
||||
# Record query start and store message_id
|
||||
message_id = ''
|
||||
|
||||
@@ -11,8 +11,11 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
|
||||
from ...agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from ...agent.runner.config_migration import ConfigMigration
|
||||
from ...agent.runner.config_resolver import RunnerConfigResolver
|
||||
from ...agent.runner import config_schema
|
||||
from ...agent.runner.resource_policy import ResourcePolicyProjector
|
||||
from ...provider.tools.toolmgr import ToolManager
|
||||
from ..extension_preferences import normalize_extension_preferences
|
||||
|
||||
|
||||
DEFAULT_PROMPT_CONFIG = [
|
||||
@@ -77,6 +80,28 @@ class PreProcessor(stage.PipelineStage):
|
||||
self.ap.logger.warning(f'Fallback model {fallback_uuid} not found, skipping')
|
||||
return valid_fallbacks
|
||||
|
||||
async def _resolve_host_tools(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
runner_config: dict[str, typing.Any],
|
||||
bound_plugins: list[str] | None,
|
||||
bound_mcp_servers: list[str] | None,
|
||||
include_mcp_resource_tools: bool,
|
||||
) -> list[typing.Any]:
|
||||
catalog = await self.ap.tool_mgr.get_resolved_tool_catalog(
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_skill_authoring=True,
|
||||
include_mcp_resource_tools=include_mcp_resource_tools,
|
||||
)
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
runner_config,
|
||||
resolved_tool_names=ResourcePolicyProjector.extract_tool_names(catalog),
|
||||
)
|
||||
catalog = ResourcePolicyProjector.filter_tools(catalog, policy)
|
||||
ToolManager.bind_query_tool_sources(query, catalog)
|
||||
return ToolManager.tools_from_catalog(catalog)
|
||||
|
||||
def _runner_accepts_multimodal_input(self, descriptor: AgentRunnerDescriptor | None) -> bool:
|
||||
if descriptor is None:
|
||||
return True
|
||||
@@ -136,9 +161,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
strict_thread=True,
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(
|
||||
f'Unable to load Transcript history view for conversation {conversation_uuid}: {e}'
|
||||
)
|
||||
self.ap.logger.warning(f'Unable to load Transcript history view for conversation {conversation_uuid}: {e}')
|
||||
return None
|
||||
|
||||
return messages or None
|
||||
@@ -168,14 +191,16 @@ class PreProcessor(stage.PipelineStage):
|
||||
) -> entities.StageProcessResult:
|
||||
"""Process"""
|
||||
# Resolve runner ID from the current ai.runner.id shape.
|
||||
runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(query.pipeline_config)
|
||||
|
||||
# Get runner config from ai.runner_config[runner_id].
|
||||
runner_config = ConfigMigration.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {}
|
||||
runner_config = (
|
||||
RunnerConfigResolver.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {}
|
||||
)
|
||||
query.variables = query.variables or {}
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
|
||||
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True) is True
|
||||
descriptor = await self._get_runner_descriptor(runner_id, bound_plugins)
|
||||
|
||||
session = await self.ap.sess_mgr.get_session(query)
|
||||
@@ -204,7 +229,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
# been idle for longer than the configured conversation expire time.
|
||||
# The idle window is measured from the last preprocess/update time, not
|
||||
# from the conversation creation time.
|
||||
conversation_expire_time = ConfigMigration.get_expire_time(query.pipeline_config)
|
||||
conversation_expire_time = RunnerConfigResolver.get_expire_time(query.pipeline_config)
|
||||
now = datetime.datetime.now()
|
||||
if conversation_expire_time is not None and conversation_expire_time > 0:
|
||||
last_update_time = getattr(conversation, 'update_time', None) or getattr(conversation, 'create_time', None)
|
||||
@@ -236,10 +261,12 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
||||
|
||||
if uses_host_tools and 'func_call' in (llm_model.model_entity.abilities or []):
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||
query.use_funcs = await self._resolve_host_tools(
|
||||
query,
|
||||
runner_config,
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_mcp_resource_tools=include_mcp_resource_tools,
|
||||
include_mcp_resource_tools,
|
||||
)
|
||||
|
||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||
@@ -249,16 +276,20 @@ class PreProcessor(stage.PipelineStage):
|
||||
# If primary model doesn't support func_call but fallback models exist,
|
||||
# load tools anyway since fallback models may support them
|
||||
if uses_host_tools and not query.use_funcs and query.variables.get('_fallback_model_uuids'):
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||
query.use_funcs = await self._resolve_host_tools(
|
||||
query,
|
||||
runner_config,
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_mcp_resource_tools=include_mcp_resource_tools,
|
||||
include_mcp_resource_tools,
|
||||
)
|
||||
elif uses_host_tools:
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||
query.use_funcs = await self._resolve_host_tools(
|
||||
query,
|
||||
runner_config,
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_mcp_resource_tools=include_mcp_resource_tools,
|
||||
include_mcp_resource_tools,
|
||||
)
|
||||
|
||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||
@@ -372,13 +403,13 @@ class PreProcessor(stage.PipelineStage):
|
||||
|
||||
if getattr(self.ap, 'skill_mgr', None) is not None:
|
||||
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
|
||||
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
|
||||
enable_all_skills = extensions_prefs.get('enable_all_skills', True)
|
||||
extensions_prefs = normalize_extension_preferences((pipeline_data or {}).get('extensions_preferences'))
|
||||
enable_all_skills = extensions_prefs['enable_all_skills']
|
||||
|
||||
if enable_all_skills:
|
||||
bound_skills = None # None = all loaded skills are visible
|
||||
else:
|
||||
bound_skills = extensions_prefs.get('skills', [])
|
||||
bound_skills = extensions_prefs['skills']
|
||||
|
||||
query.variables['_pipeline_bound_skills'] = bound_skills
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from ... import entities
|
||||
from ... import plugin_diagnostics
|
||||
|
||||
import langbot_plugin.api.entities.events as events
|
||||
from ....agent.runner.config_migration import ConfigMigration
|
||||
from ....agent.runner.config_resolver import RunnerConfigResolver
|
||||
from ....agent.runner import config_schema
|
||||
from ....utils import constants, runner as runner_utils
|
||||
from ....telemetry import features as telemetry_features
|
||||
@@ -312,8 +312,10 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
if prompt_config:
|
||||
return prompt_config
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
|
||||
runner_config = ConfigMigration.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {}
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(query.pipeline_config)
|
||||
runner_config = (
|
||||
RunnerConfigResolver.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {}
|
||||
)
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
descriptor = await self._get_runner_descriptor(runner_id, bound_plugins)
|
||||
return config_schema.extract_prompt_config(descriptor, runner_config, DEFAULT_PROMPT_CONFIG)
|
||||
|
||||
Reference in New Issue
Block a user