feat: collect beta-only adapter acceptance evidence

This commit is contained in:
Hyu
2026-09-15 20:40:03 +08:00
parent 7c1445051b
commit f7cfb23480
20 changed files with 426 additions and 11 deletions
@@ -174,6 +174,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
await self._dispatch_eba_event(eba_event)
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -293,6 +293,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
await self.logger.error(f'Error in dingtalk native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -390,6 +390,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
await self.logger.error(f'Error in discord {kind}: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -202,6 +202,7 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
await self.logger.error(f'Error handling KOOK event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -765,6 +765,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
await self.logger.error(f'Error in lark message event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.Event):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -192,6 +192,7 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
await self.logger.error(f'Error in officialaccount native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -388,6 +388,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
await self.logger.error(f'Error in qqofficial native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -183,6 +183,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
await self.logger.error(f'Error in slack native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -482,6 +482,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
"""Dispatch once, preferring the most specific registered listener."""
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -387,7 +387,13 @@ class LegacyEventConverter(abstract_platform_adapter.AbstractEventConverter):
return event.source_platform_object
@staticmethod
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
@diagnostics.observe(
'event',
'platform.target2yiri',
source='platform',
stage='convert',
fields=lambda _: {'attributes': {'adapter_evidence': False}},
)
async def target2yiri(event: Update, bot: telegram.Bot, bot_account_id: str):
"""Convert to legacy format (FriendMessage / GroupMessage)."""
import langbot_plugin.api.entities.builtin.platform.events as legacy_events
@@ -198,6 +198,7 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
await self.logger.error(f'Error in wecom native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -312,6 +312,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
await self.logger.error(f'Error in wecombot feedback event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -214,6 +214,7 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
await self.logger.error(f'Error in wecomcs native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
+2 -1
View File
@@ -1549,7 +1549,6 @@ class RuntimeBot:
from ..telemetry.diagnostic_catalog import snapshot_bot
snapshot_bot(self)
get_supported_events = getattr(self.adapter, 'get_supported_events', None)
supported_events: list[str] = []
if callable(get_supported_events):
@@ -1621,6 +1620,8 @@ class RuntimeBot:
platform_events.EBAEvent,
tenant_scoped_listener(on_eba_event),
)
# Registration is evidence of an installed listener, not a live connection.
snapshot_bot(self, listener_registered=True)
async def run(self):
async def exception_wrapper():
@@ -0,0 +1,70 @@
"""Finite adapter acceptance evidence, without message contents or target IDs."""
from __future__ import annotations
from .diagnostic_catalog import catalog
def boundary_fields(module, kind, operation, bound, parent):
"""Only the actual adapter API / EBA conversion boundary counts as a test."""
entry = next((v for k, v in catalog().items() if k in module.split('.')), None)
if entry is None or '.platform.adapters.' not in module:
return {}
evidence = kind == 'event' and operation == 'platform.target2yiri'
resolved = operation
if kind == 'api':
evidence = True
if operation == 'call_platform_api':
action = bound.get('action')
if isinstance(action, str) and 'platform_api.' + action in entry['specific_apis']:
resolved = 'platform_api.' + action
elif action in entry['apis'] and action != 'call_platform_api':
resolved = action
else:
# Keep the generic boundary for investigation, not a false named test.
evidence = False
# A forwarding API may call another decorated method. Count the outer call.
if parent and parent.adapter_api_active:
evidence = False
if not evidence:
return {'_adapter_api_active': kind == 'api'}
return {
'_adapter_api_active': kind == 'api',
'operation': resolved,
'attributes': {'adapter_evidence': True, **message_scenario(bound)},
}
def message_scenario(bound):
"""Read typed routing/media categories only; never serialize a payload."""
from langbot_plugin.api.entities.builtin.platform.events import MessageReceivedEvent
from langbot_plugin.api.entities.builtin.platform.message import MessageChain
event = bound.get('event') or bound.get('message_source')
target = bound.get('target_type') or bound.get('chat_type')
message = bound.get('message') or bound.get('new_content')
if isinstance(event, MessageReceivedEvent):
target = event.chat_type
message = event.message_chain
result = {}
if target is not None:
target = getattr(target, 'value', target)
if target == 'private':
target = 'person'
result['chat_type'] = target if target in ('person', 'group') else 'unknown'
if isinstance(message, MessageChain):
types = set()
for item in message:
name = type(item).__name__
types.add(
{
'Plain': 'text',
'Image': 'image',
'Voice': 'audio',
'Audio': 'audio',
'Video': 'video',
'File': 'file',
}.get(name, 'other')
)
result['content_type'] = next(iter(types)) if len(types) == 1 else 'mixed' if types else 'unknown'
return result
@@ -15,7 +15,7 @@ def catalog():
result = {}
base = Path(__file__).resolve().parents[1] / 'platform'
for manifest in sorted((base / 'adapters').glob('*/manifest.yaml')):
data = yaml.safe_load(manifest.read_text())
data = yaml.safe_load(manifest.read_text(encoding='utf-8'))
spec = data.get('spec', {})
name = data['metadata']['name']
privacy.code_value('adapter', name)
@@ -26,9 +26,16 @@ def catalog():
privacy.code_value('platform_event_type', event)
for operation in apis:
privacy.code_value('operation', operation)
specific_apis = []
for api in spec.get('platform_specific_apis', []):
privacy.code_value('operation', api['action'])
result[manifest.parent.name] = {'adapter': name, 'events': events, 'apis': apis}
specific_apis.append(privacy.code_value('operation', 'platform_api.' + api['action']))
result[manifest.parent.name] = {
'adapter': name,
'events': events,
'apis': apis,
'specific_apis': specific_apis,
}
return result
@@ -38,7 +45,7 @@ def adapter_fields(adapter):
return {'adapter': entry['adapter']} if entry else {}
def _snapshot_bot(bot):
def _snapshot_bot(bot, *, listener_registered=False):
from . import diagnostics
manager = getattr(bot.ap, 'diagnostics', None)
@@ -51,13 +58,15 @@ def _snapshot_bot(bot):
entry = next(e for e in catalog().values() if e['adapter'] == fields['adapter'])
for capability_type, method, declared in (
('event', 'get_supported_events', entry['events']),
('api', 'get_supported_apis', entry['apis']),
('api', 'get_supported_apis', entry['apis'] + entry['specific_apis']),
):
try:
supported = set(getattr(bot.adapter, method)() or [])
except Exception:
supported = set()
for name in declared:
if name == 'call_platform_api':
continue
manager.emit(
'capability',
name if capability_type == 'api' else 'platform.receive',
@@ -69,15 +78,16 @@ def _snapshot_bot(bot):
attributes={
'capability_type': capability_type,
'capability_name': name,
'supported': name in supported,
'supported': name in supported
or (name in entry['specific_apis'] and 'call_platform_api' in supported),
'configured': True,
'available': True,
**({'listener_registered': listener_registered} if capability_type == 'event' else {}),
},
)
def snapshot_bot(bot):
def snapshot_bot(bot, *, listener_registered=False):
try:
_snapshot_bot(bot)
_snapshot_bot(bot, listener_registered=listener_registered)
except Exception:
pass
@@ -32,8 +32,12 @@ VOCABULARY: dict[str, set[str]] = {
'arch': {'x86_64', 'aarch64', 'arm64', 'amd64'},
'database': {'sqlite', 'postgresql'},
'edition': {'community', 'cloud', 'enterprise'},
'chat_type': {'person', 'group', 'unknown'},
'content_type': {'text', 'image', 'audio', 'video', 'file', 'mixed', 'other', 'unknown'},
}
BOOLS = frozenset('stream synthetic configured available previous_session_unclean recovered supported'.split())
BOOLS = frozenset(
'stream synthetic configured available previous_session_unclean recovered supported adapter_evidence listener_registered'.split()
)
NUMBERS = frozenset(
'attempts successes failures cancellations timeouts partial unknown generated queued acked dropped retried failed queue_size capacity result_count input_tokens output_tokens'.split()
)
+74
View File
@@ -34,6 +34,43 @@ def annotate(**fields):
span.fields.update(fields)
def adapter_event_received(owner, event):
"""Record one converted event before dispatch, including native callback paths."""
with contextlib.suppress(Exception):
manager = _manager(owner)
if manager is None:
return
from langbot_plugin.api.entities.builtin.platform.events import EBAEvent
from .adapter_diagnostics import message_scenario
if not isinstance(event, EBAEvent) or not _context_matches(manager, owner, _owner_context(owner)):
return
fields = _context_fields(owner, {'event': event})
fields['attributes'] = {
**fields.get('attributes', {}),
'adapter_evidence': True,
**message_scenario({'event': event}),
}
parent = current_span()
if (
parent
and parent.manager is manager
and not (
fields.get('workspace_uuid')
and parent.fields.get('workspace_uuid')
and fields['workspace_uuid'] != parent.fields['workspace_uuid']
)
):
fields['trace_id'] = parent.fields['trace_id']
fields['parent_span_id'] = parent.fields['span_id']
if parent.fields.get('source') in ('webui_debug', 'synthetic'):
fields['source'] = parent.fields['source']
fields['attributes']['synthetic'] = True
fields.setdefault('source', 'platform')
privacy.code_value('operation', 'platform.adapter_event')
manager.emit('event', 'platform.adapter_event', 'succeeded', stage='accepted', **fields)
def _owner_app(owner):
"""An explicit owner (even absent/disabled) is an inheritance barrier."""
if owner is None:
@@ -148,6 +185,7 @@ class Span:
self.kind = kind
self.operation = operation
self.fields = dict(fields)
self.adapter_api_active = bool(self.fields.pop('_adapter_api_active', False))
if (
parent
and parent.fields.get('workspace_uuid')
@@ -167,6 +205,7 @@ class Span:
)
self.fields['span_id'] = str(uuid4())
if parent and parent.manager is manager:
self.adapter_api_active = self.adapter_api_active or parent.adapter_api_active
self.fields['parent_span_id'] = parent.fields['span_id']
for key in ('workspace_uuid', 'adapter', 'processor_type', 'platform_event_type', 'run_id'):
if not self.fields.get(key) and parent.fields.get(key):
@@ -213,8 +252,25 @@ def result_outcome(value):
if span is not None and span.fields.get('stage') == 'convert':
if isinstance(value, EBAEvent):
annotate(platform_event_type=value.type)
if span.fields.get('attributes', {}).get('adapter_evidence'):
from .adapter_diagnostics import message_scenario
annotate(attributes={**span.fields['attributes'], **message_scenario({'event': value})})
# Successful conversion is counted once at dispatch, which also
# covers adapters constructing EBA events in native callbacks.
span.fields['attributes']['adapter_evidence'] = False
elif value is None:
set_outcome('skipped', reason_code='not_matched')
if not isinstance(value, EBAEvent) and span.fields.get('attributes', {}).get('adapter_evidence'):
span.fields['attributes']['adapter_evidence'] = False
if span is not None and span.kind == 'api' and span.fields.get('attributes', {}).get('adapter_evidence'):
# Common adapter response contracts expose status without inspecting content.
if isinstance(value, dict) and (
value.get('ok') is False
or value.get('status') == 'failed'
or (type(value.get('retcode')) is int and value['retcode'] != 0)
):
set_outcome('failed', reason_code='response_error')
if isinstance(value, ActionResponse):
if value.code != 0:
set_outcome('failed', reason_code='response_error')
@@ -261,6 +317,24 @@ def observe(kind, operation, *, source='internal', stage='execute', ap=None, fie
extra = fields(bound)
extra['attributes'] = {**metadata.get('attributes', {}), **extra.get('attributes', {})}
metadata.update(extra)
from .adapter_diagnostics import boundary_fields
if parent and (
parent.manager is not manager
or (
metadata.get('workspace_uuid')
and parent.fields.get('workspace_uuid')
and metadata['workspace_uuid'] != parent.fields['workspace_uuid']
)
):
parent = None
evidence = (
boundary_fields(fn.__module__, kind, operation, bound, parent)
if metadata.get('attributes', {}).get('adapter_evidence') is not False
else {}
)
metadata['attributes'] = {**metadata.get('attributes', {}), **evidence.pop('attributes', {})}
metadata.update(evidence)
return Span(manager, kind, operation, metadata)
except Exception:
return None