feat: make agent runner config schema driven

This commit is contained in:
huanghuoguoguo
2026-05-19 12:20:28 +08:00
parent f4f91c43b5
commit be8d30894a
20 changed files with 901 additions and 236 deletions

View File

@@ -18,6 +18,7 @@ import langbot_plugin.api.entities.builtin.provider.session as provider_session
# Counter for generating unique IDs
_query_counter = 0
DEFAULT_RUNNER_ID = "plugin:langbot/local-agent/default"
def _next_query_id() -> int:
@@ -163,10 +164,12 @@ def _base_query(
"bot_uuid": "test-bot-uuid",
"pipeline_config": {
"ai": {
"runner": {"runner": "local-agent"},
"local-agent": {
"model": {"primary": "test-model-uuid", "fallbacks": []},
"prompt": "test-prompt",
"runner": {"id": DEFAULT_RUNNER_ID},
"runner_config": {
DEFAULT_RUNNER_ID: {
"model": {"primary": "test-model-uuid", "fallbacks": []},
"prompt": [{"role": "system", "content": "test-prompt"}],
},
},
},
"output": {"misc": {"at-sender": False, "quote-origin": False}},
@@ -469,4 +472,4 @@ def at_all_query(
sender_id=sender_id,
adapter=adapter,
**overrides,
)
)

View File

@@ -132,7 +132,7 @@ class TestResolveRunnerConfig:
assert config == {'model': 'uuid-123', 'max_round': 10}
def test_resolve_old_format_config(self):
"""Resolve runner config from old format."""
"""Runtime config resolver should not read old format."""
pipeline_config = {
'ai': {
'local-agent': {
@@ -146,6 +146,23 @@ class TestResolveRunnerConfig:
pipeline_config,
'plugin:langbot/local-agent/default',
)
assert config == {}
def test_resolve_legacy_config_for_migration(self):
"""Migration helper should read old format."""
pipeline_config = {
'ai': {
'local-agent': {
'model': 'uuid-123',
'max_round': 10,
},
},
}
config = ConfigMigration.resolve_legacy_runner_config(
pipeline_config,
'plugin:langbot/local-agent/default',
)
assert config == {'model': 'uuid-123', 'max_round': 10}
def test_resolve_no_config(self):
@@ -228,4 +245,4 @@ class TestGetOldRunnerName:
def test_get_old_runner_name_not_mapped(self):
"""Get old runner name for unmapped runner ID."""
old_name = ConfigMigration.get_old_runner_name('plugin:alice/my-agent/custom')
assert old_name is None
assert old_name is None

View File

@@ -229,8 +229,8 @@ class TestResolveRunnerIdBackwardCompat:
assert runner_id == 'plugin:new-runner/default'
class TestResolveRunnerConfigBackwardCompat:
"""Tests for backward compatibility in resolve_runner_config."""
class TestResolveRunnerConfig:
"""Tests for runtime runner config resolution."""
def test_resolve_new_format_config(self):
"""resolve_runner_config should read from runner_config."""
@@ -245,13 +245,23 @@ class TestResolveRunnerConfigBackwardCompat:
assert runner_config['max-round'] == 20
def test_resolve_old_format_config(self):
"""resolve_runner_config should read from old ai.local-agent."""
"""resolve_runner_config should not read old ai.local-agent at runtime."""
config = {
'ai': {
'local-agent': {'max-round': 15},
},
}
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default')
assert runner_config == {}
def test_resolve_legacy_runner_config_for_migration(self):
"""resolve_legacy_runner_config should read old ai.local-agent for migration."""
config = {
'ai': {
'local-agent': {'max-round': 15},
},
}
runner_config = ConfigMigration.resolve_legacy_runner_config(config, 'plugin:langbot/local-agent/default')
assert runner_config['max-round'] == 15
def test_resolve_new_format_priority(self):

View File

@@ -16,8 +16,9 @@ import pytest
import types
from unittest.mock import AsyncMock, MagicMock
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
from langbot.pkg.plugin.handler import _build_tool_detail
from langbot.pkg.plugin.handler import _build_tool_detail, _get_pipeline_knowledge_base_uuids
# Import shared test fixtures from conftest.py
from .conftest import make_resources
@@ -105,11 +106,53 @@ class MockApplication:
self.persistence_mgr.execute_async = AsyncMock(return_value=MagicMock(first=lambda: None))
class FakeAgentRunnerRegistry:
async def get(self, runner_id, bound_plugins=None):
return AgentRunnerDescriptor(
id=runner_id,
source='plugin',
label={'en_US': 'Test Runner'},
plugin_author='test',
plugin_name='runner',
runner_name='default',
config_schema=[
{'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector', 'default': []},
],
capabilities={'knowledge_retrieval': True},
permissions={'knowledge_bases': ['list', 'retrieve']},
)
class MockConnection:
"""Mock connection for testing."""
pass
class TestPipelineKnowledgeBaseScope:
"""Tests for schema-driven pipeline KB scope resolution."""
@pytest.mark.asyncio
async def test_uses_preprocessed_query_scope(self):
app = MockApplication()
query = MockQuery()
query.variables = {'_knowledge_base_uuids': ['kb_var', '__none__', 'kb_var']}
kb_uuids = await _get_pipeline_knowledge_base_uuids(app, query)
assert kb_uuids == ['kb_var']
@pytest.mark.asyncio
async def test_uses_runner_schema_when_query_scope_not_preprocessed(self):
app = MockApplication()
app.agent_runner_registry = FakeAgentRunnerRegistry()
query = MockQuery()
query.variables = {}
kb_uuids = await _get_pipeline_knowledge_base_uuids(app, query)
assert kb_uuids == ['kb_001', 'kb_002']
class MockDisconnectCallback:
"""Mock disconnect callback for testing."""
async def __call__(self):

View File

@@ -1,6 +1,7 @@
"""Integration-style tests for AgentRunOrchestrator with a fake plugin runner."""
from __future__ import annotations
import asyncio
import datetime
import types
from unittest.mock import AsyncMock
@@ -61,9 +62,10 @@ class FakeKnowledgeBase:
class FakePluginConnector:
is_enable_plugin = True
def __init__(self, results=None, error: Exception | None = None):
def __init__(self, results=None, error: Exception | None = None, delay: float = 0):
self.results = results or []
self.error = error
self.delay = delay
self.calls: list[dict] = []
self.contexts: list[dict] = []
self.sessions_during_run: list[dict | None] = []
@@ -83,6 +85,8 @@ class FakePluginConnector:
raise self.error
for result in self.results:
if self.delay:
await asyncio.sleep(self.delay)
yield result
@@ -125,7 +129,11 @@ def make_descriptor() -> AgentRunnerDescriptor:
plugin_name="local-agent",
runner_name="default",
protocol_version="1",
capabilities={"streaming": True, "tool_calling": True},
capabilities={"streaming": True, "tool_calling": True, "knowledge_retrieval": True},
config_schema=[
{"name": "model", "type": "model-fallback-selector"},
{"name": "knowledge-bases", "type": "knowledge-base-multi-selector", "default": []},
],
permissions={
"models": ["invoke", "stream"],
"tools": ["list", "detail", "call"],
@@ -367,3 +375,27 @@ async def test_orchestrator_unregisters_session_after_runner_failure():
context = plugin_connector.contexts[0]
assert plugin_connector.sessions_during_run[0] is not None
assert await get_session_registry().get(context["run_id"]) is None
@pytest.mark.asyncio
async def test_orchestrator_enforces_total_runner_deadline():
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "too late"}},
}
],
delay=0.05,
)
orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector), FakeRegistry(descriptor))
query = make_query()
query.pipeline_config["ai"]["runner_config"][RUNNER_ID]["timeout"] = 0.01
with pytest.raises(RunnerExecutionError) as exc_info:
[message async for message in orchestrator.run_from_query(query)]
assert exc_info.value.retryable is True
assert "runner.timeout" in str(exc_info.value)
assert await get_session_registry().get(plugin_connector.contexts[0]["run_id"]) is None

View File

@@ -13,10 +13,12 @@ Source: src/langbot/pkg/api/http/service/model.py
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.api.http.service.model import (
LLMModelsService,
EmbeddingModelsService,
@@ -28,6 +30,7 @@ from langbot.pkg.entity.persistence.model import LLMModel, EmbeddingModel, Reran
pytestmark = pytest.mark.asyncio
RUNNER_ID = 'plugin:test/runner/default'
def _create_mock_llm_model(
@@ -98,6 +101,22 @@ def _create_mock_result(items: list = None, first_item=None):
return result
class FakeAgentRunnerRegistry:
async def get(self, runner_id, bound_plugins=None):
return AgentRunnerDescriptor(
id=runner_id,
source='plugin',
label={'en_US': 'Test Runner'},
plugin_author='test',
plugin_name='runner',
runner_name='default',
config_schema=[
{'name': 'model', 'type': 'model-fallback-selector', 'default': {'primary': '', 'fallbacks': []}},
],
permissions={'models': ['invoke']},
)
class TestParseProviderApiKeys:
"""Tests for _parse_provider_api_keys helper function."""
@@ -402,6 +421,51 @@ class TestLLMModelsServiceCreateLLMModel:
# Verify
assert model_uuid == 'preserved-uuid'
async def test_create_llm_model_auto_sets_schema_defined_default_pipeline_model(self):
"""Auto-default model selection should use runner schema, not legacy field names."""
ap = SimpleNamespace()
ap.logger = Mock()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.llm_models = []
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
ap.pipeline_service = SimpleNamespace(update_pipeline=AsyncMock())
ap.agent_runner_registry = FakeAgentRunnerRegistry()
pipeline = SimpleNamespace(
uuid='pipeline-uuid',
config={
'ai': {
'runner': {'id': RUNNER_ID},
'runner_config': {
RUNNER_ID: {
'model': {'primary': '', 'fallbacks': []},
},
},
},
},
)
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=pipeline))
service = LLMModelsService(ap)
model_uuid = await service.create_llm_model({
'uuid': 'new-model-uuid',
'name': 'New LLM',
'provider_uuid': 'provider-uuid',
'abilities': [],
'extra_args': {},
}, preserve_uuid=True)
assert model_uuid == 'new-model-uuid'
ap.pipeline_service.update_pipeline.assert_awaited_once()
updated_config = ap.pipeline_service.update_pipeline.await_args.args[1]['config']
assert updated_config['ai']['runner_config'][RUNNER_ID]['model'] == {
'primary': 'new-model-uuid',
'fallbacks': [],
}
async def test_create_llm_model_provider_not_found_raises_error(self):
"""Raises Exception when provider not found in runtime."""
# Setup
@@ -961,4 +1025,4 @@ class TestRerankModelsServiceGetRerankModelsByProvider:
result = await service.get_rerank_models_by_provider('provider-uuid')
# Verify
assert len(result) == 2
assert len(result) == 2

View File

@@ -27,6 +27,9 @@ import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot.pkg.pipeline import entities as pipeline_entities
DEFAULT_RUNNER_ID = 'plugin:langbot/local-agent/default'
class MockApplication:
"""Mock Application object providing all basic dependencies needed by stages"""
@@ -202,8 +205,13 @@ def sample_query(sample_message_chain, sample_message_event, mock_adapter):
bot_uuid='test-bot-uuid',
pipeline_config={
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
'runner': {'id': DEFAULT_RUNNER_ID},
'runner_config': {
DEFAULT_RUNNER_ID: {
'model': {'primary': 'test-model-uuid', 'fallbacks': []},
'prompt': [{'role': 'system', 'content': 'test-prompt'}],
},
},
},
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
'trigger': {'misc': {'combine-quote-message': False}},
@@ -227,8 +235,13 @@ def sample_pipeline_config():
"""Provides sample pipeline configuration"""
return {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
'runner': {'id': DEFAULT_RUNNER_ID},
'runner_config': {
DEFAULT_RUNNER_ID: {
'model': {'primary': 'test-model-uuid', 'fallbacks': []},
'prompt': [{'role': 'system', 'content': 'test-prompt'}],
},
},
},
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
'trigger': {'misc': {'combine-quote-message': False}},

View File

@@ -13,6 +13,24 @@ from unittest.mock import AsyncMock, Mock
from tests.factories import FakeApp
DEFAULT_RUNNER_ID = 'plugin:langbot/local-agent/default'
def runner_pipeline_config(output_misc: dict) -> dict:
return {
'output': {'misc': output_misc},
'ai': {
'runner': {'id': DEFAULT_RUNNER_ID},
'runner_config': {
DEFAULT_RUNNER_ID: {
'prompt': [{'role': 'system', 'content': 'default'}],
'model': {'primary': 'test', 'fallbacks': []},
},
},
},
}
# ============== FIXTURE USING IMPORT ISOLATION UTILITY ==============
@pytest.fixture(scope='module')
@@ -53,7 +71,22 @@ def mock_circular_import_chain():
@pytest.fixture
def fake_app():
"""Create FakeApp instance."""
return FakeApp()
app = FakeApp()
class ProviderRunnerBackedOrchestrator:
async def run_from_query(self, query):
import sys
runner_class = sys.modules['langbot.pkg.provider.runner'].preregistered_runners[0]
runner = runner_class(app, {})
async for result in runner.run(query):
yield result
def resolve_runner_id_for_telemetry(self, query):
return DEFAULT_RUNNER_ID
app.agent_run_orchestrator = ProviderRunnerBackedOrchestrator()
return app
@pytest.fixture
@@ -301,10 +334,9 @@ class TestChatHandlerExceptions:
query.adapter.is_stream_output_supported = AsyncMock(return_value=False)
query.user_message = Message(role='user', content=[])
query.pipeline_config = {
'output': {'misc': {'exception-handling': 'show-hint', 'failure-hint': 'Request failed.'}},
'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}},
}
query.pipeline_config = runner_pipeline_config(
{'exception-handling': 'show-hint', 'failure-hint': 'Request failed.'}
)
class FailingRunner:
name = 'local-agent'
@@ -344,10 +376,7 @@ class TestChatHandlerExceptions:
query.adapter.is_stream_output_supported = AsyncMock(return_value=False)
query.user_message = Message(role='user', content=[])
query.pipeline_config = {
'output': {'misc': {'exception-handling': 'show-error'}},
'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}},
}
query.pipeline_config = runner_pipeline_config({'exception-handling': 'show-error'})
class ErrorRunner:
name = 'local-agent'
@@ -384,10 +413,7 @@ class TestChatHandlerExceptions:
query.adapter.is_stream_output_supported = AsyncMock(return_value=False)
query.user_message = Message(role='user', content=[])
query.pipeline_config = {
'output': {'misc': {'exception-handling': 'hide'}},
'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}},
}
query.pipeline_config = runner_pipeline_config({'exception-handling': 'hide'})
class HideErrorRunner:
name = 'local-agent'
@@ -433,4 +459,4 @@ class TestChatHandlerHelper:
chat = get_chat_handler()
handler = chat.ChatMessageHandler(fake_app)
result = handler.cut_str('first line\nsecond line')
assert '...' in result
assert '...' in result

View File

@@ -21,6 +21,9 @@ from tests.factories import (
import langbot_plugin.api.entities.builtin.provider.message as provider_message
RUNNER_ID = 'plugin:langbot/local-agent/default'
def get_msgtrun_module():
"""Lazy import to avoid circular import issues."""
# Import pipelinemgr first to trigger stage registration
@@ -47,9 +50,12 @@ def make_truncate_config(max_round: int = 5):
"""Create a pipeline config with max-round setting."""
return {
'ai': {
'local-agent': {
'max-round': max_round,
}
'runner': {'id': RUNNER_ID},
'runner_config': {
RUNNER_ID: {
'max-round': max_round,
},
},
}
}

View File

@@ -24,6 +24,9 @@ from tests.factories import (
)
RUNNER_ID = 'plugin:langbot/local-agent/default'
def get_preproc_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.pipeline.preproc.preproc')
@@ -34,6 +37,76 @@ def get_entities_module():
return import_module('langbot.pkg.pipeline.entities')
class FakeAgentRunnerRegistry:
def __init__(self, descriptor):
self.descriptor = descriptor
async def get(self, runner_id, bound_plugins=None):
return self.descriptor
def make_host_model_runner_descriptor(
*,
multimodal_input: bool = True,
tool_calling: bool = True,
knowledge_retrieval: bool = True,
):
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
return AgentRunnerDescriptor(
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
plugin_author='langbot',
plugin_name='local-agent',
runner_name='default',
config_schema=[
{'name': 'model', 'type': 'model-fallback-selector'},
{'name': 'prompt', 'type': 'prompt-editor', 'default': []},
{'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector', 'default': []},
],
capabilities={
'tool_calling': tool_calling,
'knowledge_retrieval': knowledge_retrieval,
'multimodal_input': multimodal_input,
},
permissions={
'models': ['list', 'invoke', 'stream'],
'tools': ['list', 'detail', 'call'],
'knowledge_bases': ['list', 'retrieve'],
},
)
def set_runner_descriptor(app, descriptor=None):
app.agent_runner_registry = FakeAgentRunnerRegistry(
descriptor or make_host_model_runner_descriptor()
)
def make_runner_config(
*,
primary: str = 'test-model-uuid',
fallbacks: list[str] | None = None,
prompt: list[dict] | None = None,
knowledge_bases: list[str] | None = None,
):
return {
'ai': {
'runner': {'id': RUNNER_ID},
'runner_config': {
RUNNER_ID: {
'model': {'primary': primary, 'fallbacks': fallbacks or []},
'prompt': prompt if prompt is not None else [],
'knowledge-bases': knowledge_bases or [],
},
},
},
'output': {'misc': {'at-sender': False}},
'trigger': {'misc': {}},
}
class TestPreProcessorNormalText:
"""Tests for normal text message preprocessing."""
@@ -107,6 +180,7 @@ class TestPreProcessorNormalText:
mock_model.model_entity = Mock(uuid='test-model', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
set_runner_descriptor(app)
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
@@ -195,6 +269,7 @@ class TestPreProcessorImageSegment:
stage = preproc.PreProcessor(app)
# Image query with base64
query = image_query(text="look at this", url=None)
query.pipeline_config = make_runner_config(primary='vision-model')
# Set base64 on the image component
import langbot_plugin.api.entities.builtin.platform.message as platform_message
chain = platform_message.MessageChain([
@@ -206,8 +281,8 @@ class TestPreProcessorImageSegment:
result = await stage.process(query, 'PreProcessor')
assert result.result_type == preproc.entities.ResultType.CONTINUE
# User message should have content
assert result.new_query.user_message.content is not None
content_types = [elem.type for elem in result.new_query.user_message.content]
assert 'image_base64' in content_types
@pytest.mark.asyncio
async def test_image_without_vision_model(self):
@@ -232,6 +307,7 @@ class TestPreProcessorImageSegment:
mock_model.model_entity = Mock(uuid='text-only-model', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
set_runner_descriptor(app)
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
@@ -239,10 +315,13 @@ class TestPreProcessorImageSegment:
stage = preproc.PreProcessor(app)
query = image_query(text="describe this")
query.pipeline_config = make_runner_config(primary='text-only-model')
result = await stage.process(query, 'PreProcessor')
assert result.result_type == preproc.entities.ResultType.CONTINUE
content_types = [elem.type for elem in result.new_query.user_message.content]
assert 'image_url' not in content_types
class TestPreProcessorModelSelection:
@@ -270,6 +349,7 @@ class TestPreProcessorModelSelection:
mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
set_runner_descriptor(app)
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
@@ -279,17 +359,7 @@ class TestPreProcessorModelSelection:
query = text_query("hello")
# Set pipeline config with primary model
query.pipeline_config = {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {
'model': {'primary': 'primary-model-uuid', 'fallbacks': []},
'prompt': 'default',
},
},
'output': {'misc': {'at-sender': False}},
'trigger': {'misc': {}},
}
query.pipeline_config = make_runner_config(primary='primary-model-uuid')
result = await stage.process(query, 'PreProcessor')
@@ -329,6 +399,7 @@ class TestPreProcessorModelSelection:
app.model_mgr.get_model_by_uuid = AsyncMock(side_effect=mock_get_model)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
set_runner_descriptor(app)
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
@@ -337,17 +408,7 @@ class TestPreProcessorModelSelection:
stage = preproc.PreProcessor(app)
query = text_query("hello")
query.pipeline_config = {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {
'model': {'primary': 'primary-uuid', 'fallbacks': ['fallback-uuid']},
'prompt': 'default',
},
},
'output': {'misc': {'at-sender': False}},
'trigger': {'misc': {}},
}
query.pipeline_config = make_runner_config(primary='primary-uuid', fallbacks=['fallback-uuid'])
result = await stage.process(query, 'PreProcessor')

View File

@@ -12,6 +12,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot.pkg.api.http.service.model import _runtime_model_data
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.api.http.service.provider import ModelProviderService
from langbot.pkg.entity.persistence import model as persistence_model
from langbot.pkg.pipeline.preproc.preproc import PreProcessor
@@ -23,6 +24,32 @@ from langbot.pkg.provider.modelmgr.token import TokenManager
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
DEFAULT_RUNNER_ID = 'plugin:langbot/local-agent/default'
class FakeAgentRunnerRegistry:
async def get(self, runner_id, bound_plugins=None):
return AgentRunnerDescriptor(
id=runner_id,
source='plugin',
label={'en_US': 'Local Agent'},
plugin_author='langbot',
plugin_name='local-agent',
runner_name='default',
config_schema=[
{'name': 'model', 'type': 'model-fallback-selector'},
{'name': 'prompt', 'type': 'prompt-editor', 'default': []},
{'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector', 'default': []},
],
capabilities={'tool_calling': True, 'knowledge_retrieval': True, 'multimodal_input': True},
permissions={
'models': ['list', 'invoke', 'stream'],
'tools': ['list', 'detail', 'call'],
'knowledge_bases': ['list', 'retrieve'],
},
)
def test_runtime_llm_model_data_preserves_uuid_after_update_payload_uuid_removed():
update_payload = {
'name': 'Qwen3.5-27B',
@@ -190,6 +217,7 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline()
ap = SimpleNamespace()
ap.logger = Mock()
ap.agent_runner_registry = FakeAgentRunnerRegistry()
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
ap.tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[]))
ap.skill_mgr = None # PreProcessor only uses skill_mgr for the local-agent skill-binding branch
@@ -253,11 +281,13 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline()
)
pipeline_config = {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {
'model': {'primary': model_uuid, 'fallbacks': []},
'prompt': [],
'knowledge-bases': [],
'runner': {'id': DEFAULT_RUNNER_ID},
'runner_config': {
DEFAULT_RUNNER_ID: {
'model': {'primary': model_uuid, 'fallbacks': []},
'prompt': [],
'knowledge-bases': [],
},
},
},
'trigger': {'misc': {'combine-quote-message': False}},