mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-12 13:50:59 +00:00
chore(agent-runner): remove split-out incidental fixes
This commit is contained in:
@@ -12,7 +12,7 @@ class MCPRouterGroup(group.RouterGroup):
|
|||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
"""List MCP servers or create a new MCP server."""
|
"""获取MCP服务器列表"""
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ class MCPRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(server_name: str) -> str:
|
async def _(server_name: str) -> str:
|
||||||
"""Get, update, or delete an MCP server configuration."""
|
"""获取、更新或删除MCP服务器配置"""
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
server_name = unquote(server_name)
|
server_name = unquote(server_name)
|
||||||
@@ -59,7 +59,7 @@ class MCPRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(server_name: str) -> str:
|
async def _(server_name: str) -> str:
|
||||||
"""Test an MCP server connection."""
|
"""测试MCP服务器连接"""
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
server_name = unquote(server_name)
|
server_name = unquote(server_name)
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ class MCPService:
|
|||||||
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
|
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
|
||||||
|
|
||||||
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
|
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
|
||||||
"""Test an MCP server connection and return the task ID."""
|
"""测试 MCP 服务器连接并返回任务 ID"""
|
||||||
|
|
||||||
runtime_mcp_session: RuntimeMCPSession | None = None
|
runtime_mcp_session: RuntimeMCPSession | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
"""ensure mcp_servers readme column exists
|
|
||||||
|
|
||||||
Revision ID: 8f24d6c9b1a0
|
|
||||||
Revises: 7b2c1d9e4f30
|
|
||||||
Create Date: 2026-06-13
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
revision = '8f24d6c9b1a0'
|
|
||||||
down_revision = '7b2c1d9e4f30'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def _table_exists(table_name: str) -> bool:
|
|
||||||
return table_name in sa.inspect(op.get_bind()).get_table_names()
|
|
||||||
|
|
||||||
|
|
||||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
|
||||||
return column_name in {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
if not _table_exists('mcp_servers') or _column_exists('mcp_servers', 'readme'):
|
|
||||||
return
|
|
||||||
with op.batch_alter_table('mcp_servers', schema=None) as batch_op:
|
|
||||||
batch_op.add_column(sa.Column('readme', sa.Text(), nullable=False, server_default=''))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
if not _table_exists('mcp_servers') or not _column_exists('mcp_servers', 'readme'):
|
|
||||||
return
|
|
||||||
with op.batch_alter_table('mcp_servers', schema=None) as batch_op:
|
|
||||||
batch_op.drop_column('readme')
|
|
||||||
@@ -206,11 +206,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
self,
|
self,
|
||||||
file_bytes: bytes,
|
file_bytes: bytes,
|
||||||
task_context: taskmgr.TaskContext | None,
|
task_context: taskmgr.TaskContext | None,
|
||||||
) -> tuple[str | None, str | None, str | None]:
|
) -> tuple[str | None, str | None]:
|
||||||
"""Extract plugin identity and dependency metadata from a plugin package."""
|
"""Extract plugin identity and dependency metadata from a plugin package."""
|
||||||
plugin_author = None
|
plugin_author = None
|
||||||
plugin_name = None
|
plugin_name = None
|
||||||
plugin_version = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||||
@@ -219,7 +218,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
metadata = manifest.get('metadata', {})
|
metadata = manifest.get('metadata', {})
|
||||||
plugin_author = metadata.get('author')
|
plugin_author = metadata.get('author')
|
||||||
plugin_name = metadata.get('name')
|
plugin_name = metadata.get('name')
|
||||||
plugin_version = metadata.get('version')
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -238,7 +236,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return plugin_author, plugin_name, plugin_version
|
return plugin_author, plugin_name
|
||||||
|
|
||||||
async def _install_mcp_from_marketplace(
|
async def _install_mcp_from_marketplace(
|
||||||
self,
|
self,
|
||||||
@@ -377,10 +375,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
install_source: PluginInstallSource,
|
install_source: PluginInstallSource,
|
||||||
install_info: dict[str, Any],
|
install_info: dict[str, Any],
|
||||||
task_context: taskmgr.TaskContext | None = None,
|
task_context: taskmgr.TaskContext | None = None,
|
||||||
):
|
):
|
||||||
plugin_author = install_info.get('plugin_author')
|
plugin_author = install_info.get('plugin_author')
|
||||||
plugin_name = install_info.get('plugin_name')
|
plugin_name = install_info.get('plugin_name')
|
||||||
plugin_file_transferred = False
|
|
||||||
|
|
||||||
if install_source == PluginInstallSource.MARKETPLACE:
|
if install_source == PluginInstallSource.MARKETPLACE:
|
||||||
# Handle marketplace plugin/mcp/skill installation
|
# Handle marketplace plugin/mcp/skill installation
|
||||||
@@ -475,18 +472,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
)
|
)
|
||||||
|
|
||||||
file_bytes = download_resp.content
|
file_bytes = download_resp.content
|
||||||
plugin_author, plugin_name, plugin_version = self._inspect_plugin_package(
|
plugin_author, plugin_name = self._inspect_plugin_package(
|
||||||
file_bytes,
|
file_bytes,
|
||||||
task_context,
|
task_context,
|
||||||
)
|
)
|
||||||
if task_context is not None and plugin_author and plugin_name:
|
if task_context is not None and plugin_author and plugin_name:
|
||||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
if task_context is not None and plugin_version:
|
|
||||||
task_context.metadata['plugin_version'] = plugin_version
|
|
||||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||||
install_info['plugin_file_key'] = file_key
|
install_info['plugin_file_key'] = file_key
|
||||||
install_source = PluginInstallSource.LOCAL
|
|
||||||
plugin_file_transferred = True
|
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||||
# Continue to install via runtime
|
# Continue to install via runtime
|
||||||
else:
|
else:
|
||||||
@@ -502,14 +495,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
mcp_resp.raise_for_status()
|
mcp_resp.raise_for_status()
|
||||||
raise Exception(f'Failed to get MCP {plugin_author}/{plugin_name}')
|
raise Exception(f'Failed to get MCP {plugin_author}/{plugin_name}')
|
||||||
|
|
||||||
if install_source == PluginInstallSource.LOCAL and not plugin_file_transferred:
|
if install_source == PluginInstallSource.LOCAL:
|
||||||
# transfer file before install
|
# transfer file before install
|
||||||
file_bytes = install_info['plugin_file']
|
file_bytes = install_info['plugin_file']
|
||||||
plugin_author, plugin_name, plugin_version = self._inspect_plugin_package(file_bytes, task_context)
|
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||||
if task_context is not None and plugin_author and plugin_name:
|
if task_context is not None and plugin_author and plugin_name:
|
||||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
if task_context is not None and plugin_version:
|
|
||||||
task_context.metadata['plugin_version'] = plugin_version
|
|
||||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||||
install_info['plugin_file_key'] = file_key
|
install_info['plugin_file_key'] = file_key
|
||||||
del install_info['plugin_file']
|
del install_info['plugin_file']
|
||||||
@@ -546,11 +537,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
||||||
|
|
||||||
file_bytes = b''.join(chunks)
|
file_bytes = b''.join(chunks)
|
||||||
plugin_author, plugin_name, plugin_version = self._inspect_plugin_package(file_bytes, task_context)
|
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||||
if task_context is not None and plugin_author and plugin_name:
|
if task_context is not None and plugin_author and plugin_name:
|
||||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
if task_context is not None and plugin_version:
|
|
||||||
task_context.metadata['plugin_version'] = plugin_version
|
|
||||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||||
install_info['plugin_file_key'] = file_key
|
install_info['plugin_file_key'] = file_key
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
@@ -85,19 +84,8 @@ class ModelManager:
|
|||||||
self.ap.logger.info('LangBot Space Models service is disabled, skipping sync.')
|
self.ap.logger.info('LangBot Space Models service is disabled, skipping sync.')
|
||||||
return
|
return
|
||||||
|
|
||||||
sync_timeout = space_config.get('models_sync_timeout')
|
|
||||||
try:
|
try:
|
||||||
if sync_timeout:
|
await self.sync_new_models_from_space()
|
||||||
await asyncio.wait_for(
|
|
||||||
self.sync_new_models_from_space(),
|
|
||||||
timeout=float(sync_timeout),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await self.sync_new_models_from_space()
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'LangBot Space model sync timed out after {sync_timeout}s, skipping startup sync.'
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.warning('Failed to sync new models from LangBot Space, model list may not be updated.')
|
self.ap.logger.warning('Failed to sync new models from LangBot Space, model list may not be updated.')
|
||||||
self.ap.logger.warning(f' - Error: {e}')
|
self.ap.logger.warning(f' - Error: {e}')
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from alembic.script import ScriptDirectory
|
from alembic.script import ScriptDirectory
|
||||||
from sqlalchemy import inspect, text
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
@@ -148,42 +147,6 @@ class TestSQLiteMigrationUpgrade:
|
|||||||
rev2 = await get_alembic_current(sqlite_engine)
|
rev2 = await get_alembic_current(sqlite_engine)
|
||||||
assert rev2 == rev1, f"Expected {rev1}, got {rev2}"
|
assert rev2 == rev1, f"Expected {rev1}, got {rev2}"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_upgrade_repairs_head_stamped_mcp_readme_column(self, sqlite_engine):
|
|
||||||
"""
|
|
||||||
A database may already be stamped at the previous head while missing a
|
|
||||||
column added by an earlier guarded migration. Upgrade should still
|
|
||||||
repair mcp_servers.readme so startup ORM queries do not fail.
|
|
||||||
"""
|
|
||||||
async with sqlite_engine.begin() as conn:
|
|
||||||
await conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
CREATE TABLE mcp_servers (
|
|
||||||
uuid VARCHAR(255) NOT NULL PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
enable BOOLEAN NOT NULL,
|
|
||||||
mode VARCHAR(255) NOT NULL,
|
|
||||||
extra_args JSON NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await run_alembic_stamp(sqlite_engine, '7b2c1d9e4f30')
|
|
||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
|
||||||
|
|
||||||
async with sqlite_engine.connect() as conn:
|
|
||||||
columns = await conn.run_sync(
|
|
||||||
lambda sync_conn: {column['name'] for column in inspect(sync_conn).get_columns('mcp_servers')}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert 'readme' in columns
|
|
||||||
assert await get_alembic_current(sqlite_engine) == alembic_head_revision()
|
|
||||||
|
|
||||||
|
|
||||||
class TestSQLiteMigrationFreshDatabase:
|
class TestSQLiteMigrationFreshDatabase:
|
||||||
"""Tests for fresh database workflow."""
|
"""Tests for fresh database workflow."""
|
||||||
|
|
||||||
|
|||||||
@@ -49,30 +49,6 @@ class TestExtractDepsMetadata:
|
|||||||
assert 'flask' in task_context.metadata['deps_list']
|
assert 'flask' in task_context.metadata['deps_list']
|
||||||
assert 'numpy' in task_context.metadata['deps_list']
|
assert 'numpy' in task_context.metadata['deps_list']
|
||||||
|
|
||||||
def test_extract_plugin_identity_includes_version(self):
|
|
||||||
"""Extract plugin identity and version from manifest.yaml."""
|
|
||||||
connector = self._create_connector()
|
|
||||||
|
|
||||||
zip_buffer = io.BytesIO()
|
|
||||||
with zipfile.ZipFile(zip_buffer, 'w') as zf:
|
|
||||||
zf.writestr(
|
|
||||||
'manifest.yaml',
|
|
||||||
'\n'.join(
|
|
||||||
[
|
|
||||||
'metadata:',
|
|
||||||
' author: langbot-team',
|
|
||||||
' name: LangRAG',
|
|
||||||
' version: 0.1.8',
|
|
||||||
]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert connector._inspect_plugin_package(zip_buffer.getvalue(), None) == (
|
|
||||||
'langbot-team',
|
|
||||||
'LangRAG',
|
|
||||||
'0.1.8',
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_extract_deps_empty_requirements(self):
|
def test_extract_deps_empty_requirements(self):
|
||||||
"""Handle empty requirements.txt."""
|
"""Handle empty requirements.txt."""
|
||||||
connector = self._create_connector()
|
connector = self._create_connector()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
@@ -115,28 +114,6 @@ def test_token_manager_next_token_ignores_empty_token_list():
|
|||||||
assert token_mgr.using_token_index == 0
|
assert token_mgr.using_token_index == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_model_manager_initialize_skips_space_sync_after_timeout():
|
|
||||||
ap = SimpleNamespace()
|
|
||||||
ap.discover = SimpleNamespace(get_components_by_kind=Mock(return_value=[]))
|
|
||||||
ap.instance_config = SimpleNamespace(data={'space': {'models_sync_timeout': 0.01}})
|
|
||||||
ap.logger = Mock()
|
|
||||||
|
|
||||||
mgr = ModelManager(ap)
|
|
||||||
mgr.load_models_from_db = AsyncMock()
|
|
||||||
|
|
||||||
async def slow_sync():
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
mgr.sync_new_models_from_space = AsyncMock(side_effect=slow_sync)
|
|
||||||
|
|
||||||
await mgr.initialize()
|
|
||||||
|
|
||||||
mgr.load_models_from_db.assert_awaited_once()
|
|
||||||
mgr.sync_new_models_from_space.assert_awaited_once()
|
|
||||||
ap.logger.warning.assert_any_call('LangBot Space model sync timed out after 0.01s, skipping startup sync.')
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
|
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
|
||||||
from langbot.pkg.api.http.service.model import LLMModelsService
|
from langbot.pkg.api.http.service.model import LLMModelsService
|
||||||
|
|||||||
@@ -310,7 +310,6 @@ function SingleSelectField({
|
|||||||
{options.map((opt) => (
|
{options.map((opt) => (
|
||||||
<div key={opt.id}>
|
<div key={opt.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(opt.id)}
|
onClick={() => onChange(opt.id)}
|
||||||
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors ${
|
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors ${
|
||||||
value === opt.id
|
value === opt.id
|
||||||
@@ -362,16 +361,8 @@ function MultiSelectField({
|
|||||||
const selected = value.includes(opt.id);
|
const selected = value.includes(opt.id);
|
||||||
return (
|
return (
|
||||||
<div key={opt.id}>
|
<div key={opt.id}>
|
||||||
<div
|
<button
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => toggle(opt.id)}
|
onClick={() => toggle(opt.id)}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
toggle(opt.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
|
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
|
||||||
selected
|
selected
|
||||||
? 'border-primary bg-primary/5 text-primary'
|
? 'border-primary bg-primary/5 text-primary'
|
||||||
@@ -380,7 +371,7 @@ function MultiSelectField({
|
|||||||
>
|
>
|
||||||
<Checkbox checked={selected} className="pointer-events-none" />
|
<Checkbox checked={selected} className="pointer-events-none" />
|
||||||
{getI18nText(opt.label)}
|
{getI18nText(opt.label)}
|
||||||
</div>
|
</button>
|
||||||
{opt.has_input && selected && (
|
{opt.has_input && selected && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
Reference in New Issue
Block a user