mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-28 00:14:21 +00:00
feat: make agent runner config schema driven
This commit is contained in:
@@ -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}},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user