mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(telemetry): add isolated beta quality diagnostics and release identity
This commit is contained in:
@@ -1,4 +1,10 @@
|
|||||||
.github
|
.github
|
||||||
|
.git
|
||||||
|
**/.git
|
||||||
|
__pycache__
|
||||||
|
**/__pycache__
|
||||||
|
*.pyc
|
||||||
|
**/*.pyc
|
||||||
.venv
|
.venv
|
||||||
.vscode
|
.vscode
|
||||||
.data
|
.data
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
build-args: |
|
||||||
|
LANGBOT_BUILD_REVISION=${{ github.sha }}
|
||||||
tags: |
|
tags: |
|
||||||
rockchin/langbot:${{ steps.image.outputs.branch_tag }}
|
rockchin/langbot:${{ steps.image.outputs.branch_tag }}
|
||||||
rockchin/langbot:${{ steps.image.outputs.sha_tag }}
|
rockchin/langbot:${{ steps.image.outputs.sha_tag }}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
run: docker buildx create --name mybuilder --use
|
run: docker buildx create --name mybuilder --use
|
||||||
- name: Build for Release # only relase, exlude pre-release
|
- name: Build for Release # only relase, exlude pre-release
|
||||||
if: ${{ github.event.release.prerelease == false }}
|
if: ${{ github.event.release.prerelease == false }}
|
||||||
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
|
run: docker buildx build --build-arg LANGBOT_BUILD_REVISION=${{ github.sha }} --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
|
||||||
- name: Build for Pre-release # no update for latest tag
|
- name: Build for Pre-release # no update for latest tag
|
||||||
if: ${{ github.event.release.prerelease == true }}
|
if: ${{ github.event.release.prerelease == true }}
|
||||||
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push
|
run: docker buildx build --build-arg LANGBOT_BUILD_REVISION=${{ github.sha }} --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push
|
||||||
@@ -31,6 +31,9 @@ jobs:
|
|||||||
echo ::set-output name=version::${GITHUB_REF}
|
echo ::set-output name=version::${GITHUB_REF}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Stamp build revision
|
||||||
|
run: python3 scripts/stamp_build_revision.py
|
||||||
|
|
||||||
- name: Make Temp Directory
|
- name: Make Temp Directory
|
||||||
run: |
|
run: |
|
||||||
mkdir -p /tmp/langbot_build_web
|
mkdir -p /tmp/langbot_build_web
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: |
|
run: |
|
||||||
|
python3 scripts/stamp_build_revision.py
|
||||||
uv build
|
uv build
|
||||||
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
ARG LANGBOT_BUILD_REVISION
|
||||||
|
RUN if [ -n "$LANGBOT_BUILD_REVISION" ]; then python3 scripts/stamp_build_revision.py --revision "$LANGBOT_BUILD_REVISION"; fi
|
||||||
|
|
||||||
COPY --from=node /app/web/dist ./web/dist
|
COPY --from=node /app/web/dist ./web/dist
|
||||||
|
|
||||||
# nsjail binary built in the dedicated stage above. Self-contained sandbox
|
# nsjail binary built in the dedicated stage above. Self-contained sandbox
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.11.0-beta.1"
|
version = "4.11.0-beta.2"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Stamp the exact source revision into distributable artifacts (build time only)."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def stamp(root: Path, revision: str | None = None) -> str:
|
||||||
|
if revision is None:
|
||||||
|
revision = subprocess.check_output(['git', 'rev-parse', '--verify', 'HEAD'], cwd=root, text=True).strip()
|
||||||
|
if not re.fullmatch(r'[0-9a-f]{40}', revision):
|
||||||
|
raise ValueError('Build revision must be a full lowercase Git SHA')
|
||||||
|
target = root / 'src/langbot/_build_info.py'
|
||||||
|
target.write_text(f'"""Exact source revision stamped at artifact build time."""\n\nCORE_REVISION = {revision!r}\n')
|
||||||
|
return revision
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--revision')
|
||||||
|
args = parser.parse_args()
|
||||||
|
print(stamp(Path(__file__).resolve().parents[1], args.revision))
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Build identity; release workflows replace the revision before packaging."""
|
||||||
|
|
||||||
|
CORE_REVISION = ''
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
@@ -91,6 +93,7 @@ class InteractionManager:
|
|||||||
self.ap = ap
|
self.ap = ap
|
||||||
self.store = store or InteractionStore(ap.persistence_mgr.get_db_engine())
|
self.store = store or InteractionStore(ap.persistence_mgr.get_db_engine())
|
||||||
|
|
||||||
|
@diagnostics.observe('interaction', 'interaction.request', source='agent', stage='execute')
|
||||||
async def handle_result(
|
async def handle_result(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -198,14 +201,17 @@ class InteractionManager:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await self.store.mark_delivery_failed(run_id, request.interaction_id, str(exc))
|
await self.store.mark_delivery_failed(run_id, request.interaction_id, str(exc))
|
||||||
raise
|
raise
|
||||||
|
diagnostics.set_outcome('waiting', reason_code='waiting')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@diagnostics.observe('interaction', 'interaction.acknowledge', source='platform', stage='ack')
|
||||||
async def acknowledge_submission(self, record: dict[str, typing.Any], adapter: typing.Any) -> None:
|
async def acknowledge_submission(self, record: dict[str, typing.Any], adapter: typing.Any) -> None:
|
||||||
"""Best-effort transition of submitted controls into a read-only state."""
|
"""Best-effort transition of submitted controls into a read-only state."""
|
||||||
delivery_result = record.get('delivery_result')
|
delivery_result = record.get('delivery_result')
|
||||||
if not isinstance(delivery_result, dict) or not self._supports_platform_api(
|
if not isinstance(delivery_result, dict) or not self._supports_platform_api(
|
||||||
adapter, INTERACTION_ACKNOWLEDGE_API
|
adapter, INTERACTION_ACKNOWLEDGE_API
|
||||||
):
|
):
|
||||||
|
diagnostics.set_outcome('skipped')
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
delivery_result = await adapter.call_platform_api(
|
delivery_result = await adapter.call_platform_api(
|
||||||
@@ -223,6 +229,8 @@ class InteractionManager:
|
|||||||
delivery_result,
|
delivery_result,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
diagnostics.set_outcome('failed', reason_code='response_error')
|
||||||
|
diagnostics.annotate(error=exc)
|
||||||
self._warning(f'Failed to acknowledge interaction submission: {exc}')
|
self._warning(f'Failed to acknowledge interaction submission: {exc}')
|
||||||
|
|
||||||
async def _find_update_target(
|
async def _find_update_target(
|
||||||
@@ -273,6 +281,7 @@ class InteractionManager:
|
|||||||
f'interaction field {field.id} has duplicate option values',
|
f'interaction field {field.id} has duplicate option values',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('interaction', 'interaction.consume', source='platform', stage='dispatch')
|
||||||
async def consume_callback(
|
async def consume_callback(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
import contextlib
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||||
@@ -10,6 +11,7 @@ from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
|||||||
|
|
||||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||||
|
|
||||||
|
from ...telemetry import diagnostics as diagnostics
|
||||||
from .reply_stream import ReplyStreamSession
|
from .reply_stream import ReplyStreamSession
|
||||||
from ...core import app
|
from ...core import app
|
||||||
from ...api.http.context import ExecutionContext
|
from ...api.http.context import ExecutionContext
|
||||||
@@ -79,6 +81,7 @@ class AgentRunOrchestrator:
|
|||||||
self.journal = AgentRunJournal(ap)
|
self.journal = AgentRunJournal(ap)
|
||||||
self._session_registry = get_session_registry()
|
self._session_registry = get_session_registry()
|
||||||
|
|
||||||
|
@diagnostics.observe('run', 'runner.run', source='agent', stage='prepare')
|
||||||
async def run(
|
async def run(
|
||||||
self,
|
self,
|
||||||
event: AgentEventEnvelope,
|
event: AgentEventEnvelope,
|
||||||
@@ -105,6 +108,7 @@ class AgentRunOrchestrator:
|
|||||||
bound_plugins,
|
bound_plugins,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
diagnostics.runner_metadata(self.ap, descriptor, binding.processor_type)
|
||||||
usage = 'event' if binding.processor_type == 'event_processor' else 'agent'
|
usage = 'event' if binding.processor_type == 'event_processor' else 'agent'
|
||||||
if usage not in descriptor.usages:
|
if usage not in descriptor.usages:
|
||||||
raise ValueError(f'The selected Runner does not support {usage} usage')
|
raise ValueError(f'The selected Runner does not support {usage} usage')
|
||||||
@@ -158,6 +162,7 @@ class AgentRunOrchestrator:
|
|||||||
|
|
||||||
state_context = build_state_context(event, binding, descriptor)
|
state_context = build_state_context(event, binding, descriptor)
|
||||||
run_id = context['run_id']
|
run_id = context['run_id']
|
||||||
|
diagnostics.annotate(run_id=run_id, stage='execute')
|
||||||
context['context']['available_apis']['reply_stream'] = hasattr(PluginToRuntimeAction, 'REPLY_STREAM') and any(
|
context['context']['available_apis']['reply_stream'] = hasattr(PluginToRuntimeAction, 'REPLY_STREAM') and any(
|
||||||
tool.get('tool_name') == 'event_reply' and tool.get('tool_type') == 'platform'
|
tool.get('tool_name') == 'event_reply' and tool.get('tool_type') == 'platform'
|
||||||
for tool in resources.get('tools', [])
|
for tool in resources.get('tools', [])
|
||||||
@@ -168,6 +173,7 @@ class AgentRunOrchestrator:
|
|||||||
source=(adapter_context or {}).get('_platform_event')
|
source=(adapter_context or {}).get('_platform_event')
|
||||||
or getattr((adapter_context or {}).get('_query'), 'message_event', None),
|
or getattr((adapter_context or {}).get('_query'), 'message_event', None),
|
||||||
)
|
)
|
||||||
|
reply_streams.diagnostics = getattr(self.ap, 'diagnostics', None)
|
||||||
available_apis = context.get('context', {}).get('available_apis')
|
available_apis = context.get('context', {}).get('available_apis')
|
||||||
run_authorization = {
|
run_authorization = {
|
||||||
'runner_id': descriptor.id,
|
'runner_id': descriptor.id,
|
||||||
@@ -236,14 +242,17 @@ class AgentRunOrchestrator:
|
|||||||
event_log_id=event_log_id,
|
event_log_id=event_log_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async for result_dict in self.invoker.invoke(descriptor, context):
|
async with contextlib.aclosing(self.invoker.invoke(descriptor, context)) as results:
|
||||||
|
async for result_dict in results:
|
||||||
result_dict = dict(result_dict)
|
result_dict = dict(result_dict)
|
||||||
sequence = result_dict.get('sequence')
|
sequence = result_dict.get('sequence')
|
||||||
if sequence is not None:
|
if sequence is not None:
|
||||||
try:
|
try:
|
||||||
sequence_int = int(sequence)
|
sequence_int = int(sequence)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
self.ap.logger.warning(f'Runner {descriptor.id} returned invalid result sequence: {sequence}')
|
self.ap.logger.warning(
|
||||||
|
f'Runner {descriptor.id} returned invalid result sequence: {sequence}'
|
||||||
|
)
|
||||||
sequence_int = last_sequence + 1
|
sequence_int = last_sequence + 1
|
||||||
result_dict['sequence'] = sequence_int
|
result_dict['sequence'] = sequence_int
|
||||||
else:
|
else:
|
||||||
@@ -355,6 +364,12 @@ class AgentRunOrchestrator:
|
|||||||
terminal_status = 'cancelled'
|
terminal_status = 'cancelled'
|
||||||
terminal_reason = run_snapshot.get('status_reason') or 'cancel_requested'
|
terminal_reason = run_snapshot.get('status_reason') or 'cancel_requested'
|
||||||
break
|
break
|
||||||
|
diagnostics.set_outcome(
|
||||||
|
{'completed': 'succeeded', 'failed': 'failed', 'cancelled': 'cancelled'}.get(
|
||||||
|
terminal_status, 'succeeded'
|
||||||
|
),
|
||||||
|
reason_code='runner_failed' if terminal_status == 'failed' else '',
|
||||||
|
)
|
||||||
await self.journal.finalize_run(
|
await self.journal.finalize_run(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
status=terminal_status or 'completed',
|
status=terminal_status or 'completed',
|
||||||
@@ -362,6 +377,7 @@ class AgentRunOrchestrator:
|
|||||||
usage=terminal_usage,
|
usage=terminal_usage,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
diagnostics.set_outcome('timeout' if self._is_deadline_exhausted(context) else 'failed')
|
||||||
failed_usage = terminal_usage
|
failed_usage = terminal_usage
|
||||||
await self.journal.finalize_run(
|
await self.journal.finalize_run(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
@@ -387,6 +403,7 @@ class AgentRunOrchestrator:
|
|||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('lifecycle', 'runner.query_prepare', source='pipeline', stage='prepare')
|
||||||
async def run_from_query(
|
async def run_from_query(
|
||||||
self,
|
self,
|
||||||
query: pipeline_query.Query,
|
query: pipeline_query.Query,
|
||||||
@@ -403,12 +420,15 @@ class AgentRunOrchestrator:
|
|||||||
# Materialize inbound attachments into sandbox before running
|
# Materialize inbound attachments into sandbox before running
|
||||||
await self._materialize_inbound_attachments(query, plan.event)
|
await self._materialize_inbound_attachments(query, plan.event)
|
||||||
|
|
||||||
async for result in self.run(
|
async with contextlib.aclosing(
|
||||||
|
self.run(
|
||||||
plan.event,
|
plan.event,
|
||||||
plan.binding,
|
plan.binding,
|
||||||
bound_plugins=plan.bound_plugins,
|
bound_plugins=plan.bound_plugins,
|
||||||
adapter_context=adapter_context,
|
adapter_context=adapter_context,
|
||||||
):
|
)
|
||||||
|
) as results:
|
||||||
|
async for result in results:
|
||||||
yield result
|
yield result
|
||||||
|
|
||||||
async def _materialize_inbound_attachments(
|
async def _materialize_inbound_attachments(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...telemetry import diagnostics
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import typing
|
import typing
|
||||||
@@ -667,6 +669,7 @@ def resolve_platform_api_call(session, bot_uuid, action, params, context_tool=No
|
|||||||
raise ValueError(f'Platform API {action} or its target is not authorized for this run')
|
raise ValueError(f'Platform API {action} or its target is not authorized for this run')
|
||||||
|
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'host.platform_tool', source='agent')
|
||||||
async def execute_platform_tool(
|
async def execute_platform_tool(
|
||||||
ap: typing.Any,
|
ap: typing.Any,
|
||||||
execution_context: typing.Any,
|
execution_context: typing.Any,
|
||||||
@@ -686,6 +689,7 @@ async def execute_platform_tool(
|
|||||||
normalized = _event_params(definition, context, normalized)
|
normalized = _event_params(definition, context, normalized)
|
||||||
# This flag is frozen by the Host from the synthetic debug envelope, not tool arguments.
|
# This flag is frozen by the Host from the synthetic debug envelope, not tool arguments.
|
||||||
if delivery.get('surface') == 'webui' and (delivery.get('platform_capabilities') or {}).get('debug_mock') is True:
|
if delivery.get('surface') == 'webui' and (delivery.get('platform_capabilities') or {}).get('debug_mock') is True:
|
||||||
|
diagnostics.annotate(source='webui_debug', attributes={'synthetic': True})
|
||||||
result = _execute_mock_platform_tool(definition, context, normalized)
|
result = _execute_mock_platform_tool(definition, context, normalized)
|
||||||
if message_chain is not None:
|
if message_chain is not None:
|
||||||
result['parameters']['message'] = message_chain.model_dump(mode='json')
|
result['parameters']['message'] = message_chain.model_dump(mode='json')
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
@@ -119,7 +121,7 @@ class RunnerRegistry:
|
|||||||
typed_manifest = RunnerManifest.model_validate(manifest)
|
typed_manifest = RunnerManifest.model_validate(manifest)
|
||||||
config_schema = [item.model_dump(mode='json') for item in typed_manifest.config_schema]
|
config_schema = [item.model_dump(mode='json') for item in typed_manifest.config_schema]
|
||||||
|
|
||||||
return RunnerDescriptor(
|
descriptor = RunnerDescriptor(
|
||||||
id=runner_id,
|
id=runner_id,
|
||||||
component_kind=typed_manifest.component_kind,
|
component_kind=typed_manifest.component_kind,
|
||||||
usages=typed_manifest.usages,
|
usages=typed_manifest.usages,
|
||||||
@@ -136,6 +138,10 @@ class RunnerRegistry:
|
|||||||
permissions=typed_manifest.permissions,
|
permissions=typed_manifest.permissions,
|
||||||
raw_manifest=manifest,
|
raw_manifest=manifest,
|
||||||
)
|
)
|
||||||
|
manager = getattr(self.ap, 'diagnostics', None)
|
||||||
|
if isinstance(manager, diagnostics.DiagnosticsManager) and manager.enabled:
|
||||||
|
diagnostics.declare_runner(descriptor)
|
||||||
|
return descriptor
|
||||||
|
|
||||||
async def refresh(self, context: TenantContext) -> None:
|
async def refresh(self, context: TenantContext) -> None:
|
||||||
"""Refresh runner cache.
|
"""Refresh runner cache.
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from ...telemetry import diagnostics
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
from langbot_plugin.api.entities.builtin.platform import events, message
|
from langbot_plugin.api.entities.builtin.platform import events, message
|
||||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||||
@@ -54,6 +56,9 @@ class ReplyStreamSession:
|
|||||||
self._closed = False
|
self._closed = False
|
||||||
self._active: set[asyncio.Task] = set()
|
self._active: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
@diagnostics.observe(
|
||||||
|
'delivery', 'reply_stream.apply', source='agent', fields=lambda b: {'attributes': {'stream': True}}
|
||||||
|
)
|
||||||
async def apply(self, request: ReplyStreamRequest) -> dict:
|
async def apply(self, request: ReplyStreamRequest) -> dict:
|
||||||
task = asyncio.create_task(self._apply(request))
|
task = asyncio.create_task(self._apply(request))
|
||||||
self._active.add(task)
|
self._active.add(task)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...telemetry import diagnostics
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import copy
|
import copy
|
||||||
import typing
|
import typing
|
||||||
@@ -170,6 +172,7 @@ class AgentRunSessionRegistry:
|
|||||||
'query_id': query_id,
|
'query_id': query_id,
|
||||||
'execution_query': execution_query,
|
'execution_query': execution_query,
|
||||||
'reply_streams': reply_streams,
|
'reply_streams': reply_streams,
|
||||||
|
'_diagnostic_context': diagnostics.capture_context(),
|
||||||
'plugin_identity': plugin_identity,
|
'plugin_identity': plugin_identity,
|
||||||
'authorization': authorization,
|
'authorization': authorization,
|
||||||
'status': {
|
'status': {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from ..authz import (
|
|||||||
)
|
)
|
||||||
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||||
from ....cloud.support_admin import SupportAdminSessionError
|
from ....cloud.support_admin import SupportAdminSessionError
|
||||||
|
from ... import management_diagnostics as diagnostics
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from ....core.app import Application
|
from ....core.app import Application
|
||||||
@@ -223,6 +224,7 @@ class RouterGroup(abc.ABC):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if request_context is not None:
|
if request_context is not None:
|
||||||
|
diagnostics.workspace(request_context)
|
||||||
with bounded_executor.blocking_work_scope(request_context.workspace_uuid):
|
with bounded_executor.blocking_work_scope(request_context.workspace_uuid):
|
||||||
persistence_mgr = getattr(
|
persistence_mgr = getattr(
|
||||||
self.ap,
|
self.ap,
|
||||||
@@ -274,7 +276,14 @@ class RouterGroup(abc.ABC):
|
|||||||
)
|
)
|
||||||
return self.internal_error_response(request_id)
|
return self.internal_error_response(request_id)
|
||||||
|
|
||||||
new_f = handler_error
|
# Observe outside authentication, using the registered Core handler
|
||||||
|
# identity rather than the URL (which can contain user identifiers).
|
||||||
|
new_f = diagnostics.observe(
|
||||||
|
diagnostics.operation_id('http', f, rule=rule, methods=options.get('methods')),
|
||||||
|
source='http',
|
||||||
|
ap=self.ap,
|
||||||
|
http=True,
|
||||||
|
)(handler_error)
|
||||||
# Quart/Flask requires a unique endpoint name even when the same URL
|
# Quart/Flask requires a unique endpoint name even when the same URL
|
||||||
# intentionally has separate handlers for different HTTP methods.
|
# intentionally has separate handlers for different HTTP methods.
|
||||||
# Include the method set so CRUD routes can declare distinct
|
# Include the method set so CRUD routes can declare distinct
|
||||||
@@ -561,6 +570,7 @@ class RouterGroup(abc.ABC):
|
|||||||
def fail(self, code: int | str, msg: str) -> quart.Response:
|
def fail(self, code: int | str, msg: str) -> quart.Response:
|
||||||
"""Return an error response"""
|
"""Return an error response"""
|
||||||
|
|
||||||
|
diagnostics.outcome('failed')
|
||||||
return quart.jsonify(
|
return quart.jsonify(
|
||||||
{
|
{
|
||||||
'code': code,
|
'code': code,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import contextlib
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import quart
|
import quart
|
||||||
|
from .... import management_diagnostics as diagnostics
|
||||||
|
|
||||||
from .....agent.runner.errors import (
|
from .....agent.runner.errors import (
|
||||||
RunnerError,
|
RunnerError,
|
||||||
@@ -29,6 +30,9 @@ def debug_stream_response(service, context, agent_uuid: str, payload: dict) -> q
|
|||||||
result = await service.debug_agent(context, agent_uuid, payload, on_result=on_result)
|
result = await service.debug_agent(context, agent_uuid, payload, on_result=on_result)
|
||||||
await queue.put({'kind': 'completed', 'data': result})
|
await queue.put({'kind': 'completed', 'data': result})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
# The stream still uses HTTP 200 when execution returns an
|
||||||
|
# error frame. Mark the inherited request, not its contents.
|
||||||
|
diagnostics.outcome('failed')
|
||||||
if isinstance(exc, RunnerExecutionError):
|
if isinstance(exc, RunnerExecutionError):
|
||||||
code, message = exc.error_code or 'runner_execution_failed', exc.message
|
code, message = exc.error_code or 'runner_execution_failed', exc.message
|
||||||
elif isinstance(exc, RunnerNotFoundError):
|
elif isinstance(exc, RunnerNotFoundError):
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import httpx
|
|||||||
import quart
|
import quart
|
||||||
|
|
||||||
from ... import group
|
from ... import group
|
||||||
|
from ..... import management_diagnostics as diagnostics
|
||||||
from ......utils import httpclient, paths
|
from ......utils import httpclient, paths
|
||||||
from ......platform.sources.websocket_manager import WebSocketScope, is_valid_session_id, ws_connection_manager
|
from ......platform.sources.websocket_manager import WebSocketScope, is_valid_session_id, ws_connection_manager
|
||||||
from .websocket_chat import create_scoped_duplex_tasks, wait_for_duplex_tasks
|
from .websocket_chat import create_scoped_duplex_tasks, wait_for_duplex_tasks
|
||||||
@@ -327,20 +328,24 @@ class EmbedRouterGroup(group.RouterGroup):
|
|||||||
# -- Embed WebSocket endpoint ----------------------------------------
|
# -- Embed WebSocket endpoint ----------------------------------------
|
||||||
|
|
||||||
@self.quart_app.websocket(self.path + '/<bot_uuid>/ws/connect')
|
@self.quart_app.websocket(self.path + '/<bot_uuid>/ws/connect')
|
||||||
|
@diagnostics.observe('websocket.embed.session', source='websocket', ap=self.ap)
|
||||||
async def embed_websocket_connect(bot_uuid: str):
|
async def embed_websocket_connect(bot_uuid: str):
|
||||||
"""WebSocket connection for embed widget, keyed by bot_uuid."""
|
"""WebSocket connection for embed widget, keyed by bot_uuid."""
|
||||||
await quart.websocket.accept()
|
await quart.websocket.accept()
|
||||||
if not _is_valid_uuid(bot_uuid):
|
if not _is_valid_uuid(bot_uuid):
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'}))
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
return
|
return
|
||||||
|
|
||||||
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
|
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
|
||||||
if runtime_bot is None:
|
if runtime_bot is None:
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
return
|
return
|
||||||
|
|
||||||
session_type = quart.websocket.args.get('session_type', 'person')
|
session_type = quart.websocket.args.get('session_type', 'person')
|
||||||
if session_type not in ['person', 'group']:
|
if session_type not in ['person', 'group']:
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
await quart.websocket.send(
|
await quart.websocket.send(
|
||||||
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
||||||
)
|
)
|
||||||
@@ -349,6 +354,7 @@ class EmbedRouterGroup(group.RouterGroup):
|
|||||||
session_id = quart.websocket.args.get('session_id', '')
|
session_id = quart.websocket.args.get('session_id', '')
|
||||||
if not is_valid_session_id(session_id):
|
if not is_valid_session_id(session_id):
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Valid session_id is required'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Valid session_id is required'}))
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -356,13 +362,16 @@ class EmbedRouterGroup(group.RouterGroup):
|
|||||||
await self._assert_execution_active(runtime_bot)
|
await self._assert_execution_active(runtime_bot)
|
||||||
except Exception:
|
except Exception:
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'}))
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
diagnostics.workspace(runtime_bot.execution_context)
|
||||||
try:
|
try:
|
||||||
proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context)
|
proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context)
|
||||||
websocket_adapter = proxy_bot.adapter
|
websocket_adapter = proxy_bot.adapter
|
||||||
if not websocket_adapter:
|
if not websocket_adapter:
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
return
|
return
|
||||||
|
|
||||||
connection = await ws_connection_manager.add_connection(
|
connection = await ws_connection_manager.add_connection(
|
||||||
@@ -420,11 +429,13 @@ class EmbedRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
await wait_for_duplex_tasks(receive_task, send_task)
|
await wait_for_duplex_tasks(receive_task, send_task)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
diagnostics.outcome('failed')
|
||||||
logger.error(f'Embed WebSocket task error: {e}')
|
logger.error(f'Embed WebSocket task error: {e}')
|
||||||
finally:
|
finally:
|
||||||
await ws_connection_manager.remove_connection(connection.connection_id)
|
await ws_connection_manager.remove_connection(connection.connection_id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
diagnostics.outcome('failed')
|
||||||
logger.error(f'Embed WebSocket connection error: {e}', exc_info=True)
|
logger.error(f'Embed WebSocket connection error: {e}', exc_info=True)
|
||||||
try:
|
try:
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
||||||
@@ -439,6 +450,8 @@ class EmbedRouterGroup(group.RouterGroup):
|
|||||||
message = await quart.websocket.receive()
|
message = await quart.websocket.receive()
|
||||||
await ws_connection_manager.update_activity(connection.connection_id)
|
await ws_connection_manager.update_activity(connection.connection_id)
|
||||||
|
|
||||||
|
with diagnostics.scope(self.ap, 'websocket.embed.message', source='websocket'):
|
||||||
|
diagnostics.workspace(getattr(owner_bot, 'execution_context', None))
|
||||||
try:
|
try:
|
||||||
data = await asyncio.to_thread(json.loads, message)
|
data = await asyncio.to_thread(json.loads, message)
|
||||||
message_type = data.get('type', 'message')
|
message_type = data.get('type', 'message')
|
||||||
@@ -451,16 +464,21 @@ class EmbedRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
current_bot = await self._resolve_connected_bot(owner_bot, pipeline_uuid)
|
current_bot = await self._resolve_connected_bot(owner_bot, pipeline_uuid)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
await connection.send_queue.put({'type': 'error', 'message': 'Bot is unavailable'})
|
await connection.send_queue.put({'type': 'error', 'message': 'Bot is unavailable'})
|
||||||
break
|
break
|
||||||
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=current_bot)
|
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=current_bot)
|
||||||
elif message_type == 'disconnect':
|
elif message_type == 'disconnect':
|
||||||
break
|
break
|
||||||
|
else:
|
||||||
|
diagnostics.outcome('skipped')
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
diagnostics.outcome('failed')
|
||||||
logger.error(f'Embed receive error: {e}', exc_info=True)
|
logger.error(f'Embed receive error: {e}', exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
connection.is_active = False
|
connection.is_active = False
|
||||||
@@ -476,11 +494,13 @@ class EmbedRouterGroup(group.RouterGroup):
|
|||||||
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||||
if message is None:
|
if message is None:
|
||||||
break
|
break
|
||||||
|
with diagnostics.scope(self.ap, 'websocket.embed.send', source='websocket'):
|
||||||
encoded = await asyncio.to_thread(json.dumps, message)
|
encoded = await asyncio.to_thread(json.dumps, message)
|
||||||
await quart.websocket.send(encoded)
|
await quart.websocket.send(encoded)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
diagnostics.outcome('failed')
|
||||||
logger.error(f'Embed send error: {e}', exc_info=True)
|
logger.error(f'Embed send error: {e}', exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
connection.is_active = False
|
connection.is_active = False
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import quart
|
|||||||
from ....authz import Permission, permissions_for_role, require_permission
|
from ....authz import Permission, permissions_for_role, require_permission
|
||||||
from ....context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
from ....context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||||
from ... import group
|
from ... import group
|
||||||
|
from ..... import management_diagnostics as diagnostics
|
||||||
from ......core.task_boundary import run_in_workspace_uow
|
from ......core.task_boundary import run_in_workspace_uow
|
||||||
from ......platform.sources.websocket_manager import WebSocketScope, ws_connection_manager
|
from ......platform.sources.websocket_manager import WebSocketScope, ws_connection_manager
|
||||||
from ......utils import bounded_executor
|
from ......utils import bounded_executor
|
||||||
@@ -210,6 +211,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@self.quart_app.websocket(self.path + '/connect')
|
@self.quart_app.websocket(self.path + '/connect')
|
||||||
|
@diagnostics.observe('websocket.dashboard.session', source='websocket', ap=self.ap)
|
||||||
async def websocket_connect(pipeline_uuid: str):
|
async def websocket_connect(pipeline_uuid: str):
|
||||||
"""Open one authenticated dashboard debug connection."""
|
"""Open one authenticated dashboard debug connection."""
|
||||||
|
|
||||||
@@ -217,11 +219,14 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
request_context, token = await self._authenticate_websocket()
|
request_context, token = await self._authenticate_websocket()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'}))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
diagnostics.workspace(request_context)
|
||||||
session_type = quart.websocket.args.get('session_type', 'person')
|
session_type = quart.websocket.args.get('session_type', 'person')
|
||||||
if session_type not in ['person', 'group']:
|
if session_type not in ['person', 'group']:
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
await quart.websocket.send(
|
await quart.websocket.send(
|
||||||
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
||||||
)
|
)
|
||||||
@@ -230,6 +235,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid)
|
websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid)
|
||||||
if websocket_adapter is None:
|
if websocket_adapter is None:
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Pipeline not found'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Pipeline not found'}))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -288,11 +294,13 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
await wait_for_duplex_tasks(receive_task, send_task)
|
await wait_for_duplex_tasks(receive_task, send_task)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
diagnostics.outcome('failed')
|
||||||
logger.error(f'WebSocket task execution error: {exc}')
|
logger.error(f'WebSocket task execution error: {exc}')
|
||||||
finally:
|
finally:
|
||||||
await ws_connection_manager.remove_connection(connection.connection_id)
|
await ws_connection_manager.remove_connection(connection.connection_id)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
diagnostics.outcome('failed')
|
||||||
logger.error('Dashboard WebSocket connection error', exc_info=True)
|
logger.error('Dashboard WebSocket connection error', exc_info=True)
|
||||||
try:
|
try:
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
||||||
@@ -410,6 +418,8 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
message = await quart.websocket.receive()
|
message = await quart.websocket.receive()
|
||||||
await ws_connection_manager.update_activity(connection.connection_id)
|
await ws_connection_manager.update_activity(connection.connection_id)
|
||||||
|
|
||||||
|
with diagnostics.scope(self.ap, 'websocket.dashboard.message', source='websocket'):
|
||||||
|
diagnostics.workspace(request_context)
|
||||||
try:
|
try:
|
||||||
data = await asyncio.to_thread(json.loads, message)
|
data = await asyncio.to_thread(json.loads, message)
|
||||||
message_type = data.get('type', 'message')
|
message_type = data.get('type', 'message')
|
||||||
@@ -421,17 +431,21 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
request_context = await self._revalidate_websocket_authorization(request_context, token)
|
request_context = await self._revalidate_websocket_authorization(request_context, token)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'})
|
await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'})
|
||||||
break
|
break
|
||||||
await websocket_adapter.handle_websocket_message(connection, data)
|
await websocket_adapter.handle_websocket_message(connection, data)
|
||||||
elif message_type == 'disconnect':
|
elif message_type == 'disconnect':
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
|
diagnostics.outcome('skipped')
|
||||||
logger.warning(f'Unknown WebSocket message type: {message_type}')
|
logger.warning(f'Unknown WebSocket message type: {message_type}')
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
diagnostics.outcome('failed')
|
||||||
logger.error('Dashboard WebSocket receive error', exc_info=True)
|
logger.error('Dashboard WebSocket receive error', exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
connection.is_active = False
|
connection.is_active = False
|
||||||
@@ -447,11 +461,13 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||||
if message is None:
|
if message is None:
|
||||||
break
|
break
|
||||||
|
with diagnostics.scope(self.ap, 'websocket.dashboard.send', source='websocket'):
|
||||||
encoded = await asyncio.to_thread(json.dumps, message)
|
encoded = await asyncio.to_thread(json.dumps, message)
|
||||||
await quart.websocket.send(encoded)
|
await quart.websocket.send(encoded)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
|
diagnostics.outcome('failed')
|
||||||
logger.error('Dashboard WebSocket send error', exc_info=True)
|
logger.error('Dashboard WebSocket send error', exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
connection.is_active = False
|
connection.is_active = False
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import fnmatch
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import typing
|
import typing
|
||||||
|
from ... import management_diagnostics as diagnostics
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||||
@@ -130,6 +131,7 @@ class AgentService:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@diagnostics.observe('http.agent.debug_agent', source='webui_debug')
|
||||||
async def debug_agent(
|
async def debug_agent(
|
||||||
self,
|
self,
|
||||||
context: RequestContext,
|
context: RequestContext,
|
||||||
@@ -144,6 +146,7 @@ class AgentService:
|
|||||||
delivers outputs to a real platform, and supports both message and
|
delivers outputs to a real platform, and supports both message and
|
||||||
non-message event envelopes.
|
non-message event envelopes.
|
||||||
"""
|
"""
|
||||||
|
diagnostics.workspace(context)
|
||||||
agent = await self.get_agent(context, agent_uuid)
|
agent = await self.get_agent(context, agent_uuid)
|
||||||
if agent is None or agent.get('kind') not in {AGENT_KIND_AGENT, AGENT_KIND_EVENT_PROCESSOR}:
|
if agent is None or agent.get('kind') not in {AGENT_KIND_AGENT, AGENT_KIND_EVENT_PROCESSOR}:
|
||||||
raise ValueError('Agent not found')
|
raise ValueError('Agent not found')
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Content-free management boundaries. Diagnostics never participate in I/O.
|
||||||
|
|
||||||
|
Only source-code identities and status categories enter these helpers. Never
|
||||||
|
pass a URL, request, frame, token, tool argument, or serialized result to them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import contextvars
|
||||||
|
import functools
|
||||||
|
import re
|
||||||
|
|
||||||
|
import quart
|
||||||
|
from quart.wrappers.response import IterableBody, ResponseBody
|
||||||
|
|
||||||
|
from ..telemetry import diagnostics as d
|
||||||
|
from ..telemetry import diagnostic_privacy as privacy
|
||||||
|
|
||||||
|
_ACTIVE_BOUNDARY = contextvars.ContextVar('management_diagnostic_boundary', default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def operation_id(source, fn, *, rule='', methods=()):
|
||||||
|
"""Called at registration with a Core function, never a client tool name."""
|
||||||
|
module = fn.__module__.split('.groups.', 1)[-1]
|
||||||
|
if source != 'http':
|
||||||
|
module = ''
|
||||||
|
name = fn.__name__
|
||||||
|
if source == 'http' and name == '_':
|
||||||
|
# Many Core routes use the anonymous function name `_`. Disambiguate
|
||||||
|
# using ONLY the source-declared template and methods at registration.
|
||||||
|
# This is never quart.request.path, url_rule, endpoint or request.method.
|
||||||
|
template = re.sub(r'[^A-Za-z0-9_.:-]+', '.', rule).strip('.') or 'root'
|
||||||
|
name = '.'.join((*sorted(methods or ('GET',)), template))
|
||||||
|
return '.'.join(part for part in (source, module, name) if part)[:128]
|
||||||
|
|
||||||
|
|
||||||
|
def outcome(value, reason_code='response_error'):
|
||||||
|
try:
|
||||||
|
d.set_outcome(value, reason_code=reason_code)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def workspace(context):
|
||||||
|
"""Annotate only this boundary's owned span with a matching trusted context."""
|
||||||
|
try:
|
||||||
|
from .http.context import ExecutionContext, RequestContext
|
||||||
|
|
||||||
|
if not isinstance(context, (ExecutionContext, RequestContext)):
|
||||||
|
return
|
||||||
|
boundary = _ACTIVE_BOUNDARY.get()
|
||||||
|
span = d.current_span()
|
||||||
|
if boundary is None or span is None or span is not boundary.span:
|
||||||
|
return
|
||||||
|
if d._manager(boundary.ap) is not span.manager:
|
||||||
|
return
|
||||||
|
instance = getattr(
|
||||||
|
getattr(boundary.ap, 'workspace_service', None),
|
||||||
|
'instance_uuid',
|
||||||
|
getattr(span.manager, 'instance_id', None),
|
||||||
|
)
|
||||||
|
# Structural recorders need not expose instance metadata. Real managers
|
||||||
|
# do, and must never receive another instance's Workspace annotation.
|
||||||
|
if instance is not None and instance != context.instance_uuid:
|
||||||
|
return
|
||||||
|
d.annotate(workspace_uuid=context.workspace_uuid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Boundary:
|
||||||
|
def __init__(self, ap, operation, source):
|
||||||
|
self.ap = ap
|
||||||
|
self.span = None
|
||||||
|
try:
|
||||||
|
manager = d._manager(ap)
|
||||||
|
if manager is not None:
|
||||||
|
fields = {'source': source, 'stage': 'execute'}
|
||||||
|
if source == 'webui_debug':
|
||||||
|
fields['attributes'] = {'synthetic': True}
|
||||||
|
self.span = d.Span(manager, 'api', operation, fields)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def activate(self):
|
||||||
|
# None is an explicit inheritance barrier, not a no-op: disabled or
|
||||||
|
# broken B work must not borrow enabled A's span through ContextVars.
|
||||||
|
token = d._CURRENT.set(self.span)
|
||||||
|
boundary_token = _ACTIVE_BOUNDARY.set(self)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
_ACTIVE_BOUNDARY.reset(boundary_token)
|
||||||
|
d._CURRENT.reset(token)
|
||||||
|
|
||||||
|
def finish(self, error=None):
|
||||||
|
if self.span is not None:
|
||||||
|
try:
|
||||||
|
self.span.finish(error)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _ObservedBody(ResponseBody):
|
||||||
|
"""Carry the HTTP parent through iteration, not generator suspension."""
|
||||||
|
|
||||||
|
def __init__(self, body, boundary):
|
||||||
|
self.body = body
|
||||||
|
self.boundary = boundary
|
||||||
|
self.iterator = None
|
||||||
|
self.exhausted = False
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
try:
|
||||||
|
with self.boundary.activate():
|
||||||
|
entered = await self.body.__aenter__()
|
||||||
|
self.iterator = entered.__aiter__()
|
||||||
|
return self
|
||||||
|
except BaseException as exc:
|
||||||
|
self.boundary.finish(exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_value, tb):
|
||||||
|
try:
|
||||||
|
with self.boundary.activate():
|
||||||
|
result = await self.body.__aexit__(exc_type, exc_value, tb)
|
||||||
|
except BaseException as exc:
|
||||||
|
self.boundary.finish(exc)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self.boundary.finish(exc_value if exc_value is not None else (None if self.exhausted else GeneratorExit()))
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def __aiter__(self):
|
||||||
|
try:
|
||||||
|
if self.iterator is None:
|
||||||
|
with self.boundary.activate():
|
||||||
|
self.iterator = self.body.__aiter__()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with self.boundary.activate():
|
||||||
|
value = await anext(self.iterator)
|
||||||
|
except StopAsyncIteration:
|
||||||
|
self.exhausted = True
|
||||||
|
self.boundary.finish()
|
||||||
|
return
|
||||||
|
yield value
|
||||||
|
except BaseException as exc:
|
||||||
|
self.boundary.finish(exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _response_status(value):
|
||||||
|
"""Inspect response metadata only; never read or deserialize a body."""
|
||||||
|
response = value[0] if isinstance(value, tuple) else value
|
||||||
|
status = response.status_code if isinstance(response, quart.Response) else 200
|
||||||
|
if isinstance(value, tuple) and len(value) > 1 and type(value[1]) is int:
|
||||||
|
status = value[1]
|
||||||
|
if status in (401, 403):
|
||||||
|
outcome('rejected')
|
||||||
|
elif status >= 400:
|
||||||
|
outcome('failed')
|
||||||
|
elif isinstance(response, dict) and 'code' in response and response['code'] != 0:
|
||||||
|
outcome('failed')
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def scope(ap, operation, *, source):
|
||||||
|
"""A fixed operation around an existing non-generator statement block."""
|
||||||
|
try:
|
||||||
|
privacy.code_value('operation', operation)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
boundary = Boundary(ap, operation, source)
|
||||||
|
try:
|
||||||
|
with boundary.activate():
|
||||||
|
yield
|
||||||
|
except BaseException as exc:
|
||||||
|
boundary.finish(exc)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
boundary.finish()
|
||||||
|
|
||||||
|
|
||||||
|
def observe(operation, *, source, ap=None, http=False):
|
||||||
|
# The operation is supplied by Core registration code, not request data.
|
||||||
|
try:
|
||||||
|
privacy.code_value('operation', operation)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def decorator(fn):
|
||||||
|
@functools.wraps(fn)
|
||||||
|
async def wrapped(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
owner = (ap() if callable(ap) else ap) if ap is not None else getattr(args[0], 'ap', None)
|
||||||
|
except Exception:
|
||||||
|
owner = None
|
||||||
|
boundary = Boundary(owner, operation, source)
|
||||||
|
streaming = False
|
||||||
|
try:
|
||||||
|
with boundary.activate():
|
||||||
|
value = await fn(*args, **kwargs)
|
||||||
|
if http:
|
||||||
|
try:
|
||||||
|
response = _response_status(value)
|
||||||
|
if isinstance(response, quart.Response) and isinstance(response.response, IterableBody):
|
||||||
|
response.response = _ObservedBody(response.response, boundary)
|
||||||
|
streaming = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return value
|
||||||
|
except BaseException as exc:
|
||||||
|
boundary.finish(exc)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if not streaming:
|
||||||
|
boundary.finish()
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
return decorator
|
||||||
@@ -24,6 +24,7 @@ import uuid
|
|||||||
from ..http.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
from ..http.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||||
from .context import bind_request_context, reset_request_context
|
from .context import bind_request_context, reset_request_context
|
||||||
from .server import LangBotMCPServer
|
from .server import LangBotMCPServer
|
||||||
|
from .. import management_diagnostics as diagnostics
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from ...core import app as app_module
|
from ...core import app as app_module
|
||||||
@@ -85,13 +86,8 @@ class MCPMount:
|
|||||||
authenticate_api_key = self.ap.apikey_service.authenticate_api_key
|
authenticate_api_key = self.ap.apikey_service.authenticate_api_key
|
||||||
is_mcp_path = self._is_mcp_path
|
is_mcp_path = self._is_mcp_path
|
||||||
|
|
||||||
async def dispatcher(scope, receive, send): # type: ignore[no-untyped-def]
|
@diagnostics.observe('mcp.request', source='mcp', ap=self.ap)
|
||||||
# Pass through non-HTTP scopes (lifespan, websocket) to Quart so its
|
async def dispatch_mcp(scope, receive, send):
|
||||||
# own startup/shutdown and websocket routes keep working.
|
|
||||||
if scope['type'] != 'http' or not is_mcp_path(scope.get('path', '')):
|
|
||||||
await quart_asgi(scope, receive, send)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Authenticate MCP HTTP requests with a LangBot API key.
|
# Authenticate MCP HTTP requests with a LangBot API key.
|
||||||
api_key = _extract_api_key(scope.get('headers', []))
|
api_key = _extract_api_key(scope.get('headers', []))
|
||||||
identity = None
|
identity = None
|
||||||
@@ -100,6 +96,7 @@ class MCPMount:
|
|||||||
identity = await authenticate_api_key(api_key)
|
identity = await authenticate_api_key(api_key)
|
||||||
|
|
||||||
if identity is None:
|
if identity is None:
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
await send(
|
await send(
|
||||||
{
|
{
|
||||||
'type': 'http.response.start',
|
'type': 'http.response.start',
|
||||||
@@ -126,6 +123,7 @@ class MCPMount:
|
|||||||
entitlement = await resolver.resolve(identity.workspace_uuid)
|
entitlement = await resolver.resolve(identity.workspace_uuid)
|
||||||
entitlement_revision = entitlement.entitlement_revision
|
entitlement_revision = entitlement.entitlement_revision
|
||||||
except Exception:
|
except Exception:
|
||||||
|
diagnostics.outcome('rejected')
|
||||||
await send(
|
await send(
|
||||||
{
|
{
|
||||||
'type': 'http.response.start',
|
'type': 'http.response.start',
|
||||||
@@ -153,6 +151,7 @@ class MCPMount:
|
|||||||
),
|
),
|
||||||
entitlement_revision=entitlement_revision,
|
entitlement_revision=entitlement_revision,
|
||||||
)
|
)
|
||||||
|
diagnostics.workspace(request_context)
|
||||||
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
|
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
|
||||||
if not callable(tenant_scope):
|
if not callable(tenant_scope):
|
||||||
raise RuntimeError('MCP request persistence scope is unavailable')
|
raise RuntimeError('MCP request persistence scope is unavailable')
|
||||||
@@ -165,4 +164,12 @@ class MCPMount:
|
|||||||
finally:
|
finally:
|
||||||
reset_request_context(token)
|
reset_request_context(token)
|
||||||
|
|
||||||
|
async def dispatcher(scope, receive, send): # type: ignore[no-untyped-def]
|
||||||
|
# Non-MCP traffic keeps its existing Quart boundaries. In
|
||||||
|
# particular, never derive an operation from an ASGI request path.
|
||||||
|
if scope['type'] != 'http' or not is_mcp_path(scope.get('path', '')):
|
||||||
|
await quart_asgi(scope, receive, send)
|
||||||
|
return
|
||||||
|
await dispatch_mcp(scope, receive, send)
|
||||||
|
|
||||||
return dispatcher
|
return dispatcher
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from mcp.server.fastmcp import FastMCP
|
|||||||
|
|
||||||
from ..http.authz import Permission, require_permission
|
from ..http.authz import Permission, require_permission
|
||||||
from .context import get_request_context
|
from .context import get_request_context
|
||||||
|
from .. import management_diagnostics as diagnostics
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from ...core import app as app_module
|
from ...core import app as app_module
|
||||||
@@ -52,6 +53,7 @@ def _dump(value: typing.Any) -> str:
|
|||||||
def _authorized(permission: Permission):
|
def _authorized(permission: Permission):
|
||||||
context = get_request_context()
|
context = get_request_context()
|
||||||
require_permission(context, permission)
|
require_permission(context, permission)
|
||||||
|
diagnostics.workspace(context)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ class LangBotMCPServer:
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
def _register_tools(self) -> None:
|
def _register_tools(self) -> None:
|
||||||
ap = self.ap
|
ap = self.ap
|
||||||
mcp = self.mcp
|
mcp = self
|
||||||
|
|
||||||
# ----- System (read-only) -------------------------------------- #
|
# ----- System (read-only) -------------------------------------- #
|
||||||
@mcp.tool(description='Get basic LangBot system/runtime information (version, edition).')
|
@mcp.tool(description='Get basic LangBot system/runtime information (version, edition).')
|
||||||
@@ -322,6 +324,15 @@ class LangBotMCPServer:
|
|||||||
context = _authorized(Permission.RESOURCE_VIEW)
|
context = _authorized(Permission.RESOURCE_VIEW)
|
||||||
return _dump(await ap.skill_service.get_skill(context, skill_name))
|
return _dump(await ap.skill_service.get_skill(context, skill_name))
|
||||||
|
|
||||||
|
def tool(self, **options):
|
||||||
|
"""Register a tool boundary before its authorization and service call."""
|
||||||
|
|
||||||
|
def register(fn):
|
||||||
|
observed = diagnostics.observe(diagnostics.operation_id('mcp', fn), source='mcp', ap=self.ap)(fn)
|
||||||
|
return self.mcp.tool(**options)(observed)
|
||||||
|
|
||||||
|
return register
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# ASGI app
|
# ASGI app
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
@@ -162,6 +164,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
def _uses_websocket(self) -> bool:
|
def _uses_websocket(self) -> bool:
|
||||||
return self.uses_websocket()
|
return self.uses_websocket()
|
||||||
|
|
||||||
|
@diagnostics.observe('lifecycle', 'box.initialize', source='runtime', stage='execute')
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
async with self._lifecycle_lock:
|
async with self._lifecycle_lock:
|
||||||
if self._closing:
|
if self._closing:
|
||||||
|
|||||||
@@ -597,6 +597,10 @@ class Application:
|
|||||||
if self.telemetry is not None:
|
if self.telemetry is not None:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await self.telemetry.shutdown()
|
await self.telemetry.shutdown()
|
||||||
|
diagnostics_manager = getattr(self, 'diagnostics', None)
|
||||||
|
if diagnostics_manager is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await diagnostics_manager.shutdown()
|
||||||
if self.vector_db_mgr is not None:
|
if self.vector_db_mgr is not None:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await self.vector_db_mgr.shutdown()
|
await self.vector_db_mgr.shutdown()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import os
|
|||||||
|
|
||||||
from . import app
|
from . import app
|
||||||
from . import stage
|
from . import stage
|
||||||
|
from ..telemetry import diagnostics
|
||||||
from ..utils import constants, importutil
|
from ..utils import constants, importutil
|
||||||
|
|
||||||
# Import startup stage implementation to register
|
# Import startup stage implementation to register
|
||||||
@@ -39,7 +40,33 @@ async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:
|
|||||||
stage_cls = stage.preregistered_stages[stage_name]
|
stage_cls = stage.preregistered_stages[stage_name]
|
||||||
stage_inst = stage_cls()
|
stage_inst = stage_cls()
|
||||||
|
|
||||||
await stage_inst.run(ap)
|
if stage_name == 'GenKeysStage':
|
||||||
|
# Optional diagnostics must not make startup depend on package
|
||||||
|
# metadata, session markers, or its background transport.
|
||||||
|
ap.diagnostics = None
|
||||||
|
space_config = ap.instance_config.data.get('space', {})
|
||||||
|
if not space_config.get('disable_telemetry', False) and not space_config.get(
|
||||||
|
'disable_beta_diagnostics', False
|
||||||
|
):
|
||||||
|
manager = None
|
||||||
|
try:
|
||||||
|
manager = diagnostics.DiagnosticsManager(ap, marker_path='data/labels/beta_diagnostics_session')
|
||||||
|
await manager.start_session()
|
||||||
|
manager.start()
|
||||||
|
ap.diagnostics = manager
|
||||||
|
except BaseException as exc:
|
||||||
|
if manager is not None:
|
||||||
|
# Cleanup faults cannot replace the startup fault.
|
||||||
|
try:
|
||||||
|
await manager.shutdown(drain_timeout=0)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if not isinstance(exc, asyncio.CancelledError):
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not isinstance(exc, Exception):
|
||||||
|
raise
|
||||||
|
await diagnostics.observe('lifecycle', 'startup.' + stage_name, source='startup', ap=ap)(stage_inst.run)(ap)
|
||||||
|
|
||||||
await ap.initialize()
|
await ap.initialize()
|
||||||
except BaseException:
|
except BaseException:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import enum
|
import enum
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -153,6 +155,7 @@ class PersistenceManager:
|
|||||||
default=None,
|
default=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('lifecycle', 'persistence.initialize', source='startup', stage='execute')
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
|
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
|
||||||
self.ap.logger.info(f'Initializing database type: {database_type}...')
|
self.ap.logger.info(f'Initializing database type: {database_type}...')
|
||||||
@@ -1648,6 +1651,7 @@ class PersistenceManager:
|
|||||||
|
|
||||||
# =================================
|
# =================================
|
||||||
|
|
||||||
|
@diagnostics.observe('lifecycle', 'persistence.run_alembic_migrations', source='startup', stage='execute')
|
||||||
async def _run_alembic_migrations(self, target_revision: str = 'head'):
|
async def _run_alembic_migrations(self, target_revision: str = 'head'):
|
||||||
"""Run the supported Alembic-based 4.x migrations."""
|
"""Run the supported Alembic-based 4.x migrations."""
|
||||||
from . import alembic_runner
|
from . import alembic_runner
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..telemetry import diagnostics
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import typing
|
import typing
|
||||||
import traceback
|
import traceback
|
||||||
@@ -178,6 +180,7 @@ class RuntimePipeline:
|
|||||||
raise WorkspaceInvariantError('RuntimePipeline instance does not match the active Workspace binding')
|
raise WorkspaceInvariantError('RuntimePipeline instance does not match the active Workspace binding')
|
||||||
return execution_context
|
return execution_context
|
||||||
|
|
||||||
|
@diagnostics.observe('run', 'pipeline.run', source='pipeline', fields=lambda b: {'processor_type': 'pipeline'})
|
||||||
async def run(self, query: pipeline_query.Query):
|
async def run(self, query: pipeline_query.Query):
|
||||||
if (
|
if (
|
||||||
query.instance_uuid != self.execution_context.instance_uuid
|
query.instance_uuid != self.execution_context.instance_uuid
|
||||||
@@ -254,6 +257,7 @@ class RuntimePipeline:
|
|||||||
if result.console_notice:
|
if result.console_notice:
|
||||||
self.ap.logger.info(result.console_notice)
|
self.ap.logger.info(result.console_notice)
|
||||||
if result.error_notice:
|
if result.error_notice:
|
||||||
|
diagnostics.set_outcome('failed', reason_code='response_error')
|
||||||
self.ap.logger.error(result.error_notice)
|
self.ap.logger.error(result.error_notice)
|
||||||
# Mark query as having error
|
# Mark query as having error
|
||||||
query.variables['_monitoring_has_error'] = True
|
query.variables['_monitoring_has_error'] = True
|
||||||
@@ -426,6 +430,7 @@ class RuntimePipeline:
|
|||||||
await self._assert_execution_active(query)
|
await self._assert_execution_active(query)
|
||||||
|
|
||||||
if event_ctx.is_prevented_default():
|
if event_ctx.is_prevented_default():
|
||||||
|
diagnostics.set_outcome('skipped', reason_code='discarded')
|
||||||
self.ap.logger.debug(
|
self.ap.logger.debug(
|
||||||
f'MessageReceived event prevented default for query {query.query_id}, pipeline={pipeline_name}'
|
f'MessageReceived event prevented default for query {query.query_id}, pipeline={pipeline_name}'
|
||||||
)
|
)
|
||||||
@@ -468,8 +473,11 @@ class RuntimePipeline:
|
|||||||
self.ap.logger.error(f'Failed to record query response: {e}')
|
self.ap.logger.error(f'Failed to record query response: {e}')
|
||||||
|
|
||||||
except WorkspaceError as e:
|
except WorkspaceError as e:
|
||||||
|
diagnostics.set_outcome('rejected', reason_code='processor_incompatible')
|
||||||
self.ap.logger.info(f'Dropped query {query.query_id} because its Workspace execution binding is stale: {e}')
|
self.ap.logger.info(f'Dropped query {query.query_id} because its Workspace execution binding is stale: {e}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
diagnostics.set_outcome('failed', reason_code='response_error')
|
||||||
|
diagnostics.annotate(error=e)
|
||||||
inst_name = query.current_stage_name if query.current_stage_name else 'unknown'
|
inst_name = query.current_stage_name if query.current_stage_name else 'unknown'
|
||||||
self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}')
|
self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}')
|
||||||
self.ap.logger.error(f'Traceback: {traceback.format_exc()}')
|
self.ap.logger.error(f'Traceback: {traceback.format_exc()}')
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..telemetry import diagnostics
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import inspect
|
import inspect
|
||||||
@@ -305,6 +307,7 @@ class QueryPool:
|
|||||||
)
|
)
|
||||||
object.__setattr__(query, 'query_uuid', query_uuid)
|
object.__setattr__(query, 'query_uuid', query_uuid)
|
||||||
object.__setattr__(query, '_execution_context', execution_context)
|
object.__setattr__(query, '_execution_context', execution_context)
|
||||||
|
object.__setattr__(query, '_diagnostic_context', diagnostics.capture_context())
|
||||||
|
|
||||||
self.queries.append(query)
|
self.queries.append(query)
|
||||||
self.cached_queries[(execution_context.workspace_uuid, query_uuid)] = query
|
self.cached_queries[(execution_context.workspace_uuid, query_uuid)] = query
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
import typing
|
import typing
|
||||||
@@ -94,6 +96,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
|||||||
'call_platform_api',
|
'call_platform_api',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||||
handler = PLATFORM_API_MAP.get(action)
|
handler = PLATFORM_API_MAP.get(action)
|
||||||
if handler is None:
|
if handler is None:
|
||||||
@@ -146,6 +149,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
|||||||
await self.logger.info(f'WebSocket connection established, bot id: {self.bot_account_id}')
|
await self.logger.info(f'WebSocket connection established, bot id: {self.bot_account_id}')
|
||||||
await self._dispatch_native_event(event)
|
await self._dispatch_native_event(event)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||||
async def _handle_native_event(self, event: aiocqhttp.Event):
|
async def _handle_native_event(self, event: aiocqhttp.Event):
|
||||||
self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id)
|
self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id)
|
||||||
if getattr(event, 'type', None) == 'message' and str(getattr(event, 'user_id', '')) == self.bot_account_id:
|
if getattr(event, 'type', None) == 'message' and str(getattr(event, 'user_id', '')) == self.bot_account_id:
|
||||||
@@ -163,6 +167,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
|||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Error in aiocqhttp native event: {traceback.format_exc()}')
|
await self.logger.error(f'Error in aiocqhttp native event: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||||
async def _dispatch_native_event(self, event: aiocqhttp.Event):
|
async def _dispatch_native_event(self, event: aiocqhttp.Event):
|
||||||
eba_event = await self.event_converter.target2yiri(event, self.bot, self.bot_account_id, self._lookup)
|
eba_event = await self.event_converter.target2yiri(event, self.bot, self.bot_account_id, self._lookup)
|
||||||
if eba_event:
|
if eba_event:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import aiocqhttp
|
import aiocqhttp
|
||||||
@@ -15,6 +17,7 @@ from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedErro
|
|||||||
class AiocqhttpAPIMixin:
|
class AiocqhttpAPIMixin:
|
||||||
bot: aiocqhttp.CQHttp
|
bot: aiocqhttp.CQHttp
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
@@ -35,6 +38,7 @@ class AiocqhttpAPIMixin:
|
|||||||
raise ValueError(f'Unsupported aiocqhttp target_type: {target_type}')
|
raise ValueError(f'Unsupported aiocqhttp target_type: {target_type}')
|
||||||
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -49,6 +53,7 @@ class AiocqhttpAPIMixin:
|
|||||||
raw = await self.bot.send(message_source.source_platform_object, aiocq_msg)
|
raw = await self.bot.send(message_source.source_platform_object, aiocq_msg)
|
||||||
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||||
async def delete_message(
|
async def delete_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -57,6 +62,7 @@ class AiocqhttpAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
await self.bot.delete_msg(message_id=int(message_id))
|
await self.bot.delete_msg(message_id=int(message_id))
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||||
async def forward_message(
|
async def forward_message(
|
||||||
self,
|
self,
|
||||||
from_chat_type: str,
|
from_chat_type: str,
|
||||||
@@ -75,6 +81,7 @@ class AiocqhttpAPIMixin:
|
|||||||
raise ValueError(f'Unsupported aiocqhttp to_chat_type: {to_chat_type}')
|
raise ValueError(f'Unsupported aiocqhttp to_chat_type: {to_chat_type}')
|
||||||
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||||
async def get_message(
|
async def get_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -100,6 +107,7 @@ class AiocqhttpAPIMixin:
|
|||||||
)
|
)
|
||||||
return await AiocqhttpEventConverter.message_to_eba(event, self.bot)
|
return await AiocqhttpEventConverter.message_to_eba(event, self.bot)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||||
raw = await self.bot.get_group_info(group_id=int(group_id))
|
raw = await self.bot.get_group_info(group_id=int(group_id))
|
||||||
return platform_entities.UserGroup(
|
return platform_entities.UserGroup(
|
||||||
@@ -108,6 +116,7 @@ class AiocqhttpAPIMixin:
|
|||||||
member_count=raw.get('member_count'),
|
member_count=raw.get('member_count'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_list', source='platform', stage='accepted')
|
||||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||||
raw_list = await self.bot.get_group_list()
|
raw_list = await self.bot.get_group_list()
|
||||||
return [
|
return [
|
||||||
@@ -119,6 +128,7 @@ class AiocqhttpAPIMixin:
|
|||||||
for item in raw_list
|
for item in raw_list
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||||
async def get_group_member_list(
|
async def get_group_member_list(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -126,6 +136,7 @@ class AiocqhttpAPIMixin:
|
|||||||
raw_list = await self.bot.get_group_member_list(group_id=int(group_id))
|
raw_list = await self.bot.get_group_member_list(group_id=int(group_id))
|
||||||
return [self._member_to_entity(item, group_id) for item in raw_list]
|
return [self._member_to_entity(item, group_id) for item in raw_list]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||||
async def get_group_member_info(
|
async def get_group_member_info(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -134,9 +145,11 @@ class AiocqhttpAPIMixin:
|
|||||||
raw = await self.bot.get_group_member_info(group_id=int(group_id), user_id=int(user_id), no_cache=True)
|
raw = await self.bot.get_group_member_info(group_id=int(group_id), user_id=int(user_id), no_cache=True)
|
||||||
return self._member_to_entity(raw, group_id)
|
return self._member_to_entity(raw, group_id)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'set_group_name', source='platform', stage='accepted')
|
||||||
async def set_group_name(self, group_id: typing.Union[int, str], name: str) -> None:
|
async def set_group_name(self, group_id: typing.Union[int, str], name: str) -> None:
|
||||||
await self.bot.set_group_name(group_id=int(group_id), group_name=name)
|
await self.bot.set_group_name(group_id=int(group_id), group_name=name)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'mute_member', source='platform', stage='accepted')
|
||||||
async def mute_member(
|
async def mute_member(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -145,15 +158,19 @@ class AiocqhttpAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
await self.bot.set_group_ban(group_id=int(group_id), user_id=int(user_id), duration=int(duration))
|
await self.bot.set_group_ban(group_id=int(group_id), user_id=int(user_id), duration=int(duration))
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'unmute_member', source='platform', stage='accepted')
|
||||||
async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]) -> None:
|
async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]) -> None:
|
||||||
await self.bot.set_group_ban(group_id=int(group_id), user_id=int(user_id), duration=0)
|
await self.bot.set_group_ban(group_id=int(group_id), user_id=int(user_id), duration=0)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'kick_member', source='platform', stage='accepted')
|
||||||
async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]) -> None:
|
async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]) -> None:
|
||||||
await self.bot.set_group_kick(group_id=int(group_id), user_id=int(user_id), reject_add_request=False)
|
await self.bot.set_group_kick(group_id=int(group_id), user_id=int(user_id), reject_add_request=False)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'leave_group', source='platform', stage='accepted')
|
||||||
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
||||||
await self.bot.set_group_leave(group_id=int(group_id), is_dismiss=False)
|
await self.bot.set_group_leave(group_id=int(group_id), is_dismiss=False)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||||
raw = await self.bot.get_stranger_info(user_id=int(user_id), no_cache=True)
|
raw = await self.bot.get_stranger_info(user_id=int(user_id), no_cache=True)
|
||||||
return platform_entities.User(
|
return platform_entities.User(
|
||||||
@@ -162,6 +179,7 @@ class AiocqhttpAPIMixin:
|
|||||||
avatar_url=raw.get('avatar_url'),
|
avatar_url=raw.get('avatar_url'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||||
raw_list = await self.bot.get_friend_list()
|
raw_list = await self.bot.get_friend_list()
|
||||||
return [
|
return [
|
||||||
@@ -173,6 +191,7 @@ class AiocqhttpAPIMixin:
|
|||||||
for item in raw_list
|
for item in raw_list
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'approve_friend_request', source='platform', stage='accepted')
|
||||||
async def approve_friend_request(
|
async def approve_friend_request(
|
||||||
self,
|
self,
|
||||||
request_id: typing.Union[int, str],
|
request_id: typing.Union[int, str],
|
||||||
@@ -181,12 +200,15 @@ class AiocqhttpAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
await self.bot.set_friend_add_request(flag=str(request_id), approve=approve, remark=remark or '')
|
await self.bot.set_friend_add_request(flag=str(request_id), approve=approve, remark=remark or '')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'approve_group_invite', source='platform', stage='accepted')
|
||||||
async def approve_group_invite(self, request_id: typing.Union[int, str], approve: bool = True) -> None:
|
async def approve_group_invite(self, request_id: typing.Union[int, str], approve: bool = True) -> None:
|
||||||
await self.bot.set_group_add_request(flag=str(request_id), sub_type='invite', approve=approve, reason='')
|
await self.bot.set_group_add_request(flag=str(request_id), sub_type='invite', approve=approve, reason='')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||||
raise NotSupportedError('upload_file')
|
raise NotSupportedError('upload_file')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||||
async def get_file_url(self, file_id: str) -> str:
|
async def get_file_url(self, file_id: str) -> str:
|
||||||
raise NotSupportedError('get_file_url')
|
raise NotSupportedError('get_file_url')
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import aiocqhttp
|
import aiocqhttp
|
||||||
@@ -21,6 +23,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return getattr(event, 'source_platform_object', None)
|
return getattr(event, 'source_platform_object', None)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||||
async def target2yiri(
|
async def target2yiri(
|
||||||
event: aiocqhttp.Event,
|
event: aiocqhttp.Event,
|
||||||
bot: aiocqhttp.CQHttp | None = None,
|
bot: aiocqhttp.CQHttp | None = None,
|
||||||
@@ -39,6 +42,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||||
async def target2legacy(
|
async def target2legacy(
|
||||||
event: aiocqhttp.Event,
|
event: aiocqhttp.Event,
|
||||||
bot: aiocqhttp.CQHttp | None = None,
|
bot: aiocqhttp.CQHttp | None = None,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import traceback
|
import traceback
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
@@ -133,6 +135,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
def _plain_message(text: str) -> platform_message.MessageChain:
|
def _plain_message(text: str) -> platform_message.MessageChain:
|
||||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
@@ -149,6 +152,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
raise ValueError(f'Unsupported dingtalk target_type: {target_type}')
|
raise ValueError(f'Unsupported dingtalk target_type: {target_type}')
|
||||||
return platform_events.MessageResult(raw=raw if isinstance(raw, dict) else {'result': raw})
|
return platform_events.MessageResult(raw=raw if isinstance(raw, dict) else {'result': raw})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -165,6 +169,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
raw=raw if isinstance(raw, dict) else {'result': raw},
|
raw=raw if isinstance(raw, dict) else {'result': raw},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message_chunk', source='platform', stage='accepted')
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -188,6 +193,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
if is_final and bot_message.tool_calls is None:
|
if is_final and bot_message.tool_calls is None:
|
||||||
self.card_instance_id_dict.pop(message_id)
|
self.card_instance_id_dict.pop(message_id)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'create_message_card', source='platform', stage='accepted')
|
||||||
async def create_message_card(self, message_id, event):
|
async def create_message_card(self, message_id, event):
|
||||||
while len(self.card_instance_id_dict) >= 1000:
|
while len(self.card_instance_id_dict) >= 1000:
|
||||||
self.card_instance_id_dict.pop(next(iter(self.card_instance_id_dict)), None)
|
self.card_instance_id_dict.pop(next(iter(self.card_instance_id_dict)), None)
|
||||||
@@ -205,6 +211,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
return bool(self.config.get('enable-stream-reply', False))
|
return bool(self.config.get('enable-stream-reply', False))
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||||
if action == 'interaction.request' and action in self.get_supported_apis():
|
if action == 'interaction.request' and action in self.get_supported_apis():
|
||||||
return await send_interaction(self, params)
|
return await send_interaction(self, params)
|
||||||
@@ -260,6 +267,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
DingTalkCardCallbackHandler(self),
|
DingTalkCardCallbackHandler(self),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||||
async def _handle_native_event(self, event: DingTalkEvent):
|
async def _handle_native_event(self, event: DingTalkEvent):
|
||||||
try:
|
try:
|
||||||
interaction_event = interaction_event_from_native(event, self.interaction_callback_contexts)
|
interaction_event = interaction_event_from_native(event, self.interaction_callback_contexts)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from langbot.libs.dingtalk_api.api import DingTalkClient
|
from langbot.libs.dingtalk_api.api import DingTalkClient
|
||||||
@@ -14,6 +16,7 @@ class DingTalkAPIMixin:
|
|||||||
_user_cache: dict[str, platform_entities.User]
|
_user_cache: dict[str, platform_entities.User]
|
||||||
_group_cache: dict[str, platform_entities.UserGroup]
|
_group_cache: dict[str, platform_entities.UserGroup]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||||
async def get_message(
|
async def get_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -25,18 +28,22 @@ class DingTalkAPIMixin:
|
|||||||
raise NotSupportedError('get_message:message_not_cached')
|
raise NotSupportedError('get_message:message_not_cached')
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||||
return self._group_cache.get(str(group_id)) or platform_entities.UserGroup(id=group_id, name='')
|
return self._group_cache.get(str(group_id)) or platform_entities.UserGroup(id=group_id, name='')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_list', source='platform', stage='accepted')
|
||||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||||
return list(self._group_cache.values())
|
return list(self._group_cache.values())
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||||
async def get_group_member_list(
|
async def get_group_member_list(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
) -> list[platform_entities.UserGroupMember]:
|
) -> list[platform_entities.UserGroupMember]:
|
||||||
raise NotSupportedError('get_group_member_list')
|
raise NotSupportedError('get_group_member_list')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||||
async def get_group_member_info(
|
async def get_group_member_info(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -52,14 +59,18 @@ class DingTalkAPIMixin:
|
|||||||
display_name=user.nickname,
|
display_name=user.nickname,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||||
return self._user_cache.get(str(user_id)) or platform_entities.User(id=user_id, nickname='')
|
return self._user_cache.get(str(user_id)) or platform_entities.User(id=user_id, nickname='')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||||
return list(self._user_cache.values())
|
return list(self._user_cache.values())
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||||
raise NotSupportedError('upload_file')
|
raise NotSupportedError('upload_file')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||||
async def get_file_url(self, file_id: str) -> str:
|
async def get_file_url(self, file_id: str) -> str:
|
||||||
return await self.bot.get_file_url(file_id)
|
return await self.bot.get_file_url(file_id)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
|
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
|
||||||
@@ -16,6 +18,7 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return getattr(event, 'source_platform_object', None)
|
return getattr(event, 'source_platform_object', None)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||||
async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_events.Event | None:
|
async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_events.Event | None:
|
||||||
if event.conversation in {'FriendMessage', 'GroupMessage'}:
|
if event.conversation in {'FriendMessage', 'GroupMessage'}:
|
||||||
return await DingTalkEventConverter.message_to_eba(event, bot_name)
|
return await DingTalkEventConverter.message_to_eba(event, bot_name)
|
||||||
@@ -27,6 +30,7 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return DingTalkEventConverter.platform_specific(event, f'message.{event.conversation or "unknown"}')
|
return DingTalkEventConverter.platform_specific(event, f'message.{event.conversation or "unknown"}')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||||
async def target2legacy(
|
async def target2legacy(
|
||||||
event: DingTalkEvent,
|
event: DingTalkEvent,
|
||||||
bot_name: str,
|
bot_name: str,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
@@ -45,10 +47,30 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
adapter_self = self
|
adapter_self = self
|
||||||
|
|
||||||
class LangBotDiscordClient(discord.Client):
|
class LangBotDiscordClient(discord.Client):
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_ready(self: discord.Client):
|
async def on_ready(self: discord.Client):
|
||||||
adapter_self.bot_account_id = str(self.user.id) if self.user else ''
|
adapter_self.bot_account_id = str(self.user.id) if self.user else ''
|
||||||
await adapter_self.logger.info(f'Discord adapter running as {self.user}')
|
await adapter_self.logger.info(f'Discord adapter running as {self.user}')
|
||||||
|
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_message(self: discord.Client, message: discord.Message):
|
async def on_message(self: discord.Client, message: discord.Message):
|
||||||
if self.user and message.author.id == self.user.id:
|
if self.user and message.author.id == self.user.id:
|
||||||
return
|
return
|
||||||
@@ -72,6 +94,16 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
except Exception:
|
except Exception:
|
||||||
await adapter_self.logger.error(f'Error in discord on_message: {traceback.format_exc()}')
|
await adapter_self.logger.error(f'Error in discord on_message: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_interaction(self: discord.Client, interaction: discord.Interaction):
|
async def on_interaction(self: discord.Client, interaction: discord.Interaction):
|
||||||
custom_id = (interaction.data or {}).get('custom_id') if isinstance(interaction.data, dict) else None
|
custom_id = (interaction.data or {}).get('custom_id') if isinstance(interaction.data, dict) else None
|
||||||
try:
|
try:
|
||||||
@@ -91,16 +123,46 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
except Exception:
|
except Exception:
|
||||||
await adapter_self.logger.error(f'Error in Discord interaction callback: {traceback.format_exc()}')
|
await adapter_self.logger.error(f'Error in Discord interaction callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_message_edit(self: discord.Client, before: discord.Message, after: discord.Message):
|
async def on_message_edit(self: discord.Client, before: discord.Message, after: discord.Message):
|
||||||
await adapter_self._dispatch_gateway_tuple(
|
await adapter_self._dispatch_gateway_tuple(
|
||||||
'message_edit', (before, after), self.user.id if self.user else None
|
'message_edit', (before, after), self.user.id if self.user else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_message_delete(self: discord.Client, message: discord.Message):
|
async def on_message_delete(self: discord.Client, message: discord.Message):
|
||||||
await adapter_self._dispatch_gateway_tuple(
|
await adapter_self._dispatch_gateway_tuple(
|
||||||
'message_delete', message, self.user.id if self.user else None
|
'message_delete', message, self.user.id if self.user else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_raw_message_delete(self: discord.Client, payload: discord.RawMessageDeleteEvent):
|
async def on_raw_message_delete(self: discord.Client, payload: discord.RawMessageDeleteEvent):
|
||||||
await adapter_self._dispatch_gateway_tuple(
|
await adapter_self._dispatch_gateway_tuple(
|
||||||
'raw_message_delete',
|
'raw_message_delete',
|
||||||
@@ -108,6 +170,16 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
self.user.id if self.user else None,
|
self.user.id if self.user else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_reaction_add(
|
async def on_reaction_add(
|
||||||
self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member
|
self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member
|
||||||
):
|
):
|
||||||
@@ -117,6 +189,16 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
'reaction_add', (reaction, user), self.user.id if self.user else None
|
'reaction_add', (reaction, user), self.user.id if self.user else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_reaction_remove(
|
async def on_reaction_remove(
|
||||||
self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member
|
self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member
|
||||||
):
|
):
|
||||||
@@ -126,6 +208,16 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
'reaction_remove', (reaction, user), self.user.id if self.user else None
|
'reaction_remove', (reaction, user), self.user.id if self.user else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_raw_reaction_add(self: discord.Client, payload: discord.RawReactionActionEvent):
|
async def on_raw_reaction_add(self: discord.Client, payload: discord.RawReactionActionEvent):
|
||||||
if self.user and payload.user_id == self.user.id:
|
if self.user and payload.user_id == self.user.id:
|
||||||
return
|
return
|
||||||
@@ -135,6 +227,16 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
self.user.id if self.user else None,
|
self.user.id if self.user else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_raw_reaction_remove(self: discord.Client, payload: discord.RawReactionActionEvent):
|
async def on_raw_reaction_remove(self: discord.Client, payload: discord.RawReactionActionEvent):
|
||||||
if self.user and payload.user_id == self.user.id:
|
if self.user and payload.user_id == self.user.id:
|
||||||
return
|
return
|
||||||
@@ -144,15 +246,55 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
self.user.id if self.user else None,
|
self.user.id if self.user else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_member_join(self: discord.Client, member: discord.Member):
|
async def on_member_join(self: discord.Client, member: discord.Member):
|
||||||
await adapter_self._dispatch_gateway_tuple('member_join', member, self.user.id if self.user else None)
|
await adapter_self._dispatch_gateway_tuple('member_join', member, self.user.id if self.user else None)
|
||||||
|
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_member_remove(self: discord.Client, member: discord.Member):
|
async def on_member_remove(self: discord.Client, member: discord.Member):
|
||||||
await adapter_self._dispatch_gateway_tuple('member_remove', member, self.user.id if self.user else None)
|
await adapter_self._dispatch_gateway_tuple('member_remove', member, self.user.id if self.user else None)
|
||||||
|
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_guild_join(self: discord.Client, guild: discord.Guild):
|
async def on_guild_join(self: discord.Client, guild: discord.Guild):
|
||||||
await adapter_self._dispatch_gateway_tuple('guild_join', guild, self.user.id if self.user else None)
|
await adapter_self._dispatch_gateway_tuple('guild_join', guild, self.user.id if self.user else None)
|
||||||
|
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_guild_remove(self: discord.Client, guild: discord.Guild):
|
async def on_guild_remove(self: discord.Client, guild: discord.Guild):
|
||||||
await adapter_self._dispatch_gateway_tuple('guild_remove', guild, self.user.id if self.user else None)
|
await adapter_self._dispatch_gateway_tuple('guild_remove', guild, self.user.id if self.user else None)
|
||||||
|
|
||||||
@@ -210,6 +352,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||||
return interaction_delivery_capabilities()
|
return interaction_delivery_capabilities()
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
content, files = await self.message_converter.yiri2target(message)
|
content, files = await self.message_converter.yiri2target(message)
|
||||||
channel = await self._get_channel(target_id)
|
channel = await self._get_channel(target_id)
|
||||||
@@ -219,6 +362,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
sent = await channel.send(**kwargs)
|
sent = await channel.send(**kwargs)
|
||||||
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
|
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -236,6 +380,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
sent = await message_source.source_platform_object.channel.send(**kwargs)
|
sent = await message_source.source_platform_object.channel.send(**kwargs)
|
||||||
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
|
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||||
async def _dispatch_gateway_tuple(self, kind: str, payload, bot_user_id: int | None):
|
async def _dispatch_gateway_tuple(self, kind: str, payload, bot_user_id: int | None):
|
||||||
try:
|
try:
|
||||||
event = await self.event_converter.target2yiri((kind, payload), bot_user_id)
|
event = await self.event_converter.target2yiri((kind, payload), bot_user_id)
|
||||||
@@ -269,6 +414,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
):
|
):
|
||||||
self.listeners.pop(event_type, None)
|
self.listeners.pop(event_type, None)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||||
if action == 'interaction.request':
|
if action == 'interaction.request':
|
||||||
return await send_interaction(self, params)
|
return await send_interaction(self, params)
|
||||||
@@ -290,6 +436,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'create_message_card', source='platform', stage='accepted')
|
||||||
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||||
"""Set up a stream context for progressive editing.
|
"""Set up a stream context for progressive editing.
|
||||||
|
|
||||||
@@ -309,6 +456,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
}
|
}
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message_chunk', source='platform', stage='accepted')
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
|
|||||||
class DiscordAPIMixin:
|
class DiscordAPIMixin:
|
||||||
bot: discord.Client
|
bot: discord.Client
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||||
async def edit_message(
|
async def edit_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -31,6 +34,7 @@ class DiscordAPIMixin:
|
|||||||
return
|
return
|
||||||
await message.edit(content=content)
|
await message.edit(content=content)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||||
async def delete_message(
|
async def delete_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -41,6 +45,7 @@ class DiscordAPIMixin:
|
|||||||
message = await channel.fetch_message(int(message_id))
|
message = await channel.fetch_message(int(message_id))
|
||||||
await message.delete()
|
await message.delete()
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||||
async def forward_message(
|
async def forward_message(
|
||||||
self,
|
self,
|
||||||
from_chat_type: str,
|
from_chat_type: str,
|
||||||
@@ -56,10 +61,12 @@ class DiscordAPIMixin:
|
|||||||
sent = await to_channel.send(content=message.content, files=files)
|
sent = await to_channel.send(content=message.content, files=files)
|
||||||
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
|
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||||
guild = await self._get_guild(group_id)
|
guild = await self._get_guild(group_id)
|
||||||
return DiscordEventConverter.group_from_guild(guild)
|
return DiscordEventConverter.group_from_guild(guild)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||||
async def get_group_member_list(
|
async def get_group_member_list(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -68,6 +75,7 @@ class DiscordAPIMixin:
|
|||||||
members = guild.members or [member async for member in guild.fetch_members(limit=None)]
|
members = guild.members or [member async for member in guild.fetch_members(limit=None)]
|
||||||
return [self._member_to_entity(member) for member in members]
|
return [self._member_to_entity(member) for member in members]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||||
async def get_group_member_info(
|
async def get_group_member_info(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -77,18 +85,22 @@ class DiscordAPIMixin:
|
|||||||
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
||||||
return self._member_to_entity(member)
|
return self._member_to_entity(member)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||||
user = self.bot.get_user(int(user_id)) or await self.bot.fetch_user(int(user_id))
|
user = self.bot.get_user(int(user_id)) or await self.bot.fetch_user(int(user_id))
|
||||||
return DiscordEventConverter.user_from_author(user)
|
return DiscordEventConverter.user_from_author(user)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||||
|
|
||||||
raise NotSupportedError('upload_file')
|
raise NotSupportedError('upload_file')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||||
async def get_file_url(self, file_id: str) -> str:
|
async def get_file_url(self, file_id: str) -> str:
|
||||||
return file_id
|
return file_id
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'mute_member', source='platform', stage='accepted')
|
||||||
async def mute_member(
|
async def mute_member(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -102,6 +114,7 @@ class DiscordAPIMixin:
|
|||||||
until = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=duration)
|
until = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=duration)
|
||||||
await member.timeout(until, reason='LangBot Omni mute_member')
|
await member.timeout(until, reason='LangBot Omni mute_member')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'unmute_member', source='platform', stage='accepted')
|
||||||
async def unmute_member(
|
async def unmute_member(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -111,6 +124,7 @@ class DiscordAPIMixin:
|
|||||||
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
||||||
await member.timeout(None, reason='LangBot Omni unmute_member')
|
await member.timeout(None, reason='LangBot Omni unmute_member')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'kick_member', source='platform', stage='accepted')
|
||||||
async def kick_member(
|
async def kick_member(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -120,6 +134,7 @@ class DiscordAPIMixin:
|
|||||||
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
||||||
await member.kick(reason='LangBot Omni kick_member')
|
await member.kick(reason='LangBot Omni kick_member')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'leave_group', source='platform', stage='accepted')
|
||||||
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
||||||
guild = await self._get_guild(group_id)
|
guild = await self._get_guild(group_id)
|
||||||
await guild.leave()
|
await guild.leave()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
@@ -16,6 +18,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||||
async def target2yiri(event: typing.Any, bot_user_id: int | None = None) -> platform_events.Event | None:
|
async def target2yiri(event: typing.Any, bot_user_id: int | None = None) -> platform_events.Event | None:
|
||||||
if isinstance(event, discord.Message):
|
if isinstance(event, discord.Message):
|
||||||
return await DiscordEventConverter.message_to_eba(event)
|
return await DiscordEventConverter.message_to_eba(event)
|
||||||
@@ -238,6 +241,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||||
async def target2legacy(message: discord.Message) -> platform_events.FriendMessage | platform_events.GroupMessage:
|
async def target2legacy(message: discord.Message) -> platform_events.FriendMessage | platform_events.GroupMessage:
|
||||||
message_chain = await DiscordMessageConverter.target2yiri(message)
|
message_chain = await DiscordMessageConverter.target2yiri(message)
|
||||||
if isinstance(message.channel, discord.DMChannel):
|
if isinstance(message.channel, discord.DMChannel):
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
from langbot.pkg.platform.sources.kook import _decode_gateway_message
|
from langbot.pkg.platform.sources.kook import _decode_gateway_message
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -99,6 +101,7 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
|||||||
'call_platform_api',
|
'call_platform_api',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||||
handler = PLATFORM_API_MAP.get(action)
|
handler = PLATFORM_API_MAP.get(action)
|
||||||
if handler is None:
|
if handler is None:
|
||||||
@@ -168,6 +171,7 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
|||||||
self.session_id = str(data.get('session_id') or '')
|
self.session_id = str(data.get('session_id') or '')
|
||||||
await self.logger.info(f'KOOK WebSocket HELLO received, session_id: {self.session_id}')
|
await self.logger.info(f'KOOK WebSocket HELLO received, session_id: {self.session_id}')
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.receive', source='platform', stage='accepted')
|
||||||
async def _handle_event(self, data: dict, sn: int):
|
async def _handle_event(self, data: dict, sn: int):
|
||||||
self.current_sn = max(self.current_sn, sn)
|
self.current_sn = max(self.current_sn, sn)
|
||||||
|
|
||||||
@@ -192,7 +196,9 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
|||||||
if eba_event:
|
if eba_event:
|
||||||
self._cache_event(eba_event)
|
self._cache_event(eba_event)
|
||||||
await self._dispatch_eba_event(eba_event)
|
await self._dispatch_eba_event(eba_event)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
diagnostics.annotate(error=exc)
|
||||||
|
diagnostics.set_outcome('failed')
|
||||||
await self.logger.error(f'Error handling KOOK event: {traceback.format_exc()}')
|
await self.logger.error(f'Error handling KOOK event: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
|
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
|
||||||
@@ -14,6 +16,7 @@ class KookAPIMixin:
|
|||||||
_user_cache: dict[str, platform_entities.User]
|
_user_cache: dict[str, platform_entities.User]
|
||||||
_group_cache: dict[str, platform_entities.UserGroup]
|
_group_cache: dict[str, platform_entities.UserGroup]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
@@ -34,6 +37,7 @@ class KookAPIMixin:
|
|||||||
data = raw.get('data') or {}
|
data = raw.get('data') or {}
|
||||||
return platform_events.MessageResult(message_id=data.get('msg_id'), raw=raw)
|
return platform_events.MessageResult(message_id=data.get('msg_id'), raw=raw)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -73,6 +77,7 @@ class KookAPIMixin:
|
|||||||
data = raw.get('data') or {}
|
data = raw.get('data') or {}
|
||||||
return platform_events.MessageResult(message_id=data.get('msg_id'), raw=raw)
|
return platform_events.MessageResult(message_id=data.get('msg_id'), raw=raw)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||||
async def get_message(
|
async def get_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -84,6 +89,7 @@ class KookAPIMixin:
|
|||||||
raise NotSupportedError('get_message:message_not_cached')
|
raise NotSupportedError('get_message:message_not_cached')
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||||
cached = self._group_cache.get(str(group_id))
|
cached = self._group_cache.get(str(group_id))
|
||||||
if cached:
|
if cached:
|
||||||
@@ -96,15 +102,18 @@ class KookAPIMixin:
|
|||||||
member_count=data.get('user_count'),
|
member_count=data.get('user_count'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_list', source='platform', stage='accepted')
|
||||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||||
return list(self._group_cache.values())
|
return list(self._group_cache.values())
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||||
async def get_group_member_list(
|
async def get_group_member_list(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
) -> list[platform_entities.UserGroupMember]:
|
) -> list[platform_entities.UserGroupMember]:
|
||||||
raise NotSupportedError('get_group_member_list')
|
raise NotSupportedError('get_group_member_list')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||||
async def get_group_member_info(
|
async def get_group_member_info(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -128,6 +137,7 @@ class KookAPIMixin:
|
|||||||
display_name=user.nickname,
|
display_name=user.nickname,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||||
cached = self._user_cache.get(str(user_id))
|
cached = self._user_cache.get(str(user_id))
|
||||||
if cached:
|
if cached:
|
||||||
@@ -142,18 +152,22 @@ class KookAPIMixin:
|
|||||||
is_bot=bool(data.get('bot', False)),
|
is_bot=bool(data.get('bot', False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||||
return list(self._user_cache.values())
|
return list(self._user_cache.values())
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||||
data = {'file': file_data}
|
data = {'file': file_data}
|
||||||
raw = await self._request('POST', '/asset/create', data=data, filename=filename)
|
raw = await self._request('POST', '/asset/create', data=data, filename=filename)
|
||||||
result = raw.get('data') or {}
|
result = raw.get('data') or {}
|
||||||
return str(result.get('url') or result.get('id') or '')
|
return str(result.get('url') or result.get('id') or '')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||||
async def get_file_url(self, file_id: str) -> str:
|
async def get_file_url(self, file_id: str) -> str:
|
||||||
return file_id
|
return file_id
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||||
async def edit_message(
|
async def edit_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -163,6 +177,7 @@ class KookAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotSupportedError('edit_message')
|
raise NotSupportedError('edit_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||||
async def delete_message(
|
async def delete_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -172,6 +187,7 @@ class KookAPIMixin:
|
|||||||
endpoint = '/message/delete' if str(chat_type).lower() in {'group', 'channel'} else '/direct-message/delete'
|
endpoint = '/message/delete' if str(chat_type).lower() in {'group', 'channel'} else '/direct-message/delete'
|
||||||
await self._request('POST', endpoint, json={'msg_id': str(message_id)})
|
await self._request('POST', endpoint, json={'msg_id': str(message_id)})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||||
async def forward_message(
|
async def forward_message(
|
||||||
self,
|
self,
|
||||||
from_chat_type: str,
|
from_chat_type: str,
|
||||||
@@ -185,6 +201,7 @@ class KookAPIMixin:
|
|||||||
raise NotSupportedError('forward_message:message_not_cached')
|
raise NotSupportedError('forward_message:message_not_cached')
|
||||||
return await self.send_message(to_chat_type, str(to_chat_id), cached.message_chain)
|
return await self.send_message(to_chat_type, str(to_chat_id), cached.message_chain)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'mute_member', source='platform', stage='accepted')
|
||||||
async def mute_member(
|
async def mute_member(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -193,6 +210,7 @@ class KookAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotSupportedError('mute_member')
|
raise NotSupportedError('mute_member')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'unmute_member', source='platform', stage='accepted')
|
||||||
async def unmute_member(
|
async def unmute_member(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -200,6 +218,7 @@ class KookAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotSupportedError('unmute_member')
|
raise NotSupportedError('unmute_member')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'kick_member', source='platform', stage='accepted')
|
||||||
async def kick_member(
|
async def kick_member(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -207,5 +226,6 @@ class KookAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotSupportedError('kick_member')
|
raise NotSupportedError('kick_member')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'leave_group', source='platform', stage='accepted')
|
||||||
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
||||||
raise NotSupportedError('leave_group')
|
raise NotSupportedError('leave_group')
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
@@ -16,6 +18,7 @@ class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||||
async def target2yiri(kook_event: dict, bot_account_id: str = '') -> platform_events.Event | None:
|
async def target2yiri(kook_event: dict, bot_account_id: str = '') -> platform_events.Event | None:
|
||||||
event_type = int(kook_event.get('type', 0) or 0)
|
event_type = int(kook_event.get('type', 0) or 0)
|
||||||
channel_type = kook_event.get('channel_type')
|
channel_type = kook_event.get('channel_type')
|
||||||
@@ -55,6 +58,7 @@ class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||||
async def target2legacy(
|
async def target2legacy(
|
||||||
kook_event: dict, bot_account_id: str = ''
|
kook_event: dict, bot_account_id: str = ''
|
||||||
) -> platform_events.FriendMessage | platform_events.GroupMessage:
|
) -> platform_events.FriendMessage | platform_events.GroupMessage:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
from langbot.pkg.platform.sources.lark import (
|
from langbot.pkg.platform.sources.lark import (
|
||||||
LarkAdapter as LegacyLarkAdapter,
|
LarkAdapter as LegacyLarkAdapter,
|
||||||
NonBlockingLarkWSClient,
|
NonBlockingLarkWSClient,
|
||||||
@@ -186,6 +188,16 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
self.request_app_ticket()
|
self.request_app_ticket()
|
||||||
|
|
||||||
def _build_event_handler(self):
|
def _build_event_handler(self):
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||||
await self._handle_message_event(event)
|
await self._handle_message_event(event)
|
||||||
|
|
||||||
@@ -326,6 +338,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
self.request_tenant_access_token(tenant_key)
|
self.request_tenant_access_token(tenant_key)
|
||||||
return self.tenant_access_tokens.get(tenant_key, {}).get('token')
|
return self.tenant_access_tokens.get(tenant_key, {}).get('token')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
@@ -359,6 +372,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
message_id=message_ids[-1] if message_ids else '', raw={'message_ids': message_ids}
|
message_id=message_ids[-1] if message_ids else '', raw={'message_ids': message_ids}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -435,6 +449,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
while len(self.pending_monitoring_msg) > 1000:
|
while len(self.pending_monitoring_msg) > 1000:
|
||||||
self.pending_monitoring_msg.pop(next(iter(self.pending_monitoring_msg)), None)
|
self.pending_monitoring_msg.pop(next(iter(self.pending_monitoring_msg)), None)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'create_message_card', source='platform', stage='accepted')
|
||||||
async def create_message_card(self, message_id, event) -> bool:
|
async def create_message_card(self, message_id, event) -> bool:
|
||||||
card_id = await self.create_card_id(message_id)
|
card_id = await self.create_card_id(message_id)
|
||||||
content = {'type': 'card', 'data': {'card_id': card_id, 'template_variable': {'content': 'Thinking...'}}}
|
content = {'type': 'card', 'data': {'card_id': card_id, 'template_variable': {'content': 'Thinking...'}}}
|
||||||
@@ -539,6 +554,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
raise RuntimeError(f'Lark card update failed: {response.code} {response.msg}')
|
raise RuntimeError(f'Lark card update failed: {response.code} {response.msg}')
|
||||||
self.closed_streaming_cards.add(card_id)
|
self.closed_streaming_cards.add(card_id)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message_chunk', source='platform', stage='accepted')
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -589,6 +605,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
self.card_last_update_dict.pop(card_id, None)
|
self.card_last_update_dict.pop(card_id, None)
|
||||||
self.closed_streaming_cards.discard(card_id)
|
self.closed_streaming_cards.discard(card_id)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||||
if action == 'interaction.request':
|
if action == 'interaction.request':
|
||||||
return await send_interaction(self, params)
|
return await send_interaction(self, params)
|
||||||
@@ -733,6 +750,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||||
async def _handle_message_event(self, event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
async def _handle_message_event(self, event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||||
try:
|
try:
|
||||||
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
|
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from lark_oapi.api.im.v1 import GetChatRequest, GetMessageRequest
|
from lark_oapi.api.im.v1 import GetChatRequest, GetMessageRequest
|
||||||
@@ -17,6 +19,7 @@ class LarkAPIMixin:
|
|||||||
_user_cache: dict[str, platform_entities.User]
|
_user_cache: dict[str, platform_entities.User]
|
||||||
_group_cache: dict[str, platform_entities.UserGroup]
|
_group_cache: dict[str, platform_entities.UserGroup]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||||
async def get_message(
|
async def get_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -52,6 +55,7 @@ class LarkAPIMixin:
|
|||||||
self._message_cache[str(message_id)] = event
|
self._message_cache[str(message_id)] = event
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||||
cached = self._group_cache.get(str(group_id))
|
cached = self._group_cache.get(str(group_id))
|
||||||
if cached:
|
if cached:
|
||||||
@@ -71,6 +75,7 @@ class LarkAPIMixin:
|
|||||||
self._group_cache[str(group.id)] = group
|
self._group_cache[str(group.id)] = group
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||||
async def get_group_member_info(
|
async def get_group_member_info(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -79,12 +84,14 @@ class LarkAPIMixin:
|
|||||||
user = self._user_cache.get(str(user_id)) or platform_entities.User(id=user_id)
|
user = self._user_cache.get(str(user_id)) or platform_entities.User(id=user_id)
|
||||||
return platform_entities.UserGroupMember(user=user, group_id=group_id, role=platform_entities.MemberRole.MEMBER)
|
return platform_entities.UserGroupMember(user=user, group_id=group_id, role=platform_entities.MemberRole.MEMBER)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||||
cached = self._user_cache.get(str(user_id))
|
cached = self._user_cache.get(str(user_id))
|
||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
return platform_entities.User(id=user_id)
|
return platform_entities.User(id=user_id)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||||
async def get_file_url(self, file_id: str) -> str:
|
async def get_file_url(self, file_id: str) -> str:
|
||||||
if str(file_id).startswith('file://'):
|
if str(file_id).startswith('file://'):
|
||||||
return str(file_id)
|
return str(file_id)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return getattr(event, 'source_platform_object', None)
|
return getattr(event, 'source_platform_object', None)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||||
async def target2yiri(
|
async def target2yiri(
|
||||||
event: lark_oapi.im.v1.P2ImMessageReceiveV1,
|
event: lark_oapi.im.v1.P2ImMessageReceiveV1,
|
||||||
api_client: lark_oapi.Client,
|
api_client: lark_oapi.Client,
|
||||||
@@ -31,6 +34,7 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return await LarkEventConverter.message_to_eba(event, api_client)
|
return await LarkEventConverter.message_to_eba(event, api_client)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||||
async def target2legacy(
|
async def target2legacy(
|
||||||
event: lark_oapi.im.v1.P2ImMessageReceiveV1,
|
event: lark_oapi.im.v1.P2ImMessageReceiveV1,
|
||||||
api_client: lark_oapi.Client,
|
api_client: lark_oapi.Client,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
import typing
|
import typing
|
||||||
@@ -94,6 +96,7 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
|
|||||||
'call_platform_api',
|
'call_platform_api',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
@@ -102,6 +105,7 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
|
|||||||
) -> platform_events.MessageResult:
|
) -> platform_events.MessageResult:
|
||||||
raise NotSupportedError('send_message:official_account_requires_inbound_webhook_reply')
|
raise NotSupportedError('send_message:official_account_requires_inbound_webhook_reply')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -118,6 +122,7 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
|
|||||||
await self.bot.set_message(source.message_id, content)
|
await self.bot.set_message(source.message_id, content)
|
||||||
return platform_events.MessageResult(message_id=source.message_id, raw={'queued': True})
|
return platform_events.MessageResult(message_id=source.message_id, raw={'queued': True})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||||
handler = PLATFORM_API_MAP.get(action)
|
handler = PLATFORM_API_MAP.get(action)
|
||||||
if handler is None:
|
if handler is None:
|
||||||
@@ -170,6 +175,7 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
|
|||||||
for msg_type in ('text', 'image', 'voice', 'event'):
|
for msg_type in ('text', 'image', 'voice', 'event'):
|
||||||
self.bot.on_message(msg_type)(self._handle_native_event)
|
self.bot.on_message(msg_type)(self._handle_native_event)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||||
async def _handle_native_event(self, event: OAEvent):
|
async def _handle_native_event(self, event: OAEvent):
|
||||||
self.bot_account_id = event.receiver_id or self.bot_account_id
|
self.bot_account_id = event.receiver_id or self.bot_account_id
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||||
@@ -12,6 +14,7 @@ class OfficialAccountAPIMixin:
|
|||||||
_message_cache: dict[str, platform_events.MessageReceivedEvent]
|
_message_cache: dict[str, platform_events.MessageReceivedEvent]
|
||||||
_user_cache: dict[str, platform_entities.User]
|
_user_cache: dict[str, platform_entities.User]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||||
async def get_message(
|
async def get_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -23,15 +26,18 @@ class OfficialAccountAPIMixin:
|
|||||||
raise NotSupportedError('get_message:message_not_cached')
|
raise NotSupportedError('get_message:message_not_cached')
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||||
user = self._user_cache.get(str(user_id))
|
user = self._user_cache.get(str(user_id))
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotSupportedError('get_user_info:not_cached')
|
raise NotSupportedError('get_user_info:not_cached')
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||||
return list(self._user_cache.values())
|
return list(self._user_cache.values())
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||||
async def edit_message(
|
async def edit_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -41,6 +47,7 @@ class OfficialAccountAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotSupportedError('edit_message')
|
raise NotSupportedError('edit_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||||
async def delete_message(
|
async def delete_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -49,6 +56,7 @@ class OfficialAccountAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotSupportedError('delete_message')
|
raise NotSupportedError('delete_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||||
async def forward_message(
|
async def forward_message(
|
||||||
self,
|
self,
|
||||||
from_chat_type: str,
|
from_chat_type: str,
|
||||||
@@ -59,24 +67,30 @@ class OfficialAccountAPIMixin:
|
|||||||
) -> platform_events.MessageResult:
|
) -> platform_events.MessageResult:
|
||||||
raise NotSupportedError('forward_message')
|
raise NotSupportedError('forward_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||||
raise NotSupportedError('upload_file')
|
raise NotSupportedError('upload_file')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||||
async def get_file_url(self, file_id: str) -> str:
|
async def get_file_url(self, file_id: str) -> str:
|
||||||
raise NotSupportedError('get_file_url')
|
raise NotSupportedError('get_file_url')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||||
raise NotSupportedError('get_group_info')
|
raise NotSupportedError('get_group_info')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_list', source='platform', stage='accepted')
|
||||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||||
raise NotSupportedError('get_group_list')
|
raise NotSupportedError('get_group_list')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||||
async def get_group_member_list(
|
async def get_group_member_list(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
) -> list[platform_entities.UserGroupMember]:
|
) -> list[platform_entities.UserGroupMember]:
|
||||||
raise NotSupportedError('get_group_member_list')
|
raise NotSupportedError('get_group_member_list')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||||
async def get_group_member_info(
|
async def get_group_member_info(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ class OfficialAccountEventConverter(abstract_platform_adapter.AbstractEventConve
|
|||||||
async def yiri2target(event: platform_events.Event) -> typing.Any:
|
async def yiri2target(event: platform_events.Event) -> typing.Any:
|
||||||
return getattr(event, 'source_platform_object', None)
|
return getattr(event, 'source_platform_object', None)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||||
async def target2legacy(self, event: OAEvent) -> platform_events.FriendMessage | None:
|
async def target2legacy(self, event: OAEvent) -> platform_events.FriendMessage | None:
|
||||||
eba_event = await self.target2yiri(event)
|
eba_event = await self.target2yiri(event)
|
||||||
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
|
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
|
||||||
@@ -31,6 +34,7 @@ class OfficialAccountEventConverter(abstract_platform_adapter.AbstractEventConve
|
|||||||
source_platform_object=event,
|
source_platform_object=event,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||||
async def target2yiri(self, event: OAEvent) -> platform_events.Event | None:
|
async def target2yiri(self, event: OAEvent) -> platform_events.Event | None:
|
||||||
if event.type in {'text', 'image', 'voice'}:
|
if event.type in {'text', 'image', 'voice'}:
|
||||||
return await self.message_to_eba(event)
|
return await self.message_to_eba(event)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
@@ -132,6 +134,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
|||||||
def _plain_message(text: str) -> platform_message.MessageChain:
|
def _plain_message(text: str) -> platform_message.MessageChain:
|
||||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
@@ -143,6 +146,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
|||||||
)
|
)
|
||||||
return platform_events.MessageResult(raw={'results': raw})
|
return platform_events.MessageResult(raw={'results': raw})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -161,6 +165,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
|||||||
)
|
)
|
||||||
return platform_events.MessageResult(message_id=source.d_id or source.id, raw={'results': raw})
|
return platform_events.MessageResult(message_id=source.d_id or source.id, raw={'results': raw})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||||
if action == 'interaction.request':
|
if action == 'interaction.request':
|
||||||
return await send_interaction(self, params)
|
return await send_interaction(self, params)
|
||||||
@@ -258,6 +263,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
|||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
return bool(self.config.get('enable-stream-reply') or self.config.get('enable_stream_reply'))
|
return bool(self.config.get('enable-stream-reply') or self.config.get('enable_stream_reply'))
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'create_message_card', source='platform', stage='accepted')
|
||||||
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||||
source = event.source_platform_object
|
source = event.source_platform_object
|
||||||
if not isinstance(source, QQOfficialEvent) or source.t != 'C2C_MESSAGE_CREATE':
|
if not isinstance(source, QQOfficialEvent) or source.t != 'C2C_MESSAGE_CREATE':
|
||||||
@@ -277,6 +283,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
|||||||
self._stream_ctx_ts[message_id] = time.time()
|
self._stream_ctx_ts[message_id] = time.time()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message_chunk', source='platform', stage='accepted')
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -364,6 +371,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
|||||||
if event is not None:
|
if event is not None:
|
||||||
await self._dispatch_eba_event(event)
|
await self._dispatch_eba_event(event)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||||
async def _handle_native_event(self, event: QQOfficialEvent):
|
async def _handle_native_event(self, event: QQOfficialEvent):
|
||||||
self.bot_account_id = self.config.get('appid', self.bot_account_id)
|
self.bot_account_id = self.config.get('appid', self.bot_account_id)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError
|
from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError
|
||||||
@@ -14,6 +16,7 @@ class QQOfficialAPIMixin:
|
|||||||
_group_cache: dict[str, platform_entities.UserGroup]
|
_group_cache: dict[str, platform_entities.UserGroup]
|
||||||
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember]
|
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||||
async def get_message(
|
async def get_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -25,21 +28,25 @@ class QQOfficialAPIMixin:
|
|||||||
raise NotSupportedError('get_message:message_not_cached')
|
raise NotSupportedError('get_message:message_not_cached')
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||||
user = self._user_cache.get(str(user_id))
|
user = self._user_cache.get(str(user_id))
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotSupportedError('get_user_info:not_cached')
|
raise NotSupportedError('get_user_info:not_cached')
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||||
return list(self._user_cache.values())
|
return list(self._user_cache.values())
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||||
group = self._group_cache.get(str(group_id))
|
group = self._group_cache.get(str(group_id))
|
||||||
if group is None:
|
if group is None:
|
||||||
raise NotSupportedError('get_group_info:not_cached')
|
raise NotSupportedError('get_group_info:not_cached')
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||||
async def get_group_member_info(
|
async def get_group_member_info(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -50,6 +57,7 @@ class QQOfficialAPIMixin:
|
|||||||
raise NotSupportedError('get_group_member_info:not_cached')
|
raise NotSupportedError('get_group_member_info:not_cached')
|
||||||
return member
|
return member
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||||
async def get_group_member_list(
|
async def get_group_member_list(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -58,6 +66,7 @@ class QQOfficialAPIMixin:
|
|||||||
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
|
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||||
async def edit_message(
|
async def edit_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -67,6 +76,7 @@ class QQOfficialAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotSupportedError('edit_message')
|
raise NotSupportedError('edit_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||||
async def delete_message(
|
async def delete_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -75,6 +85,7 @@ class QQOfficialAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotSupportedError('delete_message')
|
raise NotSupportedError('delete_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||||
async def forward_message(
|
async def forward_message(
|
||||||
self,
|
self,
|
||||||
from_chat_type: str,
|
from_chat_type: str,
|
||||||
@@ -85,20 +96,26 @@ class QQOfficialAPIMixin:
|
|||||||
) -> platform_events.MessageResult:
|
) -> platform_events.MessageResult:
|
||||||
raise NotSupportedError('forward_message')
|
raise NotSupportedError('forward_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||||
raise NotSupportedError('upload_file')
|
raise NotSupportedError('upload_file')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||||
async def get_file_url(self, file_id: str) -> str:
|
async def get_file_url(self, file_id: str) -> str:
|
||||||
raise NotSupportedError('get_file_url')
|
raise NotSupportedError('get_file_url')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'mute_member', source='platform', stage='accepted')
|
||||||
async def mute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str], duration: int = 0):
|
async def mute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str], duration: int = 0):
|
||||||
raise NotSupportedError('mute_member')
|
raise NotSupportedError('mute_member')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'unmute_member', source='platform', stage='accepted')
|
||||||
async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
|
async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
|
||||||
raise NotSupportedError('unmute_member')
|
raise NotSupportedError('unmute_member')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'kick_member', source='platform', stage='accepted')
|
||||||
async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
|
async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
|
||||||
raise NotSupportedError('kick_member')
|
raise NotSupportedError('kick_member')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'leave_group', source='platform', stage='accepted')
|
||||||
async def leave_group(self, group_id: typing.Union[int, str]):
|
async def leave_group(self, group_id: typing.Union[int, str]):
|
||||||
raise NotSupportedError('leave_group')
|
raise NotSupportedError('leave_group')
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
@@ -31,6 +33,7 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
|
|||||||
async def yiri2target(event: platform_events.Event) -> typing.Any:
|
async def yiri2target(event: platform_events.Event) -> typing.Any:
|
||||||
return getattr(event, 'source_platform_object', None)
|
return getattr(event, 'source_platform_object', None)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||||
async def target2legacy(
|
async def target2legacy(
|
||||||
self, event: QQOfficialEvent
|
self, event: QQOfficialEvent
|
||||||
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
|
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
|
||||||
@@ -65,6 +68,7 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
|
|||||||
source_platform_object=event,
|
source_platform_object=event,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||||
async def target2yiri(self, event: QQOfficialEvent) -> platform_events.Event:
|
async def target2yiri(self, event: QQOfficialEvent) -> platform_events.Event:
|
||||||
if event.t in MESSAGE_EVENT_TYPES:
|
if event.t in MESSAGE_EVENT_TYPES:
|
||||||
return await self.message_to_eba(event)
|
return await self.message_to_eba(event)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
import typing
|
import typing
|
||||||
@@ -90,6 +92,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
|||||||
'call_platform_api',
|
'call_platform_api',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
@@ -100,6 +103,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
|||||||
raw = await self._send_text(str(target_type), str(target_id), content)
|
raw = await self._send_text(str(target_type), str(target_id), content)
|
||||||
return platform_events.MessageResult(raw=raw)
|
return platform_events.MessageResult(raw=raw)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -114,6 +118,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
|||||||
raw = await self._send_text(target_type, target_id, await SlackMessageConverter.yiri2target(message))
|
raw = await self._send_text(target_type, target_id, await SlackMessageConverter.yiri2target(message))
|
||||||
return platform_events.MessageResult(message_id=source.message_id, raw=raw)
|
return platform_events.MessageResult(message_id=source.message_id, raw=raw)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||||
handler = PLATFORM_API_MAP.get(action)
|
handler = PLATFORM_API_MAP.get(action)
|
||||||
if handler is None:
|
if handler is None:
|
||||||
@@ -162,6 +167,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
|||||||
for msg_type in ('im', 'channel'):
|
for msg_type in ('im', 'channel'):
|
||||||
self.bot.on_message(msg_type)(self._handle_native_event)
|
self.bot.on_message(msg_type)(self._handle_native_event)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||||
async def _handle_native_event(self, event: SlackEvent):
|
async def _handle_native_event(self, event: SlackEvent):
|
||||||
try:
|
try:
|
||||||
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
|
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from langbot.pkg.platform.adapters.slack.errors import NotSupportedError
|
from langbot.pkg.platform.adapters.slack.errors import NotSupportedError
|
||||||
@@ -14,6 +16,7 @@ class SlackAPIMixin:
|
|||||||
_group_cache: dict[str, platform_entities.UserGroup]
|
_group_cache: dict[str, platform_entities.UserGroup]
|
||||||
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember]
|
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||||
async def get_message(
|
async def get_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -25,24 +28,29 @@ class SlackAPIMixin:
|
|||||||
raise NotSupportedError('get_message:message_not_cached')
|
raise NotSupportedError('get_message:message_not_cached')
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||||
user = self._user_cache.get(str(user_id))
|
user = self._user_cache.get(str(user_id))
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotSupportedError('get_user_info:not_cached')
|
raise NotSupportedError('get_user_info:not_cached')
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||||
return list(self._user_cache.values())
|
return list(self._user_cache.values())
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||||
group = self._group_cache.get(str(group_id))
|
group = self._group_cache.get(str(group_id))
|
||||||
if group is None:
|
if group is None:
|
||||||
raise NotSupportedError('get_group_info:not_cached')
|
raise NotSupportedError('get_group_info:not_cached')
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_list', source='platform', stage='accepted')
|
||||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||||
return list(self._group_cache.values())
|
return list(self._group_cache.values())
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||||
async def get_group_member_list(
|
async def get_group_member_list(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -51,6 +59,7 @@ class SlackAPIMixin:
|
|||||||
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
|
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||||
async def get_group_member_info(
|
async def get_group_member_info(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -61,6 +70,7 @@ class SlackAPIMixin:
|
|||||||
raise NotSupportedError('get_group_member_info:not_cached')
|
raise NotSupportedError('get_group_member_info:not_cached')
|
||||||
return member
|
return member
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||||
async def edit_message(
|
async def edit_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -70,6 +80,7 @@ class SlackAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotSupportedError('edit_message')
|
raise NotSupportedError('edit_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||||
async def delete_message(
|
async def delete_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -78,6 +89,7 @@ class SlackAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotSupportedError('delete_message')
|
raise NotSupportedError('delete_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||||
async def forward_message(
|
async def forward_message(
|
||||||
self,
|
self,
|
||||||
from_chat_type: str,
|
from_chat_type: str,
|
||||||
@@ -88,8 +100,10 @@ class SlackAPIMixin:
|
|||||||
) -> platform_events.MessageResult:
|
) -> platform_events.MessageResult:
|
||||||
raise NotSupportedError('forward_message')
|
raise NotSupportedError('forward_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||||
raise NotSupportedError('upload_file')
|
raise NotSupportedError('upload_file')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||||
async def get_file_url(self, file_id: str) -> str:
|
async def get_file_url(self, file_id: str) -> str:
|
||||||
raise NotSupportedError('get_file_url')
|
raise NotSupportedError('get_file_url')
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
async def yiri2target(event: platform_events.Event) -> typing.Any:
|
async def yiri2target(event: platform_events.Event) -> typing.Any:
|
||||||
return getattr(event, 'source_platform_object', None)
|
return getattr(event, 'source_platform_object', None)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||||
async def target2legacy(
|
async def target2legacy(
|
||||||
self, event: SlackEvent
|
self, event: SlackEvent
|
||||||
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
|
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
|
||||||
@@ -53,6 +56,7 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
source_platform_object=event,
|
source_platform_object=event,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||||
async def target2yiri(self, event: SlackEvent) -> platform_events.Event:
|
async def target2yiri(self, event: SlackEvent) -> platform_events.Event:
|
||||||
if event.type in {'im', 'channel'}:
|
if event.type in {'im', 'channel'}:
|
||||||
return await self.message_to_eba(event)
|
return await self.message_to_eba(event)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Preserves all existing functionality (messaging, streaming output, markdown card
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
@@ -68,6 +70,16 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
arbitrary_types_allowed = True
|
arbitrary_types_allowed = True
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||||
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.native_callback',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
ap=lambda: getattr(logger, 'ap', None),
|
||||||
|
fields=lambda b: {
|
||||||
|
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||||
|
},
|
||||||
|
)
|
||||||
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
if (
|
if (
|
||||||
not update.message
|
not update.message
|
||||||
@@ -206,6 +218,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
|
|
||||||
# ---- Message Send / Reply (preserving original logic) ----
|
# ---- Message Send / Reply (preserving original logic) ----
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
||||||
|
|
||||||
@@ -240,6 +253,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
args['document'] = telegram.InputFile(doc, filename=filename)
|
args['document'] = telegram.InputFile(doc, filename=filename)
|
||||||
await self.bot.send_document(**args)
|
await self.bot.send_document(**args)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -317,6 +331,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip()
|
cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip()
|
||||||
return cleaned == ''
|
return cleaned == ''
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'create_message_card', source='platform', stage='accepted')
|
||||||
async def create_message_card(self, message_id, event):
|
async def create_message_card(self, message_id, event):
|
||||||
assert isinstance(event.source_platform_object, Update)
|
assert isinstance(event.source_platform_object, Update)
|
||||||
update = event.source_platform_object
|
update = event.source_platform_object
|
||||||
@@ -331,6 +346,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message_chunk', source='platform', stage='accepted')
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -492,6 +508,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
|
|
||||||
# ---- Pass-through API ----
|
# ---- Pass-through API ----
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||||
async def call_platform_api(
|
async def call_platform_api(
|
||||||
self,
|
self,
|
||||||
action: str,
|
action: str,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Implements optional API methods defined in AbstractPlatformAdapter.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import telegram
|
import telegram
|
||||||
@@ -25,6 +27,7 @@ class TelegramAPIMixin:
|
|||||||
|
|
||||||
bot: telegram.Bot
|
bot: telegram.Bot
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||||
async def edit_message(
|
async def edit_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -52,6 +55,7 @@ class TelegramAPIMixin:
|
|||||||
await self.bot.edit_message_text(**args)
|
await self.bot.edit_message_text(**args)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||||
async def delete_message(
|
async def delete_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -61,6 +65,7 @@ class TelegramAPIMixin:
|
|||||||
"""Delete / recall a message."""
|
"""Delete / recall a message."""
|
||||||
await self.bot.delete_message(chat_id=chat_id, message_id=message_id)
|
await self.bot.delete_message(chat_id=chat_id, message_id=message_id)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||||
async def forward_message(
|
async def forward_message(
|
||||||
self,
|
self,
|
||||||
from_chat_type: str,
|
from_chat_type: str,
|
||||||
@@ -80,6 +85,7 @@ class TelegramAPIMixin:
|
|||||||
raw={'message_id': result.message_id},
|
raw={'message_id': result.message_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||||
async def get_group_info(
|
async def get_group_info(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -100,6 +106,7 @@ class TelegramAPIMixin:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||||
async def get_group_member_list(
|
async def get_group_member_list(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -134,6 +141,7 @@ class TelegramAPIMixin:
|
|||||||
)
|
)
|
||||||
return members
|
return members
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||||
async def get_group_member_info(
|
async def get_group_member_info(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -160,6 +168,7 @@ class TelegramAPIMixin:
|
|||||||
display_name=member.custom_title if hasattr(member, 'custom_title') else None,
|
display_name=member.custom_title if hasattr(member, 'custom_title') else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||||
async def get_user_info(
|
async def get_user_info(
|
||||||
self,
|
self,
|
||||||
user_id: typing.Union[int, str],
|
user_id: typing.Union[int, str],
|
||||||
@@ -172,6 +181,7 @@ class TelegramAPIMixin:
|
|||||||
username=chat.username,
|
username=chat.username,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||||
async def upload_file(
|
async def upload_file(
|
||||||
self,
|
self,
|
||||||
file_data: bytes,
|
file_data: bytes,
|
||||||
@@ -186,6 +196,7 @@ class TelegramAPIMixin:
|
|||||||
|
|
||||||
raise NotSupportedError('upload_file')
|
raise NotSupportedError('upload_file')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||||
async def get_file_url(
|
async def get_file_url(
|
||||||
self,
|
self,
|
||||||
file_id: str,
|
file_id: str,
|
||||||
@@ -194,6 +205,7 @@ class TelegramAPIMixin:
|
|||||||
file = await self.bot.get_file(file_id)
|
file = await self.bot.get_file(file_id)
|
||||||
return file.file_path
|
return file.file_path
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'mute_member', source='platform', stage='accepted')
|
||||||
async def mute_member(
|
async def mute_member(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -213,6 +225,7 @@ class TelegramAPIMixin:
|
|||||||
kwargs['until_date'] = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=duration)
|
kwargs['until_date'] = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=duration)
|
||||||
await self.bot.restrict_chat_member(**kwargs)
|
await self.bot.restrict_chat_member(**kwargs)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'unmute_member', source='platform', stage='accepted')
|
||||||
async def unmute_member(
|
async def unmute_member(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -236,6 +249,7 @@ class TelegramAPIMixin:
|
|||||||
permissions=permissions,
|
permissions=permissions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'kick_member', source='platform', stage='accepted')
|
||||||
async def kick_member(
|
async def kick_member(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -244,6 +258,7 @@ class TelegramAPIMixin:
|
|||||||
"""Kick a member from the group."""
|
"""Kick a member from the group."""
|
||||||
await self.bot.ban_chat_member(chat_id=group_id, user_id=user_id)
|
await self.bot.ban_chat_member(chat_id=group_id, user_id=user_id)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'leave_group', source='platform', stage='accepted')
|
||||||
async def leave_group(
|
async def leave_group(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Converts all Telegram Update types to unified EBA events, not just messages.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import telegram
|
import telegram
|
||||||
@@ -54,6 +56,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||||
async def target2yiri(
|
async def target2yiri(
|
||||||
update: Update,
|
update: Update,
|
||||||
bot: telegram.Bot,
|
bot: telegram.Bot,
|
||||||
@@ -384,6 +387,7 @@ class LegacyEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return event.source_platform_object
|
return event.source_platform_object
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||||
async def target2yiri(event: Update, bot: telegram.Bot, bot_account_id: str):
|
async def target2yiri(event: Update, bot: telegram.Bot, bot_account_id: str):
|
||||||
"""Convert to legacy format (FriendMessage / GroupMessage)."""
|
"""Convert to legacy format (FriendMessage / GroupMessage)."""
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as legacy_events
|
import langbot_plugin.api.entities.builtin.platform.events as legacy_events
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
import typing
|
import typing
|
||||||
@@ -90,6 +92,7 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
|||||||
'call_platform_api',
|
'call_platform_api',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
@@ -106,6 +109,7 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
|||||||
raw_results.append(await self._send_content(user_id, agent_id, content))
|
raw_results.append(await self._send_content(user_id, agent_id, content))
|
||||||
return platform_events.MessageResult(raw={'results': raw_results})
|
return platform_events.MessageResult(raw={'results': raw_results})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -121,6 +125,7 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
|||||||
raw_results.append(await self._send_content(wecom_event.user_id, int(wecom_event.agent_id), content))
|
raw_results.append(await self._send_content(wecom_event.user_id, int(wecom_event.agent_id), content))
|
||||||
return platform_events.MessageResult(message_id=wecom_event.message_id, raw={'results': raw_results})
|
return platform_events.MessageResult(message_id=wecom_event.message_id, raw={'results': raw_results})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||||
handler = PLATFORM_API_MAP.get(action)
|
handler = PLATFORM_API_MAP.get(action)
|
||||||
if handler is None:
|
if handler is None:
|
||||||
@@ -174,6 +179,7 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
|||||||
self.bot.on_message('text')(on_message)
|
self.bot.on_message('text')(on_message)
|
||||||
self.bot.on_message('image')(on_message)
|
self.bot.on_message('image')(on_message)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||||
async def _handle_native_event(self, event: WecomEvent):
|
async def _handle_native_event(self, event: WecomEvent):
|
||||||
self.bot_account_id = event.receiver_id or self.bot_account_id
|
self.bot_account_id = event.receiver_id or self.bot_account_id
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from langbot.libs.wecom_api.api import WecomClient
|
from langbot.libs.wecom_api.api import WecomClient
|
||||||
@@ -14,6 +16,7 @@ class WecomAPIMixin:
|
|||||||
_message_cache: dict[str, platform_events.MessageReceivedEvent]
|
_message_cache: dict[str, platform_events.MessageReceivedEvent]
|
||||||
_user_cache: dict[str, platform_entities.User]
|
_user_cache: dict[str, platform_entities.User]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||||
async def get_message(
|
async def get_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -25,6 +28,7 @@ class WecomAPIMixin:
|
|||||||
raise NotSupportedError('get_message:message_not_cached')
|
raise NotSupportedError('get_message:message_not_cached')
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||||
cached = self._user_cache.get(str(user_id))
|
cached = self._user_cache.get(str(user_id))
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
@@ -36,24 +40,30 @@ class WecomAPIMixin:
|
|||||||
username=info.get('alias') or info.get('userid') or None,
|
username=info.get('alias') or info.get('userid') or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||||
return list(self._user_cache.values())
|
return list(self._user_cache.values())
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||||
raise NotSupportedError('upload_file')
|
raise NotSupportedError('upload_file')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||||
async def get_file_url(self, file_id: str) -> str:
|
async def get_file_url(self, file_id: str) -> str:
|
||||||
raise NotSupportedError('get_file_url')
|
raise NotSupportedError('get_file_url')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||||
raise NotSupportedError('get_group_info')
|
raise NotSupportedError('get_group_info')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||||
async def get_group_member_list(
|
async def get_group_member_list(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
) -> list[platform_entities.UserGroupMember]:
|
) -> list[platform_entities.UserGroupMember]:
|
||||||
raise NotSupportedError('get_group_member_list')
|
raise NotSupportedError('get_group_member_list')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||||
async def get_group_member_info(
|
async def get_group_member_info(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -61,6 +71,7 @@ class WecomAPIMixin:
|
|||||||
) -> platform_entities.UserGroupMember:
|
) -> platform_entities.UserGroupMember:
|
||||||
raise NotSupportedError('get_group_member_info')
|
raise NotSupportedError('get_group_member_info')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||||
async def edit_message(
|
async def edit_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -70,6 +81,7 @@ class WecomAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotSupportedError('edit_message')
|
raise NotSupportedError('edit_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||||
async def delete_message(
|
async def delete_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from langbot.libs.wecom_api.api import WecomClient
|
from langbot.libs.wecom_api.api import WecomClient
|
||||||
@@ -17,6 +19,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return getattr(event, 'source_platform_object', None)
|
return getattr(event, 'source_platform_object', None)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||||
async def target2legacy(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.FriendMessage | None:
|
async def target2legacy(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.FriendMessage | None:
|
||||||
eba_event = await WecomEventConverter.target2yiri(event, bot)
|
eba_event = await WecomEventConverter.target2yiri(event, bot)
|
||||||
if hasattr(eba_event, 'to_legacy_event'):
|
if hasattr(eba_event, 'to_legacy_event'):
|
||||||
@@ -36,6 +39,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||||
async def target2yiri(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.Event | None:
|
async def target2yiri(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.Event | None:
|
||||||
if event.type in {'text', 'image'}:
|
if event.type in {'text', 'image'}:
|
||||||
return await WecomEventConverter.message_to_eba(event, bot)
|
return await WecomEventConverter.message_to_eba(event, bot)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
from langbot.pkg.platform.sources.wecombot import WecomBotAdapter as LegacyWecomBotAdapter
|
from langbot.pkg.platform.sources.wecombot import WecomBotAdapter as LegacyWecomBotAdapter
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -133,6 +135,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
_iter_media_components = staticmethod(LegacyWecomBotAdapter._iter_media_components)
|
_iter_media_components = staticmethod(LegacyWecomBotAdapter._iter_media_components)
|
||||||
_send_media = staticmethod(LegacyWecomBotAdapter._send_media)
|
_send_media = staticmethod(LegacyWecomBotAdapter._send_media)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
@@ -148,6 +151,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
raw = await self.bot.send_message(str(target_id), content)
|
raw = await self.bot.send_message(str(target_id), content)
|
||||||
return platform_events.MessageResult(raw={'result': raw})
|
return platform_events.MessageResult(raw={'result': raw})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -169,6 +173,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
raw = await self.bot.set_message(event.message_id, content)
|
raw = await self.bot.set_message(event.message_id, content)
|
||||||
return platform_events.MessageResult(message_id=event.message_id, raw={'result': raw})
|
return platform_events.MessageResult(message_id=event.message_id, raw={'result': raw})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message_chunk', source='platform', stage='accepted')
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -196,6 +201,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
return self.config.get('enable-stream-reply', True)
|
return self.config.get('enable-stream-reply', True)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||||
if action == 'interaction.request' and 'interaction.request' in self.get_supported_apis():
|
if action == 'interaction.request' and 'interaction.request' in self.get_supported_apis():
|
||||||
return await send_interaction(self, params)
|
return await send_interaction(self, params)
|
||||||
@@ -271,6 +277,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
self.bot.on_message('event')(self._handle_native_event)
|
self.bot.on_message('event')(self._handle_native_event)
|
||||||
self.bot.on_message('template_card_event')(self._handle_interaction_event)
|
self.bot.on_message('template_card_event')(self._handle_interaction_event)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||||
async def _handle_interaction_event(self, event: WecomBotEvent):
|
async def _handle_interaction_event(self, event: WecomBotEvent):
|
||||||
try:
|
try:
|
||||||
interaction_event = interaction_event_from_native(event)
|
interaction_event = interaction_event_from_native(event)
|
||||||
@@ -279,6 +286,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Error in WeComBot interaction callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in WeComBot interaction callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||||
async def _handle_native_event(self, event: WecomBotEvent):
|
async def _handle_native_event(self, event: WecomBotEvent):
|
||||||
try:
|
try:
|
||||||
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
|
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||||
@@ -14,6 +16,7 @@ class WecomBotAPIMixin:
|
|||||||
_group_cache: dict[str, platform_entities.UserGroup]
|
_group_cache: dict[str, platform_entities.UserGroup]
|
||||||
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember]
|
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||||
async def get_message(
|
async def get_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -25,21 +28,25 @@ class WecomBotAPIMixin:
|
|||||||
raise NotSupportedError('get_message:message_not_cached')
|
raise NotSupportedError('get_message:message_not_cached')
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||||
cached = self._user_cache.get(str(user_id))
|
cached = self._user_cache.get(str(user_id))
|
||||||
if cached is None:
|
if cached is None:
|
||||||
raise NotSupportedError('get_user_info:not_cached')
|
raise NotSupportedError('get_user_info:not_cached')
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||||
return list(self._user_cache.values())
|
return list(self._user_cache.values())
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||||
cached = self._group_cache.get(str(group_id))
|
cached = self._group_cache.get(str(group_id))
|
||||||
if cached is None:
|
if cached is None:
|
||||||
raise NotSupportedError('get_group_info:not_cached')
|
raise NotSupportedError('get_group_info:not_cached')
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||||
async def get_group_member_info(
|
async def get_group_member_info(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -50,6 +57,7 @@ class WecomBotAPIMixin:
|
|||||||
raise NotSupportedError('get_group_member_info:not_cached')
|
raise NotSupportedError('get_group_member_info:not_cached')
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||||
async def get_group_member_list(
|
async def get_group_member_list(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -58,12 +66,15 @@ class WecomBotAPIMixin:
|
|||||||
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
|
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||||
raise NotSupportedError('upload_file')
|
raise NotSupportedError('upload_file')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||||
async def get_file_url(self, file_id: str) -> str:
|
async def get_file_url(self, file_id: str) -> str:
|
||||||
raise NotSupportedError('get_file_url')
|
raise NotSupportedError('get_file_url')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||||
async def edit_message(
|
async def edit_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -73,6 +84,7 @@ class WecomBotAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotSupportedError('edit_message')
|
raise NotSupportedError('edit_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||||
async def delete_message(
|
async def delete_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -81,6 +93,7 @@ class WecomBotAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotSupportedError('delete_message')
|
raise NotSupportedError('delete_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||||
async def forward_message(
|
async def forward_message(
|
||||||
self,
|
self,
|
||||||
from_chat_type: str,
|
from_chat_type: str,
|
||||||
@@ -91,14 +104,18 @@ class WecomBotAPIMixin:
|
|||||||
) -> platform_events.MessageResult:
|
) -> platform_events.MessageResult:
|
||||||
raise NotSupportedError('forward_message')
|
raise NotSupportedError('forward_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'mute_member', source='platform', stage='accepted')
|
||||||
async def mute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str], duration: int = 0):
|
async def mute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str], duration: int = 0):
|
||||||
raise NotSupportedError('mute_member')
|
raise NotSupportedError('mute_member')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'unmute_member', source='platform', stage='accepted')
|
||||||
async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
|
async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
|
||||||
raise NotSupportedError('unmute_member')
|
raise NotSupportedError('unmute_member')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'kick_member', source='platform', stage='accepted')
|
||||||
async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
|
async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
|
||||||
raise NotSupportedError('kick_member')
|
raise NotSupportedError('kick_member')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'leave_group', source='platform', stage='accepted')
|
||||||
async def leave_group(self, group_id: typing.Union[int, str]):
|
async def leave_group(self, group_id: typing.Union[int, str]):
|
||||||
raise NotSupportedError('leave_group')
|
raise NotSupportedError('leave_group')
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
async def yiri2target(event: platform_events.Event) -> typing.Any:
|
async def yiri2target(event: platform_events.Event) -> typing.Any:
|
||||||
return getattr(event, 'source_platform_object', None)
|
return getattr(event, 'source_platform_object', None)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||||
async def target2legacy(
|
async def target2legacy(
|
||||||
self, event: WecomBotEvent
|
self, event: WecomBotEvent
|
||||||
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
|
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
|
||||||
@@ -49,6 +52,7 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
source_platform_object=event,
|
source_platform_object=event,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||||
async def target2yiri(self, event: WecomBotEvent) -> platform_events.Event:
|
async def target2yiri(self, event: WecomBotEvent) -> platform_events.Event:
|
||||||
if event.type in {'single', 'group'} and event.msgtype != 'event':
|
if event.type in {'single', 'group'} and event.msgtype != 'event':
|
||||||
return await self.message_to_eba(event)
|
return await self.message_to_eba(event)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
@@ -92,6 +94,7 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
'call_platform_api',
|
'call_platform_api',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
@@ -110,6 +113,7 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
)
|
)
|
||||||
return platform_events.MessageResult(raw={'results': raw_results})
|
return platform_events.MessageResult(raw={'results': raw_results})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -132,6 +136,7 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
)
|
)
|
||||||
return platform_events.MessageResult(message_id=wecom_event.message_id, raw={'results': raw_results})
|
return platform_events.MessageResult(message_id=wecom_event.message_id, raw={'results': raw_results})
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||||
handler = PLATFORM_API_MAP.get(action)
|
handler = PLATFORM_API_MAP.get(action)
|
||||||
if handler is None:
|
if handler is None:
|
||||||
@@ -186,6 +191,7 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
for msg_type in ('text', 'image', 'file', 'voice'):
|
for msg_type in ('text', 'image', 'file', 'voice'):
|
||||||
self.bot.on_message(msg_type)(on_message)
|
self.bot.on_message(msg_type)(on_message)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||||
async def _handle_native_event(self, event: WecomCSEvent):
|
async def _handle_native_event(self, event: WecomCSEvent):
|
||||||
self.bot_account_id = event.receiver_id or self.bot_account_id
|
self.bot_account_id = event.receiver_id or self.bot_account_id
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from langbot.libs.wecom_customer_service_api.api import WecomCSClient
|
from langbot.libs.wecom_customer_service_api.api import WecomCSClient
|
||||||
@@ -14,6 +16,7 @@ class WecomCSAPIMixin:
|
|||||||
_message_cache: dict[str, platform_events.MessageReceivedEvent]
|
_message_cache: dict[str, platform_events.MessageReceivedEvent]
|
||||||
_user_cache: dict[str, platform_entities.User]
|
_user_cache: dict[str, platform_entities.User]
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||||
async def get_message(
|
async def get_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -25,6 +28,7 @@ class WecomCSAPIMixin:
|
|||||||
raise NotSupportedError('get_message:message_not_cached')
|
raise NotSupportedError('get_message:message_not_cached')
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||||
cached = self._user_cache.get(str(user_id))
|
cached = self._user_cache.get(str(user_id))
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
@@ -39,24 +43,30 @@ class WecomCSAPIMixin:
|
|||||||
username=info.get('external_userid') or None,
|
username=info.get('external_userid') or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||||
return list(self._user_cache.values())
|
return list(self._user_cache.values())
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||||
raise NotSupportedError('upload_file')
|
raise NotSupportedError('upload_file')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||||
async def get_file_url(self, file_id: str) -> str:
|
async def get_file_url(self, file_id: str) -> str:
|
||||||
raise NotSupportedError('get_file_url')
|
raise NotSupportedError('get_file_url')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||||
raise NotSupportedError('get_group_info')
|
raise NotSupportedError('get_group_info')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||||
async def get_group_member_list(
|
async def get_group_member_list(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
) -> list[platform_entities.UserGroupMember]:
|
) -> list[platform_entities.UserGroupMember]:
|
||||||
raise NotSupportedError('get_group_member_list')
|
raise NotSupportedError('get_group_member_list')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||||
async def get_group_member_info(
|
async def get_group_member_info(
|
||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
@@ -64,6 +74,7 @@ class WecomCSAPIMixin:
|
|||||||
) -> platform_entities.UserGroupMember:
|
) -> platform_entities.UserGroupMember:
|
||||||
raise NotSupportedError('get_group_member_info')
|
raise NotSupportedError('get_group_member_info')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||||
async def edit_message(
|
async def edit_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
@@ -73,6 +84,7 @@ class WecomCSAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotSupportedError('edit_message')
|
raise NotSupportedError('edit_message')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||||
async def delete_message(
|
async def delete_message(
|
||||||
self,
|
self,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from langbot.libs.wecom_customer_service_api.api import WecomCSClient
|
from langbot.libs.wecom_customer_service_api.api import WecomCSClient
|
||||||
@@ -17,6 +19,7 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return getattr(event, 'source_platform_object', None)
|
return getattr(event, 'source_platform_object', None)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||||
async def target2legacy(
|
async def target2legacy(
|
||||||
event: WecomCSEvent, bot: WecomCSClient | None = None
|
event: WecomCSEvent, bot: WecomCSClient | None = None
|
||||||
) -> platform_events.FriendMessage | None:
|
) -> platform_events.FriendMessage | None:
|
||||||
@@ -26,6 +29,7 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||||
async def target2yiri(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.Event | None:
|
async def target2yiri(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.Event | None:
|
||||||
if event.type in {'text', 'image', 'file', 'voice'}:
|
if event.type in {'text', 'image', 'file', 'voice'}:
|
||||||
return await WecomCSEventConverter.message_to_eba(event, bot)
|
return await WecomCSEventConverter.message_to_eba(event, bot)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..telemetry import diagnostics
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import dataclasses
|
import dataclasses
|
||||||
@@ -368,6 +370,22 @@ class RuntimeBot:
|
|||||||
) -> dict[str, typing.Any]:
|
) -> dict[str, typing.Any]:
|
||||||
"""Record structured event routing state while preserving the human log."""
|
"""Record structured event routing state while preserving the human log."""
|
||||||
binding = binding or {}
|
binding = binding or {}
|
||||||
|
diagnostics.event(
|
||||||
|
self,
|
||||||
|
'route',
|
||||||
|
'route.primary',
|
||||||
|
{
|
||||||
|
'not_matched': 'skipped',
|
||||||
|
'discarded': 'skipped',
|
||||||
|
'matched': 'started',
|
||||||
|
'delivered': 'succeeded',
|
||||||
|
'failed': 'failed',
|
||||||
|
}.get(status, 'unknown'),
|
||||||
|
stage='dispatch',
|
||||||
|
platform_event_type=event_type,
|
||||||
|
processor_type=target_type or binding.get('target_type', ''),
|
||||||
|
reason_code=failure_code or status,
|
||||||
|
)
|
||||||
metadata = {
|
metadata = {
|
||||||
'kind': 'event_route_trace',
|
'kind': 'event_route_trace',
|
||||||
'event_type': event_type,
|
'event_type': event_type,
|
||||||
@@ -380,6 +398,16 @@ class RuntimeBot:
|
|||||||
'reason': reason or text,
|
'reason': reason or text,
|
||||||
'run_id': run_id,
|
'run_id': run_id,
|
||||||
}
|
}
|
||||||
|
diagnostics.set_outcome(
|
||||||
|
{
|
||||||
|
'not_matched': 'skipped',
|
||||||
|
'discarded': 'skipped',
|
||||||
|
'matched': 'started',
|
||||||
|
'delivered': 'succeeded',
|
||||||
|
'failed': 'failed',
|
||||||
|
}.get(status, 'unknown'),
|
||||||
|
reason_code=failure_code or status,
|
||||||
|
)
|
||||||
log_method = getattr(self.logger, level, self.logger.info)
|
log_method = getattr(self.logger, level, self.logger.info)
|
||||||
await log_method(text, metadata=metadata)
|
await log_method(text, metadata=metadata)
|
||||||
return metadata
|
return metadata
|
||||||
@@ -835,6 +863,7 @@ class RuntimeBot:
|
|||||||
processor_id=agent.get('uuid'),
|
processor_id=agent.get('uuid'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.receive', source='platform', stage='dispatch')
|
||||||
async def _handle_platform_event(
|
async def _handle_platform_event(
|
||||||
self,
|
self,
|
||||||
event: platform_events.EBAEvent,
|
event: platform_events.EBAEvent,
|
||||||
@@ -864,6 +893,7 @@ class RuntimeBot:
|
|||||||
if isinstance(result, BaseException):
|
if isinstance(result, BaseException):
|
||||||
await self.logger.error(f'Event delivery failed: {result}')
|
await self.logger.error(f'Event delivery failed: {result}')
|
||||||
|
|
||||||
|
@diagnostics.observe('route', 'route.subscription', source='platform', stage='dispatch')
|
||||||
async def _dispatch_plugin_subscription(self, event, adapter, processor_uuid):
|
async def _dispatch_plugin_subscription(self, event, adapter, processor_uuid):
|
||||||
event_type = event.type
|
event_type = event.type
|
||||||
event_binding = {
|
event_binding = {
|
||||||
@@ -882,6 +912,7 @@ class RuntimeBot:
|
|||||||
# Resolve the installed declaration each time, including after plugin updates.
|
# Resolve the installed declaration each time, including after plugin updates.
|
||||||
patterns = descriptor.supported_event_patterns
|
patterns = descriptor.supported_event_patterns
|
||||||
if not patterns or not self._agent_supports_event_type(patterns, event_type):
|
if not patterns or not self._agent_supports_event_type(patterns, event_type):
|
||||||
|
diagnostics.set_outcome('skipped', reason_code='not_matched')
|
||||||
return
|
return
|
||||||
agent = {**agent, 'supported_event_patterns': patterns}
|
agent = {**agent, 'supported_event_patterns': patterns}
|
||||||
return await self._dispatch_eba_event_to_processor(event, adapter, event_binding, agent)
|
return await self._dispatch_eba_event_to_processor(event, adapter, event_binding, agent)
|
||||||
@@ -898,6 +929,7 @@ class RuntimeBot:
|
|||||||
text=f'Plugin processor {processor_uuid} failed: {exc}',
|
text=f'Plugin processor {processor_uuid} failed: {exc}',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('route', 'route.dispatch', source='platform', stage='dispatch')
|
||||||
async def _dispatch_eba_event_to_processor(
|
async def _dispatch_eba_event_to_processor(
|
||||||
self,
|
self,
|
||||||
event: platform_events.EBAEvent,
|
event: platform_events.EBAEvent,
|
||||||
@@ -1165,6 +1197,7 @@ class RuntimeBot:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.logger.error(f'Failed to record discarded message: {e}')
|
await self.logger.error(f'Failed to record discarded message: {e}')
|
||||||
|
|
||||||
|
@diagnostics.observe('event', 'platform.legacy_receive', source='platform', stage='dispatch')
|
||||||
async def _handle_legacy_message_event(
|
async def _handle_legacy_message_event(
|
||||||
self,
|
self,
|
||||||
event: platform_events.FriendMessage | platform_events.GroupMessage,
|
event: platform_events.FriendMessage | platform_events.GroupMessage,
|
||||||
@@ -1250,6 +1283,7 @@ class RuntimeBot:
|
|||||||
execution_context=self.execution_context,
|
execution_context=self.execution_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('interaction', 'interaction.submit', source='platform', stage='dispatch')
|
||||||
async def _handle_interaction_submission(
|
async def _handle_interaction_submission(
|
||||||
self,
|
self,
|
||||||
event: platform_events.PlatformSpecificEvent,
|
event: platform_events.PlatformSpecificEvent,
|
||||||
@@ -1358,6 +1392,7 @@ class RuntimeBot:
|
|||||||
execution_context=self.execution_context,
|
execution_context=self.execution_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('interaction', 'interaction.resume', source='platform', stage='resume')
|
||||||
async def _resume_agent_interaction(
|
async def _resume_agent_interaction(
|
||||||
self,
|
self,
|
||||||
record: dict[str, typing.Any],
|
record: dict[str, typing.Any],
|
||||||
@@ -1512,6 +1547,9 @@ class RuntimeBot:
|
|||||||
return
|
return
|
||||||
await self._handle_platform_event(self._legacy_message_to_eba_event(event, adapter), adapter)
|
await self._handle_platform_event(self._legacy_message_to_eba_event(event, adapter), adapter)
|
||||||
|
|
||||||
|
from ..telemetry.diagnostic_catalog import snapshot_bot
|
||||||
|
|
||||||
|
snapshot_bot(self)
|
||||||
get_supported_events = getattr(self.adapter, 'get_supported_events', None)
|
get_supported_events = getattr(self.adapter, 'get_supported_events', None)
|
||||||
supported_events: list[str] = []
|
supported_events: list[str] = []
|
||||||
if callable(get_supported_events):
|
if callable(get_supported_events):
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..telemetry import diagnostics
|
||||||
|
|
||||||
from typing import Any, Union
|
from typing import Any, Union
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
@@ -302,6 +304,7 @@ async def _validate_agent_run_session(
|
|||||||
if not available_apis.get(api_capability, False) and not has_admin_permission:
|
if not available_apis.get(api_capability, False) and not has_admin_permission:
|
||||||
return None, handler.ActionResponse.error(message=f'{api_name} access not authorized')
|
return None, handler.ActionResponse.error(message=f'{api_name} access not authorized')
|
||||||
|
|
||||||
|
diagnostics.link_context(session.get('_diagnostic_context'))
|
||||||
return session, None
|
return session, None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# For connect to plugin runtime.
|
# For connect to plugin runtime.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import contextvars
|
import contextvars
|
||||||
@@ -668,6 +670,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('lifecycle', 'runtime.prepare_connected_runtime', source='runtime', stage='execute')
|
||||||
async def _prepare_connected_runtime(self) -> None:
|
async def _prepare_connected_runtime(self) -> None:
|
||||||
"""Handshake follow-up: pin OSS compatibility, then replay authority."""
|
"""Handshake follow-up: pin OSS compatibility, then replay authority."""
|
||||||
|
|
||||||
@@ -853,6 +856,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
self.schedule_reconnect()
|
self.schedule_reconnect()
|
||||||
failures = 0
|
failures = 0
|
||||||
|
|
||||||
|
@diagnostics.observe('lifecycle', 'runtime.initialize', source='runtime', stage='execute')
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
if not self.is_enable_plugin:
|
if not self.is_enable_plugin:
|
||||||
self.ap.logger.info('Plugin system is disabled.')
|
self.ap.logger.info('Plugin system is disabled.')
|
||||||
@@ -1705,6 +1709,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
)
|
)
|
||||||
return plugin_package, latest_version
|
return plugin_package, latest_version
|
||||||
|
|
||||||
|
@diagnostics.observe('lifecycle', 'runtime.install_plugin', source='runtime', stage='execute')
|
||||||
async def install_plugin(
|
async def install_plugin(
|
||||||
self,
|
self,
|
||||||
install_source: PluginInstallSource,
|
install_source: PluginInstallSource,
|
||||||
@@ -1820,6 +1825,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
task_context.set_current_action('plugin updated' if operation == 'upgrade' else 'plugin installed')
|
task_context.set_current_action('plugin updated' if operation == 'upgrade' else 'plugin installed')
|
||||||
task_context.metadata['progress_percent'] = 100
|
task_context.metadata['progress_percent'] = 100
|
||||||
|
|
||||||
|
@diagnostics.observe('lifecycle', 'runtime.upgrade_plugin', source='runtime', stage='execute')
|
||||||
async def upgrade_plugin(
|
async def upgrade_plugin(
|
||||||
self,
|
self,
|
||||||
plugin_author: str,
|
plugin_author: str,
|
||||||
@@ -1846,6 +1852,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
)
|
)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
@diagnostics.observe('lifecycle', 'runtime.delete_plugin', source='runtime', stage='execute')
|
||||||
async def delete_plugin(
|
async def delete_plugin(
|
||||||
self,
|
self,
|
||||||
plugin_author: str,
|
plugin_author: str,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..telemetry import diagnostics
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
import inspect
|
||||||
import typing
|
import typing
|
||||||
@@ -639,6 +641,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
avoids reserving one pooled connection across provider and network waits.
|
avoids reserving one pooled connection across provider and network waits.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
diagnostics.annotate(workspace_uuid=action_context.workspace_uuid)
|
||||||
persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
|
persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
|
||||||
if persistence_mgr is None:
|
if persistence_mgr is None:
|
||||||
yield
|
yield
|
||||||
@@ -687,10 +690,13 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
return
|
return
|
||||||
if trusted_plugin_identity is not None:
|
if trusted_plugin_identity is not None:
|
||||||
safe_data['caller_plugin_identity'] = trusted_plugin_identity
|
safe_data['caller_plugin_identity'] = trusted_plugin_identity
|
||||||
async for response in _action_handler(safe_data):
|
async with contextlib.aclosing(_action_handler(safe_data)) as responses:
|
||||||
|
async for response in responses:
|
||||||
yield response
|
yield response
|
||||||
|
|
||||||
self.actions[action_name] = secured_stream_action
|
self.actions[action_name] = diagnostics.observe(
|
||||||
|
'api', 'host.' + action_name, source='plugin', ap=self.ap
|
||||||
|
)(secured_stream_action)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
async def secured_action(
|
async def secured_action(
|
||||||
@@ -719,7 +725,9 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
response = await response
|
response = await response
|
||||||
return response
|
return response
|
||||||
|
|
||||||
self.actions[action_name] = secured_action
|
self.actions[action_name] = diagnostics.observe('api', 'host.' + action_name, source='plugin', ap=self.ap)(
|
||||||
|
secured_action
|
||||||
|
)
|
||||||
|
|
||||||
async def _get_plugin_setting(
|
async def _get_plugin_setting(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
import typing
|
import typing
|
||||||
import time
|
import time
|
||||||
@@ -98,6 +100,7 @@ class RuntimeProvider:
|
|||||||
raise WorkspaceInvariantError('LLM invocation requires an ExecutionContext when query is absent')
|
raise WorkspaceInvariantError('LLM invocation requires an ExecutionContext when query is absent')
|
||||||
return execution_context
|
return execution_context
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'model.invoke_llm', source='agent')
|
||||||
async def invoke_llm(
|
async def invoke_llm(
|
||||||
self,
|
self,
|
||||||
query: pipeline_query.Query | None,
|
query: pipeline_query.Query | None,
|
||||||
@@ -182,6 +185,7 @@ class RuntimeProvider:
|
|||||||
except Exception as monitor_err:
|
except Exception as monitor_err:
|
||||||
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM call: {monitor_err}')
|
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM call: {monitor_err}')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'model.invoke_llm_stream', source='agent')
|
||||||
async def invoke_llm_stream(
|
async def invoke_llm_stream(
|
||||||
self,
|
self,
|
||||||
query: pipeline_query.Query | None,
|
query: pipeline_query.Query | None,
|
||||||
@@ -264,6 +268,7 @@ class RuntimeProvider:
|
|||||||
except Exception as monitor_err:
|
except Exception as monitor_err:
|
||||||
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM stream call: {monitor_err}')
|
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM stream call: {monitor_err}')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'model.invoke_embedding', source='agent')
|
||||||
async def invoke_embedding(
|
async def invoke_embedding(
|
||||||
self,
|
self,
|
||||||
model: RuntimeEmbeddingModel,
|
model: RuntimeEmbeddingModel,
|
||||||
@@ -331,6 +336,7 @@ class RuntimeProvider:
|
|||||||
except Exception as monitor_err:
|
except Exception as monitor_err:
|
||||||
self.requester.ap.logger.error(f'[Monitoring] Failed to record embedding call: {monitor_err}')
|
self.requester.ap.logger.error(f'[Monitoring] Failed to record embedding call: {monitor_err}')
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'model.invoke_rerank', source='agent')
|
||||||
async def invoke_rerank(
|
async def invoke_rerank(
|
||||||
self,
|
self,
|
||||||
model: RuntimeRerankModel,
|
model: RuntimeRerankModel,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
import time
|
import time
|
||||||
import inspect
|
import inspect
|
||||||
@@ -473,6 +475,7 @@ class ToolManager:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'tool.execute', source='agent', stage='execute')
|
||||||
async def execute_func_call(
|
async def execute_func_call(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics
|
||||||
|
|
||||||
import posixpath
|
import posixpath
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@@ -71,6 +73,7 @@ class RAGRuntimeService:
|
|||||||
raise WorkspaceNotFoundError('Knowledge base not found')
|
raise WorkspaceNotFoundError('Knowledge base not found')
|
||||||
return kb_uuid
|
return kb_uuid
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'knowledge.vector_upsert', source='agent')
|
||||||
async def vector_upsert(
|
async def vector_upsert(
|
||||||
self,
|
self,
|
||||||
execution_context: ExecutionContext,
|
execution_context: ExecutionContext,
|
||||||
@@ -98,6 +101,7 @@ class RAGRuntimeService:
|
|||||||
documents=documents,
|
documents=documents,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'knowledge.vector_search', source='agent')
|
||||||
async def vector_search(
|
async def vector_search(
|
||||||
self,
|
self,
|
||||||
execution_context: ExecutionContext,
|
execution_context: ExecutionContext,
|
||||||
@@ -122,6 +126,7 @@ class RAGRuntimeService:
|
|||||||
vector_weight=vector_weight,
|
vector_weight=vector_weight,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'knowledge.vector_delete', source='agent')
|
||||||
async def vector_delete(
|
async def vector_delete(
|
||||||
self,
|
self,
|
||||||
execution_context: ExecutionContext,
|
execution_context: ExecutionContext,
|
||||||
@@ -158,6 +163,7 @@ class RAGRuntimeService:
|
|||||||
)
|
)
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'knowledge.vector_list', source='agent')
|
||||||
async def vector_list(
|
async def vector_list(
|
||||||
self,
|
self,
|
||||||
execution_context: ExecutionContext,
|
execution_context: ExecutionContext,
|
||||||
@@ -186,6 +192,7 @@ class RAGRuntimeService:
|
|||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@diagnostics.observe('api', 'knowledge.get_file_stream', source='agent')
|
||||||
async def get_file_stream(
|
async def get_file_stream(
|
||||||
self,
|
self,
|
||||||
execution_context: ExecutionContext,
|
execution_context: ExecutionContext,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Packaged capability catalog and projections from existing runtime declarations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from . import diagnostic_privacy as privacy
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
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())
|
||||||
|
spec = data.get('spec', {})
|
||||||
|
name = data['metadata']['name']
|
||||||
|
privacy.code_value('adapter', name)
|
||||||
|
events = spec.get('supported_events', [])
|
||||||
|
apis = spec.get('supported_apis', {})
|
||||||
|
apis = apis if isinstance(apis, list) else [v for items in apis.values() for v in items]
|
||||||
|
for event in events:
|
||||||
|
privacy.code_value('platform_event_type', event)
|
||||||
|
for operation in apis:
|
||||||
|
privacy.code_value('operation', operation)
|
||||||
|
for api in spec.get('platform_specific_apis', []):
|
||||||
|
privacy.code_value('operation', api['action'])
|
||||||
|
result[manifest.parent.name] = {'adapter': name, 'events': events, 'apis': apis}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def adapter_fields(adapter):
|
||||||
|
module = type(adapter).__module__.split('.')
|
||||||
|
entry = next((entry for directory, entry in catalog().items() if directory in module), None)
|
||||||
|
return {'adapter': entry['adapter']} if entry else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_bot(bot):
|
||||||
|
from . import diagnostics
|
||||||
|
|
||||||
|
manager = getattr(bot.ap, 'diagnostics', None)
|
||||||
|
if not isinstance(manager, diagnostics.DiagnosticsManager) or not manager.enabled:
|
||||||
|
return
|
||||||
|
fields = adapter_fields(bot.adapter)
|
||||||
|
if not fields:
|
||||||
|
return
|
||||||
|
fields['workspace_uuid'] = bot.execution_context.workspace_uuid
|
||||||
|
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']),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
supported = set(getattr(bot.adapter, method)() or [])
|
||||||
|
except Exception:
|
||||||
|
supported = set()
|
||||||
|
for name in declared:
|
||||||
|
manager.emit(
|
||||||
|
'capability',
|
||||||
|
name if capability_type == 'api' else 'platform.receive',
|
||||||
|
'succeeded',
|
||||||
|
source='platform',
|
||||||
|
stage='snapshot',
|
||||||
|
**fields,
|
||||||
|
platform_event_type=name if capability_type == 'event' else '',
|
||||||
|
attributes={
|
||||||
|
'capability_type': capability_type,
|
||||||
|
'capability_name': name,
|
||||||
|
'supported': name in supported,
|
||||||
|
'configured': True,
|
||||||
|
'available': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_bot(bot):
|
||||||
|
try:
|
||||||
|
_snapshot_bot(bot)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Closed, content-free projection for Beta diagnostics; never serialize payloads."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import builtins
|
||||||
|
import hashlib
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
KINDS = frozenset('lifecycle event route run api delivery interaction capability transport summary'.split())
|
||||||
|
SOURCES = frozenset(
|
||||||
|
'platform pipeline agent event_processor webui_debug http websocket mcp plugin runtime startup internal synthetic'.split()
|
||||||
|
)
|
||||||
|
OUTCOMES = frozenset('started succeeded failed cancelled timeout skipped rejected waiting partial unknown'.split())
|
||||||
|
PROCESSORS = frozenset(('', 'pipeline', 'agent', 'event_processor'))
|
||||||
|
# These sets are populated ONLY by source-code decorators / packaged manifests,
|
||||||
|
# never by requests, installed third-party plugin declarations or configuration.
|
||||||
|
VOCABULARY: dict[str, set[str]] = {
|
||||||
|
'operation': {'diagnostics.transport', 'startup', 'route.primary', 'route.subscription', 'runner.run'},
|
||||||
|
'stage': {'execute', 'prepare', 'convert', 'dispatch', 'accepted', 'ack', 'resume', 'shutdown', 'snapshot'},
|
||||||
|
'adapter': set(),
|
||||||
|
'platform_event_type': set(),
|
||||||
|
'reason_code': set(
|
||||||
|
'runner_failed response_error route_not_found processor_incompatible processor_not_found discarded not_matched matched delivered waiting interaction_rejected generator_closed transport_loss'.split()
|
||||||
|
),
|
||||||
|
'capability_type': {'event', 'api', 'processor'},
|
||||||
|
'tool_category': {'native', 'plugin', 'mcp', 'skill', 'platform', 'unknown'},
|
||||||
|
'transport': {'stdio', 'websocket', 'http', 'unknown'},
|
||||||
|
'runner_usage': {'agent', 'event'},
|
||||||
|
'os': {'linux', 'darwin', 'windows'},
|
||||||
|
'arch': {'x86_64', 'aarch64', 'arm64', 'amd64'},
|
||||||
|
'database': {'sqlite', 'postgresql'},
|
||||||
|
'edition': {'community', 'cloud', 'enterprise'},
|
||||||
|
}
|
||||||
|
BOOLS = frozenset('stream synthetic configured available previous_session_unclean recovered supported'.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()
|
||||||
|
)
|
||||||
|
VERSIONS = frozenset('sdk_version plugin_version runner_version python_version'.split())
|
||||||
|
|
||||||
|
|
||||||
|
def code_value(field: str, value: str) -> str:
|
||||||
|
"""Register a literal from trusted Core source, not a runtime string."""
|
||||||
|
VOCABULARY.setdefault(field, set()).add(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def category(field: str, value) -> str:
|
||||||
|
return value if type(value) is str and value in VOCABULARY.get(field, ()) else ''
|
||||||
|
|
||||||
|
|
||||||
|
def opaque(value) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return ''
|
||||||
|
try:
|
||||||
|
return str(UUID(value))
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def version_string(value) -> str:
|
||||||
|
# Public package versions only; do not permit arbitrary PEP440 local labels.
|
||||||
|
return value if isinstance(value, str) and re.fullmatch(r'[0-9][0-9.abrcdevpost-]{0,47}', value) else ''
|
||||||
|
|
||||||
|
|
||||||
|
def attributes(values) -> dict:
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
return {}
|
||||||
|
result = {}
|
||||||
|
for key, value in values.items():
|
||||||
|
if key in BOOLS and type(value) is bool:
|
||||||
|
result[key] = value
|
||||||
|
elif key in NUMBERS and type(value) is int and math.isfinite(value) and 0 <= value <= 1_000_000:
|
||||||
|
result[key] = value
|
||||||
|
elif key in VERSIONS and version_string(value):
|
||||||
|
result[key] = value
|
||||||
|
elif key in VOCABULARY and key not in {'operation', 'stage', 'adapter', 'platform_event_type', 'reason_code'}:
|
||||||
|
if category(key, value):
|
||||||
|
result[key] = value
|
||||||
|
elif key == 'capability_name' and any(category(f, value) for f in ('operation', 'platform_event_type')):
|
||||||
|
result[key] = value
|
||||||
|
elif key == 'capability_name' and value in PROCESSORS - {''}:
|
||||||
|
result[key] = value
|
||||||
|
elif key in ('event_types', 'api_operations', 'processor_types') and isinstance(value, (tuple, list)):
|
||||||
|
allowed = (
|
||||||
|
VOCABULARY['platform_event_type']
|
||||||
|
if key == 'event_types'
|
||||||
|
else VOCABULARY['operation']
|
||||||
|
if key == 'api_operations'
|
||||||
|
else PROCESSORS
|
||||||
|
)
|
||||||
|
result[key] = [v for v in value[:128] if type(v) is str and v in allowed]
|
||||||
|
elif (
|
||||||
|
key in ('source_revision', 'target_revision')
|
||||||
|
and isinstance(value, str)
|
||||||
|
and re.fullmatch('[a-f0-9]{40}', value)
|
||||||
|
):
|
||||||
|
result[key] = value
|
||||||
|
elif key == 'latency_buckets' and isinstance(value, (list, tuple)):
|
||||||
|
result[key] = [v for v in value[:128] if type(v) is int and 0 <= v <= 1_000_000]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def error_fields(error: BaseException, operation: str, stage: str) -> dict:
|
||||||
|
cls = type(error)
|
||||||
|
# Third party exception names may be dynamically constructed from input.
|
||||||
|
name = cls.__name__ if getattr(builtins, cls.__name__, None) is cls else 'Exception'
|
||||||
|
if isinstance(error, TimeoutError):
|
||||||
|
name = 'TimeoutError'
|
||||||
|
# No message, traceback, filename, line number, or locals are inspected.
|
||||||
|
fingerprint = hashlib.sha256(f'{operation}|{stage}|{cls.__module__}|{cls.__qualname__}'.encode()).hexdigest()
|
||||||
|
return {'error_type': name, 'error_fingerprint': fingerprint}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
"""Bounded, memory-only Beta diagnostics transport, isolated from usage telemetry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import importlib.metadata
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from packaging.version import Version, InvalidVersion
|
||||||
|
|
||||||
|
from . import diagnostic_privacy as privacy
|
||||||
|
|
||||||
|
|
||||||
|
class DiagnosticsManager:
|
||||||
|
"""All producers are synchronous; only this owned worker performs I/O.
|
||||||
|
|
||||||
|
Retention: 2048 events / 15 minutes, 5 attempts, 8 KiB per event. An in-flight
|
||||||
|
event stays in the same bounded queue until explicitly ACKed. No disk spool
|
||||||
|
means abrupt process loss cannot be counted after restart (documented).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ap, *, version=None, instance_id=None, capacity=2048, marker_path=None):
|
||||||
|
from ..utils import constants
|
||||||
|
from .diagnostic_catalog import catalog
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
catalog()
|
||||||
|
self.ap = ap
|
||||||
|
self.marker_path = Path(marker_path) if marker_path is not None else None
|
||||||
|
self._session_id = str(uuid4())
|
||||||
|
self._marker_active = False
|
||||||
|
raw_version = version or importlib.metadata.version('langbot')
|
||||||
|
try:
|
||||||
|
parsed = Version(raw_version)
|
||||||
|
self.version = str(parsed)
|
||||||
|
self.beta = bool(parsed.pre and parsed.pre[0] == 'b' and not parsed.local)
|
||||||
|
except InvalidVersion:
|
||||||
|
self.version, self.beta = '', False
|
||||||
|
self.instance_id = instance_id or constants.instance_id
|
||||||
|
try:
|
||||||
|
from langbot._build_info import CORE_REVISION
|
||||||
|
except ImportError:
|
||||||
|
CORE_REVISION = ''
|
||||||
|
candidate = os.getenv('LANGBOT_BUILD_REVISION', CORE_REVISION)
|
||||||
|
self.revision = candidate.lower() if re.fullmatch('[a-fA-F0-9]{40}', candidate or '') else ''
|
||||||
|
try:
|
||||||
|
self.sdk_version = privacy.version_string(importlib.metadata.version('langbot-plugin'))
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
self.sdk_version = ''
|
||||||
|
self.capacity = max(1, min(capacity, 2048))
|
||||||
|
self.pending: list[dict] = []
|
||||||
|
self.counters = Counter()
|
||||||
|
self._reported = Counter()
|
||||||
|
self._last_retry = False
|
||||||
|
self.max_attempts = 5
|
||||||
|
self.retention_seconds = 900
|
||||||
|
self.request_timeout = 10
|
||||||
|
self.client: httpx.AsyncClient | None = None
|
||||||
|
self._attempts: dict[str, int] = {}
|
||||||
|
self._born: dict[str, float] = {}
|
||||||
|
self._worker = None
|
||||||
|
self._request = None
|
||||||
|
self._closing = False
|
||||||
|
self._wake = asyncio.Event()
|
||||||
|
self._flush_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def start_session(self):
|
||||||
|
"""One bounded, content-free marker per process; never per-event disk I/O."""
|
||||||
|
if not self.enabled:
|
||||||
|
await self._clear_marker()
|
||||||
|
return
|
||||||
|
previous = False
|
||||||
|
if self.marker_path is not None:
|
||||||
|
|
||||||
|
def mark():
|
||||||
|
previous = self.marker_path.exists()
|
||||||
|
self.marker_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.marker_path.write_text(self._session_id)
|
||||||
|
return previous
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
previous = await asyncio.to_thread(mark)
|
||||||
|
self._marker_active = True
|
||||||
|
privacy.code_value('operation', 'startup.session')
|
||||||
|
self.emit(
|
||||||
|
'lifecycle',
|
||||||
|
'startup.session',
|
||||||
|
'started',
|
||||||
|
source='startup',
|
||||||
|
stage='snapshot',
|
||||||
|
attributes={
|
||||||
|
'previous_session_unclean': previous,
|
||||||
|
'os': platform.system().lower(),
|
||||||
|
'arch': platform.machine().lower(),
|
||||||
|
'python_version': '.'.join(map(str, sys.version_info[:3])),
|
||||||
|
'database': self.ap.instance_config.data.get('database', {}).get('use', ''),
|
||||||
|
'edition': self.ap.instance_config.data.get('system', {}).get('edition', ''),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _clear_marker(self):
|
||||||
|
if self.marker_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def remove():
|
||||||
|
if self.marker_path.exists():
|
||||||
|
with self.marker_path.open() as handle:
|
||||||
|
owner = handle.read(64)
|
||||||
|
if owner == self._session_id or not self._marker_active:
|
||||||
|
self.marker_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await asyncio.to_thread(remove)
|
||||||
|
self._marker_active = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self):
|
||||||
|
config = getattr(getattr(self.ap, 'instance_config', None), 'data', {}).get('space', {})
|
||||||
|
return (
|
||||||
|
self.beta
|
||||||
|
and not self._closing
|
||||||
|
and not config.get('disable_telemetry', False)
|
||||||
|
and not config.get('disable_beta_diagnostics', False)
|
||||||
|
and bool(config.get('url'))
|
||||||
|
)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self.pending.clear()
|
||||||
|
self._attempts.clear()
|
||||||
|
self._born.clear()
|
||||||
|
if self._request is not None and not self._request.done():
|
||||||
|
self._request.cancel()
|
||||||
|
|
||||||
|
def emit(
|
||||||
|
self,
|
||||||
|
kind,
|
||||||
|
operation,
|
||||||
|
outcome,
|
||||||
|
*,
|
||||||
|
source='internal',
|
||||||
|
workspace_uuid='',
|
||||||
|
attributes=None,
|
||||||
|
stage='execute',
|
||||||
|
adapter='',
|
||||||
|
processor_type='',
|
||||||
|
platform_event_type='',
|
||||||
|
reason_code='',
|
||||||
|
duration_ms=0,
|
||||||
|
error=None,
|
||||||
|
trace_id='',
|
||||||
|
span_id='',
|
||||||
|
parent_span_id='',
|
||||||
|
run_id='',
|
||||||
|
**ignored,
|
||||||
|
):
|
||||||
|
"""Project known scalar fields; failures in diagnostics never escape."""
|
||||||
|
try:
|
||||||
|
if not self.enabled:
|
||||||
|
self.clear()
|
||||||
|
return
|
||||||
|
if kind not in privacy.KINDS or outcome not in privacy.OUTCOMES:
|
||||||
|
return
|
||||||
|
self.counters['generated'] += 1
|
||||||
|
event = {
|
||||||
|
'schema_version': 1,
|
||||||
|
'event_id': str(uuid4()),
|
||||||
|
'kind': kind,
|
||||||
|
'instance_id': self.instance_id,
|
||||||
|
'workspace_uuid': privacy.opaque(workspace_uuid),
|
||||||
|
'core_version': self.version,
|
||||||
|
'core_revision': self.revision,
|
||||||
|
'sdk_version': self.sdk_version,
|
||||||
|
'release_channel': 'beta',
|
||||||
|
'source': source if source in privacy.SOURCES else 'internal',
|
||||||
|
'operation': privacy.category('operation', operation),
|
||||||
|
'stage': privacy.category('stage', stage),
|
||||||
|
'outcome': outcome,
|
||||||
|
'reason_code': privacy.category('reason_code', reason_code),
|
||||||
|
'adapter': privacy.category('adapter', adapter),
|
||||||
|
'processor_type': processor_type if processor_type in privacy.PROCESSORS else '',
|
||||||
|
'platform_event_type': privacy.category('platform_event_type', platform_event_type),
|
||||||
|
'occurred_at': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'count': 1,
|
||||||
|
'sample_rate': 1,
|
||||||
|
'duration_ms': max(0, min(float(duration_ms), 86400000)),
|
||||||
|
'attributes': privacy.attributes(attributes),
|
||||||
|
}
|
||||||
|
for key, value in (
|
||||||
|
('trace_id', trace_id),
|
||||||
|
('span_id', span_id),
|
||||||
|
('parent_span_id', parent_span_id),
|
||||||
|
('run_id', run_id),
|
||||||
|
):
|
||||||
|
if privacy.opaque(value):
|
||||||
|
event[key] = privacy.opaque(value)
|
||||||
|
if error is not None:
|
||||||
|
event.update(privacy.error_fields(error, event['operation'], event['stage']))
|
||||||
|
encoded = json.dumps(event, allow_nan=False).encode()
|
||||||
|
if (
|
||||||
|
not isinstance(self.instance_id, str)
|
||||||
|
or not 0 < len(self.instance_id) <= 128
|
||||||
|
or len(encoded) > 8192
|
||||||
|
or len(self.pending) >= self.capacity
|
||||||
|
):
|
||||||
|
self.counters['dropped'] += 1
|
||||||
|
return
|
||||||
|
self.pending.append(event)
|
||||||
|
self._born[event['event_id']] = time.monotonic()
|
||||||
|
self.counters['queued'] += 1
|
||||||
|
self._wake.set()
|
||||||
|
except Exception:
|
||||||
|
self.counters['dropped'] += 1
|
||||||
|
|
||||||
|
async def credentials(self, workspace_uuid):
|
||||||
|
# Resolve each Workspace independently in the background. No OSS secret.
|
||||||
|
token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip()
|
||||||
|
if token:
|
||||||
|
return {'X-LangBot-Telemetry-Token': token}
|
||||||
|
if not workspace_uuid:
|
||||||
|
return {}
|
||||||
|
users = getattr(self.ap, 'user_service', None)
|
||||||
|
space = getattr(self.ap, 'space_service', None)
|
||||||
|
if users is None or space is None:
|
||||||
|
return {}
|
||||||
|
owner = await users.get_workspace_owner(workspace_uuid)
|
||||||
|
email = getattr(owner, 'user', None)
|
||||||
|
token = await space.get_valid_access_token(email) if email else None
|
||||||
|
return {'Authorization': f'Bearer {token}'} if token else {}
|
||||||
|
|
||||||
|
def _remove(self, ids):
|
||||||
|
self.pending[:] = [e for e in self.pending if e['event_id'] not in ids]
|
||||||
|
for event_id in ids:
|
||||||
|
self._attempts.pop(event_id, None)
|
||||||
|
self._born.pop(event_id, None)
|
||||||
|
|
||||||
|
async def flush_once(self):
|
||||||
|
async with self._flush_lock:
|
||||||
|
if not self.enabled:
|
||||||
|
self.clear()
|
||||||
|
return
|
||||||
|
expired = {
|
||||||
|
e['event_id']
|
||||||
|
for e in self.pending
|
||||||
|
if time.monotonic() - self._born.get(e['event_id'], 0) > self.retention_seconds
|
||||||
|
}
|
||||||
|
self.counters['dropped'] += len(expired)
|
||||||
|
self._remove(expired)
|
||||||
|
if not self.pending:
|
||||||
|
return
|
||||||
|
# Do not authenticate a mixed-Workspace batch with one owner's token.
|
||||||
|
workspace = self.pending[0]['workspace_uuid']
|
||||||
|
batch, size = [], 64
|
||||||
|
for event in self.pending:
|
||||||
|
n = len(json.dumps(event).encode()) + 2
|
||||||
|
if event['workspace_uuid'] != workspace:
|
||||||
|
continue
|
||||||
|
if len(batch) == 50 or size + n > 256 * 1024:
|
||||||
|
break
|
||||||
|
batch.append(event)
|
||||||
|
size += n
|
||||||
|
ids = {e['event_id'] for e in batch}
|
||||||
|
for event_id in ids:
|
||||||
|
self._attempts[event_id] = self._attempts.get(event_id, 0) + 1
|
||||||
|
acked, rejected = set(), set()
|
||||||
|
permanent = False
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(self.request_timeout):
|
||||||
|
headers = {}
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(min(1.0, self.request_timeout / 3)):
|
||||||
|
headers = await self.credentials(workspace)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not self.enabled:
|
||||||
|
self.clear()
|
||||||
|
return
|
||||||
|
if self.client is None:
|
||||||
|
self.client = httpx.AsyncClient(timeout=self.request_timeout, follow_redirects=False)
|
||||||
|
url = (
|
||||||
|
self.ap.instance_config.data['space']['url'].rstrip('/') + '/api/v1/telemetry/diagnostics/batch'
|
||||||
|
)
|
||||||
|
# Stream the response to bound malicious/old server responses too.
|
||||||
|
async with self.client.stream(
|
||||||
|
'POST', url, json={'schema_version': 1, 'events': batch}, headers=headers
|
||||||
|
) as response:
|
||||||
|
body = bytearray()
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
body.extend(chunk)
|
||||||
|
if len(body) > 65536:
|
||||||
|
raise ValueError('diagnostics_response_limit')
|
||||||
|
if response.status_code == 200:
|
||||||
|
payload = json.loads(body)
|
||||||
|
if payload.get('code') == 200 and isinstance(payload.get('data'), dict):
|
||||||
|
data = payload['data']
|
||||||
|
acked = {x for x in data.get('accepted_event_ids', []) if isinstance(x, str)} & ids
|
||||||
|
rejected = {
|
||||||
|
x.get('event_id')
|
||||||
|
for x in data.get('rejected', [])
|
||||||
|
if isinstance(x, dict) and x.get('code') == 'invalid_event'
|
||||||
|
} & ids
|
||||||
|
elif isinstance(payload.get('code'), int):
|
||||||
|
permanent = 400 <= payload['code'] < 500 and payload['code'] != 429
|
||||||
|
else:
|
||||||
|
permanent = 400 <= response.status_code < 500 and response.status_code != 429
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not self.enabled:
|
||||||
|
self.clear()
|
||||||
|
return
|
||||||
|
rejected -= acked
|
||||||
|
remaining = ids - acked - rejected
|
||||||
|
if remaining:
|
||||||
|
self.counters['failed'] += 1
|
||||||
|
exhausted = {i for i in remaining if permanent or self._attempts.get(i, 0) >= self.max_attempts}
|
||||||
|
self.counters['acked'] += len(acked)
|
||||||
|
self.counters['dropped'] += len(rejected | exhausted)
|
||||||
|
self._last_retry = bool(remaining - exhausted)
|
||||||
|
self.counters['retried'] += len(remaining - exhausted)
|
||||||
|
self._remove(acked | rejected | exhausted)
|
||||||
|
|
||||||
|
def report_transport(self, stage='snapshot'):
|
||||||
|
# Snapshot interval deltas before enqueue; retries retain this event ID.
|
||||||
|
snapshot = self.counters.copy()
|
||||||
|
delta = snapshot - self._reported
|
||||||
|
before = self.counters['queued']
|
||||||
|
self.emit(
|
||||||
|
'transport',
|
||||||
|
'diagnostics.transport',
|
||||||
|
'partial' if delta['dropped'] else 'succeeded',
|
||||||
|
stage=stage,
|
||||||
|
attributes={**dict(delta), 'queue_size': len(self.pending), 'capacity': self.capacity},
|
||||||
|
)
|
||||||
|
if self.counters['queued'] > before:
|
||||||
|
self._reported = snapshot
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self.beta and self._worker is None:
|
||||||
|
self._worker = asyncio.create_task(self._loop(), name='beta-diagnostics')
|
||||||
|
|
||||||
|
async def _loop(self):
|
||||||
|
next_send, next_summary, backoff = 0.0, time.monotonic() + 60, 0.0
|
||||||
|
try:
|
||||||
|
while not self._closing:
|
||||||
|
if not self.enabled:
|
||||||
|
self.clear()
|
||||||
|
if self._marker_active:
|
||||||
|
await self._clear_marker()
|
||||||
|
elif time.monotonic() >= next_summary:
|
||||||
|
self.report_transport()
|
||||||
|
next_summary = time.monotonic() + 60
|
||||||
|
if self._request is not None and self._request.done():
|
||||||
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||||
|
self._request.result()
|
||||||
|
self._request = None
|
||||||
|
backoff = min(30, max(1, backoff * 2)) if self._last_retry else 0
|
||||||
|
next_send = time.monotonic() + random.uniform(0.5, 1.5) * backoff
|
||||||
|
if self.enabled and self.pending and self._request is None and time.monotonic() >= next_send:
|
||||||
|
self._request = asyncio.create_task(self.flush_once(), name='beta-diagnostics-batch')
|
||||||
|
self._wake.clear()
|
||||||
|
with contextlib.suppress(TimeoutError):
|
||||||
|
await asyncio.wait_for(self._wake.wait(), 0.1)
|
||||||
|
finally:
|
||||||
|
if self._request is not None:
|
||||||
|
self._request.cancel()
|
||||||
|
await asyncio.gather(self._request, return_exceptions=True)
|
||||||
|
|
||||||
|
async def shutdown(self, drain_timeout=2):
|
||||||
|
if self._worker is not None:
|
||||||
|
self._worker.cancel()
|
||||||
|
await asyncio.gather(self._worker, return_exceptions=True)
|
||||||
|
self._worker = None
|
||||||
|
if self.enabled and drain_timeout > 0:
|
||||||
|
self.report_transport(stage='shutdown')
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
async with asyncio.timeout(drain_timeout):
|
||||||
|
while self.pending:
|
||||||
|
await self.flush_once()
|
||||||
|
if self.pending:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
self.counters['dropped'] += len(self.pending)
|
||||||
|
self._closing = True
|
||||||
|
self.clear()
|
||||||
|
await self._clear_marker()
|
||||||
|
if self.client is not None:
|
||||||
|
await self.client.aclose()
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
"""Explicit diagnostic boundaries, preserving coroutine and generator semantics."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import contextvars
|
||||||
|
import functools
|
||||||
|
import inspect
|
||||||
|
import time
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from .diagnostic_transport import DiagnosticsManager # noqa: F401
|
||||||
|
from . import diagnostic_privacy as privacy
|
||||||
|
|
||||||
|
_CURRENT = contextvars.ContextVar('beta_diagnostic_span', default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def current_span():
|
||||||
|
return _CURRENT.get()
|
||||||
|
|
||||||
|
|
||||||
|
def set_outcome(outcome, *, reason_code=''):
|
||||||
|
span = current_span()
|
||||||
|
if span is not None:
|
||||||
|
span.outcome = outcome if outcome in privacy.OUTCOMES else 'unknown'
|
||||||
|
span.fields['reason_code'] = privacy.category('reason_code', reason_code)
|
||||||
|
|
||||||
|
|
||||||
|
def annotate(**fields):
|
||||||
|
"""Trusted hook metadata is still projected at the transport boundary."""
|
||||||
|
span = current_span()
|
||||||
|
if span is not None:
|
||||||
|
span.fields.update(fields)
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_app(owner):
|
||||||
|
"""An explicit owner (even absent/disabled) is an inheritance barrier."""
|
||||||
|
if owner is None:
|
||||||
|
return None, False
|
||||||
|
if hasattr(owner, 'ap'):
|
||||||
|
return owner.ap, True
|
||||||
|
if hasattr(owner, 'diagnostics') or hasattr(owner, 'instance_config'):
|
||||||
|
return owner, True
|
||||||
|
for name in ('requester', 'logger', 'adapter'):
|
||||||
|
nested = getattr(owner, name, None)
|
||||||
|
if nested is not None and nested is not owner:
|
||||||
|
app, explicit = _owner_app(nested)
|
||||||
|
if explicit:
|
||||||
|
return app, True
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(owner):
|
||||||
|
app, _ = _owner_app(owner)
|
||||||
|
manager = getattr(app, 'diagnostics', None)
|
||||||
|
if manager is None or not callable(getattr(manager, 'emit', None)) or not getattr(manager, 'enabled', False):
|
||||||
|
return None
|
||||||
|
# Honor the shared Span/management producer interface without inheriting a
|
||||||
|
# different producer just because this application's producer is absent.
|
||||||
|
config = getattr(getattr(app, 'instance_config', None), 'data', {}).get('space', {})
|
||||||
|
if config.get('disable_telemetry', False) or config.get('disable_beta_diagnostics', False):
|
||||||
|
return None
|
||||||
|
# Direct manager holders (e.g. ReplyStreamSession) are intentional. An app
|
||||||
|
# must never borrow another app's manager, policy, or credential resolver.
|
||||||
|
if hasattr(app, 'instance_config') and getattr(manager, 'ap', app) is not app:
|
||||||
|
return None
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_context(owner):
|
||||||
|
context = getattr(owner, 'execution_context', None)
|
||||||
|
if context is not None:
|
||||||
|
return context
|
||||||
|
for name in ('requester', 'logger', 'adapter'):
|
||||||
|
nested = getattr(owner, name, None)
|
||||||
|
if nested is not None and nested is not owner:
|
||||||
|
context = _owner_context(nested)
|
||||||
|
if context is not None:
|
||||||
|
return context
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _execution_context(owner, bound):
|
||||||
|
context = bound.get('execution_context') or _owner_context(owner)
|
||||||
|
adapter_context = bound.get('adapter_context')
|
||||||
|
if isinstance(adapter_context, dict):
|
||||||
|
context = adapter_context.get('_execution_context') or context
|
||||||
|
query = bound.get('query')
|
||||||
|
if query is not None:
|
||||||
|
context = getattr(query, '_execution_context', None) or context
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def _context_matches(manager, owner, context):
|
||||||
|
from ..api.http.context import ExecutionContext
|
||||||
|
|
||||||
|
if not isinstance(context, ExecutionContext):
|
||||||
|
return True
|
||||||
|
instance = getattr(getattr(manager.ap, 'workspace_service', None), 'instance_uuid', manager.instance_id)
|
||||||
|
if instance != context.instance_uuid:
|
||||||
|
return False
|
||||||
|
owned = _owner_context(owner)
|
||||||
|
return not isinstance(owned, ExecutionContext) or (
|
||||||
|
owned.instance_uuid,
|
||||||
|
owned.workspace_uuid,
|
||||||
|
owned.placement_generation,
|
||||||
|
) == (context.instance_uuid, context.workspace_uuid, context.placement_generation)
|
||||||
|
|
||||||
|
|
||||||
|
def _context_fields(owner, bound):
|
||||||
|
context = _execution_context(owner, bound)
|
||||||
|
query = bound.get('query')
|
||||||
|
from .diagnostic_catalog import adapter_fields
|
||||||
|
|
||||||
|
adapter = getattr(owner, 'adapter', None) or owner
|
||||||
|
fields = adapter_fields(adapter)
|
||||||
|
# ExecutionContext is constructed/validated by the existing auth boundary;
|
||||||
|
# do not infer Workspace identity from arbitrary payload dicts or event IDs.
|
||||||
|
if context is not None:
|
||||||
|
from ..api.http.context import ExecutionContext
|
||||||
|
|
||||||
|
if isinstance(context, ExecutionContext):
|
||||||
|
fields['workspace_uuid'] = context.workspace_uuid
|
||||||
|
saved = getattr(query, '_diagnostic_context', None) if query is not None else None
|
||||||
|
if isinstance(saved, dict) and saved.get('workspace_uuid') == context.workspace_uuid:
|
||||||
|
fields.update(saved)
|
||||||
|
binding = bound.get('binding')
|
||||||
|
if binding is not None:
|
||||||
|
fields['processor_type'] = getattr(binding, 'processor_type', '')
|
||||||
|
event = bound.get('event')
|
||||||
|
if event is not None:
|
||||||
|
fields['platform_event_type'] = getattr(event, 'event_type', None) or getattr(event, 'type', '')
|
||||||
|
delivery = getattr(event, 'delivery', None)
|
||||||
|
if getattr(delivery, 'surface', None) == 'webui':
|
||||||
|
fields['source'] = 'webui_debug'
|
||||||
|
fields['attributes'] = {'synthetic': True}
|
||||||
|
if getattr(owner, 'mock', False) is True:
|
||||||
|
fields['source'] = 'webui_debug'
|
||||||
|
fields['attributes'] = {'synthetic': True}
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
class Span:
|
||||||
|
def __init__(self, manager, kind, operation, fields):
|
||||||
|
parent = current_span()
|
||||||
|
self.manager = manager
|
||||||
|
self.kind = kind
|
||||||
|
self.operation = operation
|
||||||
|
self.fields = dict(fields)
|
||||||
|
if (
|
||||||
|
parent
|
||||||
|
and parent.fields.get('workspace_uuid')
|
||||||
|
and self.fields.get('workspace_uuid')
|
||||||
|
and parent.fields['workspace_uuid'] != self.fields['workspace_uuid']
|
||||||
|
):
|
||||||
|
parent = None
|
||||||
|
if self.fields.get('operation'):
|
||||||
|
self.operation = privacy.category('operation', self.fields.pop('operation')) or operation
|
||||||
|
self.outcome = None
|
||||||
|
self.finished = False
|
||||||
|
self.started = time.monotonic()
|
||||||
|
self.fields['trace_id'] = (
|
||||||
|
parent.fields['trace_id']
|
||||||
|
if parent and parent.manager is manager
|
||||||
|
else (privacy.opaque(self.fields.get('trace_id')) or str(uuid4()))
|
||||||
|
)
|
||||||
|
self.fields['span_id'] = str(uuid4())
|
||||||
|
if parent and parent.manager is manager:
|
||||||
|
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):
|
||||||
|
self.fields[key] = parent.fields[key]
|
||||||
|
if parent.fields.get('source') in ('webui_debug', 'synthetic'):
|
||||||
|
self.fields['source'] = parent.fields['source']
|
||||||
|
self.fields['attributes'] = {**self.fields.get('attributes', {}), 'synthetic': True}
|
||||||
|
self.emit('started')
|
||||||
|
|
||||||
|
def emit(self, outcome, **extra):
|
||||||
|
if self.manager is not None:
|
||||||
|
self.manager.emit(self.kind, self.operation, outcome, **{**self.fields, **extra})
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def activate(self):
|
||||||
|
token = _CURRENT.set(self)
|
||||||
|
try:
|
||||||
|
yield self
|
||||||
|
finally:
|
||||||
|
_CURRENT.reset(token)
|
||||||
|
|
||||||
|
def finish(self, error=None):
|
||||||
|
if self.finished:
|
||||||
|
return
|
||||||
|
self.finished = True
|
||||||
|
error = error if error is not None else self.fields.pop('error', None)
|
||||||
|
if isinstance(error, (asyncio.CancelledError, GeneratorExit)):
|
||||||
|
outcome = 'cancelled'
|
||||||
|
elif isinstance(error, TimeoutError):
|
||||||
|
outcome = 'timeout'
|
||||||
|
elif error is not None:
|
||||||
|
outcome = self.outcome if self.outcome in ('timeout', 'partial', 'rejected') else 'failed'
|
||||||
|
else:
|
||||||
|
outcome = self.outcome or 'succeeded'
|
||||||
|
self.emit(outcome, error=error, duration_ms=(time.monotonic() - self.started) * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def result_outcome(value):
|
||||||
|
"""Inspect only the SDK response's status, not arbitrary result contents."""
|
||||||
|
from langbot_plugin.api.entities.builtin.platform.events import EBAEvent
|
||||||
|
from langbot_plugin.runtime.io.handler import ActionResponse
|
||||||
|
|
||||||
|
span = current_span()
|
||||||
|
if span is not None and span.fields.get('stage') == 'convert':
|
||||||
|
if isinstance(value, EBAEvent):
|
||||||
|
annotate(platform_event_type=value.type)
|
||||||
|
elif value is None:
|
||||||
|
set_outcome('skipped', reason_code='not_matched')
|
||||||
|
if isinstance(value, ActionResponse):
|
||||||
|
if value.code != 0:
|
||||||
|
set_outcome('failed', reason_code='response_error')
|
||||||
|
|
||||||
|
|
||||||
|
def observe(kind, operation, *, source='internal', stage='execute', ap=None, fields=None):
|
||||||
|
"""Explicit boundary with a stable/off fast path and transparent generators."""
|
||||||
|
privacy.code_value('operation', operation)
|
||||||
|
privacy.code_value('stage', stage)
|
||||||
|
|
||||||
|
def decorate(fn):
|
||||||
|
signature = inspect.signature(fn)
|
||||||
|
|
||||||
|
def span_for(args, kwargs):
|
||||||
|
try:
|
||||||
|
bound = signature.bind_partial(*args, **kwargs).arguments
|
||||||
|
owner = bound.get(next(iter(signature.parameters), ''))
|
||||||
|
manager_owner = (ap() if callable(ap) else ap) if ap is not None else owner
|
||||||
|
manager = _manager(manager_owner)
|
||||||
|
_, explicit = _owner_app(manager_owner)
|
||||||
|
context = _execution_context(owner, bound)
|
||||||
|
parent = current_span()
|
||||||
|
if manager is None and not explicit and ap is None and parent is not None:
|
||||||
|
# Stateless converters may inherit, but a different explicit
|
||||||
|
# Workspace must not select a parent's credentials/policy.
|
||||||
|
if context is not None and getattr(context, 'workspace_uuid', None) != parent.fields.get(
|
||||||
|
'workspace_uuid'
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
manager = parent.manager
|
||||||
|
if manager is None or not manager.enabled:
|
||||||
|
return None
|
||||||
|
if not _context_matches(manager, owner, context):
|
||||||
|
return None
|
||||||
|
metadata = {'source': source, 'stage': stage}
|
||||||
|
metadata.update(_context_fields(owner, bound))
|
||||||
|
from .diagnostic_catalog import catalog
|
||||||
|
|
||||||
|
for directory, entry in catalog().items():
|
||||||
|
if directory in fn.__module__.split('.') and '.platform.' in fn.__module__:
|
||||||
|
metadata['adapter'] = entry['adapter']
|
||||||
|
break
|
||||||
|
if fields:
|
||||||
|
extra = fields(bound)
|
||||||
|
extra['attributes'] = {**metadata.get('attributes', {}), **extra.get('attributes', {})}
|
||||||
|
metadata.update(extra)
|
||||||
|
return Span(manager, kind, operation, metadata)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def inspect_result(value):
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
result_outcome(value)
|
||||||
|
|
||||||
|
def finish(span, error=None):
|
||||||
|
if span is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
span.finish(error)
|
||||||
|
|
||||||
|
if inspect.isasyncgenfunction(fn):
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
class ObservedGenerator(AsyncGenerator):
|
||||||
|
"""Delegate each native protocol operation, without extra close.
|
||||||
|
|
||||||
|
A yield-based proxy cannot distinguish athrow(GeneratorExit)
|
||||||
|
(which may yield) from aclose() (which must reject a yield).
|
||||||
|
Let the native generator implement that distinction and retain
|
||||||
|
its own primary/cleanup exception and cancellation semantics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, args, kwargs):
|
||||||
|
self.gen = fn(*args, **kwargs)
|
||||||
|
self.args, self.kwargs = args, kwargs
|
||||||
|
self.span = None
|
||||||
|
self.started = False
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(self.gen, name)
|
||||||
|
|
||||||
|
async def _advance(self, method, *values):
|
||||||
|
if not self.started:
|
||||||
|
self.started = True
|
||||||
|
self.span = span_for(self.args, self.kwargs)
|
||||||
|
token = _CURRENT.set(self.span)
|
||||||
|
try:
|
||||||
|
value = await method(*values)
|
||||||
|
if self.span is not None and method != self.gen.aclose:
|
||||||
|
inspect_result(value)
|
||||||
|
if method == self.gen.aclose:
|
||||||
|
finish(self.span, GeneratorExit())
|
||||||
|
return value
|
||||||
|
except StopAsyncIteration:
|
||||||
|
finish(self.span)
|
||||||
|
raise
|
||||||
|
except BaseException as exc:
|
||||||
|
# Rejected protocol calls (e.g. concurrent asend, a
|
||||||
|
# yielded GeneratorExit) need not terminate the stream.
|
||||||
|
if self.gen.ag_frame is None:
|
||||||
|
finish(self.span, exc)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
_CURRENT.reset(token)
|
||||||
|
|
||||||
|
def __anext__(self):
|
||||||
|
return self._advance(self.gen.__anext__)
|
||||||
|
|
||||||
|
def asend(self, value):
|
||||||
|
return self._advance(self.gen.asend, value)
|
||||||
|
|
||||||
|
def athrow(self, *values):
|
||||||
|
return self._advance(self.gen.athrow, *values)
|
||||||
|
|
||||||
|
def aclose(self):
|
||||||
|
return self._advance(self.gen.aclose)
|
||||||
|
|
||||||
|
@functools.wraps(fn)
|
||||||
|
def stream(*args, **kwargs):
|
||||||
|
return ObservedGenerator(args, kwargs)
|
||||||
|
|
||||||
|
return stream
|
||||||
|
|
||||||
|
@functools.wraps(fn)
|
||||||
|
async def call(*args, **kwargs):
|
||||||
|
span = span_for(args, kwargs)
|
||||||
|
if span is None:
|
||||||
|
token = _CURRENT.set(None)
|
||||||
|
try:
|
||||||
|
return await fn(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
_CURRENT.reset(token)
|
||||||
|
try:
|
||||||
|
with span.activate():
|
||||||
|
value = await fn(*args, **kwargs)
|
||||||
|
inspect_result(value)
|
||||||
|
finish(span)
|
||||||
|
return value
|
||||||
|
except BaseException as exc:
|
||||||
|
finish(span, exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return call
|
||||||
|
|
||||||
|
return decorate
|
||||||
|
|
||||||
|
|
||||||
|
def event(owner, kind, operation, outcome, **fields):
|
||||||
|
"""Emit a point-in-time fact from an existing state transition."""
|
||||||
|
try:
|
||||||
|
manager = _manager(owner)
|
||||||
|
_, explicit = _owner_app(owner)
|
||||||
|
parent = current_span()
|
||||||
|
context = _owner_context(owner)
|
||||||
|
if manager is None and not explicit and parent:
|
||||||
|
if context is not None and getattr(context, 'workspace_uuid', None) != parent.fields.get('workspace_uuid'):
|
||||||
|
return
|
||||||
|
manager = parent.manager
|
||||||
|
if manager is not None and _context_matches(manager, owner, context):
|
||||||
|
inherited = dict(parent.fields) if parent and parent.manager is manager else {}
|
||||||
|
if context is not None and getattr(context, 'workspace_uuid', None) != inherited.get('workspace_uuid'):
|
||||||
|
inherited = _context_fields(owner, {})
|
||||||
|
inherited.update(fields)
|
||||||
|
manager.emit(kind, operation, outcome, **inherited)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def capture_context():
|
||||||
|
span = current_span()
|
||||||
|
if span is None or not span.manager.enabled:
|
||||||
|
return None
|
||||||
|
result = {
|
||||||
|
k: span.fields[k]
|
||||||
|
for k in ('trace_id', 'workspace_uuid', 'run_id', 'adapter', 'processor_type', 'platform_event_type', 'source')
|
||||||
|
if k in span.fields
|
||||||
|
}
|
||||||
|
result['parent_span_id'] = span.fields['span_id']
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def link_context(saved):
|
||||||
|
"""Link only after the existing Host run/installation validator accepted it."""
|
||||||
|
span = current_span()
|
||||||
|
if span is None or not isinstance(saved, dict):
|
||||||
|
return
|
||||||
|
if span.fields.get('workspace_uuid') and span.fields['workspace_uuid'] != saved.get('workspace_uuid'):
|
||||||
|
return
|
||||||
|
for key in ('trace_id', 'parent_span_id', 'workspace_uuid', 'run_id'):
|
||||||
|
if privacy.opaque(saved.get(key)):
|
||||||
|
span.fields[key] = saved[key]
|
||||||
|
for key in ('adapter', 'platform_event_type'):
|
||||||
|
if privacy.category(key, saved.get(key)):
|
||||||
|
span.fields[key] = saved[key]
|
||||||
|
if saved.get('processor_type') in privacy.PROCESSORS:
|
||||||
|
span.fields['processor_type'] = saved['processor_type']
|
||||||
|
if saved.get('source') in ('webui_debug', 'synthetic'):
|
||||||
|
span.fields['source'] = saved['source']
|
||||||
|
span.fields['attributes'] = {**span.fields.get('attributes', {}), 'synthetic': True}
|
||||||
|
|
||||||
|
|
||||||
|
def declare_runner(descriptor):
|
||||||
|
"""Allow only identifiers from a validated installed public Runner manifest."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
pairs = {'plugin_id': descriptor.get_plugin_id(), 'runner_id': descriptor.id}
|
||||||
|
for field, value in pairs.items():
|
||||||
|
allowed = privacy.VOCABULARY.setdefault(field, set())
|
||||||
|
if (
|
||||||
|
isinstance(value, str)
|
||||||
|
and len(value) <= 128
|
||||||
|
and len(allowed) < 1024
|
||||||
|
and re.fullmatch(r'(?:plugin:)?[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?', value)
|
||||||
|
):
|
||||||
|
allowed.add(value)
|
||||||
|
|
||||||
|
|
||||||
|
def runner_metadata(owner, descriptor, processor_type):
|
||||||
|
manager = _manager(owner)
|
||||||
|
if manager is None or not manager.enabled:
|
||||||
|
return
|
||||||
|
metadata = {
|
||||||
|
'plugin_id': descriptor.get_plugin_id(),
|
||||||
|
'runner_id': descriptor.id,
|
||||||
|
'plugin_version': getattr(descriptor, 'plugin_version', ''),
|
||||||
|
'runner_usage': 'event' if processor_type == 'event_processor' else 'agent',
|
||||||
|
}
|
||||||
|
annotate(attributes=metadata)
|
||||||
|
event(
|
||||||
|
owner,
|
||||||
|
'capability',
|
||||||
|
'runner.run',
|
||||||
|
'succeeded',
|
||||||
|
source='runtime',
|
||||||
|
stage='snapshot',
|
||||||
|
processor_type=processor_type,
|
||||||
|
attributes={
|
||||||
|
**metadata,
|
||||||
|
'capability_type': 'processor',
|
||||||
|
'capability_name': processor_type,
|
||||||
|
'supported': True,
|
||||||
|
'configured': True,
|
||||||
|
'available': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -416,4 +416,8 @@ space:
|
|||||||
# OAuth authorization page URL (user will be redirected here)
|
# OAuth authorization page URL (user will be redirected here)
|
||||||
oauth_authorize_url: 'https://space.langbot.app/auth/authorize'
|
oauth_authorize_url: 'https://space.langbot.app/auth/authorize'
|
||||||
disable_models_service: false
|
disable_models_service: false
|
||||||
|
# Master OFF for usage telemetry and Beta diagnostics.
|
||||||
disable_telemetry: false
|
disable_telemetry: false
|
||||||
|
# Beta builds only: opt out of content-free quality diagnostics separately.
|
||||||
|
# Stable, alpha and RC builds never enable this diagnostics producer.
|
||||||
|
disable_beta_diagnostics: false
|
||||||
|
|||||||
@@ -1473,3 +1473,56 @@ async def test_synthetic_event_query_exposes_trusted_workspace_to_tools(clean_ag
|
|||||||
app.skill_mgr.get_skills = lambda scope: received.append(scope) or {}
|
app.skill_mgr.get_skills = lambda scope: received.append(scope) or {}
|
||||||
get_visible_skills(app, synthetic)
|
get_visible_skills(app, synthetic)
|
||||||
assert received[0].workspace_uuid == context.workspace_uuid
|
assert received[0].workspace_uuid == context.workspace_uuid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beta_diagnostics_close_releases_real_orchestrator_session(clean_agent_state):
|
||||||
|
from langbot.pkg.telemetry.diagnostics import DiagnosticsManager
|
||||||
|
|
||||||
|
plugin_connector = FakePluginConnector(
|
||||||
|
results=[{'type': 'message.completed', 'data': {'message': {'role': 'assistant', 'content': 'CANARY'}}}]
|
||||||
|
)
|
||||||
|
ap = FakeApplication(plugin_connector, clean_agent_state)
|
||||||
|
ap.instance_config = types.SimpleNamespace(data={'space': {'url': 'https://example.invalid'}})
|
||||||
|
ap.diagnostics = DiagnosticsManager(ap, version='4.11.0b2', instance_id='instance-test')
|
||||||
|
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(make_descriptor()))
|
||||||
|
gen = orchestrator.run_from_query(make_query())
|
||||||
|
assert (await anext(gen)).content == 'CANARY'
|
||||||
|
run_id = plugin_connector.contexts[0]['run_id']
|
||||||
|
assert await get_session_registry().get(run_id) is not None
|
||||||
|
await gen.aclose()
|
||||||
|
assert await get_session_registry().get(run_id) is None
|
||||||
|
records = [e for e in ap.diagnostics.pending if e['operation'] == 'runner.run']
|
||||||
|
assert records[-1]['outcome'] == 'cancelled'
|
||||||
|
import json
|
||||||
|
|
||||||
|
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('terminal', ['run.completed', 'run.failed'])
|
||||||
|
async def test_beta_diagnostics_real_runner_terminal(clean_agent_state, terminal):
|
||||||
|
from langbot.pkg.telemetry.diagnostics import DiagnosticsManager
|
||||||
|
|
||||||
|
plugin_connector = FakePluginConnector(
|
||||||
|
results=[
|
||||||
|
{
|
||||||
|
'type': terminal,
|
||||||
|
'data': {'finish_reason': 'stop'} if terminal == 'run.completed' else {'error': 'CANARY'},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
ap = FakeApplication(plugin_connector, clean_agent_state)
|
||||||
|
ap.instance_config = types.SimpleNamespace(data={'space': {'url': 'https://example.invalid'}})
|
||||||
|
ap.diagnostics = DiagnosticsManager(ap, version='4.11.0b2', instance_id='instance-test')
|
||||||
|
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(make_descriptor()))
|
||||||
|
try:
|
||||||
|
_ = [v async for v in orchestrator.run_from_query(make_query())]
|
||||||
|
except RunnerExecutionError:
|
||||||
|
assert terminal == 'run.failed'
|
||||||
|
records = [e for e in ap.diagnostics.pending if e['operation'] == 'runner.run']
|
||||||
|
assert records[-1]['outcome'] == ('succeeded' if terminal == 'run.completed' else 'failed')
|
||||||
|
assert records[-1]['run_id'] == plugin_connector.contexts[0]['run_id']
|
||||||
|
import json
|
||||||
|
|
||||||
|
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Real Quart registration boundaries with content-canary payloads."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from langbot.pkg.api.http.controller.group import AuthType, RouterGroup
|
||||||
|
from langbot.pkg.telemetry import diagnostics as d
|
||||||
|
|
||||||
|
|
||||||
|
class Recorder:
|
||||||
|
enabled = True
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.events = []
|
||||||
|
|
||||||
|
def emit(self, kind, operation, outcome, **fields):
|
||||||
|
# A real transport projects the exception class, never its message.
|
||||||
|
error = fields.pop('error', None)
|
||||||
|
if error is not None:
|
||||||
|
fields['error_type'] = type(error).__name__
|
||||||
|
self.events.append(dict(kind=kind, operation=operation, outcome=outcome, **fields))
|
||||||
|
|
||||||
|
|
||||||
|
class Routes(RouterGroup):
|
||||||
|
path = '/management'
|
||||||
|
name = 'management'
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
@self.route('/ok/<identifier>', auth_type=AuthType.NONE)
|
||||||
|
async def success(identifier):
|
||||||
|
self.ap.seen.append(d.current_span())
|
||||||
|
return self.success({'secret': identifier})
|
||||||
|
|
||||||
|
@self.route('/business', auth_type=AuthType.NONE)
|
||||||
|
async def business():
|
||||||
|
return self.fail('private-error-code', 'private-error-message')
|
||||||
|
|
||||||
|
@self.route('/auth')
|
||||||
|
async def authenticated():
|
||||||
|
raise AssertionError('must not run')
|
||||||
|
|
||||||
|
@self.route('/error', auth_type=AuthType.NONE)
|
||||||
|
async def error():
|
||||||
|
raise ValueError('private-exception-message')
|
||||||
|
|
||||||
|
@self.route('/cancel', auth_type=AuthType.NONE)
|
||||||
|
async def cancel():
|
||||||
|
raise asyncio.CancelledError('private-cancel-message')
|
||||||
|
|
||||||
|
@self.route('/stream', auth_type=AuthType.NONE)
|
||||||
|
async def stream():
|
||||||
|
async def body():
|
||||||
|
self.ap.seen.append(d.current_span())
|
||||||
|
yield b'private-stream-chunk'
|
||||||
|
self.ap.seen.append(d.current_span())
|
||||||
|
|
||||||
|
return quart.Response(body())
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(manager=True):
|
||||||
|
app = quart.Quart(__name__)
|
||||||
|
ap = SimpleNamespace(seen=[])
|
||||||
|
if manager is True:
|
||||||
|
manager = Recorder()
|
||||||
|
if manager is not None:
|
||||||
|
ap.diagnostics = manager
|
||||||
|
routes = Routes(ap, app)
|
||||||
|
await routes.initialize()
|
||||||
|
return app, ap, routes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_success_uses_code_identity_not_path_or_payload():
|
||||||
|
app, ap, _ = await setup()
|
||||||
|
response = await app.test_client().get(
|
||||||
|
'/management/ok/private-id?token=private-query', headers={'Authorization': 'private-token'}
|
||||||
|
)
|
||||||
|
assert (await response.get_json())['data']['secret'] == 'private-id'
|
||||||
|
assert [e['outcome'] for e in ap.diagnostics.events] == ['started', 'succeeded']
|
||||||
|
event = ap.diagnostics.events[-1]
|
||||||
|
assert event['source'] == 'http'
|
||||||
|
assert re.fullmatch(r'[A-Za-z_][A-Za-z0-9_.:-]{0,127}', event['operation'])
|
||||||
|
assert ap.seen[0] is not None
|
||||||
|
assert d.current_span() is None
|
||||||
|
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'path,status,outcome', [('business', 200, 'failed'), ('auth', 401, 'rejected'), ('error', 500, 'failed')]
|
||||||
|
)
|
||||||
|
async def test_http_business_auth_and_exception_outcomes(path, status, outcome):
|
||||||
|
app, ap, _ = await setup()
|
||||||
|
response = await app.test_client().get('/management/' + path)
|
||||||
|
assert response.status_code == status
|
||||||
|
assert ap.diagnostics.events[-1]['outcome'] == outcome
|
||||||
|
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_cancellation_propagates():
|
||||||
|
app, ap, _ = await setup()
|
||||||
|
async with app.test_request_context('/management/cancel'):
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await app.full_dispatch_request()
|
||||||
|
assert ap.diagnostics.events[-1]['outcome'] == 'cancelled'
|
||||||
|
assert d.current_span() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_auth_cancellation_not_reinterpreted_as_api_key():
|
||||||
|
app, ap, routes = await setup()
|
||||||
|
routes._authenticate_support_admin = AsyncMock(return_value=None)
|
||||||
|
routes._authenticate_account = AsyncMock(side_effect=asyncio.CancelledError())
|
||||||
|
async with app.test_request_context('/management/auth', headers={'Authorization': 'Bearer private-token'}):
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await app.full_dispatch_request()
|
||||||
|
assert ap.diagnostics.events[-1]['outcome'] == 'cancelled'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_stream_has_parent_context_without_consumer_leak():
|
||||||
|
app, ap, _ = await setup()
|
||||||
|
async with app.test_request_context('/management/stream'):
|
||||||
|
response = await app.full_dispatch_request()
|
||||||
|
assert [e['outcome'] for e in ap.diagnostics.events] == ['started']
|
||||||
|
assert d.current_span() is None
|
||||||
|
async with response.response as body:
|
||||||
|
iterator = body.__aiter__()
|
||||||
|
assert await anext(iterator) == b'private-stream-chunk'
|
||||||
|
assert d.current_span() is None
|
||||||
|
assert ap.seen[-1] is not None
|
||||||
|
with pytest.raises(StopAsyncIteration):
|
||||||
|
await anext(iterator)
|
||||||
|
assert ap.seen[0] is ap.seen[1]
|
||||||
|
assert ap.diagnostics.events[-1]['outcome'] == 'succeeded'
|
||||||
|
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'manager', [None, SimpleNamespace(enabled=False, emit=lambda *a, **kw: pytest.fail('disabled emission'))]
|
||||||
|
)
|
||||||
|
async def test_absent_disabled_manager_preserves_result_without_context(manager):
|
||||||
|
app, ap, _ = await setup(manager)
|
||||||
|
response = await app.test_client().get('/management/ok/private-id')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert ap.seen == [None]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broken_diagnostic_emit_never_masks_operation():
|
||||||
|
class Broken(Recorder):
|
||||||
|
def emit(self, *args, **kwargs):
|
||||||
|
raise RuntimeError('diagnostics broken')
|
||||||
|
|
||||||
|
app, _, _ = await setup(Broken())
|
||||||
|
response = await app.test_client().get('/management/ok/private-id')
|
||||||
|
assert response.status_code == 200
|
||||||
|
async with app.test_request_context('/management/cancel'):
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await app.full_dispatch_request()
|
||||||
|
assert d.current_span() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anonymous_handlers_have_distinct_stable_code_operations():
|
||||||
|
app, ap, routes = await setup()
|
||||||
|
|
||||||
|
@routes.route('/items/<identifier>', auth_type=AuthType.NONE, methods=['GET'])
|
||||||
|
async def _(identifier):
|
||||||
|
return routes.success()
|
||||||
|
|
||||||
|
@routes.route('/items/<identifier>', auth_type=AuthType.NONE, methods=['POST'])
|
||||||
|
async def _(identifier):
|
||||||
|
return routes.success()
|
||||||
|
|
||||||
|
@routes.route('/other', auth_type=AuthType.NONE)
|
||||||
|
async def _():
|
||||||
|
return routes.success()
|
||||||
|
|
||||||
|
await app.test_client().get('/management/items/private-id')
|
||||||
|
await app.test_client().post('/management/items/private-id')
|
||||||
|
await app.test_client().get('/management/other')
|
||||||
|
operations = [e['operation'] for e in ap.diagnostics.events if e['outcome'] == 'started']
|
||||||
|
assert len(set(operations)) == 3
|
||||||
|
assert all(re.fullmatch(r'[A-Za-z_][A-Za-z0-9_.:-]{0,127}', op) for op in operations)
|
||||||
|
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Verify identities across every source-declared management registration."""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from langbot.pkg.api.management_diagnostics import operation_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_http_registration_has_a_distinct_wire_safe_operation():
|
||||||
|
root = Path(__file__).resolve().parents[3] / 'src/langbot/pkg/api/http/controller/groups'
|
||||||
|
identities = []
|
||||||
|
for path in root.rglob('*.py'):
|
||||||
|
module = 'langbot.pkg.api.http.controller.groups.' + '.'.join(path.relative_to(root).with_suffix('').parts)
|
||||||
|
for cls in ast.walk(ast.parse(path.read_text())):
|
||||||
|
if not isinstance(cls, ast.ClassDef):
|
||||||
|
continue
|
||||||
|
prefix = ''
|
||||||
|
for decorator in cls.decorator_list:
|
||||||
|
if (
|
||||||
|
isinstance(decorator, ast.Call)
|
||||||
|
and isinstance(decorator.func, ast.Attribute)
|
||||||
|
and decorator.func.attr == 'group_class'
|
||||||
|
):
|
||||||
|
prefix = ast.literal_eval(decorator.args[1])
|
||||||
|
for fn in ast.walk(cls):
|
||||||
|
if not isinstance(fn, ast.AsyncFunctionDef):
|
||||||
|
continue
|
||||||
|
for decorator in fn.decorator_list:
|
||||||
|
if not (
|
||||||
|
isinstance(decorator, ast.Call)
|
||||||
|
and isinstance(decorator.func, ast.Attribute)
|
||||||
|
and decorator.func.attr == 'route'
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
rule = prefix + ast.literal_eval(decorator.args[0])
|
||||||
|
methods = next(
|
||||||
|
(ast.literal_eval(k.value) for k in decorator.keywords if k.arg == 'methods'), ['GET']
|
||||||
|
)
|
||||||
|
operation = operation_id(
|
||||||
|
'http', SimpleNamespace(__module__=module, __name__=fn.name), rule=rule, methods=methods
|
||||||
|
)
|
||||||
|
assert re.fullmatch(r'[A-Za-z_][A-Za-z0-9_.:-]{0,127}', operation), operation
|
||||||
|
identities.append(operation)
|
||||||
|
assert len(identities) >= 200 # Guard against accidentally scanning an empty/subset tree.
|
||||||
|
assert len(set(identities)) == len(identities)
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
"""CORE-DIAG-6: management ownership barriers under a foreign live span."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace as NS
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from langbot.pkg.api import management_diagnostics as md
|
||||||
|
from langbot.pkg.api.http.context import (
|
||||||
|
ExecutionContext,
|
||||||
|
PrincipalContext,
|
||||||
|
PrincipalType,
|
||||||
|
RequestContext,
|
||||||
|
WorkspaceContext,
|
||||||
|
)
|
||||||
|
from langbot.pkg.api.http.controller.group import AuthType
|
||||||
|
from langbot.pkg.api.http.controller.groups.pipelines.embed import EmbedRouterGroup
|
||||||
|
from langbot.pkg.api.http.controller.groups.pipelines.websocket_chat import WebSocketChatRouterGroup
|
||||||
|
from langbot.pkg.api.http.service.agent import AgentService
|
||||||
|
from langbot.pkg.api.mcp.server import LangBotMCPServer
|
||||||
|
from langbot.pkg.telemetry import diagnostics as d
|
||||||
|
from tests.unit_tests.api.test_diagnostics_management_http import Recorder, Routes
|
||||||
|
|
||||||
|
|
||||||
|
MODES = ['enabled', 'disabled', 'absent', 'mismatched', 'policy_off', 'beta_off', 'broken']
|
||||||
|
|
||||||
|
|
||||||
|
def app(identity):
|
||||||
|
ap = NS(instance_config=NS(data={'space': {'url': 'https://example.invalid'}}))
|
||||||
|
ap.diagnostics = d.DiagnosticsManager(ap, version='4.11.0b2', instance_id=identity)
|
||||||
|
return ap
|
||||||
|
|
||||||
|
|
||||||
|
def owners(mode):
|
||||||
|
a, b = app('instance-A'), app('instance-B')
|
||||||
|
b_manager = b.diagnostics
|
||||||
|
if mode == 'disabled':
|
||||||
|
b.instance_config.data['space']['disable_telemetry'] = True
|
||||||
|
b.diagnostics = d.DiagnosticsManager(b, version='4.11.0b2', instance_id='instance-B')
|
||||||
|
b_manager = b.diagnostics
|
||||||
|
elif mode == 'absent':
|
||||||
|
del b.diagnostics
|
||||||
|
elif mode == 'mismatched':
|
||||||
|
b.diagnostics = a.diagnostics
|
||||||
|
elif mode in {'policy_off', 'beta_off'}:
|
||||||
|
# Also test an attached structural producer whose enabled flag stays true.
|
||||||
|
b.diagnostics = Recorder()
|
||||||
|
b.instance_config.data['space']['disable_telemetry' if mode == 'policy_off' else 'disable_beta_diagnostics'] = (
|
||||||
|
True
|
||||||
|
)
|
||||||
|
elif mode == 'broken':
|
||||||
|
b.diagnostics = NS(enabled=True, emit=lambda *a, **kw: (_ for _ in ()).throw(RuntimeError('broken')))
|
||||||
|
ca = ExecutionContext('instance-A', str(uuid4()), 1)
|
||||||
|
cb = ExecutionContext('instance-B', str(uuid4()), 1)
|
||||||
|
d.privacy.code_value('operation', 'http.isolation.parent')
|
||||||
|
parent = d.Span(a.diagnostics, 'api', 'http.isolation.parent', {'workspace_uuid': ca.workspace_uuid})
|
||||||
|
return a, b, b_manager, ca, cb, parent
|
||||||
|
|
||||||
|
|
||||||
|
@d.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||||
|
async def converter(native):
|
||||||
|
return native
|
||||||
|
|
||||||
|
|
||||||
|
async def nested(b, cb, seen):
|
||||||
|
md.workspace(cb)
|
||||||
|
seen.append(d.current_span())
|
||||||
|
with md.scope(b, 'websocket.isolation.inner', source='websocket'):
|
||||||
|
md.workspace(cb)
|
||||||
|
assert await converter(42) == 42
|
||||||
|
# Both an inner management boundary and an ownerless converter must be safe.
|
||||||
|
assert await converter(43) == 43
|
||||||
|
return 44
|
||||||
|
|
||||||
|
|
||||||
|
def assert_isolated(a, b, b_manager, ca, cb, parent, mode):
|
||||||
|
assert parent.fields['workspace_uuid'] == ca.workspace_uuid
|
||||||
|
assert parent.outcome is None
|
||||||
|
assert len(a.diagnostics.pending) == 1 # Only the caller's own start event.
|
||||||
|
assert not b_manager.pending if mode != 'enabled' else b_manager.pending
|
||||||
|
if isinstance(getattr(b, 'diagnostics', None), Recorder):
|
||||||
|
assert b.diagnostics.events == []
|
||||||
|
if mode == 'enabled':
|
||||||
|
assert all(e['instance_id'] == 'instance-B' for e in b_manager.pending)
|
||||||
|
assert all(e['trace_id'] != parent.fields['trace_id'] for e in b_manager.pending)
|
||||||
|
assert all(e['workspace_uuid'] != ca.workspace_uuid for e in b_manager.pending)
|
||||||
|
assert any(e['workspace_uuid'] == cb.workspace_uuid for e in b_manager.pending)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('mode', MODES)
|
||||||
|
@pytest.mark.parametrize('shape', ['decorator', 'scope'])
|
||||||
|
async def test_nested_management_masks_foreign_parent_and_restores_caller(mode, shape):
|
||||||
|
a, b, manager, ca, cb, parent = owners(mode)
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
@md.observe('http.isolation.endpoint', source='http', ap=b)
|
||||||
|
async def endpoint():
|
||||||
|
return await nested(b, cb, seen)
|
||||||
|
|
||||||
|
with parent.activate():
|
||||||
|
if shape == 'decorator':
|
||||||
|
assert await endpoint() == 44
|
||||||
|
else:
|
||||||
|
with md.scope(b, 'websocket.isolation.outer', source='websocket'):
|
||||||
|
assert await nested(b, cb, seen) == 44
|
||||||
|
assert d.current_span() is parent
|
||||||
|
assert d.current_span() is None
|
||||||
|
assert (seen[0] is not None) == (mode == 'enabled')
|
||||||
|
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('mode', MODES)
|
||||||
|
async def test_actual_agent_debug_never_mutates_foreign_workspace(mode):
|
||||||
|
a, b, manager, ca, cb, parent = owners(mode)
|
||||||
|
service = AgentService(b)
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def missing(*args):
|
||||||
|
await nested(b, cb, seen)
|
||||||
|
return None
|
||||||
|
|
||||||
|
service.get_agent = missing
|
||||||
|
with parent.activate():
|
||||||
|
with pytest.raises(ValueError, match='^Agent not found$'):
|
||||||
|
await service.debug_agent(cb, 'private-agent', {})
|
||||||
|
assert d.current_span() is parent
|
||||||
|
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('mode', MODES)
|
||||||
|
@pytest.mark.parametrize('termination', ['exhaust', 'close', 'error', 'cancel'])
|
||||||
|
async def test_real_http_body_keeps_barrier_during_advancement_and_cleanup(mode, termination):
|
||||||
|
a, b, manager, ca, cb, parent = owners(mode)
|
||||||
|
web = quart.Quart(__name__)
|
||||||
|
routes = Routes(b, web)
|
||||||
|
seen, closed = [], []
|
||||||
|
failure = asyncio.CancelledError('private-cancel') if termination == 'cancel' else ValueError('private-error')
|
||||||
|
|
||||||
|
@routes.route('/isolated', auth_type=AuthType.NONE)
|
||||||
|
async def endpoint():
|
||||||
|
await nested(b, cb, seen)
|
||||||
|
|
||||||
|
async def stream():
|
||||||
|
try:
|
||||||
|
await nested(b, cb, seen)
|
||||||
|
yield b'one'
|
||||||
|
if termination in {'cancel', 'error'}:
|
||||||
|
raise failure
|
||||||
|
await nested(b, cb, seen)
|
||||||
|
finally:
|
||||||
|
await nested(b, cb, seen)
|
||||||
|
closed.append(True)
|
||||||
|
|
||||||
|
return quart.Response(stream())
|
||||||
|
|
||||||
|
# Construct with no caller parent; the later body consumer has app A's span.
|
||||||
|
async with web.test_request_context('/management/isolated'):
|
||||||
|
response = await web.full_dispatch_request()
|
||||||
|
with parent.activate():
|
||||||
|
try:
|
||||||
|
async with response.response as body:
|
||||||
|
iterator = body.__aiter__()
|
||||||
|
assert await anext(iterator) == b'one'
|
||||||
|
assert d.current_span() is parent
|
||||||
|
if termination != 'close':
|
||||||
|
with pytest.raises((StopAsyncIteration, type(failure))) as caught:
|
||||||
|
await anext(iterator)
|
||||||
|
if termination in {'cancel', 'error'}:
|
||||||
|
assert caught.value is failure
|
||||||
|
else:
|
||||||
|
assert isinstance(caught.value, StopAsyncIteration)
|
||||||
|
finally:
|
||||||
|
assert d.current_span() is parent
|
||||||
|
assert closed == [True]
|
||||||
|
assert all((span is not None) == (mode == 'enabled') for span in seen)
|
||||||
|
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('mode', MODES)
|
||||||
|
async def test_real_mcp_registered_tool_masks_foreign_parent(mode):
|
||||||
|
a, b, manager, ca, cb, parent = owners(mode)
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def get_bot(*args, **kwargs):
|
||||||
|
return {'value': await nested(b, cb, seen)}
|
||||||
|
|
||||||
|
b.bot_service = NS(get_bot=get_bot)
|
||||||
|
server = LangBotMCPServer(b)
|
||||||
|
with parent.activate(), patch('langbot.pkg.api.mcp.server._authorized', return_value=cb):
|
||||||
|
assert await server.mcp.call_tool('get_bot', {'bot_uuid': 'private-bot'})
|
||||||
|
assert d.current_span() is parent
|
||||||
|
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('mode', MODES)
|
||||||
|
@pytest.mark.parametrize('group_class', [WebSocketChatRouterGroup, EmbedRouterGroup])
|
||||||
|
async def test_real_websocket_receive_masks_foreign_parent(mode, group_class):
|
||||||
|
a, b, manager, ca, cb, parent = owners(mode)
|
||||||
|
group = group_class(b, quart.Quart(__name__))
|
||||||
|
connection = NS(is_active=True, connection_id='private-id', send_queue=asyncio.Queue())
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def handle(*args, **kwargs):
|
||||||
|
await nested(b, cb, seen)
|
||||||
|
|
||||||
|
adapter = NS(handle_websocket_message=handle)
|
||||||
|
if group_class is WebSocketChatRouterGroup:
|
||||||
|
group._revalidate_websocket_authorization = AsyncMock(return_value=cb)
|
||||||
|
else:
|
||||||
|
group._resolve_connected_bot = AsyncMock(return_value=NS(execution_context=cb))
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
connection.is_active = False
|
||||||
|
return '{"type":"message","text":"private-prompt"}'
|
||||||
|
|
||||||
|
with (
|
||||||
|
parent.activate(),
|
||||||
|
patch('quart.websocket', NS(receive=receive)),
|
||||||
|
patch(
|
||||||
|
'langbot.pkg.api.http.controller.groups.pipelines.websocket_chat.ws_connection_manager.update_activity',
|
||||||
|
new=AsyncMock(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await group._handle_receive(connection, adapter, NS(execution_context=cb), 'private-token')
|
||||||
|
assert d.current_span() is parent
|
||||||
|
assert seen
|
||||||
|
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('kind', ['execution', 'request'])
|
||||||
|
def test_workspace_rejects_foreign_instance_or_foreign_active_span(kind):
|
||||||
|
a, b, _, ca, cb, parent = owners('enabled')
|
||||||
|
context = cb
|
||||||
|
if kind == 'request':
|
||||||
|
context = RequestContext(
|
||||||
|
cb.instance_uuid,
|
||||||
|
1,
|
||||||
|
'request',
|
||||||
|
'api_key',
|
||||||
|
PrincipalContext(PrincipalType.API_KEY),
|
||||||
|
WorkspaceContext(cb.workspace_uuid, None, None, frozenset()),
|
||||||
|
)
|
||||||
|
with md.scope(a, 'http.isolation.annotation', source='http'):
|
||||||
|
span = d.current_span()
|
||||||
|
md.workspace(context)
|
||||||
|
assert 'workspace_uuid' not in span.fields
|
||||||
|
md.workspace(ca)
|
||||||
|
assert span.fields['workspace_uuid'] == ca.workspace_uuid
|
||||||
|
with parent.activate():
|
||||||
|
before = dict(parent.fields)
|
||||||
|
md.workspace(ca)
|
||||||
|
md.workspace(context)
|
||||||
|
assert parent.fields == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_structural_recorder_workspace_and_scope_exception_identity():
|
||||||
|
recorder = Recorder()
|
||||||
|
ap = NS(diagnostics=recorder)
|
||||||
|
ctx = ExecutionContext('instance', str(uuid4()), 1)
|
||||||
|
error = ValueError('private-error')
|
||||||
|
with pytest.raises(ValueError) as caught:
|
||||||
|
with md.scope(ap, 'http.isolation.recorder', source='http'):
|
||||||
|
md.workspace(ctx)
|
||||||
|
assert d.current_span().fields['workspace_uuid'] == ctx.workspace_uuid
|
||||||
|
raise error
|
||||||
|
assert caught.value is error
|
||||||
|
assert recorder.events[-1]['outcome'] == 'failed'
|
||||||
|
assert d.current_span() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('mode', MODES[1:])
|
||||||
|
@pytest.mark.parametrize('termination', ['success', 'error', 'cancel'])
|
||||||
|
async def test_nested_off_boundary_restores_outer_annotation_and_error(mode, termination):
|
||||||
|
a, b, manager, ca, cb, parent = owners(mode)
|
||||||
|
error = asyncio.CancelledError('private-cancel') if termination == 'cancel' else ValueError('private-error')
|
||||||
|
|
||||||
|
@md.observe('http.isolation.off', source='http', ap=b)
|
||||||
|
async def endpoint():
|
||||||
|
await nested(b, cb, [])
|
||||||
|
md.outcome('failed')
|
||||||
|
if termination != 'success':
|
||||||
|
raise error
|
||||||
|
return 42
|
||||||
|
|
||||||
|
with parent.activate():
|
||||||
|
with md.scope(a, 'http.isolation.outer', source='http'):
|
||||||
|
outer = d.current_span()
|
||||||
|
if termination == 'success':
|
||||||
|
assert await endpoint() == 42
|
||||||
|
else:
|
||||||
|
with pytest.raises(type(error)) as caught:
|
||||||
|
await endpoint()
|
||||||
|
assert caught.value is error
|
||||||
|
assert d.current_span() is outer
|
||||||
|
md.workspace(ca)
|
||||||
|
assert outer.fields['workspace_uuid'] == ca.workspace_uuid
|
||||||
|
assert outer.outcome is None
|
||||||
|
assert d.current_span() is parent
|
||||||
|
assert not manager.pending
|
||||||
|
assert [e['operation'] for e in a.diagnostics.pending] == [
|
||||||
|
'http.isolation.parent',
|
||||||
|
'http.isolation.outer',
|
||||||
|
'http.isolation.outer',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('mode', MODES)
|
||||||
|
@pytest.mark.parametrize('group_class', [WebSocketChatRouterGroup, EmbedRouterGroup])
|
||||||
|
async def test_websocket_send_cancel_restores_foreign_parent(mode, group_class):
|
||||||
|
a, b, manager, ca, cb, parent = owners(mode)
|
||||||
|
group = group_class(b, quart.Quart(__name__))
|
||||||
|
connection = NS(is_active=False, send_queue=asyncio.Queue())
|
||||||
|
await connection.send_queue.put({'text': 'private-answer'})
|
||||||
|
error = asyncio.CancelledError('private-cancel')
|
||||||
|
|
||||||
|
async def send(payload):
|
||||||
|
await nested(b, cb, [])
|
||||||
|
raise error
|
||||||
|
|
||||||
|
with parent.activate(), patch('quart.websocket', NS(send=send)):
|
||||||
|
with pytest.raises(asyncio.CancelledError) as caught:
|
||||||
|
await group._handle_send(connection)
|
||||||
|
assert caught.value is error
|
||||||
|
assert d.current_span() is parent
|
||||||
|
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('mode', MODES)
|
||||||
|
async def test_sync_http_body_exhaustion_has_owned_context_without_consumer_leak(mode):
|
||||||
|
a, b, manager, ca, cb, parent = owners(mode)
|
||||||
|
seen, closed = [], []
|
||||||
|
|
||||||
|
def stream():
|
||||||
|
try:
|
||||||
|
for value in (b'one', b'two'):
|
||||||
|
md.workspace(cb)
|
||||||
|
seen.append(d.current_span())
|
||||||
|
yield value
|
||||||
|
finally:
|
||||||
|
seen.append(d.current_span())
|
||||||
|
closed.append(True)
|
||||||
|
|
||||||
|
@md.observe('http.isolation.sync_body', source='http', ap=b, http=True)
|
||||||
|
async def endpoint():
|
||||||
|
return quart.Response(stream())
|
||||||
|
|
||||||
|
response = await endpoint()
|
||||||
|
with parent.activate():
|
||||||
|
async with response.response as body:
|
||||||
|
chunks = []
|
||||||
|
async for chunk in body:
|
||||||
|
assert d.current_span() is parent
|
||||||
|
chunks.append(chunk)
|
||||||
|
assert d.current_span() is parent
|
||||||
|
assert chunks == [b'one', b'two']
|
||||||
|
assert closed == [True]
|
||||||
|
assert all((span is not None) == (mode == 'enabled') for span in seen)
|
||||||
|
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('mode', ['enabled', 'disabled', 'absent', 'mismatched'])
|
||||||
|
@pytest.mark.parametrize('failure_stage', ['enter', 'iterator', 'advance', 'exit', None])
|
||||||
|
async def test_custom_http_body_protocol_and_cancellation_identity(mode, failure_stage):
|
||||||
|
from quart.wrappers.response import IterableBody
|
||||||
|
|
||||||
|
a, b, manager, ca, cb, parent = owners(mode)
|
||||||
|
error = asyncio.CancelledError('private-body-cancel')
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def visit(stage):
|
||||||
|
md.workspace(cb)
|
||||||
|
seen.append((stage, d.current_span()))
|
||||||
|
if stage == failure_stage:
|
||||||
|
raise error
|
||||||
|
|
||||||
|
class Body(IterableBody):
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
visit('enter')
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __aiter__(self):
|
||||||
|
visit('iterator')
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __anext__(self):
|
||||||
|
visit('advance')
|
||||||
|
raise StopAsyncIteration
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
visit('exit')
|
||||||
|
|
||||||
|
@md.observe('http.isolation.body_protocol', source='http', ap=b, http=True)
|
||||||
|
async def endpoint():
|
||||||
|
response = quart.Response()
|
||||||
|
response.response = Body()
|
||||||
|
return response
|
||||||
|
|
||||||
|
response = await endpoint()
|
||||||
|
|
||||||
|
async def consume():
|
||||||
|
async with response.response as body:
|
||||||
|
assert d.current_span() is parent
|
||||||
|
async for _ in body:
|
||||||
|
pytest.fail('empty body yielded')
|
||||||
|
|
||||||
|
with parent.activate():
|
||||||
|
if failure_stage is None:
|
||||||
|
await consume()
|
||||||
|
else:
|
||||||
|
with pytest.raises(asyncio.CancelledError) as caught:
|
||||||
|
await consume()
|
||||||
|
assert caught.value is error
|
||||||
|
assert d.current_span() is parent
|
||||||
|
assert all((span is not None) == (mode == 'enabled') for _, span in seen)
|
||||||
|
assert [s for s, _ in seen].count('exit') == (0 if failure_stage in {'enter', 'iterator'} else 1)
|
||||||
|
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""HTTP streaming termination, fail-open behavior and real producer privacy."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from langbot.pkg.agent.runner import errors as runner_errors
|
||||||
|
from langbot.pkg.api import management_diagnostics as md
|
||||||
|
from langbot.pkg.api.http.controller.group import AuthType
|
||||||
|
from langbot.pkg.api.http.controller.groups.agent_debug_stream import debug_stream_response
|
||||||
|
from langbot.pkg.api.mcp.mount import MCPMount
|
||||||
|
from langbot.pkg.telemetry import diagnostics as d
|
||||||
|
from tests.unit_tests.api.test_diagnostics_management_http import Recorder, setup
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('termination', ['close', 'cancel', 'error'])
|
||||||
|
async def test_stream_termination_preserves_cleanup_and_error(termination):
|
||||||
|
app, ap, routes = await setup()
|
||||||
|
closed = []
|
||||||
|
|
||||||
|
@routes.route('/end', auth_type=AuthType.NONE)
|
||||||
|
async def end():
|
||||||
|
async def body():
|
||||||
|
try:
|
||||||
|
yield b'one'
|
||||||
|
if termination == 'error':
|
||||||
|
raise ValueError('private-error')
|
||||||
|
if termination == 'cancel':
|
||||||
|
raise asyncio.CancelledError('private-cancel')
|
||||||
|
yield b'two'
|
||||||
|
finally:
|
||||||
|
closed.append(d.current_span())
|
||||||
|
|
||||||
|
return quart.Response(body())
|
||||||
|
|
||||||
|
async with app.test_request_context('/management/end'):
|
||||||
|
response = await app.full_dispatch_request()
|
||||||
|
if termination == 'close':
|
||||||
|
async with response.response as body:
|
||||||
|
assert await anext(body.__aiter__()) == b'one'
|
||||||
|
else:
|
||||||
|
error = ValueError if termination == 'error' else asyncio.CancelledError
|
||||||
|
with pytest.raises(error):
|
||||||
|
async with response.response as body:
|
||||||
|
iterator = body.__aiter__()
|
||||||
|
assert await anext(iterator) == b'one'
|
||||||
|
await anext(iterator)
|
||||||
|
assert len(closed) == 1 and closed[0] is not None
|
||||||
|
expected = 'failed' if termination == 'error' else 'cancelled'
|
||||||
|
assert [e['outcome'] for e in ap.diagnostics.events] == ['started', expected]
|
||||||
|
assert d.current_span() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_debug_ndjson_error_marks_http_business_failure():
|
||||||
|
app, ap, routes = await setup()
|
||||||
|
service = SimpleNamespace(debug_agent=AsyncMock(side_effect=runner_errors.RunnerNotFoundError('private-error')))
|
||||||
|
|
||||||
|
@routes.route('/ndjson', auth_type=AuthType.NONE)
|
||||||
|
async def ndjson():
|
||||||
|
return debug_stream_response(service, object(), 'private-agent', {'text': 'private-text'})
|
||||||
|
|
||||||
|
response = await app.test_client().get('/management/ndjson')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert json.loads(await response.get_data())['kind'] == 'error'
|
||||||
|
assert ap.diagnostics.events[-1]['outcome'] == 'failed'
|
||||||
|
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terminal_diagnostic_failure_preserves_success_and_stream():
|
||||||
|
class BrokenFinish(Recorder):
|
||||||
|
def emit(self, kind, operation, outcome, **fields):
|
||||||
|
if outcome != 'started':
|
||||||
|
raise RuntimeError('private-diagnostic-error')
|
||||||
|
super().emit(kind, operation, outcome, **fields)
|
||||||
|
|
||||||
|
app, _, _ = await setup(BrokenFinish())
|
||||||
|
response = await app.test_client().get('/management/stream')
|
||||||
|
assert await response.get_data() == b'private-stream-chunk'
|
||||||
|
assert d.current_span() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_real_manager_status_privacy_and_no_network_in_request():
|
||||||
|
app, ap, _ = await setup(None)
|
||||||
|
ap.instance_config = SimpleNamespace(data={'space': {'url': 'https://example.invalid'}})
|
||||||
|
ap.diagnostics = d.DiagnosticsManager(ap, version='4.11.0-beta.2', instance_id='instance-test', capacity=20)
|
||||||
|
ap.diagnostics.credentials = AsyncMock(side_effect=AssertionError('must not await credentials'))
|
||||||
|
for path in ['ok/private-identifier', 'business', 'auth', 'cancel']:
|
||||||
|
async with app.test_request_context('/management/' + path):
|
||||||
|
try:
|
||||||
|
await app.full_dispatch_request()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
events = list(ap.diagnostics.pending)
|
||||||
|
assert len(events) == 8
|
||||||
|
assert {e['outcome'] for e in events} == {'started', 'succeeded', 'failed', 'rejected', 'cancelled'}
|
||||||
|
assert all(re.fullmatch(r'[A-Za-z_][A-Za-z0-9_.:-]{0,127}', e['operation']) for e in events)
|
||||||
|
assert 'private-' not in json.dumps(events)
|
||||||
|
ap.diagnostics.credentials.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'config,version',
|
||||||
|
[
|
||||||
|
({}, '4.11.0'),
|
||||||
|
({'disable_telemetry': True}, '4.11.0-beta.2'),
|
||||||
|
({'disable_beta_diagnostics': True}, '4.11.0-beta.2'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_real_manager_disabled_modes_do_not_install_context(config, version):
|
||||||
|
app, ap, _ = await setup(None)
|
||||||
|
ap.instance_config = SimpleNamespace(data={'space': config})
|
||||||
|
ap.diagnostics = d.DiagnosticsManager(ap, version=version, instance_id='instance-test')
|
||||||
|
response = await app.test_client().get('/management/ok/private-id')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert ap.seen == [None]
|
||||||
|
assert not ap.diagnostics.pending
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_mount_observes_auth_without_headers_or_body():
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
diagnostics=Recorder(), apikey_service=SimpleNamespace(authenticate_api_key=AsyncMock(return_value=None))
|
||||||
|
)
|
||||||
|
mount = MCPMount(ap)
|
||||||
|
send = AsyncMock()
|
||||||
|
fallback = AsyncMock()
|
||||||
|
await mount.wrap(fallback)(
|
||||||
|
{'type': 'http', 'path': '/mcp/private-path', 'headers': [(b'x-api-key', b'private-token')]}, AsyncMock(), send
|
||||||
|
)
|
||||||
|
assert send.call_args_list[0].args[0]['status'] == 401
|
||||||
|
assert [e['outcome'] for e in ap.diagnostics.events] == ['started', 'rejected']
|
||||||
|
assert ap.diagnostics.events[-1]['operation'] == 'mcp.request'
|
||||||
|
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||||
|
fallback.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_debug_boundary_source_inherits_without_duplicate_run():
|
||||||
|
recorder = Recorder()
|
||||||
|
ap = SimpleNamespace(diagnostics=recorder)
|
||||||
|
|
||||||
|
@md.observe('http.test.debug', source='webui_debug', ap=ap)
|
||||||
|
async def debug():
|
||||||
|
@d.observe('run', 'runner.run', source='agent', ap=ap)
|
||||||
|
async def run():
|
||||||
|
return 'private-content'
|
||||||
|
|
||||||
|
return await run()
|
||||||
|
|
||||||
|
assert await debug() == 'private-content'
|
||||||
|
assert len([e for e in recorder.events if e['kind'] == 'run' and e['outcome'] == 'started']) == 1
|
||||||
|
assert all(e['source'] == 'webui_debug' and e['attributes']['synthetic'] for e in recorder.events)
|
||||||
|
assert 'private-' not in json.dumps(recorder.events)
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Management MCP, WebSocket and debug execution boundaries."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from langbot.pkg.api.http.controller.groups.pipelines.websocket_chat import WebSocketChatRouterGroup
|
||||||
|
from langbot.pkg.api.http.controller.groups.pipelines.embed import EmbedRouterGroup
|
||||||
|
from langbot.pkg.api.mcp.server import LangBotMCPServer
|
||||||
|
from langbot.pkg.telemetry import diagnostics as d
|
||||||
|
from tests.unit_tests.api.test_diagnostics_management_http import Recorder
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_real_registered_tool_has_source_and_never_content():
|
||||||
|
recorder = Recorder()
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def get_bot(*args, **kwargs):
|
||||||
|
seen.append(d.current_span())
|
||||||
|
return {'secret': 'private-result'}
|
||||||
|
|
||||||
|
ap = SimpleNamespace(diagnostics=recorder, bot_service=SimpleNamespace(get_bot=get_bot))
|
||||||
|
server = LangBotMCPServer(ap)
|
||||||
|
with patch('langbot.pkg.api.mcp.server._authorized', return_value=object()):
|
||||||
|
result = await server.mcp.call_tool('get_bot', {'bot_uuid': 'private-bot'})
|
||||||
|
assert result
|
||||||
|
assert [e['outcome'] for e in recorder.events] == ['started', 'succeeded']
|
||||||
|
assert recorder.events[-1]['operation'] == 'mcp.get_bot'
|
||||||
|
assert recorder.events[-1]['source'] == 'mcp'
|
||||||
|
assert seen[0] is not None
|
||||||
|
assert 'private-' not in json.dumps(recorder.events)
|
||||||
|
assert d.current_span() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_tool_permission_rejection_is_observed():
|
||||||
|
server = LangBotMCPServer(SimpleNamespace(diagnostics=Recorder()))
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await server.mcp.call_tool('list_bots', {})
|
||||||
|
assert server.ap.diagnostics.events[-1]['outcome'] in {'rejected', 'failed'}
|
||||||
|
assert server.ap.diagnostics.events[-1]['source'] == 'mcp'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'group_class,path',
|
||||||
|
[
|
||||||
|
(WebSocketChatRouterGroup, '/api/v1/pipelines/private-pipeline/ws/connect'),
|
||||||
|
(EmbedRouterGroup, '/api/v1/embed/11111111-1111-4111-8111-111111111111/ws/connect?session_id=private-session'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_real_websocket_session_auth_rejection_is_content_free(group_class, path):
|
||||||
|
app = quart.Quart(__name__)
|
||||||
|
ap = SimpleNamespace(diagnostics=Recorder())
|
||||||
|
group = group_class(ap, app)
|
||||||
|
group._authenticate_websocket = AsyncMock(side_effect=ValueError('private-token'))
|
||||||
|
if group_class is EmbedRouterGroup:
|
||||||
|
group._resolve_bot = AsyncMock(return_value=(object(), 'private-pipeline'))
|
||||||
|
await group.initialize()
|
||||||
|
async with app.test_client().websocket(path) as socket:
|
||||||
|
frame = json.loads(await socket.receive())
|
||||||
|
assert frame['type'] == 'error'
|
||||||
|
terminal = [e for e in ap.diagnostics.events if e['outcome'] != 'started']
|
||||||
|
assert terminal
|
||||||
|
assert terminal[-1]['outcome'] == 'rejected'
|
||||||
|
assert terminal[-1]['source'] == 'websocket'
|
||||||
|
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('group_class', [WebSocketChatRouterGroup, EmbedRouterGroup])
|
||||||
|
async def test_websocket_received_message_boundaries_do_not_capture_frames(group_class):
|
||||||
|
app = quart.Quart(__name__)
|
||||||
|
ap = SimpleNamespace(diagnostics=Recorder())
|
||||||
|
group = group_class(ap, app)
|
||||||
|
connection = SimpleNamespace(is_active=True, connection_id='private-id', send_queue=asyncio.Queue())
|
||||||
|
adapter = SimpleNamespace(handle_websocket_message=AsyncMock())
|
||||||
|
if group_class is WebSocketChatRouterGroup:
|
||||||
|
group._revalidate_websocket_authorization = AsyncMock(return_value=object())
|
||||||
|
args = (connection, adapter, object(), 'private-token')
|
||||||
|
else:
|
||||||
|
group._resolve_connected_bot = AsyncMock(return_value=object())
|
||||||
|
args = (connection, adapter, object(), 'private-pipeline')
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
connection.is_active = False
|
||||||
|
return json.dumps({'type': 'message', 'text': 'private-prompt'})
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('quart.websocket', SimpleNamespace(receive=receive)),
|
||||||
|
patch(
|
||||||
|
'langbot.pkg.api.http.controller.groups.pipelines.websocket_chat.ws_connection_manager.update_activity',
|
||||||
|
new=AsyncMock(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await group._handle_receive(*args)
|
||||||
|
adapter.handle_websocket_message.assert_awaited_once()
|
||||||
|
assert [e['outcome'] for e in ap.diagnostics.events] == ['started', 'succeeded']
|
||||||
|
assert ap.diagnostics.events[-1]['operation'].endswith('.message')
|
||||||
|
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||||
|
assert d.current_span() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_debug_service_marks_synthetic_source_before_validation():
|
||||||
|
from langbot.pkg.api.http.service.agent import AgentService
|
||||||
|
|
||||||
|
ap = SimpleNamespace(diagnostics=Recorder())
|
||||||
|
service = AgentService(ap)
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def get_agent(*args):
|
||||||
|
seen.append(d.current_span())
|
||||||
|
return None
|
||||||
|
|
||||||
|
service.get_agent = get_agent
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await service.debug_agent(object(), 'private-id', {'text': 'private-prompt'})
|
||||||
|
assert seen[0].fields['source'] == 'webui_debug'
|
||||||
|
assert ap.diagnostics.events[-1]['operation'] == 'http.agent.debug_agent'
|
||||||
|
assert ap.diagnostics.events[-1]['source'] == 'webui_debug'
|
||||||
|
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('group_class', [WebSocketChatRouterGroup, EmbedRouterGroup])
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'frame,expected', [('private-invalid-json', 'rejected'), ('{"type":"private-unknown"}', 'skipped')]
|
||||||
|
)
|
||||||
|
async def test_websocket_invalid_frames_have_finite_outcomes(group_class, frame, expected):
|
||||||
|
app = quart.Quart(__name__)
|
||||||
|
ap = SimpleNamespace(diagnostics=Recorder())
|
||||||
|
group = group_class(ap, app)
|
||||||
|
connection = SimpleNamespace(is_active=True, connection_id='private-id', send_queue=asyncio.Queue())
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
connection.is_active = False
|
||||||
|
return frame
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('quart.websocket', SimpleNamespace(receive=receive)),
|
||||||
|
patch(
|
||||||
|
'langbot.pkg.api.http.controller.groups.pipelines.websocket_chat.ws_connection_manager.update_activity',
|
||||||
|
new=AsyncMock(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await group._handle_receive(connection, object(), object(), 'private-token')
|
||||||
|
assert ap.diagnostics.events[-1]['outcome'] == expected
|
||||||
|
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('group_class', [WebSocketChatRouterGroup, EmbedRouterGroup])
|
||||||
|
async def test_websocket_send_boundary_preserves_payload_and_cancellation(group_class):
|
||||||
|
app = quart.Quart(__name__)
|
||||||
|
ap = SimpleNamespace(diagnostics=Recorder())
|
||||||
|
group = group_class(ap, app)
|
||||||
|
connection = SimpleNamespace(is_active=False, send_queue=asyncio.Queue())
|
||||||
|
await connection.send_queue.put({'text': 'private-answer'})
|
||||||
|
send = AsyncMock(side_effect=asyncio.CancelledError('private-error'))
|
||||||
|
with patch('quart.websocket', SimpleNamespace(send=send)):
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await group._handle_send(connection)
|
||||||
|
assert json.loads(send.call_args.args[0]) == {'text': 'private-answer'}
|
||||||
|
assert ap.diagnostics.events[-1]['outcome'] == 'cancelled'
|
||||||
|
assert ap.diagnostics.events[-1]['operation'].endswith('.send')
|
||||||
|
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||||
|
assert d.current_span() is None
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""Execute real Core boundaries with content canaries and early failures."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics as d
|
||||||
|
from langbot.pkg.api.http.context import ExecutionContext
|
||||||
|
from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator
|
||||||
|
from langbot.pkg.agent.runner.reply_stream import ReplyStreamSession, ReplyStreamRequest
|
||||||
|
|
||||||
|
|
||||||
|
def make_ap():
|
||||||
|
ap = SimpleNamespace(instance_config=SimpleNamespace(data={'space': {'url': 'https://example.invalid'}}))
|
||||||
|
ap.persistence_mgr = SimpleNamespace(get_db_engine=lambda: None)
|
||||||
|
ap.diagnostics = d.DiagnosticsManager(ap, version='4.11.0b2', instance_id='instance-test')
|
||||||
|
return ap
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('processor', ['pipeline', 'agent', 'event_processor'])
|
||||||
|
async def test_real_orchestrator_prepare_failure(processor):
|
||||||
|
ap = make_ap()
|
||||||
|
registry = SimpleNamespace(get=AsyncMock(side_effect=ValueError('CANARY private runner URL')))
|
||||||
|
orchestrator = AgentRunOrchestrator(ap, registry)
|
||||||
|
context = ExecutionContext(instance_uuid='instance-test', workspace_uuid=str(uuid4()), placement_generation=1)
|
||||||
|
event = SimpleNamespace(workspace_id=context.workspace_uuid, event_type='message.received')
|
||||||
|
binding = SimpleNamespace(runner_id='CANARY', processor_type=processor)
|
||||||
|
with pytest.raises(ValueError, match='CANARY'):
|
||||||
|
await anext(orchestrator.run(event, binding, adapter_context={'_execution_context': context}))
|
||||||
|
records = ap.diagnostics.pending
|
||||||
|
assert [r['outcome'] for r in records] == ['started', 'failed']
|
||||||
|
assert records[-1]['stage'] == 'prepare'
|
||||||
|
assert records[-1]['workspace_uuid'] == context.workspace_uuid
|
||||||
|
assert records[-1]['processor_type'] == processor
|
||||||
|
assert 'CANARY' not in json.dumps(records)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_real_reply_stream_mock_is_not_platform_success():
|
||||||
|
ap = make_ap()
|
||||||
|
event = SimpleNamespace(
|
||||||
|
delivery=SimpleNamespace(reply_target={}, surface='webui', platform_capabilities={'debug_mock': True})
|
||||||
|
)
|
||||||
|
session = ReplyStreamSession(event)
|
||||||
|
# Real runtime supplies the manager at construction from the orchestrator.
|
||||||
|
session.diagnostics = ap.diagnostics
|
||||||
|
result = await session.apply(ReplyStreamRequest(stream_id=uuid4(), operation='finish', text='CANARY user reply'))
|
||||||
|
assert result['mock'] is True and result['text'] == 'CANARY user reply'
|
||||||
|
assert ap.diagnostics.pending[-1]['source'] == 'webui_debug'
|
||||||
|
assert ap.diagnostics.pending[-1]['attributes']['synthetic'] is True
|
||||||
|
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_real_bot_route_projects_status_not_reason():
|
||||||
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
|
ap = make_ap()
|
||||||
|
bot = object.__new__(RuntimeBot)
|
||||||
|
bot.ap = ap
|
||||||
|
bot.logger = SimpleNamespace(info=AsyncMock())
|
||||||
|
await bot._record_event_route_trace(
|
||||||
|
event_type='message.received',
|
||||||
|
status='not_matched',
|
||||||
|
text='CANARY secret',
|
||||||
|
reason='CANARY',
|
||||||
|
failure_code='route_not_found',
|
||||||
|
)
|
||||||
|
records = ap.diagnostics.pending
|
||||||
|
assert records[-1]['outcome'] == 'skipped'
|
||||||
|
assert records[-1]['reason_code'] == 'route_not_found'
|
||||||
|
assert 'CANARY' not in json.dumps(records)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_real_telegram_conversion_failure_before_bot_manager(monkeypatch):
|
||||||
|
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
|
||||||
|
from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter
|
||||||
|
|
||||||
|
ap = make_ap()
|
||||||
|
context = ExecutionContext(instance_uuid='instance-test', workspace_uuid=str(uuid4()), placement_generation=1)
|
||||||
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
|
logger = EventLogger('test', ap, context, 'test')
|
||||||
|
logger.error = AsyncMock()
|
||||||
|
logger.warning = AsyncMock()
|
||||||
|
adapter = TelegramAdapter({'token': '123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ_123456789'}, logger)
|
||||||
|
adapter.listeners = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
TelegramEventConverter, '_convert_message', AsyncMock(side_effect=ValueError('CANARY conversion'))
|
||||||
|
)
|
||||||
|
update = SimpleNamespace(
|
||||||
|
message=SimpleNamespace(from_user=SimpleNamespace(is_bot=False), text='CANARY text'),
|
||||||
|
edited_message=None,
|
||||||
|
chat_member=None,
|
||||||
|
my_chat_member=None,
|
||||||
|
callback_query=None,
|
||||||
|
message_reaction=None,
|
||||||
|
)
|
||||||
|
callback = adapter.application.handlers[0][0].callback
|
||||||
|
await callback(update, None)
|
||||||
|
records = [e for e in ap.diagnostics.pending if e['stage'] == 'convert' and e['outcome'] == 'failed']
|
||||||
|
assert records and records[-1]['adapter'] == 'telegram-omni'
|
||||||
|
assert records[-1]['workspace_uuid'] == context.workspace_uuid
|
||||||
|
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_real_interaction_ack_skip_and_failure():
|
||||||
|
from langbot.pkg.agent.runner.interaction_manager import InteractionManager
|
||||||
|
|
||||||
|
ap = make_ap()
|
||||||
|
interactions = InteractionManager(ap, store=SimpleNamespace(record_delivery_success=AsyncMock()))
|
||||||
|
await interactions.acknowledge_submission({}, SimpleNamespace(get_supported_apis=lambda: []))
|
||||||
|
assert ap.diagnostics.pending[-1]['outcome'] == 'skipped'
|
||||||
|
ap.diagnostics.pending.clear()
|
||||||
|
ap.logger = SimpleNamespace(warning=lambda *a: None)
|
||||||
|
adapter = SimpleNamespace(
|
||||||
|
get_supported_apis=lambda: ['interaction.acknowledge'],
|
||||||
|
call_platform_api=AsyncMock(side_effect=ValueError('CANARY ack')),
|
||||||
|
)
|
||||||
|
await interactions.acknowledge_submission({'delivery_result': {'secret': 'CANARY'}}, adapter)
|
||||||
|
assert ap.diagnostics.pending[-1]['outcome'] == 'failed'
|
||||||
|
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||||
|
|
||||||
|
|
||||||
|
def test_actual_adapter_capability_snapshot():
|
||||||
|
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
|
||||||
|
from langbot.pkg.telemetry.diagnostic_catalog import snapshot_bot
|
||||||
|
|
||||||
|
ap = make_ap()
|
||||||
|
context = ExecutionContext(instance_uuid='instance-test', workspace_uuid=str(uuid4()), placement_generation=1)
|
||||||
|
adapter = TelegramAdapter.model_construct(config={}, listeners={})
|
||||||
|
snapshot_bot(SimpleNamespace(ap=ap, adapter=adapter, execution_context=context))
|
||||||
|
records = ap.diagnostics.pending
|
||||||
|
assert records and all(e['kind'] == 'capability' for e in records)
|
||||||
|
api_rows = [e for e in records if e['attributes']['capability_type'] == 'api']
|
||||||
|
assert any(e['operation'] == 'send_message' and e['attributes']['supported'] for e in api_rows)
|
||||||
|
assert all(e['workspace_uuid'] == context.workspace_uuid for e in records)
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
"""Independent-review regressions through real ownership and lifecycle boundaries."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.metadata
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from types import SimpleNamespace as NS
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from langbot.pkg.api.http.context import ExecutionContext
|
||||||
|
from langbot.pkg.provider.modelmgr.requester import RuntimeProvider
|
||||||
|
from langbot.pkg.telemetry import diagnostics as d
|
||||||
|
|
||||||
|
|
||||||
|
def make_ap(identity='instance-test', disabled=False):
|
||||||
|
ap = NS(instance_config=NS(data={'space': {'url': 'https://example.invalid', 'disable_telemetry': disabled}}))
|
||||||
|
ap.diagnostics = d.DiagnosticsManager(ap, version='4.11.0b2', instance_id=identity)
|
||||||
|
return ap
|
||||||
|
|
||||||
|
|
||||||
|
def context(identity='instance-test'):
|
||||||
|
return ExecutionContext(instance_uuid=identity, workspace_uuid=str(uuid4()), placement_generation=1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('disabled', [True, False])
|
||||||
|
async def test_real_runtime_provider_cannot_use_other_app_manager(disabled):
|
||||||
|
a, b = make_ap('instance-A'), make_ap('instance-B', disabled)
|
||||||
|
ca, cb = context('instance-A'), context('instance-B')
|
||||||
|
requester = NS(ap=b, invoke_llm=AsyncMock(return_value='business result'))
|
||||||
|
provider = RuntimeProvider(cb, NS(workspace_uuid=cb.workspace_uuid), None, requester)
|
||||||
|
model = NS(execution_context=cb, provider=provider)
|
||||||
|
parent = d.Span(a.diagnostics, 'api', 'review.parent', {'workspace_uuid': ca.workspace_uuid})
|
||||||
|
with parent.activate():
|
||||||
|
assert await provider.invoke_llm(None, model, [], execution_context=cb) == 'business result'
|
||||||
|
assert not [e for e in a.diagnostics.pending if e['operation'] == 'model.invoke_llm']
|
||||||
|
assert len(b.diagnostics.pending) == (0 if disabled else 2)
|
||||||
|
for event in b.diagnostics.pending:
|
||||||
|
assert event['instance_id'] == 'instance-B'
|
||||||
|
assert event['workspace_uuid'] == cb.workspace_uuid
|
||||||
|
assert event['trace_id'] != parent.fields['trace_id']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('shape', ['app', 'ap', 'logger', 'requester', 'adapter', 'direct', 'absent'])
|
||||||
|
async def test_explicit_disabled_owner_blocks_parent_even_in_ownerless_children(shape):
|
||||||
|
a, b = make_ap('instance-A'), make_ap('instance-B', True)
|
||||||
|
shapes = {
|
||||||
|
'app': b,
|
||||||
|
'ap': NS(ap=b),
|
||||||
|
'logger': NS(logger=NS(ap=b)),
|
||||||
|
'requester': NS(requester=NS(ap=b)),
|
||||||
|
'adapter': NS(adapter=NS(logger=NS(ap=b))),
|
||||||
|
'direct': NS(diagnostics=b.diagnostics),
|
||||||
|
'absent': NS(ap=None),
|
||||||
|
}
|
||||||
|
|
||||||
|
@d.observe('api', 'review.child')
|
||||||
|
async def child(data):
|
||||||
|
d.event(data, 'api', 'review.point', 'succeeded')
|
||||||
|
return 42
|
||||||
|
|
||||||
|
@d.observe('api', 'review.owner')
|
||||||
|
async def call(owner):
|
||||||
|
return await child({})
|
||||||
|
|
||||||
|
@d.observe('run', 'review.stream')
|
||||||
|
async def stream(owner):
|
||||||
|
yield await child({})
|
||||||
|
|
||||||
|
parent = d.Span(a.diagnostics, 'api', 'review.parent', {})
|
||||||
|
with parent.activate():
|
||||||
|
assert await call(shapes[shape]) == 42
|
||||||
|
assert [x async for x in stream(shapes[shape])] == [42]
|
||||||
|
d.event(shapes[shape], 'api', 'review.point', 'succeeded')
|
||||||
|
assert len(a.diagnostics.pending) == 1
|
||||||
|
assert not b.diagnostics.pending
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_context_cannot_select_other_instance_or_owned_workspace():
|
||||||
|
ap = make_ap()
|
||||||
|
ca, cb = context(), context('instance-other')
|
||||||
|
|
||||||
|
@d.observe('api', 'review.context')
|
||||||
|
async def call(owner, execution_context):
|
||||||
|
return 42
|
||||||
|
|
||||||
|
owner = NS(ap=ap, execution_context=ca)
|
||||||
|
with d.Span(ap.diagnostics, 'api', 'review.parent', {'workspace_uuid': ca.workspace_uuid}).activate():
|
||||||
|
assert await call(owner, cb) == 42
|
||||||
|
assert await call(owner, context()) == 42
|
||||||
|
assert not [e for e in ap.diagnostics.pending if e['operation'] == 'review.context']
|
||||||
|
# A misplaced manager attachment is not an authoritative manager for B.
|
||||||
|
other = make_ap('instance-other')
|
||||||
|
other.diagnostics = ap.diagnostics
|
||||||
|
assert await call(NS(ap=other), cb) == 42
|
||||||
|
assert not [e for e in ap.diagnostics.pending if e['operation'] == 'review.context']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('disabled', [False, True])
|
||||||
|
@pytest.mark.parametrize('action', ['aclose', 'athrow_exit', 'athrow_value', 'athrow_cancel'])
|
||||||
|
async def test_cleanup_exception_identity_matches_native(disabled, action):
|
||||||
|
ap = make_ap(disabled=disabled)
|
||||||
|
cleanup_error = ValueError('CANARY cleanup')
|
||||||
|
|
||||||
|
async def original(owner):
|
||||||
|
try:
|
||||||
|
yield 1
|
||||||
|
finally:
|
||||||
|
raise cleanup_error
|
||||||
|
|
||||||
|
for fn in (original, d.observe('run', 'review.cleanup')(original)):
|
||||||
|
gen = fn(ap)
|
||||||
|
assert isinstance(gen, AsyncGenerator)
|
||||||
|
assert await anext(gen) == 1
|
||||||
|
with pytest.raises(ValueError) as caught:
|
||||||
|
if action == 'aclose':
|
||||||
|
await gen.aclose()
|
||||||
|
else:
|
||||||
|
error = {
|
||||||
|
'athrow_exit': GeneratorExit(),
|
||||||
|
'athrow_value': KeyError('business'),
|
||||||
|
'athrow_cancel': asyncio.CancelledError('cancel'),
|
||||||
|
}[action]
|
||||||
|
await gen.athrow(error)
|
||||||
|
assert caught.value is cleanup_error
|
||||||
|
assert d.current_span() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('disabled', [False, True])
|
||||||
|
async def test_athrow_generator_exit_can_yield_and_close_remains_native(disabled):
|
||||||
|
ap = make_ap(disabled=disabled)
|
||||||
|
|
||||||
|
async def original(owner):
|
||||||
|
try:
|
||||||
|
yield 1
|
||||||
|
except GeneratorExit:
|
||||||
|
yield 2
|
||||||
|
yield 3
|
||||||
|
|
||||||
|
for fn in (original, d.observe('run', 'review.exit')(original)):
|
||||||
|
gen = fn(ap)
|
||||||
|
assert await anext(gen) == 1
|
||||||
|
assert await gen.athrow(GeneratorExit()) == 2
|
||||||
|
assert await anext(gen) == 3
|
||||||
|
await gen.aclose()
|
||||||
|
gen = fn(ap)
|
||||||
|
assert await anext(gen) == 1
|
||||||
|
with pytest.raises(RuntimeError, match='ignored GeneratorExit'):
|
||||||
|
await gen.aclose()
|
||||||
|
# The native generator is still suspended after the refused close.
|
||||||
|
assert await anext(gen) == 3
|
||||||
|
with pytest.raises(StopAsyncIteration):
|
||||||
|
await anext(gen)
|
||||||
|
await gen.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('disabled', [False, True])
|
||||||
|
async def test_generator_send_throw_cancellation_and_primary_exception(disabled):
|
||||||
|
ap = make_ap(disabled=disabled)
|
||||||
|
entered = asyncio.Event()
|
||||||
|
primary = ValueError('CANARY business')
|
||||||
|
|
||||||
|
async def original(owner):
|
||||||
|
value = yield 1
|
||||||
|
try:
|
||||||
|
yield value
|
||||||
|
except KeyError:
|
||||||
|
yield 3
|
||||||
|
entered.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
for fn in (original, d.observe('run', 'review.protocol')(original)):
|
||||||
|
gen = fn(ap)
|
||||||
|
assert await anext(gen) == 1
|
||||||
|
assert await gen.asend(7) == 7
|
||||||
|
assert await gen.athrow(KeyError('throw')) == 3
|
||||||
|
entered.clear()
|
||||||
|
task = asyncio.create_task(anext(gen))
|
||||||
|
await entered.wait()
|
||||||
|
task.cancel('native cancellation')
|
||||||
|
with pytest.raises(asyncio.CancelledError, match='native cancellation'):
|
||||||
|
await task
|
||||||
|
await gen.aclose()
|
||||||
|
|
||||||
|
async def failing(owner):
|
||||||
|
yield 1
|
||||||
|
raise primary
|
||||||
|
|
||||||
|
gen = d.observe('run', 'review.primary')(failing)(ap)
|
||||||
|
assert await anext(gen) == 1
|
||||||
|
with pytest.raises(ValueError) as caught:
|
||||||
|
await anext(gen)
|
||||||
|
assert caught.value is primary
|
||||||
|
assert d.current_span() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_real_kook_native_entry_conversion_failure_has_trusted_workspace():
|
||||||
|
from langbot.pkg.platform.adapters.kook.adapter import KookAdapter
|
||||||
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
|
ap, ctx = make_ap(), context()
|
||||||
|
logger = EventLogger('test', ap, ctx, 'test')
|
||||||
|
logger.error = AsyncMock()
|
||||||
|
adapter = KookAdapter({'token': 'test-placeholder'}, logger)
|
||||||
|
# Invalid native timestamp fails inside the real static converter.
|
||||||
|
await adapter._handle_event({'type': 255, 'msg_timestamp': 'CANARY invalid', 'workspace_uuid': str(uuid4())}, 1)
|
||||||
|
logger.error.assert_awaited_once()
|
||||||
|
failures = [e for e in ap.diagnostics.pending if e['outcome'] == 'failed']
|
||||||
|
assert any(e['stage'] == 'convert' for e in failures)
|
||||||
|
assert any(e['operation'] == 'platform.receive' for e in failures)
|
||||||
|
assert all(e['workspace_uuid'] == ctx.workspace_uuid for e in ap.diagnostics.pending)
|
||||||
|
assert all(e['adapter'] == 'kook-omni' for e in ap.diagnostics.pending)
|
||||||
|
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def boot_stages(monkeypatch):
|
||||||
|
from langbot.pkg.core import boot
|
||||||
|
|
||||||
|
stages_run = []
|
||||||
|
|
||||||
|
class Stage:
|
||||||
|
async def run(self, app):
|
||||||
|
stages_run.append(app)
|
||||||
|
|
||||||
|
# Earlier registry tests clear this shared dictionary; cached imports do not
|
||||||
|
# re-register stages. Own the registry per test and restore it on teardown.
|
||||||
|
monkeypatch.setattr(boot.stage, 'preregistered_stages', {'LoadConfigStage': Stage, 'GenKeysStage': Stage})
|
||||||
|
return stages_run
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('failure', ['constructor', 'metadata', 'session', 'start'])
|
||||||
|
@pytest.mark.parametrize('disabled', [False, True])
|
||||||
|
async def test_real_make_app_optional_initialization_fail_open(monkeypatch, boot_stages, failure, disabled):
|
||||||
|
from langbot.pkg.core import boot
|
||||||
|
|
||||||
|
ap = NS(
|
||||||
|
instance_config=NS(data={'space': {'url': 'https://example.invalid', 'disable_telemetry': disabled}}),
|
||||||
|
initialize=AsyncMock(),
|
||||||
|
shutdown=AsyncMock(),
|
||||||
|
)
|
||||||
|
stages_run = boot_stages
|
||||||
|
monkeypatch.setattr(boot.app, 'Application', lambda: ap)
|
||||||
|
monkeypatch.setattr(boot, 'stage_order', ['LoadConfigStage', 'GenKeysStage'])
|
||||||
|
error = RuntimeError('optional diagnostics')
|
||||||
|
if failure == 'constructor':
|
||||||
|
monkeypatch.setattr(boot.diagnostics, 'DiagnosticsManager', Mock(side_effect=error))
|
||||||
|
elif failure == 'metadata':
|
||||||
|
monkeypatch.setattr(
|
||||||
|
importlib.metadata, 'version', Mock(side_effect=importlib.metadata.PackageNotFoundError('langbot'))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
manager = NS(
|
||||||
|
start_session=AsyncMock(), start=Mock(), shutdown=AsyncMock(side_effect=RuntimeError('optional cleanup'))
|
||||||
|
)
|
||||||
|
getattr(manager, 'start_session' if failure == 'session' else 'start').side_effect = error
|
||||||
|
monkeypatch.setattr(boot.diagnostics, 'DiagnosticsManager', Mock(return_value=manager))
|
||||||
|
assert await boot.make_app(asyncio.get_running_loop()) is ap
|
||||||
|
assert len(stages_run) == 2
|
||||||
|
ap.initialize.assert_awaited_once()
|
||||||
|
ap.shutdown.assert_not_awaited()
|
||||||
|
assert getattr(ap, 'diagnostics', None) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_real_make_app_session_cancellation_and_shutdown_failure_preserve_primary(monkeypatch, boot_stages):
|
||||||
|
from langbot.pkg.core import boot
|
||||||
|
|
||||||
|
cancelled = asyncio.CancelledError('genuine cancellation')
|
||||||
|
ap = NS(
|
||||||
|
instance_config=NS(data={'space': {'url': 'https://example.invalid'}}),
|
||||||
|
initialize=AsyncMock(),
|
||||||
|
shutdown=AsyncMock(side_effect=RuntimeError('shutdown error')),
|
||||||
|
)
|
||||||
|
manager = NS(start_session=AsyncMock(side_effect=cancelled), start=Mock(), shutdown=AsyncMock())
|
||||||
|
monkeypatch.setattr(boot.app, 'Application', lambda: ap)
|
||||||
|
monkeypatch.setattr(boot, 'stage_order', ['GenKeysStage'])
|
||||||
|
monkeypatch.setattr(boot.diagnostics, 'DiagnosticsManager', Mock(return_value=manager))
|
||||||
|
with pytest.raises(asyncio.CancelledError) as caught:
|
||||||
|
await boot.make_app(asyncio.get_running_loop())
|
||||||
|
assert caught.value is cancelled
|
||||||
|
ap.initialize.assert_not_awaited()
|
||||||
|
ap.shutdown.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_real_make_app_business_failure_not_masked_by_optional_shutdown(monkeypatch, boot_stages):
|
||||||
|
from langbot.pkg.core import boot
|
||||||
|
|
||||||
|
primary = ValueError('business startup')
|
||||||
|
ap = boot.app.Application()
|
||||||
|
ap.instance_config = NS(data={'space': {'url': 'https://example.invalid'}})
|
||||||
|
ap.initialize = AsyncMock(side_effect=primary)
|
||||||
|
manager = make_ap().diagnostics
|
||||||
|
manager.start_session = AsyncMock()
|
||||||
|
manager.start = Mock()
|
||||||
|
manager.shutdown = AsyncMock(side_effect=RuntimeError('optional shutdown'))
|
||||||
|
monkeypatch.setattr(boot.app, 'Application', lambda: ap)
|
||||||
|
monkeypatch.setattr(boot.diagnostics, 'DiagnosticsManager', lambda *a, **kw: manager)
|
||||||
|
monkeypatch.setattr(boot, 'stage_order', ['GenKeysStage'])
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as caught:
|
||||||
|
await boot.make_app(asyncio.get_running_loop())
|
||||||
|
assert caught.value is primary
|
||||||
|
manager.shutdown.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('disabled', [False, True])
|
||||||
|
async def test_protocol_rejected_calls_and_unstarted_throw_match_native(disabled):
|
||||||
|
ap = make_ap(disabled=disabled)
|
||||||
|
|
||||||
|
async def original(owner):
|
||||||
|
try:
|
||||||
|
yield 1
|
||||||
|
except GeneratorExit:
|
||||||
|
yield 2
|
||||||
|
yield 3
|
||||||
|
|
||||||
|
async def record(fn, actions):
|
||||||
|
gen = fn(ap)
|
||||||
|
results = []
|
||||||
|
for name, values in actions:
|
||||||
|
try:
|
||||||
|
results.append(('value', await getattr(gen, name)(*values)))
|
||||||
|
except BaseException as exc:
|
||||||
|
results.append((type(exc).__name__, str(exc)))
|
||||||
|
# Fully exhaust any generator left suspended by a refused close.
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await anext(gen)
|
||||||
|
except (StopAsyncIteration, GeneratorExit):
|
||||||
|
pass
|
||||||
|
return results
|
||||||
|
|
||||||
|
wrapped = d.observe('run', 'review.protocol_matrix')(original)
|
||||||
|
for actions in (
|
||||||
|
[('aclose', ())],
|
||||||
|
[('athrow', (GeneratorExit(),))],
|
||||||
|
[('athrow', (ValueError('unstarted'),))],
|
||||||
|
[('asend', (7,)), ('__anext__', ()), ('athrow', (ValueError('primary'),))],
|
||||||
|
[('__anext__', ()), ('aclose', ()), ('__anext__', ()), ('aclose', ())],
|
||||||
|
[('__anext__', ()), ('athrow', (GeneratorExit(),)), ('__anext__', ()), ('aclose', ())],
|
||||||
|
):
|
||||||
|
assert await record(wrapped, actions) == await record(original, actions)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('disabled', [False, True])
|
||||||
|
async def test_native_task_cancel_cleanup_error_wins_without_extra_close(disabled):
|
||||||
|
ap = make_ap(disabled=disabled)
|
||||||
|
error = ValueError('native cleanup')
|
||||||
|
entered = asyncio.Event()
|
||||||
|
|
||||||
|
async def original(owner):
|
||||||
|
yield 1
|
||||||
|
try:
|
||||||
|
entered.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
finally:
|
||||||
|
raise error
|
||||||
|
|
||||||
|
for fn in (original, d.observe('run', 'review.cancel_cleanup')(original)):
|
||||||
|
gen = fn(ap)
|
||||||
|
await anext(gen)
|
||||||
|
entered.clear()
|
||||||
|
task = asyncio.create_task(anext(gen))
|
||||||
|
await entered.wait()
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(ValueError) as caught:
|
||||||
|
await task
|
||||||
|
assert caught.value is error
|
||||||
|
await gen.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sdk_b2_handler_consumes_observed_async_generator(tmp_path):
|
||||||
|
from langbot_plugin.runtime.io.handler import Handler, ActionResponse
|
||||||
|
from langbot_plugin.entities.io.resp import ChunkStatus
|
||||||
|
|
||||||
|
ap = make_ap()
|
||||||
|
handler = Handler(NS(), file_storage_dir=str(tmp_path))
|
||||||
|
handler._send_message = AsyncMock()
|
||||||
|
|
||||||
|
@d.observe('api', 'host.review_stream', ap=ap)
|
||||||
|
async def stream(data):
|
||||||
|
yield ActionResponse.success({'business': 'unchanged'})
|
||||||
|
|
||||||
|
handler.actions['review_stream'] = stream
|
||||||
|
await handler._handle_action({'seq_id': 1, 'action': 'review_stream', 'data': {}})
|
||||||
|
responses = [call.args[0] for call in handler._send_message.await_args_list]
|
||||||
|
assert len(responses) == 2
|
||||||
|
assert responses[0].data == {'business': 'unchanged'}
|
||||||
|
assert [response.chunk_status for response in responses] == [ChunkStatus.CONTINUE, ChunkStatus.END]
|
||||||
|
assert [e['outcome'] for e in ap.diagnostics.pending] == ['started', 'succeeded']
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
"""Content-free diagnostics contract and lifecycle regression tests."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics as d
|
||||||
|
|
||||||
|
|
||||||
|
def manager(version='4.11.0-beta.2', **config):
|
||||||
|
ap = SimpleNamespace(instance_config=SimpleNamespace(data={'space': {'url': 'https://example.invalid', **config}}))
|
||||||
|
ap.diagnostics = d.DiagnosticsManager(ap, version=version, instance_id='instance-test', capacity=4)
|
||||||
|
return ap.diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_gate_and_privacy():
|
||||||
|
assert not manager('4.11.0').enabled
|
||||||
|
assert manager().enabled
|
||||||
|
assert manager('4.11.0b2').enabled
|
||||||
|
assert not manager(disable_telemetry=True).enabled
|
||||||
|
assert not manager(disable_beta_diagnostics=True).enabled
|
||||||
|
m = manager()
|
||||||
|
m.emit(
|
||||||
|
'api',
|
||||||
|
'test.operation',
|
||||||
|
'failed',
|
||||||
|
attributes={'prompt': 'CANARY', 'plugin_id': 'CANARY', 'attempts': 1},
|
||||||
|
error=ValueError('CANARY https://secret/token'),
|
||||||
|
workspace_uuid=str(uuid4()),
|
||||||
|
)
|
||||||
|
payload = m.pending[0]
|
||||||
|
assert 'CANARY' not in json.dumps(payload)
|
||||||
|
assert payload['attributes'] == {'attempts': 1}
|
||||||
|
assert payload['error_type'] == 'ValueError'
|
||||||
|
assert payload['instance_id'] != payload['workspace_uuid']
|
||||||
|
assert payload['sample_rate'] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disabled_boundary_skips_all_projection(monkeypatch):
|
||||||
|
m = manager('4.11.0')
|
||||||
|
|
||||||
|
def broken(*args, **kwargs):
|
||||||
|
raise AssertionError('diagnostic machinery ran while disabled')
|
||||||
|
|
||||||
|
monkeypatch.setattr(d, 'Span', broken)
|
||||||
|
monkeypatch.setattr(d, 'result_outcome', broken)
|
||||||
|
|
||||||
|
class Service:
|
||||||
|
ap = m.ap
|
||||||
|
|
||||||
|
@d.observe('api', 'test.disabled', fields=broken)
|
||||||
|
async def call(self):
|
||||||
|
return 42
|
||||||
|
|
||||||
|
@d.observe('run', 'test.disabled', fields=broken)
|
||||||
|
async def stream(self):
|
||||||
|
yield 42
|
||||||
|
|
||||||
|
assert await Service().call() == 42
|
||||||
|
assert [v async for v in Service().stream()] == [42]
|
||||||
|
assert not m.pending
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounds_and_disable_clear():
|
||||||
|
m = manager()
|
||||||
|
for _ in range(8):
|
||||||
|
m.emit('api', 'test.operation', 'succeeded')
|
||||||
|
assert len(m.pending) == 4
|
||||||
|
assert m.counters['dropped'] == 4
|
||||||
|
m.ap.instance_config.data['space']['disable_telemetry'] = True
|
||||||
|
m.emit('api', 'test.operation', 'succeeded')
|
||||||
|
assert not m.pending
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_partial_ack_retries_same_identity_and_drops_rejected():
|
||||||
|
m = manager()
|
||||||
|
for _ in range(3):
|
||||||
|
m.emit('api', 'test.operation', 'succeeded')
|
||||||
|
ids = [e['event_id'] for e in m.pending]
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
async def handler(req):
|
||||||
|
requests.append(json.loads(req.content))
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
'code': 200,
|
||||||
|
'data': {'accepted_event_ids': [ids[0]], 'rejected': [{'event_id': ids[1], 'code': 'invalid_event'}]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
await m.flush_once()
|
||||||
|
assert [e['event_id'] for e in m.pending] == [ids[2]]
|
||||||
|
assert m.counters['acked'] == 1
|
||||||
|
assert m.counters['dropped'] == 1
|
||||||
|
assert requests[0]['schema_version'] == 1
|
||||||
|
await m.shutdown(drain_timeout=0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_outage_finite_retry_and_slow_credentials():
|
||||||
|
m = manager()
|
||||||
|
m.max_attempts = 2
|
||||||
|
m.emit('run', 'test.operation', 'started')
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def handler(req):
|
||||||
|
calls.append(req)
|
||||||
|
return httpx.Response(503)
|
||||||
|
|
||||||
|
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
await m.flush_once()
|
||||||
|
await m.flush_once()
|
||||||
|
assert not m.pending
|
||||||
|
assert m.counters['dropped'] == 1
|
||||||
|
assert len(calls) == 2
|
||||||
|
|
||||||
|
async def credentials(workspace):
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
m.credentials = credentials
|
||||||
|
m.request_timeout = 0.01
|
||||||
|
m.emit('api', 'test.operation', 'succeeded')
|
||||||
|
await asyncio.wait_for(m.flush_once(), 0.2)
|
||||||
|
await m.shutdown(drain_timeout=0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inflight_disable_cancels_and_clears():
|
||||||
|
m = manager()
|
||||||
|
entered = asyncio.Event()
|
||||||
|
|
||||||
|
async def handler(req):
|
||||||
|
entered.set()
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
m.start()
|
||||||
|
m.emit('api', 'test.operation', 'succeeded')
|
||||||
|
await asyncio.wait_for(entered.wait(), 1)
|
||||||
|
m.ap.instance_config.data['space']['disable_beta_diagnostics'] = True
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
assert not m.pending
|
||||||
|
await asyncio.wait_for(m.shutdown(drain_timeout=0), 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_observe_returns_errors_and_cancellation():
|
||||||
|
m = manager()
|
||||||
|
|
||||||
|
class Service:
|
||||||
|
ap = m.ap
|
||||||
|
|
||||||
|
@d.observe('api', 'test.operation')
|
||||||
|
async def call(self, error=None):
|
||||||
|
if error:
|
||||||
|
raise error
|
||||||
|
return {'secret': 'CANARY'}
|
||||||
|
|
||||||
|
s = Service()
|
||||||
|
assert await s.call() == {'secret': 'CANARY'}
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await s.call(ValueError('CANARY'))
|
||||||
|
m.pending.clear()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await s.call(asyncio.CancelledError())
|
||||||
|
assert m.pending[-1]['outcome'] == 'cancelled'
|
||||||
|
assert 'CANARY' not in json.dumps(m.pending)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generator_send_throw_close_and_context_isolation():
|
||||||
|
m = manager()
|
||||||
|
m.capacity = 30
|
||||||
|
closed = []
|
||||||
|
|
||||||
|
class Service:
|
||||||
|
ap = m.ap
|
||||||
|
|
||||||
|
@d.observe('run', 'test.operation')
|
||||||
|
async def stream(self):
|
||||||
|
try:
|
||||||
|
value = yield 1
|
||||||
|
try:
|
||||||
|
yield value
|
||||||
|
except ValueError:
|
||||||
|
yield 3
|
||||||
|
finally:
|
||||||
|
closed.append(True)
|
||||||
|
|
||||||
|
gen = Service().stream()
|
||||||
|
assert await anext(gen) == 1
|
||||||
|
assert d.current_span() is None
|
||||||
|
assert await gen.asend(7) == 7
|
||||||
|
assert await gen.athrow(ValueError('CANARY')) == 3
|
||||||
|
await gen.aclose()
|
||||||
|
assert closed == [True]
|
||||||
|
assert m.pending[-1]['outcome'] == 'cancelled'
|
||||||
|
assert d.current_span() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generator_early_failure_and_explicit_terminal():
|
||||||
|
m = manager()
|
||||||
|
|
||||||
|
class Service:
|
||||||
|
ap = m.ap
|
||||||
|
|
||||||
|
@d.observe('run', 'test.operation')
|
||||||
|
async def stream(self, fail):
|
||||||
|
if fail:
|
||||||
|
raise ValueError('prepare CANARY')
|
||||||
|
d.set_outcome('failed', reason_code='runner_failed')
|
||||||
|
yield 1
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await anext(Service().stream(True))
|
||||||
|
assert m.pending[-1]['outcome'] == 'failed'
|
||||||
|
m.pending.clear()
|
||||||
|
assert [v async for v in Service().stream(False)] == [1]
|
||||||
|
assert m.pending[-1]['outcome'] == 'failed'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_projection_fault_does_not_replace_business_return(monkeypatch):
|
||||||
|
m = manager()
|
||||||
|
|
||||||
|
def broken(*args, **kwargs):
|
||||||
|
raise RuntimeError('CANARY projection')
|
||||||
|
|
||||||
|
monkeypatch.setattr(d, 'result_outcome', broken)
|
||||||
|
|
||||||
|
class Service:
|
||||||
|
ap = m.ap
|
||||||
|
|
||||||
|
@d.observe('api', 'test.projection_fault')
|
||||||
|
async def call(self):
|
||||||
|
return 42
|
||||||
|
|
||||||
|
assert await Service().call() == 42
|
||||||
|
assert m.pending[-1]['outcome'] == 'succeeded'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_workspace_batches_and_credential_failure_are_anonymous():
|
||||||
|
m = manager()
|
||||||
|
workspaces = [str(uuid4()), str(uuid4())]
|
||||||
|
for workspace in workspaces:
|
||||||
|
m.emit('api', 'test.operation', 'succeeded', workspace_uuid=workspace)
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
async def broken(workspace):
|
||||||
|
raise RuntimeError('CANARY credentials')
|
||||||
|
|
||||||
|
m.credentials = broken
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
data = json.loads(request.content)
|
||||||
|
requests.append((data, dict(request.headers)))
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={'code': 200, 'data': {'accepted_event_ids': [e['event_id'] for e in data['events']], 'rejected': []}},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
await m.flush_once()
|
||||||
|
await m.flush_once()
|
||||||
|
assert len(requests) == 2
|
||||||
|
assert [request[0]['events'][0]['workspace_uuid'] for request in requests] == workspaces
|
||||||
|
assert all('authorization' not in headers for _, headers in requests)
|
||||||
|
assert not m.pending
|
||||||
|
await m.shutdown(drain_timeout=0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_is_interval_delta_and_ack_replay_is_idempotent():
|
||||||
|
m = manager()
|
||||||
|
m.capacity = 20
|
||||||
|
m.emit('api', 'test.operation', 'succeeded')
|
||||||
|
m.report_transport()
|
||||||
|
first = m.pending[-1]
|
||||||
|
m.report_transport()
|
||||||
|
second = m.pending[-1]
|
||||||
|
assert first['attributes']['generated'] == 1
|
||||||
|
assert second['attributes']['generated'] == 1 # Only first summary itself.
|
||||||
|
assert first['event_id'] != second['event_id']
|
||||||
|
ids = [e['event_id'] for e in m.pending]
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
body = json.loads(request.content)
|
||||||
|
seen.append([e['event_id'] for e in body['events']])
|
||||||
|
return (
|
||||||
|
httpx.Response(503)
|
||||||
|
if len(seen) == 1
|
||||||
|
else httpx.Response(200, json={'code': 200, 'data': {'accepted_event_ids': ids + ids, 'rejected': []}})
|
||||||
|
)
|
||||||
|
|
||||||
|
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
await m.flush_once()
|
||||||
|
await m.flush_once()
|
||||||
|
assert seen == [ids, ids]
|
||||||
|
assert m.counters['acked'] == len(ids)
|
||||||
|
assert not m.pending
|
||||||
|
await m.shutdown(drain_timeout=0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retention_and_bounded_shutdown():
|
||||||
|
m = manager()
|
||||||
|
m.emit('api', 'test.operation', 'succeeded')
|
||||||
|
m.retention_seconds = -1
|
||||||
|
await m.flush_once()
|
||||||
|
assert not m.pending and m.counters['dropped'] == 1
|
||||||
|
m.retention_seconds = 900
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
return httpx.Response(503)
|
||||||
|
|
||||||
|
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
m.emit('api', 'test.operation', 'succeeded')
|
||||||
|
await asyncio.wait_for(m.shutdown(drain_timeout=0.01), 0.5)
|
||||||
|
assert not m.pending and m.client.is_closed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_marker_restart_and_disable(tmp_path):
|
||||||
|
marker = tmp_path / 'session.json'
|
||||||
|
m = manager()
|
||||||
|
m.marker_path = marker
|
||||||
|
await m.start_session()
|
||||||
|
assert marker.exists()
|
||||||
|
recovered = manager()
|
||||||
|
recovered.marker_path = marker
|
||||||
|
await recovered.start_session()
|
||||||
|
assert recovered.pending[-1]['attributes']['previous_session_unclean'] is True
|
||||||
|
recovered.ap.instance_config.data['space']['disable_beta_diagnostics'] = True
|
||||||
|
recovered.start()
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
assert not marker.exists()
|
||||||
|
await recovered.shutdown(drain_timeout=0)
|
||||||
|
await m.shutdown(drain_timeout=0)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Release artifacts must identify their actual source, not a branch label."""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SPEC = importlib.util.spec_from_file_location('stamp_build_revision', ROOT / 'scripts/stamp_build_revision.py')
|
||||||
|
assert SPEC is not None and SPEC.loader is not None
|
||||||
|
MODULE = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(MODULE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stamp_accepts_full_revision(tmp_path):
|
||||||
|
target = tmp_path / 'src/langbot/_build_info.py'
|
||||||
|
target.parent.mkdir(parents=True)
|
||||||
|
revision = 'a1' * 20
|
||||||
|
assert MODULE.stamp(tmp_path, revision) == revision
|
||||||
|
assignments = [node for node in ast.parse(target.read_text()).body if isinstance(node, ast.Assign)]
|
||||||
|
assert len(assignments) == 1
|
||||||
|
assert ast.literal_eval(assignments[0].value) == revision
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('revision', ['main', 'abc123', 'A' * 40, 'a' * 39, 'a' * 41, '../secret', 'a' * 40 + '\n'])
|
||||||
|
def test_stamp_rejects_non_revision_before_write(tmp_path, revision):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
MODULE.stamp(tmp_path, revision)
|
||||||
|
assert not (tmp_path / 'src').exists()
|
||||||
@@ -1999,7 +1999,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.11.0b1"
|
version = "4.11.0b2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
|
|||||||
@@ -72,15 +72,25 @@ test('processor forms expose their primary orchestration flow horizontally', ()
|
|||||||
'src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx',
|
'src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx',
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.match(
|
const agentSections = agentForm.slice(
|
||||||
agentForm,
|
agentForm.indexOf('const primarySections:'),
|
||||||
/name: 'runner'[\s\S]*name: 'runner_config'[\s\S]*name: 'events_and_tools'/,
|
agentForm.indexOf('const runnerStatus ='),
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[...agentSections.matchAll(/name: '([^']+)'/g)].map((match) => match[1]),
|
||||||
|
['runner', 'events_and_tools'],
|
||||||
|
);
|
||||||
|
const runnerPanel = agentForm.slice(
|
||||||
|
agentForm.indexOf("{activeSection === 'runner' &&"),
|
||||||
|
agentForm.indexOf("{activeSection === 'events_and_tools' &&"),
|
||||||
|
);
|
||||||
|
assert.match(runnerPanel, /renderDynamicStage\(runnerSelectorStage\)/);
|
||||||
|
assert.match(runnerPanel, /renderDynamicStage\(activeRunnerStage\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
pipelineForm,
|
pipelineForm,
|
||||||
/const primarySectionNames = \['trigger', 'ai', 'output'\]/,
|
/const primarySectionNames = \['trigger', 'ai', 'output'\]/,
|
||||||
);
|
);
|
||||||
assert.match(agentForm, /<TabsList[^>]*grid-cols-3/);
|
assert.match(agentForm, /<TabsList[^>]*grid-cols-2/);
|
||||||
assert.match(pipelineForm, /<TabsList[^>]*grid-cols-3/);
|
assert.match(pipelineForm, /<TabsList[^>]*grid-cols-3/);
|
||||||
assert.doesNotMatch(agentForm, /<ol className=/);
|
assert.doesNotMatch(agentForm, /<ol className=/);
|
||||||
assert.doesNotMatch(pipelineForm, /<ol className=/);
|
assert.doesNotMatch(pipelineForm, /<ol className=/);
|
||||||
|
|||||||
Reference in New Issue
Block a user