feat(agent): add event orchestration surface

This commit is contained in:
Junyan Qin
2026-06-23 23:23:09 +08:00
parent d7b97741e3
commit ed3598f8ac
35 changed files with 2983 additions and 142 deletions
@@ -0,0 +1,40 @@
from __future__ import annotations
import quart
from .. import group
@group.group_class('agents', '/api/v1/agents')
class AgentsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
sort_by = quart.request.args.get('sort_by', 'updated_at')
sort_order = quart.request.args.get('sort_order', 'DESC')
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)
return self.success(data=created)
@self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
return self.success(data=await self.ap.agent_service.get_agent_metadata())
@self.route('/<agent_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(agent_uuid: str) -> str:
if quart.request.method == 'GET':
agent = await self.ap.agent_service.get_agent(agent_uuid)
if agent is None:
return self.http_status(404, -1, 'agent not found')
return self.success(data={'agent': agent})
if quart.request.method == 'PUT':
json_data = await quart.request.json
await self.ap.agent_service.update_agent(agent_uuid, json_data)
return self.success()
await self.ap.agent_service.delete_agent(agent_uuid)
return self.success()
+220
View File
@@ -0,0 +1,220 @@
from __future__ import annotations
import datetime
import uuid
import typing
import sqlalchemy
from ....core import app
from ....entity.persistence import agent as persistence_agent
AGENT_KIND_AGENT = 'agent'
AGENT_KIND_PIPELINE = 'pipeline'
PIPELINE_EVENT_PATTERNS = ['message.*']
AGENT_DEFAULT_EVENT_PATTERNS = ['*']
class AgentService:
"""Unified product surface for Agent orchestration instances and Pipelines."""
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_agent_metadata(self) -> dict[str, typing.Any]:
"""Return metadata needed by Agent forms."""
pipeline_metadata = await self.ap.pipeline_service.get_pipeline_metadata()
ai_metadata = next((item for item in pipeline_metadata if item.get('name') == 'ai'), None)
return {
'runner_config': ai_metadata,
'kinds': [
{
'name': AGENT_KIND_AGENT,
'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS,
'message_only': False,
},
{
'name': AGENT_KIND_PIPELINE,
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
'message_only': True,
},
],
}
async def get_agents(self, sort_by: str = 'updated_at', sort_order: str = 'DESC') -> list[dict]:
agents = await self._get_agent_rows()
pipelines = await self.ap.pipeline_service.get_pipelines(sort_by='updated_at', sort_order='DESC')
items = [self._agent_to_product_item(agent) for agent in agents]
items.extend(self._pipeline_to_product_item(pipeline) for pipeline in pipelines)
reverse = sort_order == 'DESC'
sort_key = sort_by if sort_by in {'created_at', 'updated_at'} else 'updated_at'
return sorted(items, key=lambda item: self._parse_sort_time(item.get(sort_key)), reverse=reverse)
async def get_agent(self, agent_uuid: str) -> dict | None:
agent = await self._get_agent_row(agent_uuid)
if agent is not None:
return self._agent_to_product_item(agent, include_config=True)
pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid)
if pipeline is not None:
return self._pipeline_to_product_item(pipeline, include_config=True)
return None
async def create_agent(self, agent_data: dict) -> dict[str, str]:
kind = agent_data.get('kind') or AGENT_KIND_AGENT
if kind == AGENT_KIND_PIPELINE:
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
{
'name': agent_data.get('name') or 'New Pipeline',
'description': agent_data.get('description') or '',
'emoji': agent_data.get('emoji') or '⚙️',
'config': {},
}
)
return {'uuid': pipeline_uuid, 'kind': AGENT_KIND_PIPELINE}
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)
new_uuid = str(uuid.uuid4())
values = {
'uuid': new_uuid,
'name': agent_data.get('name') or 'New Agent',
'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,
'config': config,
'enabled': agent_data.get('enabled', True),
'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
}
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values))
return {'uuid': new_uuid, 'kind': AGENT_KIND_AGENT}
async def update_agent(self, agent_uuid: str, agent_data: dict) -> None:
existing_agent = await self._get_agent_row(agent_uuid)
if existing_agent is None:
pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid)
if pipeline is None:
raise ValueError(f'Agent {agent_uuid} not found')
await self.ap.pipeline_service.update_pipeline(agent_uuid, agent_data)
return
update_data = agent_data.copy()
for protected_field in ('uuid', 'kind', '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']
)
if 'supported_event_patterns' in update_data and not update_data['supported_event_patterns']:
update_data['supported_event_patterns'] = AGENT_DEFAULT_EVENT_PATTERNS
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_agent.Agent)
.where(persistence_agent.Agent.uuid == agent_uuid)
.values(**update_data)
)
async def delete_agent(self, agent_uuid: str) -> None:
existing_agent = await self._get_agent_row(agent_uuid)
if existing_agent is not None:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid)
)
return
pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid)
if pipeline is None:
raise ValueError(f'Agent {agent_uuid} not found')
await self.ap.pipeline_service.delete_pipeline(agent_uuid)
async def _get_agent_rows(self) -> list[persistence_agent.Agent]:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_agent.Agent))
return list(result.all())
async def _get_agent_row(self, agent_uuid: str) -> persistence_agent.Agent | None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid)
)
return result.first()
async def _get_default_agent_config(self) -> dict[str, typing.Any]:
runners = []
if getattr(self.ap, 'agent_runner_registry', None) is not None:
try:
runners = await self.ap.agent_runner_registry.list_runners(bound_plugins=None)
except Exception as e:
if getattr(self.ap, 'logger', None):
self.ap.logger.warning(f'Failed to load plugin agent runners for default agent config: {e}')
if not runners:
return {'runner': {'id': '', 'expire-time': 0}, 'runner_config': {}}
selected_runner = runners[0]
return {
'runner': {'id': selected_runner.id, 'expire-time': 0},
'runner_config': {
selected_runner.id: self.ap.pipeline_service._get_default_values_from_schema(
selected_runner.config_schema
)
},
}
@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,
include_config: bool = False,
) -> dict[str, typing.Any]:
item = self.ap.persistence_mgr.serialize_model(persistence_agent.Agent, agent)
item['kind'] = AGENT_KIND_AGENT
item['capability'] = {
'supported_event_patterns': item.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
'message_only': False,
}
if not include_config:
item.pop('config', None)
return item
@staticmethod
def _pipeline_to_product_item(pipeline: dict, include_config: bool = False) -> dict[str, typing.Any]:
item = pipeline.copy()
item['kind'] = AGENT_KIND_PIPELINE
item['component_ref'] = 'pipeline'
item['enabled'] = True
item['supported_event_patterns'] = PIPELINE_EVENT_PATTERNS
item['capability'] = {
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
'message_only': True,
}
if not include_config:
item.pop('config', None)
return item
@staticmethod
def _parse_sort_time(value: typing.Any) -> datetime.datetime:
if isinstance(value, datetime.datetime):
return value
if isinstance(value, str):
try:
return datetime.datetime.fromisoformat(value)
except ValueError:
return datetime.datetime.min
return datetime.datetime.min
+82
View File
@@ -6,6 +6,7 @@ import typing
from ....core import app
from ....discover import engine
from ....entity.persistence import agent as persistence_agent
from ....entity.persistence import bot as persistence_bot
from ....entity.persistence import pipeline as persistence_pipeline
@@ -36,6 +37,84 @@ class BotService:
return True
return False
@staticmethod
def _is_message_event_pattern(event_pattern: str) -> bool:
return event_pattern == 'message.*' or event_pattern.startswith('message.')
@staticmethod
def _event_pattern_covers(supported_pattern: str, binding_pattern: str) -> bool:
if supported_pattern == '*':
return True
if supported_pattern == binding_pattern:
return True
if binding_pattern == '*':
return False
if supported_pattern.endswith('.*'):
namespace = supported_pattern[:-2]
return binding_pattern == f'{namespace}.*' or binding_pattern.startswith(f'{namespace}.')
return False
@classmethod
def _agent_supports_event_pattern(cls, supported_patterns: list[str] | None, event_pattern: str) -> bool:
patterns = supported_patterns or ['*']
return any(cls._event_pattern_covers(pattern, event_pattern) for pattern in patterns)
async def _normalize_event_bindings(self, bindings: list[dict] | None) -> list[dict]:
"""Validate and normalize Bot event bindings."""
if not bindings:
return []
normalized: list[dict] = []
for index, raw_binding in enumerate(bindings):
if not isinstance(raw_binding, dict):
continue
event_pattern = str(raw_binding.get('event_pattern') or '').strip()
target_type = str(raw_binding.get('target_type') or '').strip()
target_uuid = str(raw_binding.get('target_uuid') or '').strip()
if not event_pattern or not target_type:
continue
if target_type == 'pipeline':
if not self._is_message_event_pattern(event_pattern):
raise ValueError('Pipeline can only be bound to message events')
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline.uuid).where(
persistence_pipeline.LegacyPipeline.uuid == target_uuid
)
)
if result.first() is None:
raise ValueError('Pipeline not found')
elif target_type == 'agent':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid)
)
agent = result.first()
if agent is None:
raise ValueError('Agent not found')
if not self._agent_supports_event_pattern(agent.supported_event_patterns, event_pattern):
raise ValueError('Agent does not support this event pattern')
elif target_type == 'discard':
target_uuid = ''
else:
raise ValueError(f'Unsupported event binding target type: {target_type}')
normalized.append(
{
'id': raw_binding.get('id') or str(uuid.uuid4()),
'event_pattern': event_pattern,
'target_type': target_type,
'target_uuid': target_uuid,
'filters': raw_binding.get('filters') or [],
'priority': int(raw_binding.get('priority') or 0),
'enabled': bool(raw_binding.get('enabled', True)),
'description': raw_binding.get('description') or '',
'order': index,
}
)
return normalized
async def get_bots(self, include_secret: bool = True) -> list[dict]:
"""获取所有机器人"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
@@ -137,6 +216,9 @@ class BotService:
if 'uuid' in update_data:
del update_data['uuid']
if 'event_bindings' in update_data:
update_data['event_bindings'] = await self._normalize_event_bindings(update_data.get('event_bindings'))
# set use_pipeline_name
if 'use_pipeline_uuid' in update_data:
result = await self.ap.persistence_mgr.execute_async(
+29 -1
View File
@@ -28,7 +28,7 @@ if typing.TYPE_CHECKING:
INSTRUCTIONS = """\
This MCP server manages a LangBot instance. LangBot is an LLM-native instant
messaging bot platform. Use these tools to inspect and manage bots, pipelines,
messaging bot platform. Use these tools to inspect and manage bots, agents, pipelines,
models, knowledge bases, MCP servers, and skills.
Authentication uses a LangBot API key (web-UI-created `lbk_...` key or the
@@ -141,6 +141,34 @@ class LangBotMCPServer:
await ap.pipeline_service.delete_pipeline(pipeline_uuid)
return _dump({'ok': True})
# ----- Agents -------------------------------------------------- #
@mcp.tool(description='List product-level Agents, including Agent orchestrations and Pipelines.')
async def list_agents() -> str:
return _dump(await ap.agent_service.get_agents())
@mcp.tool(description='Get a product-level Agent or Pipeline by UUID.')
async def get_agent(agent_uuid: str) -> str:
return _dump(await ap.agent_service.get_agent(agent_uuid))
@mcp.tool(
description=(
'Create an Agent orchestration or Pipeline. `agent_data` matches '
'POST /api/v1/agents; set kind to `agent` or `pipeline`. Returns the new UUID and kind.'
)
)
async def create_agent(agent_data: dict) -> str:
return _dump(await ap.agent_service.create_agent(agent_data))
@mcp.tool(description='Update an Agent orchestration or Pipeline by UUID.')
async def update_agent(agent_uuid: str, agent_data: dict) -> str:
await ap.agent_service.update_agent(agent_uuid, agent_data)
return _dump({'ok': True})
@mcp.tool(description='Delete an Agent orchestration or Pipeline by UUID.')
async def delete_agent(agent_uuid: str) -> str:
await ap.agent_service.delete_agent(agent_uuid)
return _dump({'ok': True})
# ----- Models -------------------------------------------------- #
@mcp.tool(description='List all configured LLM models. Secrets are redacted.')
async def list_llm_models() -> str: