feat(agent-runner): enforce 4.x host-owned execution

This commit is contained in:
huanghuoguoguo
2026-07-12 20:36:32 +08:00
parent e6384aae5d
commit 99d9c227f9
171 changed files with 6958 additions and 5385 deletions
@@ -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}"
)
+20 -15
View File
@@ -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 = ''
+48 -17
View File
@@ -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)