feat(agent-platform): show bot route runtime status

This commit is contained in:
huanghuoguoguo
2026-07-08 16:07:03 +08:00
parent 4e9464dd19
commit 3c6e87ab49
13 changed files with 737 additions and 13 deletions
@@ -38,6 +38,10 @@ class BotsRouterGroup(group.RouterGroup):
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
return self.success(data={'logs': logs, 'total_count': total_count})
@self.route('/<bot_uuid>/event-routes/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(bot_uuid: str) -> str:
return self.success(data=await self.ap.bot_service.list_event_route_statuses(bot_uuid))
async def _dry_run_event_route(bot_uuid: str) -> str:
json_data = await quart.request.json
if not isinstance(json_data, dict):
+101
View File
@@ -20,6 +20,7 @@ class BotService:
FAILURE_PROCESSOR_NOT_FOUND = 'processor_not_found'
FAILURE_PROCESSOR_INCOMPATIBLE = 'processor_incompatible'
FAILURE_INVALID_EVENT = 'invalid_event'
ROUTE_TRACE_KIND = 'event_route_trace'
BOT_FIELDS = {
'uuid',
@@ -103,6 +104,41 @@ class BotService:
def _format_diagnostic_steps(cls, diagnostic_details: list[dict[str, typing.Any]] | None) -> list[str]:
return [cls._format_diagnostic_step(step) for step in diagnostic_details or []]
@classmethod
def _event_route_status_from_log(cls, log: typing.Any) -> dict[str, typing.Any] | None:
if hasattr(log, 'to_json'):
log_data = log.to_json()
elif isinstance(log, dict):
log_data = log
else:
log_data = {
'seq_id': getattr(log, 'seq_id', None),
'timestamp': getattr(log, 'timestamp', None),
'level': getattr(getattr(log, 'level', None), 'value', getattr(log, 'level', None)),
'text': getattr(log, 'text', None),
'metadata': getattr(log, 'metadata', None),
}
metadata = log_data.get('metadata')
if not isinstance(metadata, dict) or metadata.get('kind') != cls.ROUTE_TRACE_KIND:
return None
return {
'binding_id': metadata.get('binding_id'),
'event_pattern': metadata.get('event_pattern'),
'event_type': metadata.get('event_type'),
'target_type': metadata.get('target_type'),
'target_uuid': metadata.get('target_uuid') or '',
'last_status': metadata.get('status'),
'failure_code': metadata.get('failure_code'),
'reason': metadata.get('reason') or log_data.get('text') or '',
'run_id': metadata.get('run_id'),
'timestamp': log_data.get('timestamp'),
'seq_id': log_data.get('seq_id'),
'level': log_data.get('level'),
'message': log_data.get('text') or '',
}
@staticmethod
def _target_kind(target_type: typing.Any, target_kind: str | None = None) -> str | None:
if target_kind:
@@ -644,6 +680,71 @@ class BotService:
return [log.to_json() for log in logs], total_count
async def list_event_route_statuses(self, bot_uuid: str) -> dict[str, typing.Any]:
"""Return recent runtime status for Bot event routes from in-memory Bot logs."""
from ....platform.botmgr import RuntimeBot
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
if runtime_bot is None:
raise Exception('Bot not found')
latest_by_binding: dict[str, dict[str, typing.Any]] = {}
unmatched_events: list[dict[str, typing.Any]] = []
for log in getattr(runtime_bot.logger, 'logs', []):
status = self._event_route_status_from_log(log)
if status is None:
continue
binding_id = status.get('binding_id')
if binding_id:
latest_by_binding[str(binding_id)] = status
else:
unmatched_events.append(status)
raw_bindings = getattr(getattr(runtime_bot, 'bot_entity', None), 'event_bindings', [])
bindings = RuntimeBot._get_event_bindings_from_value(raw_bindings)
routes: list[dict[str, typing.Any]] = []
current_binding_ids: set[str] = set()
for index, binding in enumerate(bindings):
binding_id = binding.get('id')
if binding_id:
current_binding_ids.add(str(binding_id))
route_status = {
'binding_id': binding_id,
'event_pattern': binding.get('event_pattern'),
'event_type': None,
'target_type': binding.get('target_type'),
'target_uuid': binding.get('target_uuid') or '',
'last_status': None,
'failure_code': None,
'reason': None,
'run_id': None,
'timestamp': None,
'seq_id': None,
'level': None,
'message': '',
'order': binding.get('order', index),
'enabled': binding.get('enabled', True),
'current': True,
}
if binding_id and str(binding_id) in latest_by_binding:
route_status.update(latest_by_binding[str(binding_id)])
route_status['order'] = binding.get('order', index)
route_status['enabled'] = binding.get('enabled', True)
route_status['current'] = True
routes.append(route_status)
stale_routes = [
{**status, 'current': False}
for binding_id, status in latest_by_binding.items()
if binding_id not in current_binding_ids
]
return {
'routes': routes,
'unmatched_events': unmatched_events[-10:],
'stale_routes': stale_routes,
}
async def send_message(self, bot_uuid: str, target_type: str, target_id: str, message_chain_data: dict) -> None:
"""Send message to a specific target via bot
+162 -11
View File
@@ -305,6 +305,37 @@ class RuntimeBot:
"""Return the selected event binding plus per-binding diagnostic steps."""
return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
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,
) -> None:
"""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)
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]] = []
@@ -773,10 +804,24 @@ class RuntimeBot:
event_binding = self._resolve_eba_event_binding(event, event_type)
if event_binding is None:
await self.logger.info(f'Platform event {event_type} ignored: no event route matched')
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',
)
return
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'EBA event {event_type} matched route {event_binding.get("id") or ""}'.strip(),
)
if target_type == 'discard':
if isinstance(event, platform_events.MessageReceivedEvent):
await self._dispatch_eba_message_to_pipeline(
@@ -785,12 +830,35 @@ class RuntimeBot:
pipeline_uuid=self.PIPELINE_DISCARD,
routed_by_event_binding=True,
)
await self._record_event_route_trace(
event_type=event_type,
status='discarded',
binding=event_binding,
target_type=target_type,
text=f'EBA event {event_type} discarded by event binding',
)
return
await self.logger.info(f'EBA event {event_type} discarded by event binding')
await self._record_event_route_trace(
event_type=event_type,
status='discarded',
binding=event_binding,
target_type=target_type,
text=f'EBA event {event_type} discarded by event binding',
)
return
if target_type == 'pipeline':
if not self._is_message_event_type(event_type):
await self.logger.warning(f'EBA event {event_type} ignored Pipeline target for non-message event')
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'EBA event {event_type} ignored Pipeline target for non-message event',
)
return
await self._dispatch_eba_message_to_pipeline(
event,
@@ -798,26 +866,82 @@ class RuntimeBot:
pipeline_uuid=event_binding.get('target_uuid'),
routed_by_event_binding=True,
)
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'EBA event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(),
)
return
if target_type != 'agent':
await self.logger.warning(f'EBA event {event_type} ignored unsupported target type {target_type}')
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'EBA event {event_type} ignored unsupported target type {target_type}',
)
return
target_uuid = event_binding.get('target_uuid')
agent = await self.ap.agent_service.get_agent(target_uuid)
if not agent or agent.get('kind') != 'agent':
await self.logger.warning(f'EBA event {event_type} target agent not found: {target_uuid}')
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'EBA event {event_type} target agent not found: {target_uuid}',
)
return
if not agent.get('enabled', True):
await self.logger.info(f'EBA event {event_type} target agent disabled: {target_uuid}')
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_disabled',
reason='Agent target is disabled',
text=f'EBA event {event_type} target agent disabled: {target_uuid}',
)
return
if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type):
await self.logger.info(f'EBA event {event_type} target agent does not support this event: {target_uuid}')
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'EBA event {event_type} target agent does not support this event: {target_uuid}',
)
return
binding = self._agent_product_to_binding(agent, event_binding, event_type, self.bot_entity.uuid)
if binding is None:
await self.logger.warning(f'EBA event {event_type} target agent has no runner: {target_uuid}')
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'EBA event {event_type} target agent has no runner: {target_uuid}',
)
return
envelope = self._eba_event_to_agent_envelope(event, adapter)
@@ -826,15 +950,42 @@ class RuntimeBot:
async for output in self.ap.agent_run_orchestrator.run(envelope, binding):
outputs.append(output)
except Exception:
await self.logger.error(f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}')
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
try:
await self._deliver_agent_outputs(envelope, outputs)
except Exception:
await self.logger.error(
f'Failed to deliver Agent output for EBA event {event_type}: {traceback.format_exc()}'
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='delivery_failed',
reason='Agent output delivery failed',
text=f'Failed to deliver Agent output 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'EBA event {event_type} delivered to Agent {target_uuid}',
)
async def _record_discarded_message(
self,
+14
View File
@@ -41,6 +41,9 @@ class EventLog(pydantic.BaseModel):
message_session_id: typing.Optional[str] = None
"""消息会话ID,仅收发消息事件有值"""
metadata: typing.Optional[dict[str, typing.Any]] = None
"""Structured machine-readable metadata for product surfaces."""
def to_json(self) -> dict:
return {
'seq_id': self.seq_id,
@@ -49,6 +52,7 @@ class EventLog(pydantic.BaseModel):
'text': self.text,
'images': self.images,
'message_session_id': self.message_session_id,
'metadata': self.metadata,
}
@@ -131,6 +135,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
metadata: typing.Optional[dict[str, typing.Any]] = None,
):
try:
image_keys = []
@@ -161,6 +166,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
text=text,
images=image_keys,
message_session_id=message_session_id,
metadata=metadata,
)
)
self.seq_id_inc += 1
@@ -179,6 +185,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
metadata: typing.Optional[dict[str, typing.Any]] = None,
):
await self._add_log(
level=EventLogLevel.INFO,
@@ -186,6 +193,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
images=images,
message_session_id=message_session_id,
no_throw=no_throw,
metadata=metadata,
)
async def debug(
@@ -194,6 +202,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
metadata: typing.Optional[dict[str, typing.Any]] = None,
):
await self._add_log(
level=EventLogLevel.DEBUG,
@@ -201,6 +210,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
images=images,
message_session_id=message_session_id,
no_throw=no_throw,
metadata=metadata,
)
async def warning(
@@ -209,6 +219,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
metadata: typing.Optional[dict[str, typing.Any]] = None,
):
await self._add_log(
level=EventLogLevel.WARNING,
@@ -216,6 +227,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
images=images,
message_session_id=message_session_id,
no_throw=no_throw,
metadata=metadata,
)
async def error(
@@ -224,6 +236,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
metadata: typing.Optional[dict[str, typing.Any]] = None,
):
await self._add_log(
level=EventLogLevel.ERROR,
@@ -231,4 +244,5 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
images=images,
message_session_id=message_session_id,
no_throw=no_throw,
metadata=metadata,
)