fix(runner): align SDK pin and complete real runtime verification (#2525)

* fix(runner): align SDK pin and workspace-aware integration fixtures

* fix(ci): format sources and resolve current migration head

* test(persistence): align standalone migration fixtures with current models

* test(web): align smoke fixtures with current processor UI

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
Hyu
2026-09-11 12:57:29 +08:00
committed by GitHub
parent 24ddcfe13e
commit e631da0073
37 changed files with 485 additions and 219 deletions
+1
View File
@@ -17,6 +17,7 @@ from quart import Quart, request, Response, jsonify
from langbot.libs.wecom_ai_bot_api import wecombotevent
from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
if TYPE_CHECKING:
from langbot.pkg.platform.logger import EventLogger
from langbot.pkg.utils import httpclient
@@ -1,4 +1,5 @@
"""EventLog store for writing and querying event records."""
from __future__ import annotations
import json
@@ -44,9 +45,7 @@ class EventLogStore:
def __init__(self, engine: AsyncEngine):
self.engine = engine
self._session_factory = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
self._session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def append_event(
self,
@@ -101,7 +100,7 @@ class EventLogStore:
# Truncate input summary if too long
if input_summary and len(input_summary) > self.MAX_INPUT_SUMMARY_LENGTH:
input_summary = input_summary[:self.MAX_INPUT_SUMMARY_LENGTH - 3] + "..."
input_summary = input_summary[: self.MAX_INPUT_SUMMARY_LENGTH - 3] + '...'
async with self._session_factory() as session:
event = EventLog(
@@ -144,9 +143,7 @@ class EventLogStore:
Event record as dict, or None if not found
"""
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(EventLog).where(EventLog.event_id == event_id)
)
result = await session.execute(sqlalchemy.select(EventLog).where(EventLog.event_id == event_id))
row = result.scalars().first()
if row is None:
return None
@@ -282,9 +279,7 @@ class EventLogStore:
) -> int:
"""Delete EventLog rows created before the supplied timestamp."""
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.delete(EventLog).where(EventLog.created_at < before)
)
result = await session.execute(sqlalchemy.delete(EventLog).where(EventLog.created_at < before))
await session.commit()
return result.rowcount or 0
@@ -71,17 +71,8 @@ class EmbedRouterGroup(group.RouterGroup):
``web_page_bot``, is disabled, or has no Pipeline target for messages.
"""
bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid)
pipeline_uuid = (
bot.get_pipeline_target_for_event_type('message.received')
if bot is not None
else None
)
if (
bot is not None
and bot.bot_entity.adapter == 'web_page_bot'
and bot.bot_entity.enable
and pipeline_uuid
):
pipeline_uuid = bot.get_pipeline_target_for_event_type('message.received') if bot is not None else None
if bot is not None and bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.enable and pipeline_uuid:
return bot, pipeline_uuid
return None, None
@@ -14,9 +14,7 @@ class ToolsRouterGroup(group.RouterGroup):
self,
request_context: RequestContext,
) -> list[dict] | None:
pipeline_uuid = quart.request.args.get(
'pipeline_uuid'
) or quart.request.args.get('pipeline_id')
pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id')
bound_plugins: list[str] | None = None
bound_mcp_servers: list[str] | None = None
@@ -28,14 +26,9 @@ class ToolsRouterGroup(group.RouterGroup):
if pipeline is None:
return None
extensions_prefs = normalize_extension_preferences(
pipeline.get('extensions_preferences')
)
extensions_prefs = normalize_extension_preferences(pipeline.get('extensions_preferences'))
if not extensions_prefs['enable_all_plugins']:
bound_plugins = [
f'{plugin["author"]}/{plugin["name"]}'
for plugin in extensions_prefs['plugins']
]
bound_plugins = [f'{plugin["author"]}/{plugin["name"]}' for plugin in extensions_prefs['plugins']]
if not extensions_prefs['enable_all_mcp_servers']:
bound_mcp_servers = extensions_prefs['mcp_servers']
@@ -1,4 +1,5 @@
"""Transcript persistence entity for conversation history projection."""
from __future__ import annotations
import sqlalchemy
@@ -48,12 +48,16 @@ def upgrade() -> None:
sa.column('source', sa.String(32)),
sa.column('created_by_account_uuid', sa.String(36)),
)
workspace_uuids = conn.execute(
sa.select(workspaces.c.uuid).where(
workspaces.c.instance_uuid == instance_uuid.strip(),
workspaces.c.source == 'local',
workspace_uuids = (
conn.execute(
sa.select(workspaces.c.uuid).where(
workspaces.c.instance_uuid == instance_uuid.strip(),
workspaces.c.source == 'local',
)
)
).scalars().all()
.scalars()
.all()
)
if not workspace_uuids:
return
if len(workspace_uuids) > 1:
@@ -99,10 +103,7 @@ def upgrade() -> None:
sa.column('status', sa.String(32)),
)
owner_account_uuid = conn.execute(
sa.select(users.c.uuid)
.where(users.c.status == 'active')
.order_by(users.c.id)
.limit(1)
sa.select(users.c.uuid).where(users.c.status == 'active').order_by(users.c.id).limit(1)
).scalar_one_or_none()
if owner_account_uuid is None:
return
@@ -4,6 +4,7 @@ Revision ID: 58846a8d7a81
Revises: 0005_add_llm_context_length
Create Date: 2026-05-23 15:41:47.030841
"""
from alembic import op
import sqlalchemy as sa
@@ -4,6 +4,7 @@ Revision ID: 7b2c1d9e4f30
Revises: 6dfd3dd7f0c7
Create Date: 2026-06-12
"""
from alembic import op
import sqlalchemy as sa
@@ -90,9 +90,7 @@ def upgrade() -> None:
)
else:
_add_column_if_missing('agent_run', sa.Column('queue_name', sa.String(255), nullable=True))
_add_column_if_missing(
'agent_run', sa.Column('priority', sa.Integer(), nullable=False, server_default='0')
)
_add_column_if_missing('agent_run', sa.Column('priority', sa.Integer(), nullable=False, server_default='0'))
_add_column_if_missing('agent_run', sa.Column('requested_runtime_id', sa.String(255), nullable=True))
_add_column_if_missing('agent_run', sa.Column('claimed_by_runtime_id', sa.String(255), nullable=True))
_add_column_if_missing('agent_run', sa.Column('claim_token', sa.String(255), nullable=True))
@@ -50,22 +50,16 @@ def normalize_extension_preferences(value: typing.Any) -> dict[str, typing.Any]:
normalized['enable_all_plugins'] = value.get('enable_all_plugins', True) is True
normalized['enable_all_mcp_servers'] = value.get('enable_all_mcp_servers', True) is True
normalized['enable_all_skills'] = value.get('enable_all_skills', True) is True
normalized['mcp_resource_agent_read_enabled'] = (
value.get('mcp_resource_agent_read_enabled', True) is True
)
normalized['mcp_resource_agent_read_enabled'] = value.get('mcp_resource_agent_read_enabled', True) is True
plugins = value.get('plugins', [])
plugins_are_valid = isinstance(plugins, list) and all(
_valid_plugin_binding(plugin) for plugin in plugins
)
plugins_are_valid = isinstance(plugins, list) and all(_valid_plugin_binding(plugin) for plugin in plugins)
normalized['plugins'] = list(plugins) if plugins_are_valid else []
if not plugins_are_valid:
normalized['enable_all_plugins'] = False
mcp_servers = value.get('mcp_servers', [])
mcp_servers_are_valid = isinstance(mcp_servers, list) and all(
_valid_name(server) for server in mcp_servers
)
mcp_servers_are_valid = isinstance(mcp_servers, list) and all(_valid_name(server) for server in mcp_servers)
normalized['mcp_servers'] = list(mcp_servers) if mcp_servers_are_valid else []
if not mcp_servers_are_valid:
normalized['enable_all_mcp_servers'] = False
@@ -151,6 +145,4 @@ def _validate_list_field(
raise ValueError(f"{context} field '{field_label}' must be a list")
for index, item in enumerate(items):
if not item_validator(item):
raise ValueError(
f"{context} field '{field_label}[{index}]' must be {item_description}"
)
raise ValueError(f"{context} field '{field_label}[{index}]' must be {item_description}")
@@ -65,8 +65,12 @@ class OfficialAccountMessageConverter(abstract_platform_adapter.AbstractMessageC
else:
components.append(platform_message.Unknown(text='[officialaccount voice message without media id]'))
elif event.type == 'event':
components.append(platform_message.Unknown(text=f'[officialaccount event: {event.detail_type or "unknown"}]'))
components.append(
platform_message.Unknown(text=f'[officialaccount event: {event.detail_type or "unknown"}]')
)
else:
components.append(platform_message.Unknown(text=f'[unsupported officialaccount msgtype: {event.type or "unknown"}]'))
components.append(
platform_message.Unknown(text=f'[unsupported officialaccount msgtype: {event.type or "unknown"}]')
)
return platform_message.MessageChain(components)
@@ -3,4 +3,3 @@
from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter
__all__ = ['QQOfficialAdapter']
@@ -54,7 +54,9 @@ class QQOfficialAPIMixin:
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)]
return [
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
]
async def edit_message(
self,
@@ -100,4 +102,3 @@ class QQOfficialAPIMixin:
async def leave_group(self, group_id: typing.Union[int, str]):
raise NotSupportedError('leave_group')
@@ -8,4 +8,3 @@ except ModuleNotFoundError:
def __init__(self, api_name: str, *args):
super().__init__(f"API '{api_name}' is not supported by this adapter", *args)
self.api_name = api_name
@@ -34,4 +34,3 @@ PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable
'get_gateway_url': get_gateway_url,
'get_mode': get_mode,
}
@@ -47,7 +47,9 @@ class SlackAPIMixin:
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)]
return [
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
]
async def get_group_member_info(
self,
@@ -19,7 +19,9 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
async def yiri2target(event: platform_events.Event) -> typing.Any:
return getattr(event, 'source_platform_object', None)
async def target2legacy(self, event: SlackEvent) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
async def target2legacy(
self, event: SlackEvent
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
eba_event = await self.target2yiri(event)
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
return None
@@ -1,3 +1,3 @@
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
__all__ = ["TelegramAdapter"]
__all__ = ['TelegramAdapter']
@@ -14,7 +14,7 @@ async def pin_message(bot: telegram.Bot, params: dict) -> dict:
message_id=params['message_id'],
disable_notification=params.get('disable_notification', False),
)
return {"ok": True}
return {'ok': True}
async def unpin_message(bot: telegram.Bot, params: dict) -> dict:
@@ -23,26 +23,26 @@ async def unpin_message(bot: telegram.Bot, params: dict) -> dict:
chat_id=params['chat_id'],
message_id=params.get('message_id'),
)
return {"ok": True}
return {'ok': True}
async def unpin_all_messages(bot: telegram.Bot, params: dict) -> dict:
"""Unpin all messages in a chat."""
await bot.unpin_all_chat_messages(chat_id=params['chat_id'])
return {"ok": True}
return {'ok': True}
async def get_chat_administrators(bot: telegram.Bot, params: dict) -> dict:
"""Get chat administrator list."""
admins = await bot.get_chat_administrators(chat_id=params['chat_id'])
return {
"administrators": [
'administrators': [
{
"user_id": a.user.id,
"username": a.user.username,
"first_name": a.user.first_name,
"status": a.status,
"custom_title": getattr(a, 'custom_title', None),
'user_id': a.user.id,
'username': a.user.username,
'first_name': a.user.first_name,
'status': a.status,
'custom_title': getattr(a, 'custom_title', None),
}
for a in admins
]
@@ -55,7 +55,7 @@ async def set_chat_title(bot: telegram.Bot, params: dict) -> dict:
chat_id=params['chat_id'],
title=params['title'],
)
return {"ok": True}
return {'ok': True}
async def set_chat_description(bot: telegram.Bot, params: dict) -> dict:
@@ -64,13 +64,13 @@ async def set_chat_description(bot: telegram.Bot, params: dict) -> dict:
chat_id=params['chat_id'],
description=params.get('description', ''),
)
return {"ok": True}
return {'ok': True}
async def get_chat_member_count(bot: telegram.Bot, params: dict) -> dict:
"""Get chat member count."""
count = await bot.get_chat_member_count(chat_id=params['chat_id'])
return {"count": count}
return {'count': count}
async def send_chat_action(bot: telegram.Bot, params: dict) -> dict:
@@ -79,7 +79,7 @@ async def send_chat_action(bot: telegram.Bot, params: dict) -> dict:
chat_id=params['chat_id'],
action=params.get('action', 'typing'),
)
return {"ok": True}
return {'ok': True}
async def create_chat_invite_link(bot: telegram.Bot, params: dict) -> dict:
@@ -91,10 +91,10 @@ async def create_chat_invite_link(bot: telegram.Bot, params: dict) -> dict:
member_limit=params.get('member_limit'),
)
return {
"invite_link": link.invite_link,
"name": link.name,
"is_primary": link.is_primary,
"is_revoked": link.is_revoked,
'invite_link': link.invite_link,
'name': link.name,
'is_primary': link.is_primary,
'is_revoked': link.is_revoked,
}
@@ -106,20 +106,20 @@ async def answer_callback_query(bot: telegram.Bot, params: dict) -> dict:
show_alert=params.get('show_alert', False),
url=params.get('url'),
)
return {"ok": True}
return {'ok': True}
# ---- Action dispatch table ----
PLATFORM_API_MAP: dict[str, typing.Callable[[telegram.Bot, dict], typing.Awaitable[dict]]] = {
"pin_message": pin_message,
"unpin_message": unpin_message,
"unpin_all_messages": unpin_all_messages,
"get_chat_administrators": get_chat_administrators,
"set_chat_title": set_chat_title,
"set_chat_description": set_chat_description,
"get_chat_member_count": get_chat_member_count,
"send_chat_action": send_chat_action,
"create_chat_invite_link": create_chat_invite_link,
"answer_callback_query": answer_callback_query,
'pin_message': pin_message,
'unpin_message': unpin_message,
'unpin_all_messages': unpin_all_messages,
'get_chat_administrators': get_chat_administrators,
'set_chat_title': set_chat_title,
'set_chat_description': set_chat_description,
'get_chat_member_count': get_chat_member_count,
'send_chat_action': send_chat_action,
'create_chat_invite_link': create_chat_invite_link,
'answer_callback_query': answer_callback_query,
}
@@ -7,7 +7,8 @@ from enum import Enum
class TelegramChatType(str, Enum):
"""Telegram chat type."""
PRIVATE = "private"
GROUP = "group"
SUPERGROUP = "supergroup"
CHANNEL = "channel"
PRIVATE = 'private'
GROUP = 'group'
SUPERGROUP = 'supergroup'
CHANNEL = 'channel'
@@ -54,7 +54,9 @@ class WecomBotAPIMixin:
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)]
return [
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
]
async def upload_file(self, file_data: bytes, filename: str) -> str:
raise NotSupportedError('upload_file')
@@ -19,7 +19,9 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
async def yiri2target(event: platform_events.Event) -> typing.Any:
return getattr(event, 'source_platform_object', None)
async def target2legacy(self, event: WecomBotEvent) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
async def target2legacy(
self, event: WecomBotEvent
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
eba_event = await self.target2yiri(event)
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
return None
@@ -50,7 +52,9 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
async def target2yiri(self, event: WecomBotEvent) -> platform_events.Event:
if event.type in {'single', 'group'} and event.msgtype != 'event':
return await self.message_to_eba(event)
return self.platform_specific(event, f'wecombot.{event.get("eventtype") or event.msgtype or event.type or "unknown"}')
return self.platform_specific(
event, f'wecombot.{event.get("eventtype") or event.msgtype or event.type or "unknown"}'
)
async def message_to_eba(self, event: WecomBotEvent) -> platform_events.MessageReceivedEvent:
sender = platform_entities.User(id=event.userid, nickname=event.username or event.userid)
@@ -17,7 +17,9 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter):
return getattr(event, 'source_platform_object', None)
@staticmethod
async def target2legacy(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.FriendMessage | None:
async def target2legacy(
event: WecomCSEvent, bot: WecomCSClient | None = None
) -> platform_events.FriendMessage | None:
eba_event = await WecomCSEventConverter.target2yiri(event, bot)
if hasattr(eba_event, 'to_legacy_event'):
return eba_event.to_legacy_event()
@@ -30,7 +32,9 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter):
return WecomCSEventConverter.platform_specific(event, f'wecomcs.{event.type or "unknown"}')
@staticmethod
async def message_to_eba(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.MessageReceivedEvent:
async def message_to_eba(
event: WecomCSEvent, bot: WecomCSClient | None = None
) -> platform_events.MessageReceivedEvent:
message_chain = await WecomCSMessageConverter.target2yiri(event)
sender = await WecomCSEventConverter.user_from_event(event, bot)
return platform_events.MessageReceivedEvent(
+6 -4
View File
@@ -89,10 +89,12 @@ def format_human_input_text(
if actions:
lines.append('')
if input_defs:
lines.extend([
'Reply with action plus field values to continue:',
' action: <number or title>',
])
lines.extend(
[
'Reply with action plus field values to continue:',
' action: <number or title>',
]
)
else:
lines.append('Reply with the number or title to continue:')
for idx, action in enumerate(actions, start=1):
+3 -11
View File
@@ -2327,15 +2327,9 @@ class MCPLoader(loader.ToolLoader):
return items
def _session_by_source_id(
self, context: TenantContext, source_id: str
) -> RuntimeMCPSession | None:
def _session_by_source_id(self, context: TenantContext, source_id: str) -> RuntimeMCPSession | None:
return next(
(
session
for session in self._sessions_for_context(context)
if session.server_uuid == source_id
),
(session for session in self._sessions_for_context(context) if session.server_uuid == source_id),
None,
)
@@ -2397,9 +2391,7 @@ class MCPLoader(loader.ToolLoader):
source_id: str | None = None,
) -> typing.Any:
"""执行工具调用"""
execution_context = await self._assert_execution_active(
_execution_context_from_query(query)
)
execution_context = await self._assert_execution_active(_execution_context_from_query(query))
if source_id is None and name == MCP_TOOL_LIST_RESOURCES:
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is not True:
raise ToolExecutionDeniedError(name, 'MCP resource agent reads are disabled')
+2 -6
View File
@@ -368,9 +368,7 @@ class ToolManager:
return None
return await self.plugin_tool_loader.get_tool(name, source_id=source_id)
if source == 'mcp':
return await self.mcp_tool_loader.get_tool(
context, name, source_id=source_id
)
return await self.mcp_tool_loader.get_tool(context, name, source_id=source_id)
return None
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
@@ -488,9 +486,7 @@ class ToolManager:
if source_ref is not None:
execution_context = get_query_execution_context(query)
await self._bind_plugin_workspace(execution_context)
sandbox_available = await self._workspace_sandbox_available(
execution_context
)
sandbox_available = await self._workspace_sandbox_available(execution_context)
source = source_ref['source']
source_id = source_ref.get('source_id')
uses_source_id = False