mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-17 07:17:18 +00:00
feat(agent-runner): enforce 4.x host-owned execution
This commit is contained in:
@@ -16,7 +16,10 @@ class AgentsRouterGroup(group.RouterGroup):
|
||||
return self.success(data={'agents': await self.ap.agent_service.get_agents(sort_by, sort_order)})
|
||||
|
||||
json_data = await quart.request.json
|
||||
created = await self.ap.agent_service.create_agent(json_data)
|
||||
try:
|
||||
created = await self.ap.agent_service.create_agent(json_data)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data=created)
|
||||
|
||||
@self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@@ -33,7 +36,10 @@ class AgentsRouterGroup(group.RouterGroup):
|
||||
|
||||
if quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
await self.ap.agent_service.update_agent(agent_uuid, json_data)
|
||||
try:
|
||||
await self.ap.agent_service.update_agent(agent_uuid, json_data)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success()
|
||||
|
||||
await self.ap.agent_service.delete_agent(agent_uuid)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import quart
|
||||
|
||||
from ... import group
|
||||
from ......pipeline.extension_preferences import normalize_extension_preferences
|
||||
|
||||
|
||||
@group.group_class('pipelines', '/api/v1/pipelines')
|
||||
@@ -19,7 +20,10 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(json_data)
|
||||
try:
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(json_data)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
return self.success(data={'uuid': pipeline_uuid})
|
||||
|
||||
@@ -41,7 +45,10 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
|
||||
await self.ap.pipeline_service.update_pipeline(pipeline_uuid, json_data)
|
||||
try:
|
||||
await self.ap.pipeline_service.update_pipeline(pipeline_uuid, json_data)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
return self.success()
|
||||
elif quart.request.method == 'DELETE':
|
||||
@@ -81,21 +88,19 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
self.ap.logger.warning('Unable to list skills for pipeline extensions: %s', exc)
|
||||
available_skills = []
|
||||
|
||||
extensions_prefs = pipeline.get('extensions_preferences', {})
|
||||
extensions_prefs = normalize_extension_preferences(pipeline.get('extensions_preferences'))
|
||||
return self.success(
|
||||
data={
|
||||
'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
|
||||
'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
|
||||
'enable_all_skills': extensions_prefs.get('enable_all_skills', True),
|
||||
'bound_plugins': extensions_prefs.get('plugins', []),
|
||||
'enable_all_plugins': extensions_prefs['enable_all_plugins'],
|
||||
'enable_all_mcp_servers': extensions_prefs['enable_all_mcp_servers'],
|
||||
'enable_all_skills': extensions_prefs['enable_all_skills'],
|
||||
'bound_plugins': extensions_prefs['plugins'],
|
||||
'available_plugins': plugins,
|
||||
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
||||
'bound_mcp_servers': extensions_prefs['mcp_servers'],
|
||||
'available_mcp_servers': mcp_servers,
|
||||
'bound_mcp_resources': extensions_prefs.get('mcp_resources', []),
|
||||
'mcp_resource_agent_read_enabled': extensions_prefs.get(
|
||||
'mcp_resource_agent_read_enabled', True
|
||||
),
|
||||
'bound_skills': extensions_prefs.get('skills', []),
|
||||
'bound_mcp_resources': extensions_prefs['mcp_resources'],
|
||||
'mcp_resource_agent_read_enabled': extensions_prefs['mcp_resource_agent_read_enabled'],
|
||||
'bound_skills': extensions_prefs['skills'],
|
||||
'available_skills': available_skills,
|
||||
}
|
||||
)
|
||||
@@ -111,16 +116,41 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
bound_mcp_resources = json_data.get('bound_mcp_resources')
|
||||
mcp_resource_agent_read_enabled = json_data.get('mcp_resource_agent_read_enabled')
|
||||
|
||||
await self.ap.pipeline_service.update_pipeline_extensions(
|
||||
pipeline_uuid,
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
enable_all_plugins,
|
||||
enable_all_mcp_servers,
|
||||
bound_skills=bound_skills,
|
||||
enable_all_skills=enable_all_skills,
|
||||
bound_mcp_resources=bound_mcp_resources,
|
||||
mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled,
|
||||
)
|
||||
extension_flags = {
|
||||
'enable_all_plugins': enable_all_plugins,
|
||||
'enable_all_mcp_servers': enable_all_mcp_servers,
|
||||
'enable_all_skills': enable_all_skills,
|
||||
}
|
||||
for field, value in extension_flags.items():
|
||||
if field in json_data and not isinstance(value, bool):
|
||||
return self.http_status(
|
||||
400,
|
||||
-1,
|
||||
f"Pipeline extension field '{field}' must be a boolean",
|
||||
)
|
||||
|
||||
if 'mcp_resource_agent_read_enabled' in json_data and not isinstance(
|
||||
mcp_resource_agent_read_enabled, bool
|
||||
):
|
||||
return self.http_status(
|
||||
400,
|
||||
-1,
|
||||
"Pipeline extension field 'mcp_resource_agent_read_enabled' must be a boolean",
|
||||
)
|
||||
|
||||
try:
|
||||
await self.ap.pipeline_service.update_pipeline_extensions(
|
||||
pipeline_uuid,
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
enable_all_plugins,
|
||||
enable_all_mcp_servers,
|
||||
bound_skills=bound_skills,
|
||||
enable_all_skills=enable_all_skills,
|
||||
bound_mcp_resources=bound_mcp_resources,
|
||||
mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
return self.success()
|
||||
|
||||
@@ -3,59 +3,61 @@ from __future__ import annotations
|
||||
import quart
|
||||
|
||||
from ... import group
|
||||
from ......pipeline.extension_preferences import normalize_extension_preferences
|
||||
|
||||
|
||||
@group.group_class('tools', '/api/v1/tools')
|
||||
class ToolsRouterGroup(group.RouterGroup):
|
||||
async def _get_scoped_tool_catalog(self) -> list[dict] | None:
|
||||
pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id')
|
||||
bound_plugins: list[str] | None = None
|
||||
bound_mcp_servers: list[str] | None = None
|
||||
|
||||
if pipeline_uuid:
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
|
||||
if pipeline is None:
|
||||
return None
|
||||
|
||||
extensions_prefs = normalize_extension_preferences(pipeline.get('extensions_preferences'))
|
||||
if not extensions_prefs['enable_all_plugins']:
|
||||
bound_plugins = [f'{plugin["author"]}/{plugin["name"]}' for plugin in extensions_prefs['plugins']]
|
||||
if not extensions_prefs['enable_all_mcp_servers']:
|
||||
bound_mcp_servers = extensions_prefs['mcp_servers']
|
||||
|
||||
return await self.ap.tool_mgr.get_resolved_tool_catalog(
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_skill_authoring=True,
|
||||
)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""获取所有可用工具列表"""
|
||||
pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id')
|
||||
bound_plugins: list[str] | None = None
|
||||
bound_mcp_servers: list[str] | None = None
|
||||
catalog = await self._get_scoped_tool_catalog()
|
||||
if catalog is None:
|
||||
return self.http_status(404, -1, 'pipeline not found')
|
||||
return self.success(data={'tools': catalog})
|
||||
|
||||
if pipeline_uuid:
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
|
||||
if pipeline is None:
|
||||
return self.http_status(404, -1, 'pipeline not found')
|
||||
|
||||
extensions_prefs = pipeline.get('extensions_preferences', {}) or {}
|
||||
if not extensions_prefs.get('enable_all_plugins', True):
|
||||
bound_plugins = [
|
||||
f'{plugin.get("author", "")}/{plugin.get("name", "")}'
|
||||
for plugin in extensions_prefs.get('plugins', [])
|
||||
if isinstance(plugin, dict) and plugin.get('name')
|
||||
]
|
||||
if not extensions_prefs.get('enable_all_mcp_servers', True):
|
||||
bound_mcp_servers = [
|
||||
server for server in (extensions_prefs.get('mcp_servers', []) or []) if isinstance(server, str)
|
||||
]
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'tools': await self.ap.tool_mgr.get_tool_catalog(
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_skill_authoring=True,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
@self.route('/<path:tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(tool_name: str) -> str:
|
||||
"""获取特定工具详情"""
|
||||
tools = await self.ap.tool_mgr.get_all_tools()
|
||||
catalog = await self._get_scoped_tool_catalog()
|
||||
if catalog is None:
|
||||
return self.http_status(404, -1, 'pipeline not found')
|
||||
|
||||
for tool in tools:
|
||||
if tool.name == tool_name:
|
||||
for tool in catalog:
|
||||
if tool.get('name') == tool_name:
|
||||
return self.success(
|
||||
data={
|
||||
'tool': {
|
||||
'name': tool.name,
|
||||
'description': tool.description,
|
||||
'human_desc': tool.human_desc,
|
||||
'parameters': tool.parameters,
|
||||
'name': tool['name'],
|
||||
'description': tool.get('description') or '',
|
||||
'human_desc': tool.get('human_desc') or '',
|
||||
'parameters': tool.get('parameters') or {},
|
||||
'source': tool.get('source'),
|
||||
'source_name': tool.get('source_name'),
|
||||
'source_id': tool.get('source_id'),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import typing
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....agent.runner.config_resolver import RunnerConfigResolver
|
||||
from ....entity.persistence import agent as persistence_agent
|
||||
|
||||
|
||||
@@ -82,8 +83,8 @@ class AgentService:
|
||||
if kind != AGENT_KIND_AGENT:
|
||||
raise ValueError(f'Unsupported agent kind: {kind}')
|
||||
|
||||
config = agent_data.get('config') or await self._get_default_agent_config()
|
||||
runner_id = self._resolve_runner_id(config)
|
||||
config = agent_data['config'] if 'config' in agent_data else await self._get_default_agent_config()
|
||||
config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(config)
|
||||
new_uuid = str(uuid.uuid4())
|
||||
values = {
|
||||
'uuid': new_uuid,
|
||||
@@ -91,7 +92,7 @@ class AgentService:
|
||||
'description': agent_data.get('description') or '',
|
||||
'emoji': agent_data.get('emoji') or '🤖',
|
||||
'kind': AGENT_KIND_AGENT,
|
||||
'component_ref': agent_data.get('component_ref') or runner_id,
|
||||
'component_ref': runner_id,
|
||||
'config': config,
|
||||
'enabled': agent_data.get('enabled', True),
|
||||
'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
|
||||
@@ -109,12 +110,14 @@ class AgentService:
|
||||
return
|
||||
|
||||
update_data = agent_data.copy()
|
||||
for protected_field in ('uuid', 'kind', 'created_at', 'updated_at', 'capability'):
|
||||
for protected_field in ('uuid', 'kind', 'component_ref', 'created_at', 'updated_at', 'capability'):
|
||||
update_data.pop(protected_field, None)
|
||||
if 'config' in update_data:
|
||||
update_data['component_ref'] = update_data.get('component_ref') or self._resolve_runner_id(
|
||||
update_data['config']
|
||||
)
|
||||
config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(update_data['config'])
|
||||
update_data['config'] = config
|
||||
else:
|
||||
_, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(existing_agent.config)
|
||||
update_data['component_ref'] = runner_id
|
||||
if 'supported_event_patterns' in update_data and not update_data['supported_event_patterns']:
|
||||
update_data['supported_event_patterns'] = AGENT_DEFAULT_EVENT_PATTERNS
|
||||
|
||||
@@ -169,15 +172,6 @@ class AgentService:
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_runner_id(config: dict[str, typing.Any]) -> str | None:
|
||||
runner = config.get('runner') if isinstance(config, dict) else None
|
||||
if isinstance(runner, dict):
|
||||
runner_id = runner.get('id')
|
||||
if runner_id:
|
||||
return runner_id
|
||||
return None
|
||||
|
||||
def _agent_to_product_item(
|
||||
self,
|
||||
agent: persistence_agent.Agent,
|
||||
|
||||
@@ -6,7 +6,12 @@ import sqlalchemy
|
||||
import typing
|
||||
|
||||
from ....core import app
|
||||
from ....agent.runner.config_resolver import RunnerConfigResolver
|
||||
from ....entity.persistence import pipeline as persistence_pipeline
|
||||
from ....pipeline.extension_preferences import (
|
||||
normalize_extension_preferences,
|
||||
validate_extension_preferences,
|
||||
)
|
||||
|
||||
|
||||
default_stage_order = [
|
||||
@@ -163,6 +168,11 @@ class PipelineService:
|
||||
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
|
||||
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
|
||||
if 'extensions_preferences' in pipeline_data:
|
||||
self._validate_extension_preferences(pipeline_data['extensions_preferences'])
|
||||
if 'config' in pipeline_data:
|
||||
RunnerConfigResolver.validate_pipeline_config(pipeline_data['config'])
|
||||
|
||||
# Check limitation
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
max_pipelines = limitation.get('max_pipelines', -1)
|
||||
@@ -177,6 +187,7 @@ class PipelineService:
|
||||
pipeline_data['is_default'] = default
|
||||
|
||||
pipeline_data['config'] = await self.get_default_pipeline_config()
|
||||
RunnerConfigResolver.validate_pipeline_config(pipeline_data['config'])
|
||||
|
||||
# Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default
|
||||
if 'extensions_preferences' not in pipeline_data:
|
||||
@@ -203,6 +214,10 @@ class PipelineService:
|
||||
pipeline_data = pipeline_data.copy()
|
||||
for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
|
||||
pipeline_data.pop(protected_field, None)
|
||||
if 'config' in pipeline_data:
|
||||
RunnerConfigResolver.validate_pipeline_config(pipeline_data['config'])
|
||||
if 'extensions_preferences' in pipeline_data:
|
||||
self._validate_extension_preferences(pipeline_data['extensions_preferences'])
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
@@ -259,18 +274,7 @@ class PipelineService:
|
||||
'stages': original_pipeline.stages.copy() if original_pipeline.stages else default_stage_order.copy(),
|
||||
'config': original_pipeline.config.copy() if original_pipeline.config else {},
|
||||
'is_default': False,
|
||||
'extensions_preferences': (
|
||||
original_pipeline.extensions_preferences.copy()
|
||||
if original_pipeline.extensions_preferences
|
||||
else {
|
||||
'enable_all_plugins': True,
|
||||
'enable_all_mcp_servers': True,
|
||||
'plugins': [],
|
||||
'mcp_servers': [],
|
||||
'mcp_resources': [],
|
||||
'mcp_resource_agent_read_enabled': True,
|
||||
}
|
||||
),
|
||||
'extensions_preferences': normalize_extension_preferences(original_pipeline.extensions_preferences),
|
||||
}
|
||||
|
||||
# Insert the new pipeline
|
||||
@@ -297,6 +301,36 @@ class PipelineService:
|
||||
mcp_resource_agent_read_enabled: bool | None = None,
|
||||
) -> None:
|
||||
"""Update the bound plugins and MCP servers for a pipeline"""
|
||||
extension_updates: dict[str, typing.Any] = {
|
||||
'enable_all_plugins': enable_all_plugins,
|
||||
'enable_all_mcp_servers': enable_all_mcp_servers,
|
||||
'enable_all_skills': enable_all_skills,
|
||||
'plugins': bound_plugins,
|
||||
}
|
||||
if bound_mcp_servers is not None:
|
||||
extension_updates['mcp_servers'] = bound_mcp_servers
|
||||
if bound_skills is not None:
|
||||
extension_updates['skills'] = bound_skills
|
||||
if bound_mcp_resources is not None:
|
||||
extension_updates['mcp_resources'] = bound_mcp_resources
|
||||
RunnerConfigResolver.validate_mcp_resource_attachments(
|
||||
bound_mcp_resources,
|
||||
context='Pipeline extension',
|
||||
field_name='bound_mcp_resources',
|
||||
)
|
||||
if mcp_resource_agent_read_enabled is not None:
|
||||
extension_updates['mcp_resource_agent_read_enabled'] = mcp_resource_agent_read_enabled
|
||||
self._validate_extension_preferences(
|
||||
extension_updates,
|
||||
context='Pipeline extension',
|
||||
field_aliases={
|
||||
'plugins': 'bound_plugins',
|
||||
'mcp_servers': 'bound_mcp_servers',
|
||||
'skills': 'bound_skills',
|
||||
'mcp_resources': 'bound_mcp_resources',
|
||||
},
|
||||
)
|
||||
|
||||
# Get current pipeline
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
@@ -309,7 +343,7 @@ class PipelineService:
|
||||
raise ValueError(f'Pipeline {pipeline_uuid} not found')
|
||||
|
||||
# Update extensions_preferences
|
||||
extensions_preferences = pipeline.extensions_preferences or {}
|
||||
extensions_preferences = normalize_extension_preferences(pipeline.extensions_preferences)
|
||||
extensions_preferences['enable_all_plugins'] = enable_all_plugins
|
||||
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
|
||||
extensions_preferences['enable_all_skills'] = enable_all_skills
|
||||
@@ -333,3 +367,22 @@ class PipelineService:
|
||||
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
|
||||
pipeline = await self.get_pipeline(pipeline_uuid)
|
||||
await self.ap.pipeline_mgr.load_pipeline(pipeline)
|
||||
|
||||
@staticmethod
|
||||
def _validate_extension_preferences(
|
||||
value: typing.Any,
|
||||
*,
|
||||
context: str = 'Pipeline extensions_preferences',
|
||||
field_aliases: typing.Mapping[str, str] | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
validated = validate_extension_preferences(
|
||||
value,
|
||||
context=context,
|
||||
field_aliases=field_aliases,
|
||||
)
|
||||
RunnerConfigResolver.validate_mcp_resource_attachments(
|
||||
validated.get('mcp_resources'),
|
||||
context=context,
|
||||
field_name='mcp_resources',
|
||||
)
|
||||
return validated
|
||||
|
||||
Reference in New Issue
Block a user