mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-17 07:17:18 +00:00
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:
+1
-1
@@ -232,4 +232,4 @@ line-ending = "auto"
|
|||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
# Development contract: update to the matching SDK release before publishing.
|
# Development contract: update to the matching SDK release before publishing.
|
||||||
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "eac2c60509534512f9a373cd0c801e75985e0612" }
|
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" }
|
||||||
|
|||||||
@@ -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 import wecombotevent
|
||||||
from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
|
from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from langbot.pkg.platform.logger import EventLogger
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
from langbot.pkg.utils import httpclient
|
from langbot.pkg.utils import httpclient
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""EventLog store for writing and querying event records."""
|
"""EventLog store for writing and querying event records."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -44,9 +45,7 @@ class EventLogStore:
|
|||||||
|
|
||||||
def __init__(self, engine: AsyncEngine):
|
def __init__(self, engine: AsyncEngine):
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
self._session_factory = sessionmaker(
|
self._session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
engine, class_=AsyncSession, expire_on_commit=False
|
|
||||||
)
|
|
||||||
|
|
||||||
async def append_event(
|
async def append_event(
|
||||||
self,
|
self,
|
||||||
@@ -101,7 +100,7 @@ class EventLogStore:
|
|||||||
|
|
||||||
# Truncate input summary if too long
|
# Truncate input summary if too long
|
||||||
if input_summary and len(input_summary) > self.MAX_INPUT_SUMMARY_LENGTH:
|
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:
|
async with self._session_factory() as session:
|
||||||
event = EventLog(
|
event = EventLog(
|
||||||
@@ -144,9 +143,7 @@ class EventLogStore:
|
|||||||
Event record as dict, or None if not found
|
Event record as dict, or None if not found
|
||||||
"""
|
"""
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(sqlalchemy.select(EventLog).where(EventLog.event_id == event_id))
|
||||||
sqlalchemy.select(EventLog).where(EventLog.event_id == event_id)
|
|
||||||
)
|
|
||||||
row = result.scalars().first()
|
row = result.scalars().first()
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
@@ -282,9 +279,7 @@ class EventLogStore:
|
|||||||
) -> int:
|
) -> int:
|
||||||
"""Delete EventLog rows created before the supplied timestamp."""
|
"""Delete EventLog rows created before the supplied timestamp."""
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(sqlalchemy.delete(EventLog).where(EventLog.created_at < before))
|
||||||
sqlalchemy.delete(EventLog).where(EventLog.created_at < before)
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return result.rowcount or 0
|
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.
|
``web_page_bot``, is disabled, or has no Pipeline target for messages.
|
||||||
"""
|
"""
|
||||||
bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid)
|
bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid)
|
||||||
pipeline_uuid = (
|
pipeline_uuid = bot.get_pipeline_target_for_event_type('message.received') if bot is not None else None
|
||||||
bot.get_pipeline_target_for_event_type('message.received')
|
if bot is not None and bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.enable and pipeline_uuid:
|
||||||
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 bot, pipeline_uuid
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ class ToolsRouterGroup(group.RouterGroup):
|
|||||||
self,
|
self,
|
||||||
request_context: RequestContext,
|
request_context: RequestContext,
|
||||||
) -> list[dict] | None:
|
) -> list[dict] | None:
|
||||||
pipeline_uuid = quart.request.args.get(
|
pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id')
|
||||||
'pipeline_uuid'
|
|
||||||
) or quart.request.args.get('pipeline_id')
|
|
||||||
bound_plugins: list[str] | None = None
|
bound_plugins: list[str] | None = None
|
||||||
bound_mcp_servers: list[str] | None = None
|
bound_mcp_servers: list[str] | None = None
|
||||||
|
|
||||||
@@ -28,14 +26,9 @@ class ToolsRouterGroup(group.RouterGroup):
|
|||||||
if pipeline is None:
|
if pipeline is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
extensions_prefs = normalize_extension_preferences(
|
extensions_prefs = normalize_extension_preferences(pipeline.get('extensions_preferences'))
|
||||||
pipeline.get('extensions_preferences')
|
|
||||||
)
|
|
||||||
if not extensions_prefs['enable_all_plugins']:
|
if not extensions_prefs['enable_all_plugins']:
|
||||||
bound_plugins = [
|
bound_plugins = [f'{plugin["author"]}/{plugin["name"]}' for plugin in extensions_prefs['plugins']]
|
||||||
f'{plugin["author"]}/{plugin["name"]}'
|
|
||||||
for plugin in extensions_prefs['plugins']
|
|
||||||
]
|
|
||||||
if not extensions_prefs['enable_all_mcp_servers']:
|
if not extensions_prefs['enable_all_mcp_servers']:
|
||||||
bound_mcp_servers = extensions_prefs['mcp_servers']
|
bound_mcp_servers = extensions_prefs['mcp_servers']
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Transcript persistence entity for conversation history projection."""
|
"""Transcript persistence entity for conversation history projection."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|||||||
@@ -48,12 +48,16 @@ def upgrade() -> None:
|
|||||||
sa.column('source', sa.String(32)),
|
sa.column('source', sa.String(32)),
|
||||||
sa.column('created_by_account_uuid', sa.String(36)),
|
sa.column('created_by_account_uuid', sa.String(36)),
|
||||||
)
|
)
|
||||||
workspace_uuids = conn.execute(
|
workspace_uuids = (
|
||||||
sa.select(workspaces.c.uuid).where(
|
conn.execute(
|
||||||
workspaces.c.instance_uuid == instance_uuid.strip(),
|
sa.select(workspaces.c.uuid).where(
|
||||||
workspaces.c.source == 'local',
|
workspaces.c.instance_uuid == instance_uuid.strip(),
|
||||||
|
workspaces.c.source == 'local',
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
if not workspace_uuids:
|
if not workspace_uuids:
|
||||||
return
|
return
|
||||||
if len(workspace_uuids) > 1:
|
if len(workspace_uuids) > 1:
|
||||||
@@ -99,10 +103,7 @@ def upgrade() -> None:
|
|||||||
sa.column('status', sa.String(32)),
|
sa.column('status', sa.String(32)),
|
||||||
)
|
)
|
||||||
owner_account_uuid = conn.execute(
|
owner_account_uuid = conn.execute(
|
||||||
sa.select(users.c.uuid)
|
sa.select(users.c.uuid).where(users.c.status == 'active').order_by(users.c.id).limit(1)
|
||||||
.where(users.c.status == 'active')
|
|
||||||
.order_by(users.c.id)
|
|
||||||
.limit(1)
|
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if owner_account_uuid is None:
|
if owner_account_uuid is None:
|
||||||
return
|
return
|
||||||
|
|||||||
+1
@@ -4,6 +4,7 @@ Revision ID: 58846a8d7a81
|
|||||||
Revises: 0005_add_llm_context_length
|
Revises: 0005_add_llm_context_length
|
||||||
Create Date: 2026-05-23 15:41:47.030841
|
Create Date: 2026-05-23 15:41:47.030841
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|||||||
+1
@@ -4,6 +4,7 @@ Revision ID: 7b2c1d9e4f30
|
|||||||
Revises: 6dfd3dd7f0c7
|
Revises: 6dfd3dd7f0c7
|
||||||
Create Date: 2026-06-12
|
Create Date: 2026-06-12
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|||||||
@@ -90,9 +90,7 @@ def upgrade() -> None:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_add_column_if_missing('agent_run', sa.Column('queue_name', sa.String(255), nullable=True))
|
_add_column_if_missing('agent_run', sa.Column('queue_name', sa.String(255), nullable=True))
|
||||||
_add_column_if_missing(
|
_add_column_if_missing('agent_run', sa.Column('priority', sa.Integer(), nullable=False, server_default='0'))
|
||||||
'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('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('claimed_by_runtime_id', sa.String(255), nullable=True))
|
||||||
_add_column_if_missing('agent_run', sa.Column('claim_token', 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_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_mcp_servers'] = value.get('enable_all_mcp_servers', True) is True
|
||||||
normalized['enable_all_skills'] = value.get('enable_all_skills', True) is True
|
normalized['enable_all_skills'] = value.get('enable_all_skills', True) is True
|
||||||
normalized['mcp_resource_agent_read_enabled'] = (
|
normalized['mcp_resource_agent_read_enabled'] = value.get('mcp_resource_agent_read_enabled', True) is True
|
||||||
value.get('mcp_resource_agent_read_enabled', True) is True
|
|
||||||
)
|
|
||||||
|
|
||||||
plugins = value.get('plugins', [])
|
plugins = value.get('plugins', [])
|
||||||
plugins_are_valid = isinstance(plugins, list) and all(
|
plugins_are_valid = isinstance(plugins, list) and all(_valid_plugin_binding(plugin) for plugin in plugins)
|
||||||
_valid_plugin_binding(plugin) for plugin in plugins
|
|
||||||
)
|
|
||||||
normalized['plugins'] = list(plugins) if plugins_are_valid else []
|
normalized['plugins'] = list(plugins) if plugins_are_valid else []
|
||||||
if not plugins_are_valid:
|
if not plugins_are_valid:
|
||||||
normalized['enable_all_plugins'] = False
|
normalized['enable_all_plugins'] = False
|
||||||
|
|
||||||
mcp_servers = value.get('mcp_servers', [])
|
mcp_servers = value.get('mcp_servers', [])
|
||||||
mcp_servers_are_valid = isinstance(mcp_servers, list) and all(
|
mcp_servers_are_valid = isinstance(mcp_servers, list) and all(_valid_name(server) for server in mcp_servers)
|
||||||
_valid_name(server) for server in mcp_servers
|
|
||||||
)
|
|
||||||
normalized['mcp_servers'] = list(mcp_servers) if mcp_servers_are_valid else []
|
normalized['mcp_servers'] = list(mcp_servers) if mcp_servers_are_valid else []
|
||||||
if not mcp_servers_are_valid:
|
if not mcp_servers_are_valid:
|
||||||
normalized['enable_all_mcp_servers'] = False
|
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")
|
raise ValueError(f"{context} field '{field_label}' must be a list")
|
||||||
for index, item in enumerate(items):
|
for index, item in enumerate(items):
|
||||||
if not item_validator(item):
|
if not item_validator(item):
|
||||||
raise ValueError(
|
raise ValueError(f"{context} field '{field_label}[{index}]' must be {item_description}")
|
||||||
f"{context} field '{field_label}[{index}]' must be {item_description}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ class OfficialAccountMessageConverter(abstract_platform_adapter.AbstractMessageC
|
|||||||
else:
|
else:
|
||||||
components.append(platform_message.Unknown(text='[officialaccount voice message without media id]'))
|
components.append(platform_message.Unknown(text='[officialaccount voice message without media id]'))
|
||||||
elif event.type == 'event':
|
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:
|
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)
|
return platform_message.MessageChain(components)
|
||||||
|
|||||||
@@ -3,4 +3,3 @@
|
|||||||
from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter
|
from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter
|
||||||
|
|
||||||
__all__ = ['QQOfficialAdapter']
|
__all__ = ['QQOfficialAdapter']
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ class QQOfficialAPIMixin:
|
|||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
) -> list[platform_entities.UserGroupMember]:
|
) -> 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(
|
async def edit_message(
|
||||||
self,
|
self,
|
||||||
@@ -100,4 +102,3 @@ class QQOfficialAPIMixin:
|
|||||||
|
|
||||||
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')
|
||||||
|
|
||||||
|
|||||||
@@ -8,4 +8,3 @@ except ModuleNotFoundError:
|
|||||||
def __init__(self, api_name: str, *args):
|
def __init__(self, api_name: str, *args):
|
||||||
super().__init__(f"API '{api_name}' is not supported by this adapter", *args)
|
super().__init__(f"API '{api_name}' is not supported by this adapter", *args)
|
||||||
self.api_name = api_name
|
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_gateway_url': get_gateway_url,
|
||||||
'get_mode': get_mode,
|
'get_mode': get_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ class SlackAPIMixin:
|
|||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
) -> list[platform_entities.UserGroupMember]:
|
) -> 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(
|
async def get_group_member_info(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ 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)
|
||||||
|
|
||||||
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)
|
eba_event = await self.target2yiri(event)
|
||||||
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
|
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
|
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'],
|
message_id=params['message_id'],
|
||||||
disable_notification=params.get('disable_notification', False),
|
disable_notification=params.get('disable_notification', False),
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
async def unpin_message(bot: telegram.Bot, params: dict) -> dict:
|
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'],
|
chat_id=params['chat_id'],
|
||||||
message_id=params.get('message_id'),
|
message_id=params.get('message_id'),
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
async def unpin_all_messages(bot: telegram.Bot, params: dict) -> dict:
|
async def unpin_all_messages(bot: telegram.Bot, params: dict) -> dict:
|
||||||
"""Unpin all messages in a chat."""
|
"""Unpin all messages in a chat."""
|
||||||
await bot.unpin_all_chat_messages(chat_id=params['chat_id'])
|
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:
|
async def get_chat_administrators(bot: telegram.Bot, params: dict) -> dict:
|
||||||
"""Get chat administrator list."""
|
"""Get chat administrator list."""
|
||||||
admins = await bot.get_chat_administrators(chat_id=params['chat_id'])
|
admins = await bot.get_chat_administrators(chat_id=params['chat_id'])
|
||||||
return {
|
return {
|
||||||
"administrators": [
|
'administrators': [
|
||||||
{
|
{
|
||||||
"user_id": a.user.id,
|
'user_id': a.user.id,
|
||||||
"username": a.user.username,
|
'username': a.user.username,
|
||||||
"first_name": a.user.first_name,
|
'first_name': a.user.first_name,
|
||||||
"status": a.status,
|
'status': a.status,
|
||||||
"custom_title": getattr(a, 'custom_title', None),
|
'custom_title': getattr(a, 'custom_title', None),
|
||||||
}
|
}
|
||||||
for a in admins
|
for a in admins
|
||||||
]
|
]
|
||||||
@@ -55,7 +55,7 @@ async def set_chat_title(bot: telegram.Bot, params: dict) -> dict:
|
|||||||
chat_id=params['chat_id'],
|
chat_id=params['chat_id'],
|
||||||
title=params['title'],
|
title=params['title'],
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
async def set_chat_description(bot: telegram.Bot, params: dict) -> dict:
|
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'],
|
chat_id=params['chat_id'],
|
||||||
description=params.get('description', ''),
|
description=params.get('description', ''),
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
async def get_chat_member_count(bot: telegram.Bot, params: dict) -> dict:
|
async def get_chat_member_count(bot: telegram.Bot, params: dict) -> dict:
|
||||||
"""Get chat member count."""
|
"""Get chat member count."""
|
||||||
count = await bot.get_chat_member_count(chat_id=params['chat_id'])
|
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:
|
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'],
|
chat_id=params['chat_id'],
|
||||||
action=params.get('action', 'typing'),
|
action=params.get('action', 'typing'),
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
async def create_chat_invite_link(bot: telegram.Bot, params: dict) -> dict:
|
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'),
|
member_limit=params.get('member_limit'),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"invite_link": link.invite_link,
|
'invite_link': link.invite_link,
|
||||||
"name": link.name,
|
'name': link.name,
|
||||||
"is_primary": link.is_primary,
|
'is_primary': link.is_primary,
|
||||||
"is_revoked": link.is_revoked,
|
'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),
|
show_alert=params.get('show_alert', False),
|
||||||
url=params.get('url'),
|
url=params.get('url'),
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
# ---- Action dispatch table ----
|
# ---- Action dispatch table ----
|
||||||
|
|
||||||
PLATFORM_API_MAP: dict[str, typing.Callable[[telegram.Bot, dict], typing.Awaitable[dict]]] = {
|
PLATFORM_API_MAP: dict[str, typing.Callable[[telegram.Bot, dict], typing.Awaitable[dict]]] = {
|
||||||
"pin_message": pin_message,
|
'pin_message': pin_message,
|
||||||
"unpin_message": unpin_message,
|
'unpin_message': unpin_message,
|
||||||
"unpin_all_messages": unpin_all_messages,
|
'unpin_all_messages': unpin_all_messages,
|
||||||
"get_chat_administrators": get_chat_administrators,
|
'get_chat_administrators': get_chat_administrators,
|
||||||
"set_chat_title": set_chat_title,
|
'set_chat_title': set_chat_title,
|
||||||
"set_chat_description": set_chat_description,
|
'set_chat_description': set_chat_description,
|
||||||
"get_chat_member_count": get_chat_member_count,
|
'get_chat_member_count': get_chat_member_count,
|
||||||
"send_chat_action": send_chat_action,
|
'send_chat_action': send_chat_action,
|
||||||
"create_chat_invite_link": create_chat_invite_link,
|
'create_chat_invite_link': create_chat_invite_link,
|
||||||
"answer_callback_query": answer_callback_query,
|
'answer_callback_query': answer_callback_query,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from enum import Enum
|
|||||||
|
|
||||||
class TelegramChatType(str, Enum):
|
class TelegramChatType(str, Enum):
|
||||||
"""Telegram chat type."""
|
"""Telegram chat type."""
|
||||||
PRIVATE = "private"
|
|
||||||
GROUP = "group"
|
PRIVATE = 'private'
|
||||||
SUPERGROUP = "supergroup"
|
GROUP = 'group'
|
||||||
CHANNEL = "channel"
|
SUPERGROUP = 'supergroup'
|
||||||
|
CHANNEL = 'channel'
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ class WecomBotAPIMixin:
|
|||||||
self,
|
self,
|
||||||
group_id: typing.Union[int, str],
|
group_id: typing.Union[int, str],
|
||||||
) -> list[platform_entities.UserGroupMember]:
|
) -> 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:
|
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||||
raise NotSupportedError('upload_file')
|
raise NotSupportedError('upload_file')
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ 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)
|
||||||
|
|
||||||
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)
|
eba_event = await self.target2yiri(event)
|
||||||
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
|
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
|
||||||
return None
|
return None
|
||||||
@@ -50,7 +52,9 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
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)
|
||||||
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:
|
async def message_to_eba(self, event: WecomBotEvent) -> platform_events.MessageReceivedEvent:
|
||||||
sender = platform_entities.User(id=event.userid, nickname=event.username or event.userid)
|
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)
|
return getattr(event, 'source_platform_object', None)
|
||||||
|
|
||||||
@staticmethod
|
@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)
|
eba_event = await WecomCSEventConverter.target2yiri(event, bot)
|
||||||
if hasattr(eba_event, 'to_legacy_event'):
|
if hasattr(eba_event, 'to_legacy_event'):
|
||||||
return 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"}')
|
return WecomCSEventConverter.platform_specific(event, f'wecomcs.{event.type or "unknown"}')
|
||||||
|
|
||||||
@staticmethod
|
@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)
|
message_chain = await WecomCSMessageConverter.target2yiri(event)
|
||||||
sender = await WecomCSEventConverter.user_from_event(event, bot)
|
sender = await WecomCSEventConverter.user_from_event(event, bot)
|
||||||
return platform_events.MessageReceivedEvent(
|
return platform_events.MessageReceivedEvent(
|
||||||
|
|||||||
@@ -89,10 +89,12 @@ def format_human_input_text(
|
|||||||
if actions:
|
if actions:
|
||||||
lines.append('')
|
lines.append('')
|
||||||
if input_defs:
|
if input_defs:
|
||||||
lines.extend([
|
lines.extend(
|
||||||
'Reply with action plus field values to continue:',
|
[
|
||||||
' action: <number or title>',
|
'Reply with action plus field values to continue:',
|
||||||
])
|
' action: <number or title>',
|
||||||
|
]
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
lines.append('Reply with the number or title to continue:')
|
lines.append('Reply with the number or title to continue:')
|
||||||
for idx, action in enumerate(actions, start=1):
|
for idx, action in enumerate(actions, start=1):
|
||||||
|
|||||||
@@ -2327,15 +2327,9 @@ class MCPLoader(loader.ToolLoader):
|
|||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
def _session_by_source_id(
|
def _session_by_source_id(self, context: TenantContext, source_id: str) -> RuntimeMCPSession | None:
|
||||||
self, context: TenantContext, source_id: str
|
|
||||||
) -> RuntimeMCPSession | None:
|
|
||||||
return next(
|
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,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2397,9 +2391,7 @@ class MCPLoader(loader.ToolLoader):
|
|||||||
source_id: str | None = None,
|
source_id: str | None = None,
|
||||||
) -> typing.Any:
|
) -> typing.Any:
|
||||||
"""执行工具调用"""
|
"""执行工具调用"""
|
||||||
execution_context = await self._assert_execution_active(
|
execution_context = await self._assert_execution_active(_execution_context_from_query(query))
|
||||||
_execution_context_from_query(query)
|
|
||||||
)
|
|
||||||
if source_id is None and name == MCP_TOOL_LIST_RESOURCES:
|
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:
|
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is not True:
|
||||||
raise ToolExecutionDeniedError(name, 'MCP resource agent reads are disabled')
|
raise ToolExecutionDeniedError(name, 'MCP resource agent reads are disabled')
|
||||||
|
|||||||
@@ -368,9 +368,7 @@ class ToolManager:
|
|||||||
return None
|
return None
|
||||||
return await self.plugin_tool_loader.get_tool(name, source_id=source_id)
|
return await self.plugin_tool_loader.get_tool(name, source_id=source_id)
|
||||||
if source == 'mcp':
|
if source == 'mcp':
|
||||||
return await self.mcp_tool_loader.get_tool(
|
return await self.mcp_tool_loader.get_tool(context, name, source_id=source_id)
|
||||||
context, name, source_id=source_id
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
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:
|
if source_ref is not None:
|
||||||
execution_context = get_query_execution_context(query)
|
execution_context = get_query_execution_context(query)
|
||||||
await self._bind_plugin_workspace(execution_context)
|
await self._bind_plugin_workspace(execution_context)
|
||||||
sandbox_available = await self._workspace_sandbox_available(
|
sandbox_available = await self._workspace_sandbox_available(execution_context)
|
||||||
execution_context
|
|
||||||
)
|
|
||||||
source = source_ref['source']
|
source = source_ref['source']
|
||||||
source_id = source_ref.get('source_id')
|
source_id = source_ref.get('source_id')
|
||||||
uses_source_id = False
|
uses_source_id = False
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ def _package_local_agent_plugin(tmpdir: Path) -> Path:
|
|||||||
package_source = tmpdir / 'local-agent-package'
|
package_source = tmpdir / 'local-agent-package'
|
||||||
ignore = shutil.ignore_patterns(
|
ignore = shutil.ignore_patterns(
|
||||||
'.git',
|
'.git',
|
||||||
'.venv',
|
'.venv*',
|
||||||
'__pycache__',
|
'__pycache__',
|
||||||
'.pytest_cache',
|
'.pytest_cache',
|
||||||
'.ruff_cache',
|
'.ruff_cache',
|
||||||
@@ -181,7 +181,20 @@ class _FakeToolManager:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.calls: list[dict[str, Any]] = []
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
async def get_tool_schema(self, tool_name: str):
|
async def get_resolved_tool_catalog(
|
||||||
|
self,
|
||||||
|
context,
|
||||||
|
bound_plugins=None,
|
||||||
|
bound_mcp_servers=None,
|
||||||
|
include_skill_authoring=True,
|
||||||
|
include_mcp_resource_tools=False,
|
||||||
|
):
|
||||||
|
assert context.workspace_uuid
|
||||||
|
return [{'name': E2E_TOOL_NAME, 'source': 'native', 'source_id': None}]
|
||||||
|
|
||||||
|
async def get_tool_schema(self, context, tool_name: str, source_ref=None):
|
||||||
|
assert context.workspace_uuid
|
||||||
|
assert source_ref == {'source': 'native', 'source_id': None}
|
||||||
if tool_name != E2E_TOOL_NAME:
|
if tool_name != E2E_TOOL_NAME:
|
||||||
return None, None
|
return None, None
|
||||||
return (
|
return (
|
||||||
@@ -195,14 +208,15 @@ class _FakeToolManager:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_tool_detail(self, tool_name: str):
|
async def get_tool_detail(self, context, tool_name: str, source_ref=None):
|
||||||
description, parameters = await self.get_tool_schema(tool_name)
|
description, parameters = await self.get_tool_schema(context, tool_name, source_ref=source_ref)
|
||||||
if parameters is None:
|
if parameters is None:
|
||||||
return None
|
return None
|
||||||
return {'name': tool_name, 'description': description, 'parameters': parameters}
|
return {'name': tool_name, 'description': description, 'parameters': parameters}
|
||||||
|
|
||||||
async def execute_func_call(self, name: str, parameters: dict[str, Any], query: Any = None):
|
async def execute_func_call(self, name: str, parameters: dict[str, Any], query: Any = None, source_ref=None):
|
||||||
del query
|
assert query.workspace_uuid
|
||||||
|
assert source_ref == {'source': 'native', 'source_id': None}
|
||||||
self.calls.append({'name': name, 'parameters': dict(parameters)})
|
self.calls.append({'name': name, 'parameters': dict(parameters)})
|
||||||
return {
|
return {
|
||||||
'value': f'tool-result:{parameters.get("query")}',
|
'value': f'tool-result:{parameters.get("query")}',
|
||||||
@@ -223,7 +237,8 @@ class _FakeKnowledgeBase:
|
|||||||
def get_name(self) -> str:
|
def get_name(self) -> str:
|
||||||
return 'E2E Fake KB'
|
return 'E2E Fake KB'
|
||||||
|
|
||||||
async def retrieve(self, query_text: str, settings: dict[str, Any]):
|
async def retrieve(self, context, query_text: str, settings: dict[str, Any]):
|
||||||
|
assert context.workspace_uuid
|
||||||
self.retrieve_calls.append({'query_text': query_text, 'settings': settings})
|
self.retrieve_calls.append({'query_text': query_text, 'settings': settings})
|
||||||
return [
|
return [
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
@@ -248,7 +263,8 @@ class _FakeRagManager:
|
|||||||
self.kb = kb
|
self.kb = kb
|
||||||
self.knowledge_bases = {E2E_KB_UUID: kb}
|
self.knowledge_bases = {E2E_KB_UUID: kb}
|
||||||
|
|
||||||
async def get_knowledge_base_by_uuid(self, kb_uuid: str):
|
async def get_knowledge_base_by_uuid(self, context, kb_uuid: str):
|
||||||
|
assert context.workspace_uuid
|
||||||
if kb_uuid == E2E_KB_UUID:
|
if kb_uuid == E2E_KB_UUID:
|
||||||
return self.kb
|
return self.kb
|
||||||
return None
|
return None
|
||||||
@@ -443,10 +459,27 @@ def _scripted_tool_call(
|
|||||||
async def _boot_local_agent_app(tmpdir: Path):
|
async def _boot_local_agent_app(tmpdir: Path):
|
||||||
"""Boot LangBot and wait until the Local Agent runner is discoverable."""
|
"""Boot LangBot and wait until the Local Agent runner is discoverable."""
|
||||||
from langbot.pkg.core import boot
|
from langbot.pkg.core import boot
|
||||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
|
||||||
|
|
||||||
ap = await boot.make_app(asyncio.get_running_loop())
|
ap = await boot.make_app(asyncio.get_running_loop())
|
||||||
run_task = asyncio.create_task(ap.run(), name='local-agent-e2e-app')
|
run_task = asyncio.create_task(ap.run(), name='local-agent-e2e-app')
|
||||||
|
try:
|
||||||
|
await _wait_for_local_agent_runner(ap, tmpdir)
|
||||||
|
except BaseException:
|
||||||
|
# The caller has not received ap yet. Close it here so a boot failure
|
||||||
|
# cannot reconnect after the probe restores the global transport mode.
|
||||||
|
try:
|
||||||
|
await ap.shutdown()
|
||||||
|
finally:
|
||||||
|
run_task.cancel()
|
||||||
|
await asyncio.gather(run_task, return_exceptions=True)
|
||||||
|
raise
|
||||||
|
return ap, run_task
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for_local_agent_runner(ap, tmpdir: Path):
|
||||||
|
"""Install and discover the runner on the application-owned connection."""
|
||||||
|
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||||
|
|
||||||
for _ in range(60):
|
for _ in range(60):
|
||||||
handler = getattr(ap.plugin_connector, 'handler', None)
|
handler = getattr(ap.plugin_connector, 'handler', None)
|
||||||
if handler is not None:
|
if handler is not None:
|
||||||
@@ -478,8 +511,6 @@ async def _boot_local_agent_app(tmpdir: Path):
|
|||||||
else:
|
else:
|
||||||
raise AssertionError(f'{LOCAL_RUNNER_ID} was not discovered after installation')
|
raise AssertionError(f'{LOCAL_RUNNER_ID} was not discovered after installation')
|
||||||
|
|
||||||
return ap, run_task
|
|
||||||
|
|
||||||
|
|
||||||
def _run_local_agent_probe(tmpdir: Path, probe):
|
def _run_local_agent_probe(tmpdir: Path, probe):
|
||||||
"""Run one Local Agent probe inside the temporary LangBot app."""
|
"""Run one Local Agent probe inside the temporary LangBot app."""
|
||||||
@@ -689,7 +720,13 @@ def test_local_runner_retrieves_authorized_rag_context_through_host_action(
|
|||||||
assert retrieve_calls == [
|
assert retrieve_calls == [
|
||||||
{
|
{
|
||||||
'query_text': 'Answer with the retrieved RAG sentinel.',
|
'query_text': 'Answer with the retrieved RAG sentinel.',
|
||||||
'settings': {'top_k': 1, 'filters': {}},
|
'settings': {
|
||||||
|
'top_k': 1,
|
||||||
|
'filters': {},
|
||||||
|
'session_name': 'person_e2e-local-agent-rag-conversation',
|
||||||
|
'bot_uuid': '',
|
||||||
|
'sender_id': 'user-001',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert any(
|
assert any(
|
||||||
@@ -738,11 +775,13 @@ def test_local_runner_compacts_history_and_persists_checkpoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
store = TranscriptStore(ap.persistence_mgr.get_db_engine())
|
store = TranscriptStore(ap.persistence_mgr.get_db_engine())
|
||||||
|
execution_context = await ap.plugin_connector._current_execution_context()
|
||||||
for index in range(12):
|
for index in range(12):
|
||||||
await store.append_transcript(
|
await store.append_transcript(
|
||||||
transcript_id=None,
|
transcript_id=None,
|
||||||
event_id=f'e2e-local-agent-history-{index}',
|
event_id=f'e2e-local-agent-history-{index}',
|
||||||
conversation_id='e2e-local-agent-compaction-conversation',
|
conversation_id='e2e-local-agent-compaction-conversation',
|
||||||
|
workspace_id=execution_context.workspace_uuid,
|
||||||
role='user' if index % 2 == 0 else 'assistant',
|
role='user' if index % 2 == 0 else 'assistant',
|
||||||
content=(
|
content=(
|
||||||
f'HIST_SENTINEL-{index} This is intentionally long deterministic history for compaction. ' * 10
|
f'HIST_SENTINEL-{index} This is intentionally long deterministic history for compaction. ' * 10
|
||||||
@@ -847,11 +886,13 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop(
|
|||||||
ap.rag_mgr = _FakeRagManager(fake_kb)
|
ap.rag_mgr = _FakeRagManager(fake_kb)
|
||||||
|
|
||||||
store = TranscriptStore(ap.persistence_mgr.get_db_engine())
|
store = TranscriptStore(ap.persistence_mgr.get_db_engine())
|
||||||
|
execution_context = await ap.plugin_connector._current_execution_context()
|
||||||
for index in range(16):
|
for index in range(16):
|
||||||
await store.append_transcript(
|
await store.append_transcript(
|
||||||
transcript_id=None,
|
transcript_id=None,
|
||||||
event_id=f'e2e-local-agent-combo-history-{index}',
|
event_id=f'e2e-local-agent-combo-history-{index}',
|
||||||
conversation_id='e2e-local-agent-combo-conversation',
|
conversation_id='e2e-local-agent-combo-conversation',
|
||||||
|
workspace_id=execution_context.workspace_uuid,
|
||||||
role='user' if index % 2 == 0 else 'assistant',
|
role='user' if index % 2 == 0 else 'assistant',
|
||||||
content=(
|
content=(
|
||||||
f'HIST_COMBO_SENTINEL-{index} RAG_TOOL_COMBO_GOAL '
|
f'HIST_COMBO_SENTINEL-{index} RAG_TOOL_COMBO_GOAL '
|
||||||
@@ -907,7 +948,13 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop(
|
|||||||
assert retrieve_calls == [
|
assert retrieve_calls == [
|
||||||
{
|
{
|
||||||
'query_text': 'current combo request must survive; use RAG and tools before answering.',
|
'query_text': 'current combo request must survive; use RAG and tools before answering.',
|
||||||
'settings': {'top_k': 1, 'filters': {}},
|
'settings': {
|
||||||
|
'top_k': 1,
|
||||||
|
'filters': {},
|
||||||
|
'session_name': 'person_e2e-local-agent-combo-conversation',
|
||||||
|
'bot_uuid': '',
|
||||||
|
'sender_id': 'user-001',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert invoke_count >= 4
|
assert invoke_count >= 4
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import sqlalchemy
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from langbot.pkg.entity import persistence
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
from langbot.pkg.utils import importutil
|
||||||
from langbot.pkg.persistence.alembic_runner import (
|
from langbot.pkg.persistence.alembic_runner import (
|
||||||
run_alembic_downgrade,
|
run_alembic_downgrade,
|
||||||
run_alembic_upgrade,
|
run_alembic_upgrade,
|
||||||
@@ -28,6 +30,11 @@ from alembic.config import Config
|
|||||||
from alembic.script import ScriptDirectory
|
from alembic.script import ScriptDirectory
|
||||||
|
|
||||||
|
|
||||||
|
# Match PersistenceManager's model registration before create_all, including
|
||||||
|
# workspace foreign-key targets, without relying on other tests being collected.
|
||||||
|
importutil.import_modules_in_pkg(persistence)
|
||||||
|
|
||||||
|
|
||||||
def _get_script_head() -> str:
|
def _get_script_head() -> str:
|
||||||
"""Resolve the current Alembic head revision from the script directory.
|
"""Resolve the current Alembic head revision from the script directory.
|
||||||
|
|
||||||
@@ -108,7 +115,6 @@ class TestSQLiteMigrationUpgrade:
|
|||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
|
|
||||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||||
assert _get_script_head() == '0023_drop_agent_enabled'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine):
|
async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine):
|
||||||
@@ -120,7 +126,6 @@ class TestSQLiteMigrationUpgrade:
|
|||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
|
|
||||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||||
assert _get_script_head() == '0023_drop_agent_enabled'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine):
|
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine):
|
||||||
@@ -131,7 +136,7 @@ class TestSQLiteMigrationUpgrade:
|
|||||||
await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config')
|
await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config')
|
||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
|
|
||||||
assert await get_alembic_current(sqlite_engine) == '0023_drop_agent_enabled'
|
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upgrade_removes_agent_enabled_column(self, sqlite_engine):
|
async def test_upgrade_removes_agent_enabled_column(self, sqlite_engine):
|
||||||
|
|||||||
@@ -856,7 +856,7 @@ class TestPostgreSQLTenantRuntime:
|
|||||||
adapter='capacity-probe',
|
adapter='capacity-probe',
|
||||||
adapter_config={},
|
adapter_config={},
|
||||||
enable=False,
|
enable=False,
|
||||||
pipeline_routing_rules=[],
|
event_bindings=[],
|
||||||
),
|
),
|
||||||
persistence_pipeline.LegacyPipeline(
|
persistence_pipeline.LegacyPipeline(
|
||||||
uuid=f'capacity-pipeline-{suffix}',
|
uuid=f'capacity-pipeline-{suffix}',
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Keep E2E fake resources compatible with the real Host contract."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock
|
||||||
|
import pytest
|
||||||
|
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
||||||
|
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
|
||||||
|
from langbot.pkg.api.http.context import ExecutionContext
|
||||||
|
from tests.e2e.test_local_runner_fake_provider import (
|
||||||
|
E2E_KB_UUID,
|
||||||
|
E2E_TOOL_NAME,
|
||||||
|
LOCAL_RUNNER_ID,
|
||||||
|
_FakeKnowledgeBase,
|
||||||
|
_FakeRagManager,
|
||||||
|
_FakeToolManager,
|
||||||
|
_binding,
|
||||||
|
_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
CONTEXT = ExecutionContext(instance_uuid='instance-test', workspace_uuid='workspace-test', placement_generation=1)
|
||||||
|
SOURCE = {'source': 'native', 'source_id': None}
|
||||||
|
|
||||||
|
|
||||||
|
async def _resources(tool_mgr, rag_mgr, binding):
|
||||||
|
descriptor = RunnerDescriptor(
|
||||||
|
id=LOCAL_RUNNER_ID,
|
||||||
|
source='plugin',
|
||||||
|
label={'en_US': 'Local Agent'},
|
||||||
|
plugin_author='langbot-team',
|
||||||
|
plugin_name='LocalAgent',
|
||||||
|
runner_name='default',
|
||||||
|
capabilities={'tool_calling': True, 'knowledge_retrieval': True},
|
||||||
|
permissions={'tools': ['detail', 'call'], 'knowledge_bases': ['list', 'retrieve']},
|
||||||
|
)
|
||||||
|
app = SimpleNamespace(logger=Mock(), skill_mgr=None, tool_mgr=tool_mgr, rag_mgr=rag_mgr)
|
||||||
|
return await AgentResourceBuilder(app).build_resources_from_binding(
|
||||||
|
CONTEXT,
|
||||||
|
_event(event_id='evt', conversation_id='conv', text='test'),
|
||||||
|
binding,
|
||||||
|
descriptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fake_tool_is_projected_with_frozen_source_and_executed():
|
||||||
|
manager = _FakeToolManager()
|
||||||
|
resources = await _resources(
|
||||||
|
manager, _FakeRagManager(_FakeKnowledgeBase()), _binding(allowed_tool_names=[E2E_TOOL_NAME])
|
||||||
|
)
|
||||||
|
assert len(resources['tools']) == 1
|
||||||
|
tool = resources['tools'][0]
|
||||||
|
assert tool['tool_name'] == E2E_TOOL_NAME
|
||||||
|
assert {key: tool[key] for key in SOURCE} == SOURCE
|
||||||
|
assert tool['parameters']['required'] == ['query']
|
||||||
|
detail = await manager.get_tool_detail(CONTEXT, E2E_TOOL_NAME, source_ref=SOURCE)
|
||||||
|
assert detail['parameters'] == tool['parameters']
|
||||||
|
result = await manager.execute_func_call(
|
||||||
|
name=E2E_TOOL_NAME,
|
||||||
|
parameters={'query': 'alpha'},
|
||||||
|
query=SimpleNamespace(workspace_uuid=CONTEXT.workspace_uuid),
|
||||||
|
source_ref=SOURCE,
|
||||||
|
)
|
||||||
|
assert result['value'] == 'tool-result:alpha'
|
||||||
|
assert manager.calls == [{'name': E2E_TOOL_NAME, 'parameters': {'query': 'alpha'}}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fake_kb_is_projected_and_retrieved_with_execution_context():
|
||||||
|
kb = _FakeKnowledgeBase()
|
||||||
|
manager = _FakeRagManager(kb)
|
||||||
|
resources = await _resources(_FakeToolManager(), manager, _binding(allowed_kb_uuids=[E2E_KB_UUID]))
|
||||||
|
assert [item['kb_id'] for item in resources['knowledge_bases']] == [E2E_KB_UUID]
|
||||||
|
resolved = await manager.get_knowledge_base_by_uuid(CONTEXT, E2E_KB_UUID)
|
||||||
|
entries = await resolved.retrieve(CONTEXT, 'test', settings={'top_k': 1, 'filters': {}})
|
||||||
|
assert 'RAG_SENTINEL' in entries[0].content
|
||||||
|
assert kb.retrieve_calls == [{'query_text': 'test', 'settings': {'top_k': 1, 'filters': {}}}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_agent_package_excludes_alternate_virtualenvs(tmp_path, monkeypatch):
|
||||||
|
import zipfile
|
||||||
|
from tests.e2e import test_local_runner_fake_provider as fixtures
|
||||||
|
|
||||||
|
source = tmp_path / 'source'
|
||||||
|
source.mkdir()
|
||||||
|
(source / 'manifest.yaml').write_text('metadata: {author: langbot-team, name: LocalAgent}')
|
||||||
|
for name in ('.venv', '.venv311'):
|
||||||
|
(source / name).mkdir()
|
||||||
|
(source / name / 'not-plugin.py').write_text('pass')
|
||||||
|
monkeypatch.setattr(fixtures, '_local_agent_repo', lambda: source)
|
||||||
|
package = fixtures._package_local_agent_plugin(tmp_path / 'package')
|
||||||
|
with zipfile.ZipFile(package) as archive:
|
||||||
|
assert archive.namelist() == ['manifest.yaml']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_local_agent_boot_shuts_down_before_restoring_transport(monkeypatch, tmp_path):
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from langbot.pkg.core import boot
|
||||||
|
from tests.e2e import test_local_runner_fake_provider as fixtures
|
||||||
|
|
||||||
|
async def running():
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
app = SimpleNamespace(
|
||||||
|
run=running,
|
||||||
|
shutdown=AsyncMock(),
|
||||||
|
plugin_connector=SimpleNamespace(
|
||||||
|
handler=SimpleNamespace(ping=AsyncMock()), _current_execution_context=AsyncMock(return_value=CONTEXT)
|
||||||
|
),
|
||||||
|
runner_registry=SimpleNamespace(list_runners=AsyncMock(side_effect=ValueError('probe boot failure'))),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(boot, 'make_app', AsyncMock(return_value=app))
|
||||||
|
try:
|
||||||
|
with pytest.raises(ValueError, match='probe boot failure'):
|
||||||
|
await fixtures._boot_local_agent_app(tmp_path)
|
||||||
|
app.shutdown.assert_awaited_once()
|
||||||
|
assert not any(task.get_name() == 'local-agent-e2e-app' for task in asyncio.all_tasks())
|
||||||
|
finally:
|
||||||
|
tasks = [task for task in asyncio.all_tasks() if task.get_name() == 'local-agent-e2e-app']
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
@@ -2119,7 +2119,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=eac2c60509534512f9a373cd0c801e75985e0612" },
|
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" },
|
||||||
{ name = "langchain", specifier = ">=1.3.9" },
|
{ name = "langchain", specifier = ">=1.3.9" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2186,7 +2186,7 @@ dev = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=eac2c60509534512f9a373cd0c801e75985e0612#eac2c60509534512f9a373cd0c801e75985e0612" }
|
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=92a9e03fa9c791f4ed30cc3f5f0602c13b800d28#92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ test.describe('frontend CRUD smoke flows', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.locator('[data-processor-kind="pipeline"]'),
|
page.locator('[data-processor-kind="pipeline"]'),
|
||||||
).toContainText(
|
).toContainText(
|
||||||
'流水线即为经典的“收到消息、请求AI、回复用户”流程,并辅以常用的配置功能。仅处理消息事件,适合步骤明确、需要稳定控制处理过程的场景。',
|
'流水线按“接收消息、调用 AI、回复用户”的固定流程运行,可配置知识库和插件扩展。仅处理消息事件,适合步骤明确、需要控制处理过程的场景。',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1034,7 +1034,7 @@ test.describe('agent runner resource selectors', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByText('No Runner extension is installed yet.'),
|
page.getByText('No Runner extension is installed yet.'),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByText('Runner Marketplace')).toBeVisible();
|
await expect(page.getByText('Runner plugins in Marketplace')).toBeVisible();
|
||||||
const selectorPopup = page.locator('[data-slot="select-content"]');
|
const selectorPopup = page.locator('[data-slot="select-content"]');
|
||||||
await expect(
|
await expect(
|
||||||
selectorPopup.getByText('Runner used by the grouped selector test.', {
|
selectorPopup.getByText('Runner used by the grouped selector test.', {
|
||||||
@@ -1055,20 +1055,35 @@ test.describe('agent runner resource selectors', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole('option')
|
.getByRole('button', { name: 'Install Marketplace Runner', exact: true })
|
||||||
.filter({ hasText: 'Marketplace Runner' })
|
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await expect.poll(() => installRequests).toBe(1);
|
await expect.poll(() => installRequests).toBe(1);
|
||||||
await expect.poll(() => taskPolls).toBeGreaterThan(0);
|
await expect.poll(() => taskPolls).toBeGreaterThan(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('option', {
|
||||||
|
name: 'Marketplace Runner Runner used by the grouped selector test.',
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByRole('option', {
|
||||||
|
name: 'Marketplace Runner Runner used by the grouped selector test.',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
await expect(runnerSelect).toContainText('Marketplace Runner');
|
await expect(runnerSelect).toContainText('Marketplace Runner');
|
||||||
|
|
||||||
await runnerSelect.click();
|
await runnerSelect.click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('option').filter({ hasText: runnerId }),
|
page.getByRole('option', {
|
||||||
|
name: 'Marketplace Runner Runner used by the grouped selector test.',
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('Runner used by the grouped selector test.', {
|
page.getByRole('button', {
|
||||||
|
name: 'Install Marketplace Runner',
|
||||||
exact: true,
|
exact: true,
|
||||||
}),
|
}),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
@@ -1130,7 +1145,7 @@ test.describe('agent runner resource selectors', () => {
|
|||||||
const runnerSelect = page.getByRole('combobox', { name: 'Runner' });
|
const runnerSelect = page.getByRole('combobox', { name: 'Runner' });
|
||||||
await runnerSelect.click();
|
await runnerSelect.click();
|
||||||
await expect(page.getByText('Installed Runners')).toBeVisible();
|
await expect(page.getByText('Installed Runners')).toBeVisible();
|
||||||
await expect(page.getByText('Runner Marketplace')).toBeVisible();
|
await expect(page.getByText('Runner plugins in Marketplace')).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.locator('[data-slot="select-content"]')
|
.locator('[data-slot="select-content"]')
|
||||||
@@ -1139,9 +1154,10 @@ test.describe('agent runner resource selectors', () => {
|
|||||||
}),
|
}),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.getByRole('button', {
|
||||||
.getByRole('option')
|
name: 'Install Pipeline Marketplace Runner',
|
||||||
.filter({ hasText: 'Pipeline Marketplace Runner' }),
|
exact: true,
|
||||||
|
}),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1214,8 +1230,11 @@ test.describe('agent and pipeline save concurrency', () => {
|
|||||||
|
|
||||||
await page.goto('/home/agents?id=agent-save-race');
|
await page.goto('/home/agents?id=agent-save-race');
|
||||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||||
await page.getByRole('tab', { name: 'Bindable Event Range' }).click();
|
await page.getByRole('tab', { name: 'Events & tools' }).click();
|
||||||
const eventPatterns = page.getByLabel('Event Range');
|
const eventPatterns = page.getByRole('button', {
|
||||||
|
name: 'Add event',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
await expect(eventPatterns).toBeVisible();
|
await expect(eventPatterns).toBeVisible();
|
||||||
|
|
||||||
await eventPatterns.click();
|
await eventPatterns.click();
|
||||||
@@ -1231,8 +1250,8 @@ test.describe('agent and pipeline save concurrency', () => {
|
|||||||
await eventPatterns.click();
|
await eventPatterns.click();
|
||||||
await page.getByRole('option').filter({ hasText: 'group.*' }).click();
|
await page.getByRole('option').filter({ hasText: 'group.*' }).click();
|
||||||
await page
|
await page
|
||||||
.getByRole('option')
|
.getByRole('button', { name: 'Remove event', exact: true })
|
||||||
.filter({ hasText: 'message.received' })
|
.first()
|
||||||
.click();
|
.click();
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await forceFormSubmit(page, '#agent-form');
|
await forceFormSubmit(page, '#agent-form');
|
||||||
|
|||||||
@@ -725,10 +725,12 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
|||||||
return fulfillJson(route, { agents: state.pipelines });
|
return fulfillJson(route, { agents: state.pipelines });
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentDebugMatch = path.match(/^\/api\/v1\/agents\/([^/]+)\/debug$/);
|
const agentDebugMatch = path.match(
|
||||||
|
/^\/api\/v1\/agents\/([^/]+)\/debug(?:\/stream)?$/,
|
||||||
|
);
|
||||||
if (agentDebugMatch) {
|
if (agentDebugMatch) {
|
||||||
const payload = parseJsonBody(route);
|
const payload = parseJsonBody(route);
|
||||||
return fulfillJson(route, {
|
const result = {
|
||||||
event_id: nextId(state, 'event'),
|
event_id: nextId(state, 'event'),
|
||||||
event_type: String(payload.event_type || 'message.received'),
|
event_type: String(payload.event_type || 'message.received'),
|
||||||
conversation_id: String(payload.conversation_id || 'debug-session'),
|
conversation_id: String(payload.conversation_id || 'debug-session'),
|
||||||
@@ -740,7 +742,15 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
|||||||
text: 'Mock Agent response',
|
text: 'Mock Agent response',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
};
|
||||||
|
if (path.endsWith('/stream')) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/x-ndjson',
|
||||||
|
body: JSON.stringify({ kind: 'completed', data: result }) + '\n',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fulfillJson(route, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentMatch = path.match(/^\/api\/v1\/agents\/([^/]+)$/);
|
const agentMatch = path.match(/^\/api\/v1\/agents\/([^/]+)$/);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const appRoutes = [
|
|||||||
{
|
{
|
||||||
path: '/home/agents',
|
path: '/home/agents',
|
||||||
heading: 'Processors',
|
heading: 'Processors',
|
||||||
bodyText: 'Select an Agent or Pipeline from the sidebar',
|
bodyText: 'Select a processor from the sidebar',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home/extensions',
|
path: '/home/extensions',
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ test.describe('processor detail workbench', () => {
|
|||||||
|
|
||||||
const appShell = page.locator('[class*="group/sidebar-wrapper"]');
|
const appShell = page.locator('[class*="group/sidebar-wrapper"]');
|
||||||
const sidebarInset = page.locator('[data-slot="sidebar-inset"]');
|
const sidebarInset = page.locator('[data-slot="sidebar-inset"]');
|
||||||
await expect(appShell).toHaveCSS('overflow', 'clip');
|
await expect(appShell).toHaveCSS('overflow', 'hidden');
|
||||||
await expect(sidebarInset).toHaveCSS('overflow', 'clip');
|
await expect(sidebarInset).toHaveCSS('overflow', 'hidden');
|
||||||
await appShell.evaluate((element) => {
|
await appShell.evaluate((element) => {
|
||||||
element.scrollTop = 300;
|
element.scrollTop = 300;
|
||||||
});
|
});
|
||||||
@@ -79,9 +79,7 @@ test.describe('processor detail workbench', () => {
|
|||||||
const flow = configPanel.getByRole('tablist');
|
const flow = configPanel.getByRole('tablist');
|
||||||
await expect(flow.getByRole('tab').nth(0)).toContainText('Runner');
|
await expect(flow.getByRole('tab').nth(0)).toContainText('Runner');
|
||||||
await expect(flow.getByRole('tab').nth(1)).toContainText('Local Agent');
|
await expect(flow.getByRole('tab').nth(1)).toContainText('Local Agent');
|
||||||
await expect(flow.getByRole('tab').nth(2)).toContainText(
|
await expect(flow.getByRole('tab').nth(2)).toContainText('Events & tools');
|
||||||
'Bindable Event Range',
|
|
||||||
);
|
|
||||||
await expect(flow.getByRole('tab')).toHaveCount(3);
|
await expect(flow.getByRole('tab')).toHaveCount(3);
|
||||||
await expect(flow.getByText('Management')).toHaveCount(0);
|
await expect(flow.getByText('Management')).toHaveCount(0);
|
||||||
|
|
||||||
@@ -126,19 +124,32 @@ test.describe('processor detail workbench', () => {
|
|||||||
|
|
||||||
await flow.getByRole('tab').nth(2).click();
|
await flow.getByRole('tab').nth(2).click();
|
||||||
await expect(
|
await expect(
|
||||||
configPanel.getByText('Bindable Event Range', { exact: true }).last(),
|
configPanel.getByText('Events & tools', { exact: true }).last(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
const eventPicker = configPanel.getByRole('combobox', {
|
const eventPicker = configPanel.getByRole('button', {
|
||||||
name: 'Event Range',
|
name: 'Add event',
|
||||||
|
exact: true,
|
||||||
});
|
});
|
||||||
await expect(eventPicker).toBeVisible();
|
await expect(eventPicker).toBeVisible();
|
||||||
await expect(configPanel.getByRole('textbox')).toHaveCount(0);
|
await expect(configPanel.getByRole('textbox')).toHaveCount(1);
|
||||||
|
await expect(
|
||||||
|
configPanel.getByRole('textbox', { name: 'Search tools…' }),
|
||||||
|
).toBeVisible();
|
||||||
await eventPicker.click();
|
await eventPicker.click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('option').filter({ hasText: 'message.received' }),
|
page.getByRole('option').filter({ hasText: 'message.received' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByRole('group', { name: 'Messages' })).toHaveCount(1);
|
await expect(
|
||||||
await expect(page.getByRole('group', { name: 'Groups' })).toHaveCount(1);
|
page.getByRole('option').filter({ hasText: 'group.*' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.locator('[cmdk-group-heading]')
|
||||||
|
.getByText('Messages', { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator('[cmdk-group-heading]').getByText('Groups', { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
await page
|
await page
|
||||||
.getByRole('option')
|
.getByRole('option')
|
||||||
.filter({ hasText: 'message.*' })
|
.filter({ hasText: 'message.*' })
|
||||||
@@ -189,7 +200,7 @@ test.describe('processor detail workbench', () => {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
request.method() === 'POST' &&
|
request.method() === 'POST' &&
|
||||||
path === '/api/v1/agents/agent-workbench/debug'
|
path === '/api/v1/agents/agent-workbench/debug/stream'
|
||||||
) {
|
) {
|
||||||
requests.push('debug');
|
requests.push('debug');
|
||||||
}
|
}
|
||||||
@@ -237,15 +248,17 @@ test.describe('processor detail workbench', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await installLangBotApiMocks(page, { authenticated: true });
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
await page.route(
|
await page.route(
|
||||||
'**/api/v1/agents/agent-workbench/debug',
|
'**/api/v1/agents/agent-workbench/debug/stream',
|
||||||
async (route) => {
|
async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 422,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/x-ndjson',
|
||||||
body: JSON.stringify({
|
body:
|
||||||
code: 'dify.config_invalid',
|
JSON.stringify({
|
||||||
msg: 'api-key is required',
|
kind: 'error',
|
||||||
}),
|
code: 'dify.config_invalid',
|
||||||
|
msg: 'api-key is required',
|
||||||
|
}) + '\n',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -454,8 +467,8 @@ test.describe('processor detail workbench', () => {
|
|||||||
|
|
||||||
const appShell = page.locator('[class*="group/sidebar-wrapper"]');
|
const appShell = page.locator('[class*="group/sidebar-wrapper"]');
|
||||||
const sidebarInset = page.locator('[data-slot="sidebar-inset"]');
|
const sidebarInset = page.locator('[data-slot="sidebar-inset"]');
|
||||||
await expect(appShell).toHaveCSS('overflow', 'clip');
|
await expect(appShell).toHaveCSS('overflow', 'hidden');
|
||||||
await expect(sidebarInset).toHaveCSS('overflow', 'clip');
|
await expect(sidebarInset).toHaveCSS('overflow', 'hidden');
|
||||||
await expect
|
await expect
|
||||||
.poll(() => appShell.evaluate((element) => element.scrollTop))
|
.poll(() => appShell.evaluate((element) => element.scrollTop))
|
||||||
.toBe(0);
|
.toBe(0);
|
||||||
|
|||||||
@@ -2,69 +2,137 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import ts from 'typescript';
|
import ts from 'typescript';
|
||||||
const source = fs.readFileSync(new URL('../../src/app/home/agents/components/debug-execution.ts', import.meta.url), 'utf8');
|
const source = fs.readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../src/app/home/agents/components/debug-execution.ts',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
const module = { exports: {} };
|
const module = { exports: {} };
|
||||||
new Function('exports', ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText)(module.exports);
|
new Function(
|
||||||
|
'exports',
|
||||||
|
ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
target: ts.ScriptTarget.ES2022,
|
||||||
|
},
|
||||||
|
}).outputText,
|
||||||
|
)(module.exports);
|
||||||
const { executionSteps } = module.exports;
|
const { executionSteps } = module.exports;
|
||||||
const event = (type, data) => ({type, data});
|
const event = (type, data) => ({ type, data });
|
||||||
test('separates streamed thinking and text, replaces final snapshot without duplication', () => {
|
test('separates streamed thinking and text, replaces final snapshot without duplication', () => {
|
||||||
assert.deepEqual(executionSteps([
|
assert.deepEqual(
|
||||||
event('message.delta', {chunk: {content:'<think>plan'}}),
|
executionSteps([
|
||||||
event('message.delta', {chunk: {content:'</think>hello'}}),
|
event('message.delta', { chunk: { content: '<think>plan' } }),
|
||||||
event('message.completed', {message: {content:'<think>plan</think>hello'}}),
|
event('message.delta', { chunk: { content: '</think>hello' } }),
|
||||||
event('run.completed', {message: {content:'hello'}}),
|
event('message.completed', {
|
||||||
]), [{kind:'message', text:'hello', reasoning:'plan'}]);
|
message: { content: '<think>plan</think>hello' },
|
||||||
|
}),
|
||||||
|
event('run.completed', { message: { content: 'hello' } }),
|
||||||
|
]),
|
||||||
|
[{ kind: 'message', text: 'hello', reasoning: 'plan' }],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
test('retains structured reasoning and tool parameters/results in order', () => {
|
test('retains structured reasoning and tool parameters/results in order', () => {
|
||||||
const steps = executionSteps([
|
const steps = executionSteps([
|
||||||
event('message.delta', {chunk: {provider_specific_fields:{reasoning_content:'plan'}}}),
|
event('message.delta', {
|
||||||
event('message.completed', {message: {content:''}}),
|
chunk: { provider_specific_fields: { reasoning_content: 'plan' } },
|
||||||
event('tool.call.started', {tool_call_id:'1',tool_name:'exec', parameters:{command:'echo hi'}}),
|
}),
|
||||||
event('tool.call.started', {tool_call_id:'2',tool_name:'exec', parameters:{command:'bad'}}),
|
event('message.completed', { message: { content: '' } }),
|
||||||
event('tool.call.completed', {tool_call_id:'2',tool_name:'exec', error:'failed'}),
|
event('tool.call.started', {
|
||||||
event('tool.call.completed', {tool_call_id:'1',tool_name:'exec', result:{stdout:'hi'}}),
|
tool_call_id: '1',
|
||||||
|
tool_name: 'exec',
|
||||||
|
parameters: { command: 'echo hi' },
|
||||||
|
}),
|
||||||
|
event('tool.call.started', {
|
||||||
|
tool_call_id: '2',
|
||||||
|
tool_name: 'exec',
|
||||||
|
parameters: { command: 'bad' },
|
||||||
|
}),
|
||||||
|
event('tool.call.completed', {
|
||||||
|
tool_call_id: '2',
|
||||||
|
tool_name: 'exec',
|
||||||
|
error: 'failed',
|
||||||
|
}),
|
||||||
|
event('tool.call.completed', {
|
||||||
|
tool_call_id: '1',
|
||||||
|
tool_name: 'exec',
|
||||||
|
result: { stdout: 'hi' },
|
||||||
|
}),
|
||||||
event('run.failed', {}),
|
event('run.failed', {}),
|
||||||
]);
|
]);
|
||||||
assert.equal(steps[0].reasoning, 'plan');
|
assert.equal(steps[0].reasoning, 'plan');
|
||||||
assert.equal(steps[1].parameters.command, 'echo hi');
|
assert.equal(steps[1].parameters.command, 'echo hi');
|
||||||
assert.deepEqual(steps[1].result, {stdout:'hi'});
|
assert.deepEqual(steps[1].result, { stdout: 'hi' });
|
||||||
assert.equal(steps[2].status, 'failed');
|
assert.equal(steps[2].status, 'failed');
|
||||||
assert.equal(steps[2].error, 'failed');
|
assert.equal(steps[2].error, 'failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('replaces LocalAgent cumulative snapshots instead of repeating text', () => {
|
test('replaces LocalAgent cumulative snapshots instead of repeating text', () => {
|
||||||
assert.deepEqual(executionSteps([
|
assert.deepEqual(
|
||||||
event('message.delta', {chunk: {content:'hello', msg_sequence:1}}),
|
executionSteps([
|
||||||
event('message.delta', {chunk: {content:'hello world', msg_sequence:2}}),
|
event('message.delta', { chunk: { content: 'hello', msg_sequence: 1 } }),
|
||||||
event('message.delta', {chunk: {content:'hello world', msg_sequence:3, is_final:true}}),
|
event('message.delta', {
|
||||||
]), [{kind:'message', text:'hello world', reasoning:''}]);
|
chunk: { content: 'hello world', msg_sequence: 2 },
|
||||||
|
}),
|
||||||
|
event('message.delta', {
|
||||||
|
chunk: { content: 'hello world', msg_sequence: 3, is_final: true },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
[{ kind: 'message', text: 'hello world', reasoning: '' }],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows failed tool results even when the call transport completed', () => {
|
test('shows failed tool results even when the call transport completed', () => {
|
||||||
const steps = executionSteps([
|
const steps = executionSteps([
|
||||||
event('tool.call.started', {tool_call_id:'exit7', tool_name:'exec', parameters:{command:'exit 7'}}),
|
event('tool.call.started', {
|
||||||
event('tool.call.completed', {tool_call_id:'exit7', tool_name:'exec', result:{ok:false, exit_code:7, stderr:'expected'}}),
|
tool_call_id: 'exit7',
|
||||||
|
tool_name: 'exec',
|
||||||
|
parameters: { command: 'exit 7' },
|
||||||
|
}),
|
||||||
|
event('tool.call.completed', {
|
||||||
|
tool_call_id: 'exit7',
|
||||||
|
tool_name: 'exec',
|
||||||
|
result: { ok: false, exit_code: 7, stderr: 'expected' },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
assert.equal(steps[0].status,'failed');
|
assert.equal(steps[0].status, 'failed');
|
||||||
assert.equal(steps[0].result.exit_code,7);
|
assert.equal(steps[0].result.exit_code, 7);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not repeat prior thinking across LocalAgent tool turns', () => {
|
test('does not repeat prior thinking across LocalAgent tool turns', () => {
|
||||||
const prefix = '<think>first thought</think>';
|
const prefix = '<think>first thought</think>';
|
||||||
const steps = executionSteps([
|
const steps = executionSteps([
|
||||||
event('message.delta', {chunk:{content:prefix, msg_sequence:1}}),
|
event('message.delta', { chunk: { content: prefix, msg_sequence: 1 } }),
|
||||||
event('tool.call.started', {tool_call_id:'w',tool_name:'write',parameters:{path:'/workspace/a'}}),
|
event('tool.call.started', {
|
||||||
event('tool.call.completed', {tool_call_id:'w',tool_name:'write',result:{ok:true}}),
|
tool_call_id: 'w',
|
||||||
event('message.delta', {chunk:{content:prefix+'now read',msg_sequence:1}}),
|
tool_name: 'write',
|
||||||
event('tool.call.started', {tool_call_id:'r',tool_name:'read'}),
|
parameters: { path: '/workspace/a' },
|
||||||
event('tool.call.completed', {tool_call_id:'r',tool_name:'read',result:{ok:true}}),
|
}),
|
||||||
event('message.delta', {chunk:{content:prefix+'now read'+'done',msg_sequence:1}}),
|
event('tool.call.completed', {
|
||||||
event('message.completed', {message:{content:'done'}}),
|
tool_call_id: 'w',
|
||||||
|
tool_name: 'write',
|
||||||
|
result: { ok: true },
|
||||||
|
}),
|
||||||
|
event('message.delta', {
|
||||||
|
chunk: { content: prefix + 'now read', msg_sequence: 1 },
|
||||||
|
}),
|
||||||
|
event('tool.call.started', { tool_call_id: 'r', tool_name: 'read' }),
|
||||||
|
event('tool.call.completed', {
|
||||||
|
tool_call_id: 'r',
|
||||||
|
tool_name: 'read',
|
||||||
|
result: { ok: true },
|
||||||
|
}),
|
||||||
|
event('message.delta', {
|
||||||
|
chunk: { content: prefix + 'now read' + 'done', msg_sequence: 1 },
|
||||||
|
}),
|
||||||
|
event('message.completed', { message: { content: 'done' } }),
|
||||||
]);
|
]);
|
||||||
const messages = steps.filter(s=>s.kind==='message');
|
const messages = steps.filter((s) => s.kind === 'message');
|
||||||
assert.deepEqual(messages, [
|
assert.deepEqual(messages, [
|
||||||
{kind:'message',text:'',reasoning:'first thought'},
|
{ kind: 'message', text: '', reasoning: 'first thought' },
|
||||||
{kind:'message',text:'now read',reasoning:''},
|
{ kind: 'message', text: 'now read', reasoning: '' },
|
||||||
{kind:'message',text:'done',reasoning:''},
|
{ kind: 'message', text: 'done', reasoning: '' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user