Files
LangBot/src/langbot/pkg/platform/botmgr.py
T
Hyu 0efc1fb1cc feat(telemetry): trace one inbound event end to end
Execution telemetry already reported window counters. This adds one bounded,
content-free chain per inbound platform event, so a single event can be
followed from its source platform through routing and processing to the
platform API calls it caused.

- telemetry/trace.py: ContextVar trace identity with route and run scopes, and
  a 32-stage bound per chain.
- telemetry/execution.py: keeps at most 64 chains for 120s, decides sampling
  from the observed outcome, and sends one payload per chain keyed by
  query_id = trace_id, so Space fetches a chain by primary identity. Window
  counters are unchanged and remain the source of coverage statistics.
- botmgr / pipelinemgr / orchestrator: bind the trace at the ingress boundary,
  scope routing identity per dispatch, and attach the run identity, so nested
  lanes (event -> route -> pipeline/runner -> platform API) reuse one chain. A
  run reached without an ingress (WebUI debug, service API) owns its own chain.
- space.execution_trace selects off | failures | sampled | all (default
  sampled: failures, WebUI debug runs and every N-th success).

Chains carry only code-defined identifiers (adapter and runner types, route
identity as type:uuid, run id); never message content, tool arguments or
platform user identifiers.
2026-10-01 02:12:44 +08:00

2316 lines
97 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
import contextlib
import dataclasses
import functools
import json
import re
import time
import traceback
import typing
import uuid
import sqlalchemy
from ..core import app, entities as core_entities, taskmgr
from ..discover import engine
from ..entity.persistence import bot as persistence_bot
from ..entity.errors import platform as platform_errors
from ..agent.runner.config_resolver import RunnerConfigResolver
from ..agent.runner.host_models import (
AgentBinding,
AgentEventEnvelope,
BindingScope,
DeliveryPolicy,
StatePolicy,
)
from ..agent.runner.resource_policy import ResourcePolicyProjector
from ..agent.runner.platform_tools import resolve_agent_platform_tool_names
from ..entity.persistence import workspace as persistence_workspace
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
from ..api.http.authz import WorkspaceRequiredError
from ..workspace.errors import WorkspaceInvariantError
from .logger import EventLogger
from .adapter_names import canonical_adapter_name
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.events as plugin_events
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.runner.event import (
ActorContext,
SubjectContext,
RawEventRef,
)
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
class RuntimeBot:
"""运行时机器人"""
ap: app.Application
bot_entity: persistence_bot.Bot
enable: bool
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
task_wrapper: taskmgr.TaskWrapper | None
task_context: taskmgr.TaskContext
logger: EventLogger
execution_context: ExecutionContext
workspace_uuid: str
placement_generation: int
def __init__(
self,
ap: app.Application,
bot_entity: persistence_bot.Bot,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
logger: EventLogger,
execution_context: ExecutionContext,
):
if not isinstance(execution_context, ExecutionContext):
raise WorkspaceRequiredError('RuntimeBot requires an ExecutionContext')
if not execution_context.instance_uuid.strip() or not execution_context.workspace_uuid.strip():
raise WorkspaceRequiredError('RuntimeBot requires an instance and Workspace')
if execution_context.placement_generation <= 0:
raise WorkspaceRequiredError('RuntimeBot requires a positive placement generation')
entity_workspace_uuid = getattr(bot_entity, 'workspace_uuid', None)
if entity_workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceRequiredError('RuntimeBot entity Workspace does not match its ExecutionContext')
if execution_context.bot_uuid not in (None, bot_entity.uuid):
raise WorkspaceRequiredError('RuntimeBot bot UUID does not match its ExecutionContext')
self.ap = ap
self.bot_entity = bot_entity
self.execution_context = dataclasses.replace(execution_context, bot_uuid=bot_entity.uuid)
self.workspace_uuid = self.execution_context.workspace_uuid
self.placement_generation = self.execution_context.placement_generation
self.enable = bot_entity.enable
self.adapter = adapter
self.task_wrapper = None
self.task_context = taskmgr.TaskContext()
self.task_wrapper = None
self.logger = logger
self._shutdown_lock = asyncio.Lock()
self._shutdown_complete = False
async def assert_execution_active(self) -> None:
"""Fail closed when this long-lived adapter belongs to a stale placement."""
await self.ap.workspace_service.get_execution_binding(
self.workspace_uuid,
expected_generation=self.placement_generation,
)
PIPELINE_DISCARD = '__discard__'
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
EVENT_DATA_MAX_STRING_BYTES = 512
@staticmethod
def _eba_event_to_plugin_event(event: platform_events.EBAEvent) -> plugin_events.BaseEventModel | None:
"""Map a platform EBA event to a plugin EventListener event model."""
event_mapping: list[tuple[type[platform_events.EBAEvent], type[plugin_events.BaseEventModel]]] = [
(platform_events.MessageReceivedEvent, plugin_events.MessageReceived),
(platform_events.MessageEditedEvent, plugin_events.MessageEdited),
(platform_events.MessageDeletedEvent, plugin_events.MessageDeleted),
(platform_events.MessageReactionEvent, plugin_events.MessageReactionReceived),
(platform_events.FeedbackReceivedEvent, plugin_events.FeedbackReceived),
(platform_events.MemberJoinedEvent, plugin_events.GroupMemberJoined),
(platform_events.MemberLeftEvent, plugin_events.GroupMemberLeft),
(platform_events.MemberBannedEvent, plugin_events.GroupMemberBanned),
(platform_events.BotInvitedToGroupEvent, plugin_events.BotInvitedToGroup),
(platform_events.BotRemovedFromGroupEvent, plugin_events.BotRemovedFromGroup),
(platform_events.BotMutedEvent, plugin_events.BotMuted),
(platform_events.BotUnmutedEvent, plugin_events.BotUnmuted),
(platform_events.PlatformSpecificEvent, plugin_events.PlatformSpecificEventReceived),
]
for platform_event_type, plugin_event_type in event_mapping:
if isinstance(event, platform_event_type):
return plugin_event_type.from_platform_event(event)
return None
@staticmethod
def _match_event_pattern(event_type: str, pattern: str) -> bool:
if not event_type or not pattern:
return False
if pattern == '*':
return True
if pattern.endswith('.*'):
return event_type.startswith(f'{pattern[:-2]}.')
return event_type == pattern
@classmethod
def _is_message_event_type(cls, event_type: str) -> bool:
return cls._match_event_pattern(event_type, 'message.*')
@classmethod
def _agent_supports_event_type(
cls,
supported_patterns: list[str] | None,
event_type: str,
) -> bool:
patterns = supported_patterns if isinstance(supported_patterns, list) else ['*']
return any(cls._match_event_pattern(event_type, pattern) for pattern in patterns)
@staticmethod
def _get_nested_value(data: dict[str, typing.Any], path: str) -> typing.Any:
current: typing.Any = data
for key in path.split('.'):
if isinstance(current, dict):
current = current.get(key)
else:
current = getattr(current, key, None)
if current is None:
return None
return current
@classmethod
def _match_event_filter(
cls,
event_data: dict[str, typing.Any],
event_filter: dict[str, typing.Any],
) -> bool:
field = str(event_filter.get('field') or event_filter.get('path') or '').strip()
if not field:
return True
operator = str(event_filter.get('operator') or 'eq')
expected = event_filter.get('value')
actual = cls._get_nested_value(event_data, field)
if operator == 'eq':
return actual == expected
if operator == 'neq':
return actual != expected
if operator == 'contains':
if isinstance(actual, (list, tuple, set)):
return expected in actual
return str(expected) in str(actual or '')
if operator == 'not_contains':
if isinstance(actual, (list, tuple, set)):
return expected not in actual
return str(expected) not in str(actual or '')
if operator == 'starts_with':
return str(actual or '').startswith(str(expected))
if operator == 'regex':
try:
return bool(re.search(str(expected), str(actual or '')))
except re.error:
return False
return False
@classmethod
def _augment_event_data(
cls,
event_data: dict[str, typing.Any],
) -> dict[str, typing.Any]:
"""Inject virtual computed fields to simplify common filter patterns."""
message_chain = event_data.get('message_chain')
if isinstance(message_chain, list):
text_parts = [
comp.get('text', '') for comp in message_chain if isinstance(comp, dict) and comp.get('type') == 'Plain'
]
event_data['message_text'] = ''.join(text_parts)
event_data['message_element_types'] = [
comp.get('type', '') for comp in message_chain if isinstance(comp, dict)
]
if 'group' in event_data:
event_data['chat_type'] = 'group' if event_data.get('group') is not None else 'person'
return event_data
@classmethod
def _match_event_filters(
cls,
event: platform_events.EBAEvent,
filters: typing.Any,
) -> bool:
if not filters:
return True
if not isinstance(filters, list):
return False
event_data = cls._augment_event_data(cls._safe_model_dump(event))
return all(
cls._match_event_filter(event_data, event_filter)
for event_filter in filters
if isinstance(event_filter, dict)
)
@staticmethod
def _get_event_bindings_from_value(raw_bindings: typing.Any) -> list[dict[str, typing.Any]]:
raw_bindings = raw_bindings or []
if isinstance(raw_bindings, str):
try:
raw_bindings = json.loads(raw_bindings)
except json.JSONDecodeError:
raw_bindings = []
if not isinstance(raw_bindings, list):
return []
return [binding for binding in raw_bindings if isinstance(binding, dict)]
def _get_event_bindings(self) -> list[dict[str, typing.Any]]:
return self._get_event_bindings_from_value(self.bot_entity.event_bindings)
@classmethod
def _evaluate_eba_event_bindings(
cls,
bindings: list[dict[str, typing.Any]],
event: platform_events.EBAEvent,
event_type: str,
) -> tuple[dict[str, typing.Any] | None, list[dict[str, typing.Any]]]:
"""Evaluate Bot event bindings with the same precedence used at runtime."""
matched: list[tuple[int, int, dict[str, typing.Any], dict[str, typing.Any]]] = []
diagnostic_steps: list[dict[str, typing.Any]] = []
for index, binding in enumerate(bindings):
if binding.get('target_type') == 'event_processor':
continue
event_pattern = str(binding.get('event_pattern') or '')
priority = int(binding.get('priority') or 0)
order = int(binding.get('order', index))
step = {
'step': 'evaluate_binding',
'binding_id': binding.get('id'),
'event_pattern': event_pattern,
'target_type': binding.get('target_type'),
'target_uuid': binding.get('target_uuid') or '',
'enabled': binding.get('enabled', True),
'priority': priority,
'order': order,
'matched': False,
'failure_code': None,
'reason': '',
}
if not binding.get('enabled', True):
step['failure_code'] = 'binding_disabled'
step['reason'] = 'Binding is disabled'
diagnostic_steps.append(step)
continue
if not cls._match_event_pattern(event_type, event_pattern):
step['failure_code'] = 'event_pattern_mismatch'
step['reason'] = 'Event type does not match binding event_pattern'
diagnostic_steps.append(step)
continue
if not cls._match_event_filters(event, binding.get('filters')):
step['failure_code'] = 'filters_mismatch'
step['reason'] = 'Event data does not satisfy binding filters'
diagnostic_steps.append(step)
continue
step['matched'] = True
step['reason'] = 'Binding matched event pattern and filters'
diagnostic_steps.append(step)
matched.append((priority, -order, binding, step))
if not matched:
return None, diagnostic_steps
matched.sort(key=lambda item: (item[0], item[1]), reverse=True)
selected_binding = matched[0][2]
selected_step = matched[0][3]
selected_step['selected'] = True
selected_step['reason'] = 'Selected by priority and order'
for _, _, _, step in matched[1:]:
step['selected'] = False
step['failure_code'] = 'lower_priority'
step['reason'] = 'Another matching binding has higher priority or earlier order'
return selected_binding, diagnostic_steps
def _resolve_eba_event_binding(
self,
event: platform_events.EBAEvent,
event_type: str,
) -> dict[str, typing.Any] | None:
"""Resolve the highest priority Bot event binding for a platform event."""
selected, _ = self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
return selected
def diagnose_eba_event_binding(
self,
event: platform_events.EBAEvent,
event_type: str,
) -> tuple[dict[str, typing.Any] | None, list[dict[str, typing.Any]]]:
"""Return the selected event binding plus per-binding diagnostic steps."""
return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
@staticmethod
def _route_ref(
binding: dict | None,
target_type: str | None = None,
target_uuid: str | None = None,
) -> str:
"""Code-defined route identity for telemetry; never a user-facing name."""
binding = binding or {}
kind = str(target_type or binding.get('target_type') or '').strip()
target = str(target_uuid or binding.get('target_uuid') or '').strip()
if not kind or not target:
return ''
return f'{kind}:{target}'[:160]
async def _record_event_route_trace(
self,
*,
event_type: str,
status: str,
text: str,
level: str = 'info',
binding: dict[str, typing.Any] | None = None,
target_type: str | None = None,
target_uuid: str | None = None,
failure_code: str | None = None,
reason: str | None = None,
run_id: str | None = None,
) -> dict[str, typing.Any]:
"""Record structured event routing state while preserving the human log."""
binding = binding or {}
metadata = {
'kind': 'event_route_trace',
'event_type': event_type,
'status': status,
'binding_id': binding.get('id'),
'event_pattern': binding.get('event_pattern'),
'target_type': target_type or binding.get('target_type'),
'target_uuid': target_uuid or binding.get('target_uuid') or '',
'failure_code': failure_code,
'reason': reason or text,
'run_id': run_id,
}
log_method = getattr(self.logger, level, self.logger.info)
await log_method(text, metadata=metadata)
if status in {'delivered', 'failed', 'discarded', 'not_matched'}:
from ..telemetry import trace as trace_mod
from ..telemetry.execution import record
with trace_mod.scope(route_ref=self._route_ref(binding, target_type, target_uuid)):
record(
getattr(self, 'ap', None),
getattr(self, 'execution_context', None),
family='event_route',
operation=event_type,
adapter=type(getattr(self, 'adapter', None)).__name__,
mode=target_type if target_type in {'pipeline', 'agent', 'event_processor'} else 'none',
outcome={'delivered': 'success', 'failed': 'failed'}.get(status, 'skipped'),
)
return metadata
def get_pipeline_target_for_event_type(self, event_type: str = 'message.received') -> str | None:
"""Return the first Pipeline target configured for an event type."""
matched: list[tuple[int, int, str]] = []
for index, binding in enumerate(self._get_event_bindings()):
if not binding.get('enabled', True):
continue
if binding.get('target_type') != 'pipeline':
continue
target_uuid = str(binding.get('target_uuid') or '')
if not target_uuid:
continue
if not self._match_event_pattern(event_type, str(binding.get('event_pattern') or '')):
continue
priority = int(binding.get('priority') or 0)
order = int(binding.get('order', index))
matched.append((priority, -order, target_uuid))
if not matched:
return None
matched.sort(key=lambda item: (item[0], item[1]), reverse=True)
return matched[0][2]
@staticmethod
def _safe_model_dump(model: typing.Any) -> dict[str, typing.Any]:
if model is None:
return {}
if hasattr(model, 'model_dump'):
try:
return model.model_dump(mode='json')
except TypeError:
try:
return model.model_dump()
except Exception:
return {}
except Exception:
return {}
if isinstance(model, dict):
return model
return {}
@classmethod
def _compact_event_data(cls, event: platform_events.EBAEvent) -> dict[str, typing.Any]:
raw_event_data = cls._safe_model_dump(event)
compact: dict[str, typing.Any] = {}
for key, value in raw_event_data.items():
if key == 'source_platform_object' or key.startswith('_'):
continue
if value is None or isinstance(value, (bool, int, float)):
compact[key] = value
continue
if isinstance(value, str):
if len(value.encode('utf-8')) <= cls.EVENT_DATA_MAX_STRING_BYTES:
compact[key] = value
continue
if isinstance(value, (list, dict)):
try:
encoded = json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError):
continue
if len(encoded.encode('utf-8')) <= cls.EVENT_DATA_MAX_STRING_BYTES:
compact[key] = value
return compact
async def _record_adapter_event(
self,
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> dict[str, typing.Any]:
"""Record a normalized adapter event for the platform debugging surface."""
event_type = getattr(event, 'type', None) or event.__class__.__name__
metadata = {
'kind': 'adapter_event_received',
'event_type': event_type,
'event_data': self._compact_event_data(event),
'adapter': getattr(self.bot_entity, 'adapter', None) or adapter.__class__.__name__,
'bot_uuid': self.bot_entity.uuid,
}
await self.logger.info(
f'Platform adapter received {event_type}',
metadata=metadata,
)
return metadata
@staticmethod
def _get_entity_id(entity: typing.Any) -> str | None:
entity_id = getattr(entity, 'id', None)
if entity_id is None and isinstance(entity, dict):
entity_id = entity.get('id')
if entity_id is None or entity_id == '':
return None
return str(entity_id)
@staticmethod
def _get_entity_name(entity: typing.Any) -> str | None:
if entity is None:
return None
if hasattr(entity, 'get_name'):
try:
name = entity.get_name()
if name:
return str(name)
except Exception:
pass
for attr in ('nickname', 'member_name', 'name', 'display_name'):
value = getattr(entity, attr, None)
if value:
return str(value)
if isinstance(entity, dict):
for attr in ('nickname', 'member_name', 'name', 'display_name'):
value = entity.get(attr)
if value:
return str(value)
return None
@classmethod
def _infer_actor_context(cls, event: platform_events.EBAEvent) -> ActorContext | None:
actor = getattr(event, 'sender', None) or getattr(event, 'member', None) or getattr(event, 'user', None)
actor_id = cls._get_entity_id(actor)
actor_name = cls._get_entity_name(actor)
if actor_id is None:
user_id = getattr(event, 'user_id', None)
if user_id:
actor_id = str(user_id)
if actor_id is None:
return None
return ActorContext(
actor_type='user',
actor_id=actor_id,
actor_name=actor_name,
metadata={},
)
@classmethod
def _infer_subject_context(cls, event: platform_events.EBAEvent) -> SubjectContext:
group = getattr(event, 'group', None)
if group is not None:
group_id = cls._get_entity_id(group)
return SubjectContext(
subject_type='group',
subject_id=group_id,
data={'group_name': cls._get_entity_name(group)},
)
message_id = getattr(event, 'message_id', None)
if message_id:
return SubjectContext(
subject_type='message',
subject_id=str(message_id),
data={},
)
feedback_id = getattr(event, 'feedback_id', None)
if feedback_id:
return SubjectContext(
subject_type='feedback',
subject_id=str(feedback_id),
data={'message_id': getattr(event, 'message_id', None)},
)
action = getattr(event, 'action', None)
if action:
return SubjectContext(
subject_type='platform_action',
subject_id=str(action),
data={},
)
return SubjectContext(
subject_type='event',
subject_id=getattr(event, 'type', None),
data={},
)
@staticmethod
def _session_to_reply_target(session_id: str | None) -> tuple[str | None, str | None]:
if not session_id or '_' not in session_id:
return None, None
target_type, target_id = session_id.split('_', 1)
if target_type == 'person':
target_type = 'person'
elif target_type == 'group':
target_type = 'group'
else:
return None, None
return target_type, target_id or None
@classmethod
def _infer_reply_target(
cls,
event: platform_events.EBAEvent,
) -> tuple[str | None, str | None, dict[str, typing.Any]]:
metadata: dict[str, typing.Any] = {}
group = getattr(event, 'group', None)
group_id = cls._get_entity_id(group)
if group_id:
metadata['group_id'] = group_id
return 'group', group_id, metadata
chat_id = getattr(event, 'chat_id', None)
chat_type = getattr(event, 'chat_type', None)
chat_type_value = getattr(chat_type, 'value', chat_type)
if chat_id:
metadata['chat_id'] = str(chat_id)
if chat_type_value == 'group':
return 'group', str(chat_id), metadata
return 'person', str(chat_id), metadata
session_target_type, session_target_id = cls._session_to_reply_target(getattr(event, 'session_id', None))
if session_target_type and session_target_id:
return session_target_type, session_target_id, metadata
raw_data = getattr(event, 'data', None)
if isinstance(raw_data, dict):
target_type = raw_data.get('target_type') or raw_data.get('chat_type')
target_id = (
raw_data.get('target_id')
or raw_data.get('chat_id')
or raw_data.get('group_id')
or raw_data.get('user_id')
)
if target_type and target_id:
return str(target_type), str(target_id), metadata
return None, None, metadata
@staticmethod
def _extract_message_id(message_chain: platform_message.MessageChain) -> str:
for component in message_chain:
if isinstance(component, platform_message.Source):
value = getattr(component, 'id', '')
return str(value) if value is not None else ''
return ''
def _legacy_message_to_eba_event(
self,
event: platform_events.FriendMessage | platform_events.GroupMessage,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> platform_events.MessageReceivedEvent:
if isinstance(event, platform_events.GroupMessage):
group = platform_entities.UserGroup(
id=event.group.id,
name=event.group.name,
)
return platform_events.MessageReceivedEvent(
legacy_event=event,
message_id=self._extract_message_id(event.message_chain),
message_chain=event.message_chain,
sender=platform_entities.User(
id=event.sender.id,
nickname=event.sender.member_name,
),
chat_type=platform_entities.ChatType.GROUP,
chat_id=event.group.id,
group=group,
timestamp=event.time or time.time(),
bot_uuid=self.bot_entity.uuid,
adapter_name=adapter.__class__.__name__,
source_platform_object=event.source_platform_object,
)
return platform_events.MessageReceivedEvent(
legacy_event=event,
message_id=self._extract_message_id(event.message_chain),
message_chain=event.message_chain,
sender=platform_entities.User(
id=event.sender.id,
nickname=event.sender.nickname,
remark=event.sender.remark,
),
chat_type=platform_entities.ChatType.PRIVATE,
chat_id=event.sender.id,
timestamp=event.time or time.time(),
bot_uuid=self.bot_entity.uuid,
adapter_name=adapter.__class__.__name__,
source_platform_object=event.source_platform_object,
)
@classmethod
def _build_agent_input(cls, event: platform_events.EBAEvent) -> AgentInput:
text = None
contents: list[dict[str, typing.Any]] = []
message_chain = getattr(event, 'message_chain', None)
if message_chain:
text_parts: list[str] = []
try:
for component in message_chain:
if isinstance(component, platform_message.Plain):
text_parts.append(component.text)
elif isinstance(component, platform_message.Image):
if component.url:
contents.append({'type': 'image_url', 'image_url': {'url': component.url}})
elif component.base64:
contents.append({'type': 'image_base64', 'image_base64': component.base64})
except TypeError:
text_parts.append(str(message_chain))
text = ''.join(text_parts) or str(message_chain)
if text is None:
feedback_content = getattr(event, 'feedback_content', None)
if feedback_content:
text = str(feedback_content)
elif getattr(event, 'action', None):
text = str(getattr(event, 'action'))
else:
text = str(getattr(event, 'type', 'event'))
if text:
contents.insert(0, {'type': 'text', 'text': text})
return AgentInput(
text=text,
contents=contents,
attachments=[],
)
def _eba_event_to_agent_envelope(
self,
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> AgentEventEnvelope:
event_type = getattr(event, 'type', None) or event.__class__.__name__
event_time = getattr(event, 'timestamp', None) or time.time()
event_id = (
getattr(event, 'message_id', None) or getattr(event, 'feedback_id', None) or f'{event_type}:{uuid.uuid4()}'
)
target_type, target_id, target_metadata = self._infer_reply_target(event)
supported_apis = self._get_adapter_supported_apis(adapter)
conversation_id = None
if target_type and target_id:
conversation_id = f'{target_type}_{target_id}'
elif getattr(event, 'session_id', None):
conversation_id = str(getattr(event, 'session_id'))
delivery_data: dict[str, typing.Any] = {
'surface': 'platform',
'reply_target': {
'target_type': target_type,
'target_id': target_id,
'message_id': getattr(event, 'message_id', None),
**target_metadata,
},
'supports_streaming': False,
'supports_edit': 'edit_message' in supported_apis,
'supports_reaction': bool({'add_reaction', 'remove_reaction'} & set(supported_apis)),
'platform_capabilities': {
'adapter': adapter.__class__.__name__,
'event_type': event_type,
'supported_apis': supported_apis,
},
}
interaction_capabilities = self._get_adapter_interaction_capabilities(adapter, supported_apis)
if interaction_capabilities is not None:
delivery_data['interactions'] = interaction_capabilities
return AgentEventEnvelope(
event_id=f'platform:{self.bot_entity.uuid}:{event_id}',
event_type=event_type,
event_time=int(event_time) if isinstance(event_time, (int, float)) else None,
source='platform',
source_event_type=event_type,
bot_id=self.bot_entity.uuid,
workspace_id=None,
conversation_id=conversation_id,
thread_id=None,
actor=self._infer_actor_context(event),
subject=self._infer_subject_context(event),
input=self._build_agent_input(event),
delivery=DeliveryContext.model_validate(delivery_data),
raw_ref=RawEventRef(ref_id=str(event_id), storage_key=None),
data=self._compact_event_data(event),
)
@staticmethod
def _get_adapter_supported_apis(
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> list[str]:
get_supported_apis = getattr(adapter, 'get_supported_apis', None)
if not callable(get_supported_apis):
return []
try:
declared_apis = get_supported_apis()
except Exception:
return []
if not isinstance(declared_apis, (list, tuple, set)):
return []
return list(dict.fromkeys(api_name for api_name in declared_apis if isinstance(api_name, str) and api_name))
@staticmethod
def _get_adapter_interaction_capabilities(
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
supported_apis: list[str],
) -> dict[str, typing.Any] | None:
if 'interactions' not in DeliveryContext.model_fields or 'interaction.request' not in supported_apis:
return None
get_capabilities = getattr(adapter, 'get_interaction_capabilities', None)
if not callable(get_capabilities):
return None
try:
capabilities = get_capabilities()
except Exception:
return None
return capabilities if isinstance(capabilities, dict) else None
@staticmethod
def _agent_product_to_binding(
agent: dict[str, typing.Any],
event_binding: dict[str, typing.Any],
event_type: str,
bot_uuid: str,
) -> AgentBinding | None:
config = agent.get('config') if isinstance(agent, dict) else None
if config is None:
return None
_, runner_id, runner_config = RunnerConfigResolver.resolve_agent_config(config)
if not runner_id:
return None
return AgentBinding(
binding_id=(
f'event_processor:{agent["uuid"]}'
if agent.get('kind') == 'event_processor'
else f'bot:{bot_uuid}:{event_binding.get("id") or uuid.uuid4()}'
),
scope=BindingScope(scope_type='bot', scope_id=bot_uuid),
event_types=[event_type],
runner_id=runner_id,
runner_config=runner_config,
resource_policy=ResourcePolicyProjector.from_runner_config(
runner_config,
allowed_platform_tool_names=resolve_agent_platform_tool_names(config, event_type),
allowed_host_tool_names=config.get('allowed_tools'),
override_runner_tools='allowed_tools' in config,
),
state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']),
delivery_policy=DeliveryPolicy(
enable_streaming=False,
enable_reply=False,
enable_interactions=agent.get('kind') != 'event_processor',
),
agent_id=agent.get('uuid'),
processor_type=agent.get('kind', 'agent'),
processor_id=agent.get('uuid'),
)
async def _handle_platform_event(
self,
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> None:
# One inbound event owns one execution trace; every stage recorded while
# it is handled (routing, runner, platform API calls) joins that trace.
from ..telemetry.execution import ingress
event.bot_uuid = self.bot_entity.uuid
with ingress(getattr(self, 'ap', None), 'event_done'):
await self._handle_platform_event_body(event, adapter)
async def _handle_platform_event_body(
self,
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> None:
from ..telemetry.execution import record
record(
getattr(self, 'ap', None),
getattr(self, 'execution_context', None),
family='platform_event',
operation=event.type,
adapter=adapter.__class__.__name__,
outcome='success',
)
await self._record_adapter_event(event, adapter)
primary = (
self._handle_interaction_submission(event, adapter)
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted'
else self._dispatch_eba_event_to_processor(event, adapter)
)
subscriptions = self._get_event_bindings_from_value(getattr(self.bot_entity, 'plugin_processors', []))
seen = set()
tasks = [primary]
for subscription in subscriptions:
processor_uuid = subscription.get('processor_uuid')
if not processor_uuid or processor_uuid in seen or not subscription.get('enabled', True):
continue
seen.add(processor_uuid)
tasks.append(self._dispatch_plugin_subscription(event, adapter, processor_uuid))
# Start all deliveries together. A slow or failed subscriber cannot block
# another subscriber or the primary route from receiving the event.
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, BaseException):
await self.logger.error(f'Event delivery failed: {result}')
async def _dispatch_plugin_subscription(self, event, adapter, processor_uuid):
event_type = event.type
event_binding = {
'id': f'plugin_processor:{processor_uuid}',
'target_type': 'event_processor',
'target_uuid': processor_uuid,
'event_pattern': event_type,
}
try:
agent = await self.ap.agent_service.get_agent(self.execution_context, processor_uuid)
if not agent or agent.get('kind') != 'event_processor':
raise ValueError('Plugin processor not found')
descriptor = await self.ap.runner_registry.get(self.execution_context, agent.get('component_ref'))
if 'event' not in descriptor.usages:
raise ValueError('Runner does not support event processing')
# Resolve the installed declaration each time, including after plugin updates.
patterns = descriptor.supported_event_patterns
if not patterns or not self._agent_supports_event_type(patterns, event_type):
return
agent = {**agent, 'supported_event_patterns': patterns}
return await self._dispatch_eba_event_to_processor(event, adapter, event_binding, agent)
except Exception as exc:
return await self._record_event_route_trace(
event_type=event_type,
status='failed',
level='error',
binding=event_binding,
target_type='event_processor',
target_uuid=processor_uuid,
failure_code='runner_failed',
reason=str(exc),
text=f'Plugin processor {processor_uuid} failed: {exc}',
)
async def _dispatch_eba_event_to_processor(
self,
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
event_binding: dict | None = None,
agent: dict | None = None,
) -> dict[str, typing.Any]:
event_type = getattr(event, 'type', None) or event.__class__.__name__
if event_binding is None:
event_binding = self._resolve_eba_event_binding(event, event_type)
if event_binding is None:
return await self._record_event_route_trace(
event_type=event_type,
status='not_matched',
failure_code='route_not_found',
reason='No event route matched',
text=f'Platform event {event_type} ignored: no event route matched',
)
target_type = event_binding.get('target_type')
await self._record_event_route_trace(
event_type=event_type,
status='matched',
binding=event_binding,
target_type=target_type,
target_uuid=event_binding.get('target_uuid'),
text=f'Event {event_type} matched route {event_binding.get("id") or ""}'.strip(),
)
if target_type == 'discard':
if isinstance(event, platform_events.MessageReceivedEvent):
from ..telemetry import trace as trace_mod
with trace_mod.scope(route_ref=self._route_ref(event_binding)):
await self._dispatch_eba_message_to_pipeline(
event,
adapter,
pipeline_uuid=self.PIPELINE_DISCARD,
routed_by_event_binding=True,
)
return await self._record_event_route_trace(
event_type=event_type,
status='discarded',
binding=event_binding,
target_type=target_type,
text=f'Event {event_type} discarded by event binding',
)
return await self._record_event_route_trace(
event_type=event_type,
status='discarded',
binding=event_binding,
target_type=target_type,
text=f'Event {event_type} discarded by event binding',
)
if target_type == 'pipeline':
if not self._is_message_event_type(event_type):
return await self._record_event_route_trace(
event_type=event_type,
status='failed',
level='warning',
binding=event_binding,
target_type=target_type,
target_uuid=event_binding.get('target_uuid'),
failure_code='processor_incompatible',
reason='Pipeline targets only support message events',
text=f'Event {event_type} ignored Pipeline target for non-message event',
)
from ..telemetry import trace as trace_mod
with trace_mod.scope(
route_ref=self._route_ref(event_binding, target_type, event_binding.get('target_uuid'))
):
await self._dispatch_eba_message_to_pipeline(
event,
adapter,
pipeline_uuid=event_binding.get('target_uuid'),
routed_by_event_binding=True,
)
return await self._record_event_route_trace(
event_type=event_type,
status='delivered',
binding=event_binding,
target_type=target_type,
target_uuid=event_binding.get('target_uuid'),
text=f'Event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(),
)
if target_type not in {'agent', 'event_processor'}:
return await self._record_event_route_trace(
event_type=event_type,
status='failed',
level='warning',
binding=event_binding,
target_type=target_type,
target_uuid=event_binding.get('target_uuid'),
failure_code='processor_incompatible',
reason=f'Unsupported event binding target type: {target_type}',
text=f'Event {event_type} ignored unsupported target type {target_type}',
)
target_uuid = event_binding.get('target_uuid')
if agent is None:
agent = await self.ap.agent_service.get_agent(self.execution_context, target_uuid)
if not agent or agent.get('kind') != target_type:
return await self._record_event_route_trace(
event_type=event_type,
status='failed',
level='warning',
binding=event_binding,
target_type=target_type,
target_uuid=target_uuid,
failure_code='processor_not_found',
reason='Agent target not found',
text=f'Event {event_type} target agent not found: {target_uuid}',
)
if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type):
return await self._record_event_route_trace(
event_type=event_type,
status='failed',
binding=event_binding,
target_type=target_type,
target_uuid=target_uuid,
failure_code='processor_incompatible',
reason='Agent target does not support this event type',
text=f'Event {event_type} target agent does not support this event: {target_uuid}',
)
try:
binding = self._agent_product_to_binding(agent, event_binding, event_type, self.bot_entity.uuid)
except Exception:
return await self._record_event_route_trace(
event_type=event_type,
status='failed',
level='error',
binding=event_binding,
target_type=target_type,
target_uuid=target_uuid,
failure_code='runner_failed',
reason='Agent configuration is invalid',
text=f'Failed to build Agent binding for EBA event {event_type}: {traceback.format_exc()}',
)
if binding is None:
return await self._record_event_route_trace(
event_type=event_type,
status='failed',
level='warning',
binding=event_binding,
target_type=target_type,
target_uuid=target_uuid,
failure_code='processor_not_found',
reason='Agent target has no runner',
text=f'Event {event_type} target agent has no runner: {target_uuid}',
)
envelope = self._eba_event_to_agent_envelope(event, adapter)
if target_type == 'event_processor':
envelope.data = event.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'})
from ..telemetry import trace as trace_mod
try:
with trace_mod.scope(route_ref=self._route_ref(event_binding, target_type, target_uuid)):
async for _ in self.ap.agent_run_orchestrator.run(
envelope,
binding,
adapter_context={
'_delivery_adapter': adapter,
'_platform_event': event,
'_execution_context': self.execution_context,
},
):
# Results are journaled by the orchestrator; platform sends require explicit actions.
pass
except Exception:
return await self._record_event_route_trace(
event_type=event_type,
status='failed',
level='error',
binding=event_binding,
target_type=target_type,
target_uuid=target_uuid,
failure_code='runner_failed',
reason='Agent runner failed',
text=f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}',
)
return await self._record_event_route_trace(
event_type=event_type,
status='delivered',
binding=event_binding,
target_type=target_type,
target_uuid=target_uuid,
text=f'Event {event_type} delivered to Agent {target_uuid}',
)
def resolve_event_pipeline_uuid(
self,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
launcher_type: str,
launcher_id: str,
message_text: str,
message_element_types: list[str] | None = None,
) -> tuple[str | None, bool]:
"""Resolve a pipeline, honoring a trusted per-task adapter override."""
get_override = getattr(adapter, 'get_pipeline_uuid_override', None)
if callable(get_override):
override = get_override()
if override:
return str(override), False
return self.resolve_pipeline_uuid(
launcher_type,
launcher_id,
message_text,
message_element_types,
)
async def _record_discarded_message(
self,
launcher_type: provider_session.LauncherTypes,
launcher_id: str | int,
sender_id: str | int,
message_event: platform_events.MessageEvent,
message_chain: platform_message.MessageChain,
) -> None:
"""Record a discarded message in the monitoring system."""
try:
if hasattr(message_chain, 'model_dump'):
chain_dump = message_chain.model_dump()
if hasattr(self.ap, 'storage_mgr') and hasattr(self.ap.storage_mgr, 'media_cache'):
chain_dump = await self.ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
message_content = json.dumps(chain_dump, ensure_ascii=False)
else:
message_content = str(message_chain)
sender_name = None
if hasattr(message_event, 'sender'):
if hasattr(message_event.sender, 'nickname'):
sender_name = message_event.sender.nickname
elif hasattr(message_event.sender, 'member_name'):
sender_name = message_event.sender.member_name
# Use the same session_id format as monitoring_helper.py
session_id = f'{launcher_type}_{launcher_id}'
platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)
await self.ap.monitoring_service.record_message(
self.execution_context,
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name or self.bot_entity.uuid,
pipeline_id=self.PIPELINE_DISCARD,
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
message_content=message_content,
session_id=session_id,
status='discarded',
level='info',
platform=platform,
user_id=str(sender_id),
user_name=sender_name,
)
# Ensure the session exists so the message appears in the session monitor.
# Don't overwrite pipeline info — a session may have messages from
# multiple pipelines; discarding shouldn't change the displayed pipeline.
session_updated = await self.ap.monitoring_service.update_session_activity(
self.execution_context,
session_id,
)
if not session_updated:
# No session yet (first message for this launcher was discarded).
await self.ap.monitoring_service.record_session_start(
self.execution_context,
session_id=session_id,
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name or self.bot_entity.uuid,
pipeline_id=self.PIPELINE_DISCARD,
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
platform=platform,
user_id=str(sender_id),
user_name=sender_name,
)
except Exception as e:
await self.logger.error(f'Failed to record discarded message: {e}')
async def _handle_legacy_message_event(
self,
event: platform_events.FriendMessage | platform_events.GroupMessage,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid_override: str | None = None,
routed_by_event_binding: bool = False,
variables: dict[str, typing.Any] | None = None,
) -> None:
await self.assert_execution_active()
is_group_message = isinstance(event, platform_events.GroupMessage)
launcher_kind = 'group' if is_group_message else 'person'
launcher_type = (
provider_session.LauncherTypes.GROUP if is_group_message else provider_session.LauncherTypes.PERSON
)
launcher_id = event.group.id if is_group_message else event.sender.id
sender_id = event.sender.id
image_components = [
component for component in event.message_chain if isinstance(component, platform_message.Image)
]
await self.logger.info(
f'{event.message_chain}',
images=image_components,
message_session_id=f'{launcher_kind}_{launcher_id}',
)
skip_pipeline = False
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
if is_group_message:
skip_pipeline = await self.ap.webhook_pusher.push_group_message(
self.execution_context,
event,
self.bot_entity.uuid,
adapter.__class__.__name__,
)
else:
skip_pipeline = await self.ap.webhook_pusher.push_person_message(
self.execution_context,
event,
self.bot_entity.uuid,
adapter.__class__.__name__,
)
if skip_pipeline:
await self.logger.info(f'Pipeline skipped for {launcher_kind} message due to webhook response')
return
if hasattr(adapter, 'get_launcher_id'):
custom_launcher_id = adapter.get_launcher_id(event)
if custom_launcher_id:
launcher_id = custom_launcher_id
if pipeline_uuid_override is None:
pipeline_uuid = None
routed_by_rule = False
else:
pipeline_uuid = pipeline_uuid_override
routed_by_rule = routed_by_event_binding
if pipeline_uuid == self.PIPELINE_DISCARD:
await self.logger.info(f'{launcher_kind.title()} message discarded by routing rule')
await self._record_discarded_message(
launcher_type,
launcher_id,
sender_id,
event,
event.message_chain,
)
return
await self.ap.msg_aggregator.add_message(
bot_uuid=self.bot_entity.uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=sender_id,
message_event=event,
message_chain=event.message_chain,
adapter=adapter,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
variables=variables,
execution_context=self.execution_context,
)
async def _handle_interaction_submission(
self,
event: platform_events.PlatformSpecificEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> None:
"""Consume a platform interaction callback and resume its original processor."""
data = event.data if isinstance(event.data, dict) else {}
callback_token = str(data.get('callback_token') or '')
actor_id = str(data.get('actor_id') or data.get('user_id') or '') or None
target_type = str(data.get('target_type') or '') or None
target_id = str(data.get('target_id') or data.get('chat_id') or '') or None
conversation_id = f'{target_type}_{target_id}' if target_type and target_id else None
submission = {
'interaction_id': data.get('interaction_id'),
'action_id': data.get('action_id'),
'values': data.get('values') if isinstance(data.get('values'), dict) else {},
'submitted_at': int(event.timestamp) if event.timestamp else None,
}
for ref_name in ('action_ref', 'field_ref', 'option_ref'):
if data.get(ref_name) is not None:
submission[ref_name] = data[ref_name]
record = await self.ap.agent_run_orchestrator.interaction_manager.consume_callback(
callback_token=callback_token,
submission=submission,
bot_id=self.bot_entity.uuid,
conversation_id=conversation_id,
actor_id=actor_id,
)
await self.ap.agent_run_orchestrator.interaction_manager.acknowledge_submission(record, adapter)
if record['processor_type'] == 'agent':
await self._resume_agent_interaction(record, event, adapter, actor_id)
return
if record['processor_type'] != 'pipeline':
raise ValueError(f'Unsupported interaction processor type: {record["processor_type"]}')
pipeline = await self.ap.pipeline_service.get_pipeline(
self.execution_context,
record['processor_id'],
include_secret=True,
)
if not pipeline:
raise ValueError(f'Interaction target Pipeline is unavailable: {record["processor_id"]}')
current_runner_id = RunnerConfigResolver.resolve_runner_id(pipeline.get('config') or {})
if current_runner_id != record['runner_id']:
raise ValueError('Interaction target Pipeline runner changed after the request was created')
message_components: list[platform_message.MessageComponent] = []
callback_message_id = data.get('message_id')
if callback_message_id:
message_components.append(
platform_message.Source(
id=str(callback_message_id),
time=int(event.timestamp) if event.timestamp else int(time.time()),
)
)
message_components.append(
platform_message.Plain(text=str(data.get('display_text') or data.get('action_id') or 'submitted'))
)
message_chain = platform_message.MessageChain(message_components)
delivery_target = record.get('delivery_target') or {}
original_target_type = delivery_target.get('target_type')
original_target_id = delivery_target.get('target_id')
if original_target_type == 'group':
group = platform_entities.Group(
id=original_target_id,
name='',
permission=platform_entities.Permission.Member,
)
message_event: platform_events.MessageEvent = platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=actor_id or '',
member_name='',
permission=platform_entities.Permission.Member,
group=group,
),
message_chain=message_chain,
time=event.timestamp,
source_platform_object=event.source_platform_object,
)
else:
message_event = platform_events.FriendMessage(
sender=platform_entities.Friend(id=actor_id or '', nickname='', remark=''),
message_chain=message_chain,
time=event.timestamp,
source_platform_object=event.source_platform_object,
)
launcher_type = (
provider_session.LauncherTypes.GROUP
if original_target_type == 'group'
else provider_session.LauncherTypes.PERSON
)
await self.ap.msg_aggregator.add_message(
bot_uuid=self.bot_entity.uuid,
launcher_type=launcher_type,
launcher_id=original_target_id or target_id or actor_id or '',
sender_id=actor_id or '',
message_event=message_event,
message_chain=message_chain,
adapter=adapter,
pipeline_uuid=record['processor_id'],
routed_by_rule=True,
variables={'_interaction_submission': record['submission']},
execution_context=self.execution_context,
)
async def _resume_agent_interaction(
self,
record: dict[str, typing.Any],
event: platform_events.PlatformSpecificEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
actor_id: str | None,
) -> None:
"""Resume the exact independent Agent that produced an interaction."""
agent = await self.ap.agent_service.get_agent(
self.execution_context,
record['processor_id'],
)
if not agent or agent.get('kind') != 'agent':
raise ValueError(f'Interaction target Agent is unavailable: {record["processor_id"]}')
binding = self._agent_product_to_binding(
agent,
{'id': record['binding_id']},
'interaction.submitted',
self.bot_entity.uuid,
)
if binding is None or binding.runner_id != record['runner_id']:
raise ValueError('Interaction target Agent runner changed after the request was created')
binding.binding_id = record['binding_id']
submission = record['submission']
display_text = str(
(submission or {}).get('action_id')
or (event.data if isinstance(event.data, dict) else {}).get('display_text')
or 'submitted'
)
input_data: dict[str, typing.Any] = {
'text': display_text,
'contents': [{'type': 'text', 'text': display_text}],
'attachments': [],
}
if 'interaction' in AgentInput.model_fields:
input_data['interaction'] = submission
delivery_target = record.get('delivery_target') or {}
supported_apis = self._get_adapter_supported_apis(adapter)
delivery_data: dict[str, typing.Any] = {
'surface': 'platform',
'reply_target': delivery_target,
'supports_streaming': False,
'supports_edit': 'edit_message' in supported_apis,
'supports_reaction': bool({'add_reaction', 'remove_reaction'} & set(supported_apis)),
'platform_capabilities': {
'adapter': adapter.__class__.__name__,
'event_type': 'interaction.submitted',
'supported_apis': supported_apis,
},
}
interaction_capabilities = self._get_adapter_interaction_capabilities(adapter, supported_apis)
if interaction_capabilities is not None:
delivery_data['interactions'] = interaction_capabilities
envelope = AgentEventEnvelope(
event_id=f'interaction:{record["id"]}:{uuid.uuid4()}',
event_type='interaction.submitted',
event_time=int(event.timestamp) if event.timestamp else int(time.time()),
source='platform',
source_event_type='interaction.submitted',
bot_id=self.bot_entity.uuid,
workspace_id=record.get('workspace_id'),
conversation_id=record.get('conversation_id'),
thread_id=record.get('thread_id'),
actor=ActorContext(actor_type='user', actor_id=actor_id),
subject=SubjectContext(
subject_type='interaction',
subject_id=record['interaction_id'],
data={'action_id': (submission or {}).get('action_id')},
),
input=AgentInput.model_validate(input_data),
delivery=DeliveryContext.model_validate(delivery_data),
raw_ref=RawEventRef(ref_id=f'interaction:{record["id"]}', storage_key=None),
data={'interaction': submission},
)
async for _ in self.ap.agent_run_orchestrator.run(
envelope,
binding,
adapter_context={'_delivery_adapter': adapter},
):
# Resuming an interaction follows the same explicit-action delivery policy.
pass
async def _dispatch_eba_message_to_pipeline(
self,
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid: str | None = None,
routed_by_event_binding: bool = False,
) -> None:
if not isinstance(event, platform_events.MessageReceivedEvent):
event_type = getattr(event, 'type', None) or event.__class__.__name__
await self.logger.warning(f'Event {event_type} cannot be dispatched to legacy Pipeline')
return
await self._handle_legacy_message_event(
event.to_legacy_event(),
adapter,
pipeline_uuid_override=pipeline_uuid,
routed_by_event_binding=routed_by_event_binding,
)
async def initialize(self):
from ..telemetry.platform import observe_adapter
observe_adapter(self.ap, self.execution_context, self.adapter)
def tenant_scoped_listener(listener):
@functools.wraps(listener)
async def wrapped(*args, **kwargs):
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
cloud_runtime = (
getattr(
getattr(self.ap.persistence_mgr, 'mode', None),
'value',
None,
)
== 'cloud_runtime'
)
if cloud_runtime:
if not callable(tenant_scope):
raise RuntimeError('Cloud platform callbacks require a tenant scope')
async with tenant_scope(self.workspace_uuid):
return await listener(*args, **kwargs)
return await listener(*args, **kwargs)
return wrapped
def websocket_pipeline_uuid(event, adapter):
if adapter.__class__.__name__ != 'WebSocketAdapter':
return None
value = getattr(event, '_langbot_pipeline_uuid', None)
return value if isinstance(value, str) and value else None
async def on_friend_message(
event: platform_events.FriendMessage,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
pipeline_uuid = websocket_pipeline_uuid(event, adapter)
if pipeline_uuid:
await self._handle_legacy_message_event(event, adapter, pipeline_uuid_override=pipeline_uuid)
return
await self._handle_platform_event(self._legacy_message_to_eba_event(event, adapter), adapter)
async def on_group_message(
event: platform_events.GroupMessage,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
pipeline_uuid = websocket_pipeline_uuid(event, adapter)
if pipeline_uuid:
await self._handle_legacy_message_event(event, adapter, pipeline_uuid_override=pipeline_uuid)
return
await self._handle_platform_event(self._legacy_message_to_eba_event(event, adapter), adapter)
get_supported_events = getattr(self.adapter, 'get_supported_events', None)
supported_events: list[str] = []
if callable(get_supported_events):
try:
supported_events = list(get_supported_events() or [])
except Exception:
supported_events = []
# EBA adapters emit MessageReceivedEvent directly. Registering both
# entry paths would process each native message twice because these
# adapters also expose legacy conversion for compatibility consumers.
if 'message.received' not in supported_events:
self.adapter.register_listener(
platform_events.FriendMessage,
tenant_scoped_listener(on_friend_message),
)
self.adapter.register_listener(
platform_events.GroupMessage,
tenant_scoped_listener(on_group_message),
)
# Register feedback listener (only effective on adapters that support it)
async def on_feedback(
event: platform_events.FeedbackEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
try:
await self.assert_execution_active()
pipeline_id = self.get_pipeline_target_for_event_type('message.received') or ''
pipeline_name = ''
if pipeline_id:
pipeline = await self.ap.pipeline_service.get_pipeline(
self.execution_context,
pipeline_id,
)
pipeline_name = pipeline.get('name', '') if pipeline else ''
await self.ap.monitoring_service.record_feedback(
self.execution_context,
feedback_id=event.feedback_id,
feedback_type=event.feedback_type,
feedback_content=event.feedback_content,
inaccurate_reasons=event.inaccurate_reasons,
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name,
pipeline_id=pipeline_id,
pipeline_name=pipeline_name,
session_id=event.session_id,
message_id=event.message_id,
stream_id=event.stream_id,
user_id=event.user_id,
platform=adapter.__class__.__name__,
)
await self.logger.info(
f'Recorded feedback: feedback_id={event.feedback_id}, type={event.feedback_type}'
)
except Exception:
await self.logger.error(f'Failed to record feedback: {traceback.format_exc()}')
self.adapter.register_listener(platform_events.FeedbackEvent, tenant_scoped_listener(on_feedback))
async def on_eba_event(
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
await self._handle_platform_event(event, adapter)
self.adapter.register_listener(
platform_events.EBAEvent,
tenant_scoped_listener(on_eba_event),
)
async def run(self):
async def exception_wrapper():
try:
self.task_context.set_current_action('Running...')
await self.adapter.run_async()
self.task_context.set_current_action('Exited.')
except Exception as e:
if isinstance(e, asyncio.CancelledError):
self.task_context.set_current_action('Exited.')
return
traceback_str = traceback.format_exc()
self.task_context.set_current_action('Exited with error.')
await self.logger.error(f'平台适配器运行出错:\n{e}\n{traceback_str}')
self.task_wrapper = self.ap.task_mgr.create_task(
exception_wrapper(),
kind='platform-adapter',
name=f'platform-adapter-{self.adapter.__class__.__name__}',
context=self.task_context,
scopes=[
core_entities.LifecycleControlScope.APPLICATION,
core_entities.LifecycleControlScope.PLATFORM,
],
instance_uuid=self.execution_context.instance_uuid,
workspace_uuid=self.execution_context.workspace_uuid,
placement_generation=(self.execution_context.placement_generation),
)
async def shutdown(self):
async with self._shutdown_lock:
if self._shutdown_complete:
return
wrapper = self.task_wrapper
self.task_wrapper = None
try:
await asyncio.wait_for(self.adapter.kill(), timeout=15)
finally:
if wrapper is not None:
self.ap.task_mgr.cancel_task(wrapper.id)
if wrapper.task is not asyncio.current_task():
with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError):
await asyncio.wait_for(wrapper.task, timeout=5)
self._shutdown_complete = True
# 控制QQ消息输入输出的类
class PlatformManager:
# ====== 4.0 ======
ap: app.Application = None
bots: list[RuntimeBot]
websocket_proxy_bots: dict[str, RuntimeBot]
adapter_components: list[engine.Component]
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]]
def __init__(self, ap: app.Application = None):
self.ap = ap
self._bots_by_key: dict[tuple[str, str, str], RuntimeBot] = {}
self._bot_keys_by_workspace: dict[
str,
set[tuple[str, str, str]],
] = {}
self._bot_keys_by_uuid: dict[str, set[tuple[str, str, str]]] = {}
self.websocket_proxy_bots = {}
self.adapter_components = []
self.adapter_dict = {}
self._scope_generations: dict[tuple[str, str], int] = {}
self._proxy_last_accessed: dict[str, float] = {}
self._runtime_mutation_lock = asyncio.Lock()
@staticmethod
def _runtime_bot_key(bot: RuntimeBot) -> tuple[str, str, str]:
context = getattr(bot, 'execution_context', None)
instance_uuid = str(getattr(context, 'instance_uuid', '__test_instance__'))
workspace_uuid = str(
getattr(bot, 'workspace_uuid', None) or getattr(context, 'workspace_uuid', '__test_workspace__')
)
bot_uuid = str(
getattr(getattr(bot, 'bot_entity', None), 'uuid', None)
or getattr(context, 'bot_uuid', None)
or f'__runtime_{id(bot)}'
)
return instance_uuid, workspace_uuid, bot_uuid
def _register_runtime_bot(self, bot: RuntimeBot) -> RuntimeBot | None:
key = self._runtime_bot_key(bot)
previous = self._bots_by_key.get(key)
self._bots_by_key[key] = bot
self._bot_keys_by_workspace.setdefault(key[1], set()).add(key)
self._bot_keys_by_uuid.setdefault(key[2], set()).add(key)
return previous
def _pop_runtime_bot(
self,
key: tuple[str, str, str],
) -> RuntimeBot | None:
bot = self._bots_by_key.pop(key, None)
if bot is None:
return None
workspace_keys = self._bot_keys_by_workspace.get(key[1])
if workspace_keys is not None:
workspace_keys.discard(key)
if not workspace_keys:
self._bot_keys_by_workspace.pop(key[1], None)
uuid_keys = self._bot_keys_by_uuid.get(key[2])
if uuid_keys is not None:
uuid_keys.discard(key)
if not uuid_keys:
self._bot_keys_by_uuid.pop(key[2], None)
return bot
@property
def bots(self) -> list[RuntimeBot]:
"""Compatibility view over the indexed platform runtime registry."""
return list(self._bots_by_key.values())
@bots.setter
def bots(self, bots: list[RuntimeBot]) -> None:
self._bots_by_key = {}
self._bot_keys_by_workspace = {}
self._bot_keys_by_uuid = {}
for bot in bots:
self._register_runtime_bot(bot)
def _max_workspace_proxies(self) -> int:
instance_data = getattr(
getattr(self.ap, 'instance_config', None),
'data',
{},
)
value = instance_data.get('system', {}).get('websocket_retention', {}).get('max_workspace_proxies', 1024)
try:
return max(int(value), 1)
except (TypeError, ValueError):
return 1024
async def _evict_idle_websocket_proxy_unlocked(self) -> None:
"""Make room without interrupting a live socket or in-flight query."""
if len(self.websocket_proxy_bots) < self._max_workspace_proxies():
return
from .sources.websocket_manager import WebSocketScope, ws_connection_manager
for workspace_uuid in sorted(
self.websocket_proxy_bots,
key=lambda item: self._proxy_last_accessed.get(item, 0.0),
):
proxy_bot = self.websocket_proxy_bots[workspace_uuid]
listener_tasks = getattr(proxy_bot.adapter, 'inbound_listener_tasks', ())
if any(not task.done() for task in tuple(listener_tasks)):
continue
scope = WebSocketScope.from_context(proxy_bot.execution_context)
if ws_connection_manager.get_stats(scope=scope)['total_connections'] > 0:
continue
self.websocket_proxy_bots.pop(workspace_uuid, None)
self._proxy_last_accessed.pop(workspace_uuid, None)
await proxy_bot.shutdown()
if not self._bot_keys_by_workspace.get(workspace_uuid):
self._scope_generations.pop(
(proxy_bot.execution_context.instance_uuid, workspace_uuid),
None,
)
return
raise RuntimeError('WebSocket Workspace proxy capacity reached and every proxy is active')
async def _observe_execution_context(self, context: ExecutionContext) -> None:
"""Shutdown superseded Workspace adapters when placement advances."""
async with self._runtime_mutation_lock:
await self._observe_execution_context_unlocked(context)
async def _observe_execution_context_unlocked(self, context: ExecutionContext) -> None:
scope = (context.instance_uuid, context.workspace_uuid)
previous_generation = self._scope_generations.get(scope)
if previous_generation is not None and context.placement_generation < previous_generation:
raise WorkspaceInvariantError('Platform runtime placement generation rolled back')
if previous_generation == context.placement_generation:
return
if previous_generation is not None:
proxy_bot = self.websocket_proxy_bots.pop(context.workspace_uuid, None)
self._proxy_last_accessed.pop(context.workspace_uuid, None)
if proxy_bot is not None and proxy_bot.enable:
await proxy_bot.shutdown()
for key in tuple(self._bot_keys_by_workspace.get(context.workspace_uuid, ())):
bot = self._pop_runtime_bot(key)
if bot is None:
continue
if bot.enable:
await bot.shutdown()
self._scope_generations[scope] = context.placement_generation
async def initialize(self):
# delete all bot log images
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
disabled_adapters = {
canonical_adapter_name(name)
for name in (self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or [])
}
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
for component in self.adapter_components:
if component.metadata.name in disabled_adapters:
continue
adapter_dict[component.metadata.name] = component.get_python_component_class()
self.adapter_dict = adapter_dict
# Filter out disabled adapters from components list (for API responses)
if disabled_adapters:
self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]
await self.load_bots_from_db()
# OSS may have no persisted bots. Its singleton Workspace still needs
# a debug WebSocket proxy. SaaS creates proxies lazily from an explicit
# request/runtime context instead of guessing among Workspaces.
if not self.websocket_proxy_bots:
try:
binding = await self.ap.workspace_service.get_execution_binding()
except Exception:
pass
else:
await self.get_websocket_proxy_bot(
ExecutionContext(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
)
)
@property
def websocket_proxy_bot(self) -> RuntimeBot:
"""Compatibility accessor that is safe only for a singleton Workspace."""
if len(self.websocket_proxy_bots) != 1:
raise WorkspaceRequiredError('An explicit Workspace is required for the WebSocket proxy bot')
return next(iter(self.websocket_proxy_bots.values()))
@websocket_proxy_bot.setter
def websocket_proxy_bot(self, runtime_bot: RuntimeBot) -> None:
"""Keep isolated tests that inject one proxy bot working."""
workspace_uuid = getattr(runtime_bot, 'workspace_uuid', '__test_singleton__')
self.websocket_proxy_bots = {workspace_uuid: runtime_bot}
@staticmethod
def _normalize_execution_context(
context: ExecutionContext | RequestContext,
*,
bot_uuid: str | None = None,
pipeline_uuid: str | None = None,
) -> ExecutionContext:
if isinstance(context, RequestContext):
return ExecutionContext.from_request(
context,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
)
if not isinstance(context, ExecutionContext):
raise WorkspaceRequiredError('Runtime operations require an ExecutionContext')
if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
raise WorkspaceRequiredError('Runtime operations require an instance and Workspace')
if context.placement_generation <= 0:
raise WorkspaceRequiredError('Runtime operations require a positive placement generation')
updates = {}
if bot_uuid is not None:
if context.bot_uuid not in (None, bot_uuid):
raise WorkspaceRequiredError('Runtime bot UUID does not match its ExecutionContext')
updates['bot_uuid'] = bot_uuid
if pipeline_uuid is not None:
if context.pipeline_uuid not in (None, pipeline_uuid):
raise WorkspaceRequiredError('Runtime pipeline UUID does not match its ExecutionContext')
updates['pipeline_uuid'] = pipeline_uuid
return dataclasses.replace(context, **updates) if updates else context
async def get_websocket_proxy_bot(
self,
context: ExecutionContext | RequestContext,
) -> RuntimeBot:
execution_context = self._normalize_execution_context(context)
await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
async with self._runtime_mutation_lock:
await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
await self._observe_execution_context_unlocked(execution_context)
existing = self.websocket_proxy_bots.get(execution_context.workspace_uuid)
if existing is not None:
if existing.placement_generation != execution_context.placement_generation:
raise WorkspaceRequiredError('WebSocket proxy placement generation is stale')
self._proxy_last_accessed[execution_context.workspace_uuid] = time.monotonic()
return existing
await self._evict_idle_websocket_proxy_unlocked()
binding = await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
websocket_adapter_class = self.adapter_dict['websocket']
websocket_logger = EventLogger(
name='websocket-adapter',
ap=self.ap,
execution_context=execution_context,
owner='websocket-proxy-bot',
)
websocket_adapter_inst = websocket_adapter_class({}, websocket_logger, ap=self.ap)
proxy_context = dataclasses.replace(
execution_context,
instance_uuid=binding.instance_uuid,
bot_uuid='websocket-proxy-bot',
)
runtime_bot = RuntimeBot(
ap=self.ap,
bot_entity=persistence_bot.Bot(
uuid='websocket-proxy-bot',
workspace_uuid=binding.workspace_uuid,
name='WebSocket',
description='',
adapter='websocket',
adapter_config={},
enable=True,
),
adapter=websocket_adapter_inst,
logger=websocket_logger,
execution_context=proxy_context,
)
await runtime_bot.initialize()
self.websocket_proxy_bots[binding.workspace_uuid] = runtime_bot
self._proxy_last_accessed[binding.workspace_uuid] = time.monotonic()
return runtime_bot
def get_running_adapters(
self,
context: ExecutionContext | RequestContext,
) -> list[abstract_platform_adapter.AbstractMessagePlatformAdapter]:
execution_context = self._normalize_execution_context(context)
return [
bot.adapter
for bot in self.bots
if bot.enable
and bot.workspace_uuid == execution_context.workspace_uuid
and bot.placement_generation == execution_context.placement_generation
]
async def load_bots_from_db(self):
self.ap.logger.info('Loading bots from db...')
async with self._runtime_mutation_lock:
old_bots = [*self.websocket_proxy_bots.values(), *self.bots]
self.websocket_proxy_bots = {}
self._proxy_last_accessed = {}
self.bots = []
self._scope_generations = {}
for bot in old_bots:
if not bot.enable:
continue
try:
await bot.shutdown()
except Exception as exc:
self.ap.logger.warning(f'Failed to stop old platform runtime during reload: {exc}')
list_bindings = getattr(
self.ap.workspace_service,
'list_active_execution_bindings',
None,
)
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
cloud_runtime = getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
if cloud_runtime:
if not callable(list_bindings) or not callable(tenant_uow):
raise RuntimeError('Cloud platform loading requires explicit instance discovery and tenant UoWs')
for binding in await list_bindings():
try:
async with tenant_uow(binding.workspace_uuid):
await self._load_workspace_bots(
binding.workspace_uuid,
_binding=binding,
)
except Exception as exc:
self.ap.logger.error(
f'Failed to load Workspace bots for {binding.workspace_uuid}: {exc}\n{traceback.format_exc()}'
)
return
instance_uow = getattr(self.ap.persistence_mgr, 'instance_discovery_uow', None)
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
if callable(instance_uow) and callable(tenant_scope):
async with instance_uow(self.ap.workspace_service.instance_uuid) as discovery:
workspace_uuids = list(
(
await discovery.session.scalars(
sqlalchemy.select(persistence_workspace.WorkspaceExecutionState.workspace_uuid)
.where(
persistence_workspace.WorkspaceExecutionState.instance_uuid
== self.ap.workspace_service.instance_uuid,
persistence_workspace.WorkspaceExecutionState.state
== persistence_workspace.WorkspaceExecutionStatus.ACTIVE.value,
persistence_workspace.WorkspaceExecutionState.write_fenced.is_(False),
)
.order_by(persistence_workspace.WorkspaceExecutionState.workspace_uuid)
)
).all()
)
else:
# Compatibility for lightweight tests and pre-tenancy managers.
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
workspace_uuids = sorted({bot.workspace_uuid for bot in result.all()})
for workspace_uuid in workspace_uuids:
try:
if callable(tenant_scope):
async with tenant_scope(workspace_uuid):
await self._load_workspace_bots(workspace_uuid)
else:
await self._load_workspace_bots(workspace_uuid)
except Exception as e:
self.ap.logger.error(
f'Failed to load Workspace bots for {workspace_uuid}: {e}\n{traceback.format_exc()}'
)
async def _load_workspace_bots(
self,
workspace_uuid: str,
*,
_binding=None,
) -> None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bot.Bot)
.where(persistence_bot.Bot.workspace_uuid == workspace_uuid)
.order_by(persistence_bot.Bot.uuid)
)
binding = _binding
for bot in result.all():
try:
if binding is None:
binding = await self.ap.workspace_service.get_execution_binding(workspace_uuid)
execution_context = ExecutionContext(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
bot_uuid=bot.uuid,
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
)
await self.load_bot(
execution_context,
bot,
_binding_validated=True,
)
except platform_errors.AdapterNotFoundError as e:
self.ap.logger.warning(f'Adapter {e.adapter_name} not found, skipping bot {bot.uuid}')
except Exception as e:
self.ap.logger.error(f'Failed to load bot {bot.uuid}: {e}\n{traceback.format_exc()}')
async def load_bot(
self,
context: ExecutionContext | RequestContext,
bot_entity: persistence_bot.Bot | sqlalchemy.Row[persistence_bot.Bot] | dict,
*,
_binding_validated: bool = False,
) -> RuntimeBot:
"""加载机器人"""
if isinstance(bot_entity, sqlalchemy.Row):
bot_entity = persistence_bot.Bot(**bot_entity._mapping)
elif isinstance(bot_entity, dict):
bot_entity = persistence_bot.Bot(**bot_entity)
execution_context = self._normalize_execution_context(context, bot_uuid=bot_entity.uuid)
if bot_entity.workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceRequiredError('Bot entity Workspace does not match its runtime context')
if not _binding_validated:
await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
async with self._runtime_mutation_lock:
if not _binding_validated:
await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
await self._observe_execution_context_unlocked(execution_context)
logger = EventLogger(
name=f'platform-adapter-{bot_entity.name}',
ap=self.ap,
execution_context=execution_context,
owner=bot_entity.uuid,
)
bot_entity.adapter = canonical_adapter_name(bot_entity.adapter)
if bot_entity.adapter not in self.adapter_dict:
raise platform_errors.AdapterNotFoundError(bot_entity.adapter)
adapter_inst = self.adapter_dict[bot_entity.adapter](
bot_entity.adapter_config,
logger,
)
if hasattr(adapter_inst, 'ap'):
adapter_inst.ap = self.ap
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
if hasattr(adapter_inst, 'set_bot_uuid'):
adapter_inst.set_bot_uuid(bot_entity.uuid)
runtime_bot = RuntimeBot(
ap=self.ap,
bot_entity=bot_entity,
adapter=adapter_inst,
logger=logger,
execution_context=execution_context,
)
await runtime_bot.initialize()
bot_key = self._runtime_bot_key(runtime_bot)
existing_bot = self._bots_by_key.get(bot_key)
if existing_bot is not None and existing_bot is not runtime_bot:
try:
if existing_bot.enable:
await existing_bot.shutdown()
except BaseException:
if runtime_bot.enable:
await runtime_bot.shutdown()
raise
self._register_runtime_bot(runtime_bot)
return runtime_bot
async def get_bot_by_uuid(
self,
context: ExecutionContext | RequestContext,
bot_uuid: str,
) -> RuntimeBot | None:
execution_context = self._normalize_execution_context(context, bot_uuid=bot_uuid)
await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
await self._observe_execution_context(execution_context)
proxy_bot = self.websocket_proxy_bots.get(execution_context.workspace_uuid)
if proxy_bot and proxy_bot.bot_entity.uuid == bot_uuid:
if proxy_bot.placement_generation != execution_context.placement_generation:
return None
return proxy_bot
bot = self._bots_by_key.get(
(
execution_context.instance_uuid,
execution_context.workspace_uuid,
bot_uuid,
)
)
if bot is None or bot.placement_generation != execution_context.placement_generation:
return None
return bot
async def resolve_public_bot(self, route_key: str) -> RuntimeBot | None:
"""Resolve an opaque public bot UUID without consulting request headers."""
try:
normalized = str(uuid.UUID(route_key))
except (ValueError, AttributeError, TypeError):
return None
keys = tuple(self._bot_keys_by_uuid.get(normalized, ()))
if len(keys) != 1:
return None
key = keys[0]
bot = self._bots_by_key.get(key)
if bot is None:
return None
try:
await self.ap.workspace_service.get_execution_binding(
bot.workspace_uuid,
expected_generation=bot.placement_generation,
)
except Exception:
return None
if self._bots_by_key.get(key) is bot:
return bot
return None
async def remove_bot(
self,
context: ExecutionContext | RequestContext,
bot_uuid: str,
) -> None:
execution_context = self._normalize_execution_context(context, bot_uuid=bot_uuid)
await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
async with self._runtime_mutation_lock:
await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
await self._observe_execution_context_unlocked(execution_context)
key = (
execution_context.instance_uuid,
execution_context.workspace_uuid,
bot_uuid,
)
bot = self._bots_by_key.get(key)
if bot is not None and bot.placement_generation == execution_context.placement_generation:
if bot.enable:
await bot.shutdown()
self._pop_runtime_bot(key)
def get_available_adapters_info(self) -> list[dict]:
return [
component.to_plain_dict() for component in self.adapter_components if component.metadata.name != 'websocket'
]
def get_available_adapter_info_by_name(self, name: str) -> dict | None:
name = canonical_adapter_name(name)
for component in self.adapter_components:
if component.metadata.name == name:
return component.to_plain_dict()
return None
def get_available_adapter_manifest_by_name(self, name: str) -> engine.Component | None:
name = canonical_adapter_name(name)
for component in self.adapter_components:
if component.metadata.name == name:
return component
return None
async def run(self):
# This method will only be called when the application launching
for proxy_bot in self.websocket_proxy_bots.values():
await proxy_bot.run()
for bot in self.bots:
if bot.enable:
await bot.run()
async def shutdown(self):
async with self._runtime_mutation_lock:
runtime_bots = [*self.websocket_proxy_bots.values(), *self.bots]
self.websocket_proxy_bots = {}
self._proxy_last_accessed = {}
self.bots = []
for bot in runtime_bots:
if not bot.enable:
continue
try:
await bot.shutdown()
except Exception as exc:
self.ap.logger.warning(f'Failed to stop platform runtime during shutdown: {exc}')
self.ap.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.PLATFORM)