feat(agent-runner): enforce 4.x host-owned execution

This commit is contained in:
huanghuoguoguo
2026-07-12 20:36:32 +08:00
parent e6384aae5d
commit 99d9c227f9
171 changed files with 6958 additions and 5385 deletions
+5 -3
View File
@@ -1,4 +1,5 @@
"""Shared test fixtures for agent runner tests."""
from __future__ import annotations
import typing
@@ -45,6 +46,7 @@ def make_session(
available_apis: dict[str, bool] | None = None,
state_policy: dict[str, typing.Any] | None = None,
state_context: dict[str, typing.Any] | None = None,
execution_query: typing.Any | None = None,
) -> dict[str, typing.Any]:
"""Create a minimal AgentRunSession dict for testing.
@@ -59,13 +61,12 @@ def make_session(
AgentRunSession dict with run-scoped authorization snapshot
"""
import time
now = int(time.time())
res = resources if resources is not None else make_resources()
apis = available_apis if available_apis is not None else {}
policy = (
state_policy
if state_policy is not None
else {'enable_state': True, 'state_scopes': ['conversation', 'actor']}
state_policy if state_policy is not None else {'enable_state': True, 'state_scopes': ['conversation', 'actor']}
)
context = state_context if state_context is not None else {}
@@ -102,6 +103,7 @@ def make_session(
'run_id': run_id,
'runner_id': runner_id,
'query_id': query_id,
'execution_query': execution_query,
'plugin_identity': plugin_identity,
'authorization': {
'resources': res,
+46 -23
View File
@@ -8,6 +8,7 @@ Tests focus on:
Avoids circular imports by using proper import structure.
"""
from __future__ import annotations
import uuid
@@ -19,11 +20,12 @@ from langbot.pkg.agent.runner.errors import (
RunnerExecutionError,
RunnerNotAuthorizedError,
)
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
# Define mock classes in dependency order (no forward references needed)
class MockLauncherType:
value = 'person'
@@ -59,6 +61,7 @@ class MockSession:
class MockQuery:
"""Mock Query for testing."""
def __init__(self):
self.query_id = 1
self.launcher_type = MockLauncherType()
@@ -93,6 +96,7 @@ class MockQuery:
class MockMessageChunk:
"""Mock MessageChunk for testing."""
def __init__(self, content, resp_message_id=None):
self.role = 'assistant'
self.content = content
@@ -106,6 +110,7 @@ class MockMessageChunk:
class MockEventContext:
"""Mock event context for testing."""
def __init__(self, prevented=False, reply_message_chain=None, user_message_alter=None):
self._prevented = prevented
self.event = MagicMock()
@@ -118,6 +123,7 @@ class MockEventContext:
class MockAgentRunOrchestrator:
"""Mock AgentRunOrchestrator for testing."""
def __init__(self, chunks=None, error=None):
self._chunks = chunks or []
self._error = error
@@ -138,6 +144,7 @@ class MockAgentRunOrchestrator:
class MockApplication:
"""Mock Application for testing."""
def __init__(self, orchestrator=None):
self.agent_run_orchestrator = orchestrator or MockAgentRunOrchestrator()
self.logger = MagicMock()
@@ -226,11 +233,11 @@ class TestStreamingBehavior:
assert len(resp_messages) == 2
class TestConfigMigrationInChatHandler:
"""Tests for ConfigMigration usage in chat handler context."""
class TestRunnerConfigResolverInChatHandler:
"""Tests for RunnerConfigResolver usage in chat handler context."""
def test_resolve_runner_id_from_pipeline_config(self):
"""Chat handler should use ConfigMigration to resolve runner ID."""
"""Chat handler should use RunnerConfigResolver to resolve runner ID."""
pipeline_config = {
'ai': {
'runner': {
@@ -239,7 +246,7 @@ class TestConfigMigrationInChatHandler:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot-team/LocalAgent/default'
def test_old_runner_field_is_not_resolved(self):
@@ -252,7 +259,7 @@ class TestConfigMigrationInChatHandler:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
assert runner_id is None
@@ -302,19 +309,23 @@ class TestChatHandlerImports:
"""Import chat handler module should work."""
# This test verifies the import works without circular dependency
from langbot.pkg.pipeline.process.handlers import chat
assert chat.ChatMessageHandler is not None
def test_chat_handler_class_exists(self):
"""ChatMessageHandler class should be defined."""
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
assert ChatMessageHandler.__name__ == 'ChatMessageHandler'
def test_chat_handler_has_handle_method(self):
"""ChatMessageHandler should have async generator handle method."""
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
assert hasattr(ChatMessageHandler, 'handle')
# handle returns AsyncGenerator, so check for async generator function
import inspect
assert inspect.isasyncgenfunction(ChatMessageHandler.handle)
@@ -353,8 +364,10 @@ class TestChatHandlerAsyncBehavior:
def make_result(*args, **kwargs):
return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE))
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
with (
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
@@ -396,8 +409,10 @@ class TestChatHandlerAsyncBehavior:
def make_result(*args, **kwargs):
return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE))
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
with (
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
@@ -439,8 +454,10 @@ class TestChatHandlerAsyncBehavior:
def make_result(*args, **kwargs):
return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE))
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
with (
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
@@ -460,9 +477,7 @@ class TestChatHandlerAsyncBehavior:
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
from langbot.pkg.pipeline import entities
orchestrator = MockAgentRunOrchestrator(
error=RunnerNotFoundError('plugin:notexist/unknown/default')
)
orchestrator = MockAgentRunOrchestrator(error=RunnerNotFoundError('plugin:notexist/unknown/default'))
mock_ap = MockApplication(orchestrator=orchestrator)
mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
@@ -479,8 +494,10 @@ class TestChatHandlerAsyncBehavior:
user_notice=kwargs.get('user_notice'),
)
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
with (
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
@@ -518,8 +535,10 @@ class TestChatHandlerAsyncBehavior:
user_notice=kwargs.get('user_notice'),
)
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
with (
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
@@ -556,8 +575,10 @@ class TestChatHandlerAsyncBehavior:
user_notice=kwargs.get('user_notice'),
)
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
with (
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
@@ -593,8 +614,10 @@ class TestChatHandlerAsyncBehavior:
def make_result(*args, **kwargs):
return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE))
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
with (
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
@@ -1,113 +0,0 @@
"""Tests for current AgentRunner config helpers."""
from __future__ import annotations
from langbot.pkg.agent.runner.config_migration import ConfigMigration
class TestResolveRunnerId:
"""Tests for ConfigMigration.resolve_runner_id."""
def test_resolve_current_runner_id(self):
pipeline_config = {
'ai': {
'runner': {
'id': 'plugin:langbot-team/LocalAgent/default',
},
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot-team/LocalAgent/default'
def test_does_not_resolve_legacy_runner_field(self):
pipeline_config = {
'ai': {
'runner': {
'runner': 'local-agent',
},
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id is None
def test_resolve_no_runner_config(self):
runner_id = ConfigMigration.resolve_runner_id({})
assert runner_id is None
class TestResolveRunnerConfig:
"""Tests for ConfigMigration.resolve_runner_config."""
def test_resolve_current_config(self):
pipeline_config = {
'ai': {
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'model': 'uuid-123',
'custom_option': 10,
},
},
},
}
config = ConfigMigration.resolve_runner_config(
pipeline_config,
'plugin:langbot-team/LocalAgent/default',
)
assert config == {'model': 'uuid-123', 'custom_option': 10}
def test_does_not_read_legacy_runner_block(self):
pipeline_config = {
'ai': {
'local-agent': {
'model': 'uuid-123',
},
},
}
config = ConfigMigration.resolve_runner_config(
pipeline_config,
'plugin:langbot-team/LocalAgent/default',
)
assert config == {}
def test_resolve_no_config(self):
config = ConfigMigration.resolve_runner_config(
{},
'plugin:langbot-team/LocalAgent/default',
)
assert config == {}
class TestGetExpireTime:
"""Tests for ConfigMigration.get_expire_time."""
def test_get_expire_time_zero(self):
pipeline_config = {
'ai': {
'runner': {
'expire-time': 0,
},
},
}
expire_time = ConfigMigration.get_expire_time(pipeline_config)
assert expire_time == 0
def test_get_expire_time_positive(self):
pipeline_config = {
'ai': {
'runner': {
'expire-time': 3600,
},
},
}
expire_time = ConfigMigration.get_expire_time(pipeline_config)
assert expire_time == 3600
def test_get_expire_time_default(self):
expire_time = ConfigMigration.get_expire_time({})
assert expire_time == 0
@@ -0,0 +1,207 @@
"""Tests for current AgentRunner config resolution."""
from __future__ import annotations
import pytest
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
class TestResolveRunnerId:
"""Tests for RunnerConfigResolver.resolve_runner_id."""
def test_resolve_current_runner_id(self):
pipeline_config = {
'ai': {
'runner': {
'id': 'plugin:langbot-team/LocalAgent/default',
},
},
}
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot-team/LocalAgent/default'
def test_does_not_resolve_legacy_runner_field(self):
pipeline_config = {
'ai': {
'runner': {
'runner': 'local-agent',
},
},
}
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
assert runner_id is None
def test_resolve_no_runner_config(self):
runner_id = RunnerConfigResolver.resolve_runner_id({})
assert runner_id is None
class TestResolveRunnerConfig:
"""Tests for RunnerConfigResolver.resolve_runner_config."""
def test_resolve_current_config(self):
pipeline_config = {
'ai': {
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'model': 'uuid-123',
'custom_option': 10,
},
},
},
}
config = RunnerConfigResolver.resolve_runner_config(
pipeline_config,
'plugin:langbot-team/LocalAgent/default',
)
assert config == {'model': 'uuid-123', 'custom_option': 10}
def test_does_not_read_legacy_runner_block(self):
pipeline_config = {
'ai': {
'local-agent': {
'model': 'uuid-123',
},
},
}
config = RunnerConfigResolver.resolve_runner_config(
pipeline_config,
'plugin:langbot-team/LocalAgent/default',
)
assert config == {}
def test_resolve_no_config(self):
config = RunnerConfigResolver.resolve_runner_config(
{},
'plugin:langbot-team/LocalAgent/default',
)
assert config == {}
class TestGetExpireTime:
"""Tests for RunnerConfigResolver.get_expire_time."""
def test_get_expire_time_zero(self):
pipeline_config = {
'ai': {
'runner': {
'expire-time': 0,
},
},
}
expire_time = RunnerConfigResolver.get_expire_time(pipeline_config)
assert expire_time == 0
def test_get_expire_time_positive(self):
pipeline_config = {
'ai': {
'runner': {
'expire-time': 3600,
},
},
}
expire_time = RunnerConfigResolver.get_expire_time(pipeline_config)
assert expire_time == 3600
class TestValidateCurrentConfig:
@pytest.mark.parametrize(
('field_name', 'invalid_value'),
[
('enable-all-tools', 0),
('enable-all-tools', None),
('enable-all-tools', 'false'),
('mcp-resource-agent-read-enabled', 0),
('mcp-resource-agent-read-enabled', None),
('mcp-resource-agent-read-enabled', 'false'),
],
)
def test_agent_rejects_non_boolean_selected_runner_security_fields(self, field_name, invalid_value):
config = {
'runner': {'id': 'plugin:test/runner/default'},
'runner_config': {
'plugin:test/runner/default': {
field_name: invalid_value,
}
},
}
with pytest.raises(ValueError, match=f'{field_name}.*boolean'):
RunnerConfigResolver.validate_agent_config(config)
@pytest.mark.parametrize(
('field_name', 'invalid_value'),
[
('enable-all-tools', 0),
('enable-all-tools', None),
('enable-all-tools', 'false'),
('mcp-resource-agent-read-enabled', 0),
('mcp-resource-agent-read-enabled', None),
('mcp-resource-agent-read-enabled', 'false'),
],
)
def test_pipeline_rejects_non_boolean_selected_runner_security_fields(self, field_name, invalid_value):
config = {
'ai': {
'runner': {'id': 'plugin:test/runner/default'},
'runner_config': {
'plugin:test/runner/default': {
field_name: invalid_value,
}
},
}
}
with pytest.raises(ValueError, match=f'{field_name}.*boolean'):
RunnerConfigResolver.validate_pipeline_config(config)
def test_only_selected_runner_security_fields_are_validated(self):
config = {
'runner': {'id': 'plugin:test/selected/default'},
'runner_config': {
'plugin:test/selected/default': {'enable-all-tools': False},
'plugin:test/unselected/default': {'enable-all-tools': 'preserved'},
},
}
assert RunnerConfigResolver.validate_agent_config(config) is config
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
@pytest.mark.parametrize('config_kind', ['agent', 'pipeline'])
def test_rejects_non_boolean_mcp_resource_enabled(self, config_kind, invalid_value):
runner_id = 'plugin:test/runner/default'
runner_config = {'mcp-resources': [{'uri': 'file:///README.md', 'enabled': invalid_value}]}
if config_kind == 'agent':
config = {
'runner': {'id': runner_id},
'runner_config': {runner_id: runner_config},
}
validate = RunnerConfigResolver.validate_agent_config
else:
config = {
'ai': {
'runner': {'id': runner_id},
'runner_config': {runner_id: runner_config},
}
}
validate = RunnerConfigResolver.validate_pipeline_config
with pytest.raises(ValueError, match=r'mcp-resources\[0\]\.enabled.*boolean'):
validate(config)
@pytest.mark.parametrize('enabled', [True, False])
def test_accepts_boolean_mcp_resource_enabled(self, enabled):
runner_config = {'mcp-resources': [{'enabled': enabled}]}
assert RunnerConfigResolver.validate_runner_security_fields(runner_config) is runner_config
def test_get_expire_time_default(self):
expire_time = RunnerConfigResolver.get_expire_time({})
assert expire_time == 0
@@ -1,10 +1,10 @@
"""Tests for persisted AgentRunner config shape."""
"""Tests for persisted AgentRunner config templates."""
from __future__ import annotations
import json
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
class TestDefaultPipelineConfig:
@@ -35,7 +35,7 @@ class TestResolveRunnerId:
'runner': {'id': 'plugin:test/my-runner/default'},
},
}
runner_id = ConfigMigration.resolve_runner_id(config)
runner_id = RunnerConfigResolver.resolve_runner_id(config)
assert runner_id == 'plugin:test/my-runner/default'
def test_old_runner_field_is_not_supported(self):
@@ -44,7 +44,7 @@ class TestResolveRunnerId:
'runner': {'runner': 'local-agent'},
},
}
runner_id = ConfigMigration.resolve_runner_id(config)
runner_id = RunnerConfigResolver.resolve_runner_id(config)
assert runner_id is None
@@ -59,7 +59,7 @@ class TestResolveRunnerConfig:
},
},
}
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default')
runner_config = RunnerConfigResolver.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default')
assert runner_config['custom-option'] == 20
def test_old_runner_block_is_not_supported(self):
@@ -68,5 +68,5 @@ class TestResolveRunnerConfig:
'local-agent': {'custom-option': 20},
},
}
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default')
runner_config = RunnerConfigResolver.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default')
assert runner_config == {}
@@ -0,0 +1,225 @@
"""Tests for Host-only AgentRunner tool execution context."""
from __future__ import annotations
import json
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
from langbot_plugin.api.entities.builtin.provider.message import ContentElement
from langbot.pkg.agent.runner.execution_context import (
append_mcp_resource_context_to_event,
build_execution_query,
build_host_box_scope,
prepare_box_scope,
prepare_execution_query,
project_mcp_resource_config,
)
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
from langbot.pkg.utils import constants
class PlatformAdapter:
pass
def make_event(
*,
event_id: str = 'event-1',
conversation_id: str | None = 'person_user-1',
target_type: str | None = 'person',
target_id: str | None = 'user-1',
adapter: str = 'PlatformAdapter',
) -> AgentEventEnvelope:
reply_target = {}
if target_type is not None:
reply_target['target_type'] = target_type
if target_id is not None:
reply_target['target_id'] = target_id
return AgentEventEnvelope(
event_id=event_id,
event_type='message.received',
source='platform',
bot_id='bot-1',
workspace_id='workspace-1',
conversation_id=conversation_id,
input=AgentInput(text='hello'),
delivery=DeliveryContext(
surface='platform',
reply_target=reply_target,
platform_capabilities={'adapter': adapter},
),
)
def test_pipeline_and_event_execution_use_same_platform_session_scope(monkeypatch):
monkeypatch.setattr(constants, 'instance_id', 'instance-1')
event = make_event()
query = pipeline_query.Query.model_construct(
query_id=1,
launcher_type='person',
launcher_id='user-1',
sender_id='user-1',
adapter=PlatformAdapter(),
variables={},
)
prepare_execution_query(query, event, ['pdf'])
event_query = build_execution_query(event, ['pdf'])
assert query.variables['_host_box_scope'] == event_query.variables['_host_box_scope']
assert query.variables['_pipeline_bound_skills'] == ['pdf']
assert event_query.variables['_pipeline_bound_skills'] == ['pdf']
scope = json.loads(query.variables['_host_box_scope'])
assert scope == {
'instance_id': 'instance-1',
'workspace_id': 'workspace-1',
'bot_id': 'bot-1',
'platform_adapter': 'PlatformAdapter',
'target_type': 'person',
'target_id': 'user-1',
'thread_id': None,
}
def test_prepare_box_scope_does_not_change_existing_skill_projection():
event = make_event()
query = pipeline_query.Query.model_construct(
query_id=1,
launcher_type='person',
launcher_id='user-1',
variables={'_pipeline_bound_skills': ['existing']},
)
variables = prepare_box_scope(query, event)
assert variables['_host_box_scope']
assert variables['_pipeline_bound_skills'] == ['existing']
def test_prepare_box_scope_preserves_event_first_channel_scope():
channel_event = make_event(target_type='channel', target_id='same')
channel_query = build_execution_query(channel_event, [])
original_scope = channel_query.variables['_host_box_scope']
prepare_execution_query(channel_query, channel_event, [])
person_scope = build_execution_query(make_event(target_type='person', target_id='same'), []).variables[
'_host_box_scope'
]
assert channel_query.variables['_host_box_scope'] == original_scope
assert json.loads(original_scope)['target_type'] == 'channel'
assert original_scope != person_scope
def test_prepare_box_scope_overwrites_untrusted_existing_scope():
event = make_event(target_type='person', target_id='user-1')
query = pipeline_query.Query.model_construct(
query_id=1,
launcher_type='person',
launcher_id='user-1',
variables={'_host_box_scope': 'forged-scope'},
)
variables = prepare_box_scope(query, event)
assert variables['_host_box_scope'] != 'forged-scope'
assert json.loads(variables['_host_box_scope'])['target_id'] == 'user-1'
def test_project_mcp_resource_config_uses_independent_agent_runner_settings():
query = pipeline_query.Query.model_construct(variables={})
attachments = [
{
'server_uuid': 'srv-1',
'server_name': 'docs',
'uri': 'file:///README.md',
'mode': 'pinned',
}
]
variables = project_mcp_resource_config(
query,
{
'mcp-resources': attachments,
'mcp-resource-agent-read-enabled': False,
},
)
assert variables['_pipeline_mcp_resource_attachments'] == attachments
assert variables['_pipeline_mcp_resource_attachments'] is not attachments
assert variables['_pipeline_mcp_resource_agent_read_enabled'] is False
def test_project_mcp_resource_config_fails_closed_for_non_boolean_read_flag():
query = pipeline_query.Query.model_construct(variables={})
variables = project_mcp_resource_config(
query,
{'mcp-resource-agent-read-enabled': 0},
)
assert variables['_pipeline_mcp_resource_agent_read_enabled'] is False
def test_pinned_context_updates_text_and_structured_input():
event = make_event()
event.input = AgentInput(
text='hello',
contents=[ContentElement.from_text('hello')],
)
append_mcp_resource_context_to_event(event, '\n\npinned-context')
assert event.input.text == 'hello\n\npinned-context'
assert event.input.contents[0].text == 'hello\n\npinned-context'
def test_event_reply_target_populates_valid_session_identity():
event = make_event(conversation_id='rotating-transcript-id', target_type='group', target_id='room-1')
query = build_execution_query(event, [])
assert query.launcher_type.value == 'group'
assert query.launcher_id == 'room-1'
assert query.sender_id == 'room-1'
assert query.session.launcher_type.value == 'group'
assert query.session.launcher_id == 'room-1'
scope = json.loads(query.variables['_host_box_scope'])
assert scope['target_type'] == 'group'
assert scope['target_id'] == 'room-1'
assert 'rotating-transcript-id' not in query.variables['_host_box_scope']
def test_non_message_event_without_conversation_uses_event_scope():
event = make_event(
event_id='scheduled/event/中文',
conversation_id=None,
target_type=None,
target_id=None,
adapter='scheduler',
)
event.event_type = 'schedule.triggered'
query = build_execution_query(event, [])
scope = json.loads(query.variables['_host_box_scope'])
assert scope['target_type'] == 'event'
assert scope['target_id'] == event.event_id
assert query.pipeline_config is None
assert query.pipeline_uuid is None
def test_scope_isolated_by_instance_and_platform_adapter(monkeypatch):
event = make_event(adapter='AdapterA')
monkeypatch.setattr(constants, 'instance_id', 'instance-a')
first = build_host_box_scope(event)
monkeypatch.setattr(constants, 'instance_id', 'instance-b')
second = build_host_box_scope(event)
other_adapter = build_host_box_scope(make_event(adapter='AdapterB'))
assert first != second
assert second != other_adapter
+16 -16
View File
@@ -421,7 +421,7 @@ class TestRetrieveKnowledgeBaseAuthorization:
# When no run_id, the handler checks against pipeline's configured KBs
# This is the unscoped path for regular plugin calls
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
# Simulate pipeline config
pipeline_config = {
@@ -437,10 +437,10 @@ class TestRetrieveKnowledgeBaseAuthorization:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:test/runner/default'
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
allowed_kbs = runner_config.get('knowledge-bases', [])
assert 'kb_001' in allowed_kbs
assert 'kb_999' not in allowed_kbs
@@ -534,12 +534,12 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
without first resolving the runner_id, causing issues when non-local-agent runners
were used.
Fix: Now uses ConfigMigration.resolve_runner_id first, then resolve_runner_config.
Fix: Now uses RunnerConfigResolver.resolve_runner_id first, then resolve_runner_config.
"""
def test_retrieve_kb_fix_local_agent_runner(self):
"""Fix should work for local-agent runner."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
pipeline_config = {
'ai': {
@@ -554,15 +554,15 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
allowed_kbs = runner_config.get('knowledge-bases', [])
assert 'kb_001' in allowed_kbs
def test_retrieve_kb_fix_other_runner(self):
"""Fix should work for non-local-agent runners."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
pipeline_config = {
'ai': {
@@ -577,15 +577,15 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
allowed_kbs = runner_config.get('knowledge-bases', [])
assert 'kb_custom' in allowed_kbs
def test_retrieve_kb_does_not_read_old_runner_format(self):
"""The 4.x authorization path ignores legacy Pipeline runner fields."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
pipeline_config = {
'ai': {
@@ -598,8 +598,8 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
assert runner_id is None
assert runner_config == {}
@@ -922,7 +922,7 @@ class TestNoRunIdBackwardCompatPath:
@pytest.mark.asyncio
async def test_retrieve_knowledge_base_no_run_id_pipeline_check(self):
"""RETRIEVE_KNOWLEDGE_BASE: no run_id uses pipeline config check."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
# When no run_id, handler.py lines 897-914 check pipeline config
pipeline_config = {
@@ -938,8 +938,8 @@ class TestNoRunIdBackwardCompatPath:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
allowed_kb_uuids = runner_config.get('knowledge-bases', [])
# kb_001 should be allowed
@@ -234,6 +234,12 @@ def make_query():
'_pipeline_bound_plugins': ['langbot-team/LocalAgent'],
'_fallback_model_uuids': ['model_fallback'],
'_pipeline_bound_skills': ['demo'],
'_host_tool_source_refs': {
'langbot/test-tool/search': {
'source': 'plugin',
'source_id': 'langbot/test-tool',
},
},
'public_param': 'visible',
},
use_llm_model_uuid='model_primary',
@@ -704,6 +710,98 @@ async def test_orchestrator_unregisters_session_after_event_log_failure(clean_ag
assert await get_session_registry().list_active_runs() == []
@pytest.mark.asyncio
async def test_unconsumed_steering_audit_does_not_persist_pinned_context(clean_agent_state):
"""Dropped steering audits retain routing metadata but never execution-only context."""
from langbot.pkg.agent.runner.event_log_store import EventLogStore
class BlockingPluginConnector(FakePluginConnector):
def __init__(self):
super().__init__()
self.started = asyncio.Event()
self.release = asyncio.Event()
async def run_agent(self, plugin_author, plugin_name, runner_name, context):
self.calls.append(
{
'plugin_author': plugin_author,
'plugin_name': plugin_name,
'runner_name': runner_name,
}
)
self.contexts.append(context)
self.sessions_during_run.append(await get_session_registry().get(context['run_id']))
self.started.set()
await self.release.wait()
yield {
'type': 'message.completed',
'data': {'message': {'role': 'assistant', 'content': 'response'}},
}
db_engine = clean_agent_state
descriptor = make_descriptor()
descriptor.capabilities.steering = True
plugin_connector = BlockingPluginConnector()
ap = FakeApplication(plugin_connector, db_engine)
pinned_context = 'PINNED_CONTEXT_MUST_NOT_BE_PERSISTED'
async def build_resource_context(query):
attachments = query.variables.get('_pipeline_mcp_resource_attachments', [])
return pinned_context if attachments else ''
mcp_loader = types.SimpleNamespace(build_resource_context_for_query=AsyncMock(side_effect=build_resource_context))
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
active_query = make_query()
async def consume_active_run():
return [message async for message in orchestrator.run_from_query(active_query)]
active_task = asyncio.create_task(consume_active_run())
await asyncio.wait_for(plugin_connector.started.wait(), timeout=1)
steering_query = make_query()
steering_query.query_id = 1002
steering_query.message_chain[0].id = 'msg_002'
steering_query.variables['_pipeline_mcp_resource_attachments'] = [
{
'server_uuid': 'srv-1',
'server_name': 'docs',
'uri': 'file:///README.md',
'mode': 'pinned',
}
]
steering_query.variables['_pipeline_mcp_resource_agent_read_enabled'] = True
try:
claimed = await orchestrator.try_claim_steering_from_query(steering_query)
finally:
plugin_connector.release.set()
messages = await asyncio.wait_for(active_task, timeout=1)
assert claimed is True
assert len(messages) == 1
event_store = EventLogStore(db_engine)
event_logs, _, _ = await event_store.page_events(
conversation_id=steering_query.session.using_conversation.uuid,
limit=10,
)
dropped = next(event for event in event_logs if event['event_type'] == 'steering.dropped')
queued = next(
event
for event in event_logs
if event['event_type'] == 'message.received'
and event.get('metadata', {}).get('steering', {}).get('status') == 'queued'
)
assert dropped['input_summary'] == 'Unconsumed steering input dropped'
assert dropped['input_json'] is None
assert dropped['metadata']['steering']['original_event_id'] == queued['event_id']
assert pinned_context not in str(event_logs)
@pytest.mark.asyncio
async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state):
"""Test that orchestrator enforces total runner timeout."""
@@ -733,6 +831,48 @@ async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state):
class TestQueryEntrySessionQueryId:
"""Tests for internal query_id entering session registry."""
@pytest.mark.asyncio
async def test_query_box_scope_exists_before_attachment_materialization(self, clean_agent_state):
"""Inbound staging and later runner tools resolve to the same Box session."""
from langbot.pkg.box.service import BoxService
class CapturingBoxService:
available = True
def __init__(self):
self.resolver = object.__new__(BoxService)
self.materialize_session_id = None
async def materialize_inbound_attachments(self, query):
self.materialize_session_id = self.resolver.resolve_box_session_id(query)
return []
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
'type': 'message.completed',
'data': {'message': {'role': 'assistant', 'content': 'response'}},
}
]
)
ap = FakeApplication(plugin_connector, db_engine)
box_service = CapturingBoxService()
ap.box_service = box_service
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
messages = [message async for message in orchestrator.run_from_query(query)]
assert len(messages) == 1
session = plugin_connector.sessions_during_run[0]
assert session is not None
assert session['execution_query'] is query
runner_session_id = box_service.resolver.resolve_box_session_id(session['execution_query'])
assert box_service.materialize_session_id == runner_session_id
assert query.variables['_host_box_scope']
@pytest.mark.asyncio
async def test_query_id_registered_in_session_for_query_entry_flow(self, clean_agent_state):
"""query_id from Query entry flow is registered internally in session."""
@@ -765,6 +905,9 @@ class TestQueryEntrySessionQueryId:
session_during_run = plugin_connector.sessions_during_run[0]
assert session_during_run is not None
assert session_during_run['query_id'] == query.query_id
assert session_during_run['execution_query'] is query
assert query.pipeline_uuid == 'pipeline_001'
assert query.pipeline_config is not None
@pytest.mark.asyncio
async def test_no_query_id_for_pure_event_first_flow(self, clean_agent_state):
@@ -791,6 +934,10 @@ class TestQueryEntrySessionQueryId:
]
)
ap = FakeApplication(plugin_connector, db_engine)
mcp_loader = types.SimpleNamespace(
build_resource_context_for_query=AsyncMock(return_value='Pinned documentation')
)
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
# Create event and binding directly (not from Query)
@@ -805,7 +952,11 @@ class TestQueryEntrySessionQueryId:
thread_id=None,
actor=None,
subject=None,
input=AgentInput(text='hello', contents=[], attachments=[]),
input=AgentInput(
text='hello',
contents=[provider_message.ContentElement.from_text('hello')],
attachments=[],
),
delivery=DeliveryContext(surface='test', supports_streaming=True),
)
binding = AgentBinding(
@@ -813,7 +964,17 @@ class TestQueryEntrySessionQueryId:
scope=BindingScope(scope_type='agent', scope_id='pipeline_001'),
event_types=['message.received'],
runner_id=RUNNER_ID,
runner_config={},
runner_config={
'mcp-resources': [
{
'server_uuid': 'srv-1',
'server_name': 'docs',
'uri': 'file:///README.md',
'mode': 'pinned',
}
],
'mcp-resource-agent-read-enabled': True,
},
resource_policy=ResourcePolicy(),
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True),
@@ -827,6 +988,26 @@ class TestQueryEntrySessionQueryId:
session_during_run = plugin_connector.sessions_during_run[0]
assert session_during_run is not None
assert session_during_run['query_id'] is None
execution_query = session_during_run['execution_query']
assert execution_query is not None
assert execution_query.pipeline_uuid is None
assert execution_query.pipeline_config is None
assert execution_query.bot_uuid == event.bot_id
assert execution_query.launcher_id == event.conversation_id
assert execution_query.sender_id == event.conversation_id
assert execution_query.session.launcher_id == event.conversation_id
assert execution_query.message_event.type == event.event_type
assert execution_query.variables['_host_box_scope']
assert execution_query.variables['_pipeline_bound_skills'] == ['demo', 'hidden']
assert execution_query.variables['_pipeline_mcp_resource_attachments'][0]['server_uuid'] == 'srv-1'
assert execution_query.variables['_pipeline_mcp_resource_agent_read_enabled'] is True
assert 'MCP resource context selected by LangBot host:' in plugin_connector.contexts[0]['input']['text']
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['text']
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text']
assert event.input.text == 'hello'
assert event.input.contents[0].text == 'hello'
assert 'Pinned documentation' not in str(execution_query.user_message.content)
mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query)
class TestQueryEntryAdapterParams:
@@ -1106,8 +1287,21 @@ class TestQueryEntryAdapterHostCapabilities:
]
)
ap = FakeApplication(plugin_connector, db_engine)
mcp_loader = types.SimpleNamespace(
build_resource_context_for_query=AsyncMock(return_value='Pinned documentation')
)
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
query.variables['_pipeline_mcp_resource_attachments'] = [
{
'server_uuid': 'srv-1',
'server_name': 'docs',
'uri': 'file:///README.md',
'mode': 'pinned',
}
]
query.variables['_pipeline_mcp_resource_agent_read_enabled'] = True
query.user_message = provider_message.Message(
role='user',
content=[
@@ -1119,6 +1313,7 @@ class TestQueryEntryAdapterHostCapabilities:
messages = [message async for message in orchestrator.run_from_query(query)]
assert len(messages) == 1
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['text']
# Check EventLog has incoming event
event_log_store = EventLogStore(db_engine)
@@ -1132,6 +1327,7 @@ class TestQueryEntryAdapterHostCapabilities:
assert event_logs[0]['input_json']['contents'][1]['image_base64'] is None
assert event_logs[0]['input_json']['contents'][1]['content_redacted'] is True
assert 'aGVsbG8=' not in str(event_logs[0]['input_json'])
assert 'Pinned documentation' not in str(event_logs[0]['input_json'])
# Check Transcript has user and assistant messages
transcript_store = TranscriptStore(db_engine)
@@ -1149,3 +1345,4 @@ class TestQueryEntryAdapterHostCapabilities:
assert user_item['content_json']['content'][1]['image_base64'] is None
assert user_item['attachment_refs'][0]['content'] is None
assert 'aGVsbG8=' not in str(user_item)
assert 'Pinned documentation' not in str(user_item)
+144 -24
View File
@@ -1,4 +1,5 @@
"""Tests for AgentResourceBuilder."""
from __future__ import annotations
from types import SimpleNamespace
@@ -10,6 +11,7 @@ from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
from langbot.pkg.agent.runner.host_models import AgentBinding, BindingScope, ResourcePolicy
RUNNER_ID = 'plugin:test/runner/default'
@@ -100,6 +102,7 @@ def app():
mock_app.skill_mgr = None
mock_app.tool_mgr = Mock()
mock_app.tool_mgr.get_tool_schema = AsyncMock(return_value=(None, None))
mock_app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
return mock_app
@@ -130,11 +133,13 @@ async def test_build_models_authorizes_config_declared_llm_and_rerank_models(app
{'name': 'rerank-model', 'type': 'rerank-model-selector'},
],
)
query = make_query({
'model': {'primary': 'primary', 'fallbacks': ['fallback', 'primary']},
'aux-model': 'aux',
'rerank-model': 'rerank',
})
query = make_query(
{
'model': {'primary': 'primary', 'fallbacks': ['fallback', 'primary']},
'aux-model': 'aux',
'rerank-model': 'rerank',
}
)
resources = await build_resources(app, query, descriptor)
@@ -173,10 +178,12 @@ async def test_build_models_from_config_without_manifest_acl(app):
],
permissions={},
)
query = make_query({
'model': {'primary': 'primary', 'fallbacks': ['fallback']},
'rerank-model': 'rerank',
})
query = make_query(
{
'model': {'primary': 'primary', 'fallbacks': ['fallback']},
'rerank-model': 'rerank',
}
)
resources = await build_resources(app, query, descriptor)
@@ -196,10 +203,12 @@ async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
{'name': 'rerank-model', 'type': 'rerank-model-selector'},
],
)
query = make_query({
'model': 'llm',
'rerank-model': 'rerank',
})
query = make_query(
{
'model': 'llm',
'rerank-model': 'rerank',
}
)
resources = await build_resources(app, query, descriptor)
@@ -234,10 +243,12 @@ async def test_build_resources_accepts_dynamic_form_type_aliases(app):
{'name': 'knowledge-bases', 'type': 'select-knowledge-bases'},
],
)
query = make_query({
'model': 'llm_alias',
'knowledge-bases': ['kb_alias'],
})
query = make_query(
{
'model': 'llm_alias',
'knowledge-bases': ['kb_alias'],
}
)
resources = await build_resources(app, query, descriptor)
@@ -271,10 +282,12 @@ async def test_build_models_manifest_permission_narrows_binding(app):
'models': ['rerank'],
},
)
query = make_query({
'model': 'llm',
'rerank-model': 'rerank',
})
query = make_query(
{
'model': 'llm',
'rerank-model': 'rerank',
}
)
resources = await build_resources(app, query, descriptor)
@@ -309,7 +322,7 @@ async def test_build_tools_authorizes_query_declared_tools(app):
"""Tools discovered by Pipeline preprocessing become run-scoped authorized
resources, with full parameters schema prefilled by the host."""
app.tool_mgr.get_tool_schema = AsyncMock(
side_effect=lambda name: {
side_effect=lambda name, source_ref=None: {
'qa_plugin_echo': (
'Echo test tool',
{'type': 'object', 'properties': {'text': {'type': 'string'}}},
@@ -321,6 +334,12 @@ async def test_build_tools_authorizes_query_declared_tools(app):
)
query = make_query(
{},
variables={
'_host_tool_source_refs': {
'qa_plugin_echo': {'source': 'plugin', 'source_id': 'test/plugin'},
'qa_mcp_echo': {'source': 'mcp', 'source_id': 'mcp-server'},
},
},
use_funcs=[
{'name': 'qa_plugin_echo', 'description': 'Echo test tool'},
SimpleNamespace(name='qa_mcp_echo'),
@@ -332,17 +351,21 @@ async def test_build_tools_authorizes_query_declared_tools(app):
assert resources['tools'] == [
{
'tool_name': 'qa_plugin_echo',
'tool_type': None,
'tool_type': 'plugin',
'description': 'Echo test tool',
'operations': ['detail', 'call'],
'parameters': {'type': 'object', 'properties': {'text': {'type': 'string'}}},
'source': 'plugin',
'source_id': 'test/plugin',
},
{
'tool_name': 'qa_mcp_echo',
'tool_type': None,
'tool_type': 'mcp',
'description': None,
'operations': ['detail', 'call'],
'parameters': None,
'source': 'mcp',
'source_id': 'mcp-server',
},
]
@@ -369,6 +392,103 @@ async def test_build_tools_manifest_permission_denies_binding_tools(app):
assert resources['tools'] == []
@pytest.mark.asyncio
async def test_build_tools_materializes_independent_agent_all_tools_policy(app):
"""Independent Agents resolve an all-tools grant against the live Host catalog."""
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(
return_value=[
{'name': 'exec', 'source': 'builtin'},
{'name': 'plugin_tool', 'source': 'plugin', 'source_id': 'test/plugin'},
]
)
descriptor = make_descriptor(capabilities={'tool_calling': True})
binding = AgentBinding(
binding_id='agent-binding',
scope=BindingScope(scope_type='agent', scope_id='agent-1'),
runner_id=RUNNER_ID,
runner_config={},
resource_policy=ResourcePolicy(allow_all_tools=True),
)
resources = await AgentResourceBuilder(app).build_resources_from_binding(
event=QueryEntryAdapter.query_to_event(make_query({})),
binding=binding,
descriptor=descriptor,
)
assert [tool['tool_name'] for tool in resources['tools']] == ['exec', 'plugin_tool']
app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
include_skill_authoring=True,
include_mcp_resource_tools=True,
)
@pytest.mark.asyncio
async def test_build_tools_denies_mcp_resource_tools_when_agent_reads_disabled(app):
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(
return_value=[
{'name': 'exec', 'source': 'builtin'},
{'name': 'langbot_mcp_list_resources', 'source': 'mcp'},
{'name': 'langbot_mcp_read_resource', 'source': 'mcp'},
]
)
descriptor = make_descriptor(capabilities={'tool_calling': True})
binding = AgentBinding(
binding_id='agent-binding',
scope=BindingScope(scope_type='agent', scope_id='agent-1'),
runner_id=RUNNER_ID,
runner_config={'mcp-resource-agent-read-enabled': False},
resource_policy=ResourcePolicy(allow_all_tools=True),
)
resources = await AgentResourceBuilder(app).build_resources_from_binding(
event=QueryEntryAdapter.query_to_event(make_query({})),
binding=binding,
descriptor=descriptor,
)
assert [tool['tool_name'] for tool in resources['tools']] == ['exec']
@pytest.mark.asyncio
async def test_build_tools_keeps_plugin_using_synthetic_mcp_tool_name_when_reads_disabled(app):
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(
return_value=[
{
'name': 'langbot_mcp_read_resource',
'source': 'plugin',
'source_id': 'test/resource-reader',
},
]
)
descriptor = make_descriptor(capabilities={'tool_calling': True})
binding = AgentBinding(
binding_id='agent-binding',
scope=BindingScope(scope_type='agent', scope_id='agent-1'),
runner_id=RUNNER_ID,
runner_config={'mcp-resource-agent-read-enabled': False},
resource_policy=ResourcePolicy(allow_all_tools=True),
)
resources = await AgentResourceBuilder(app).build_resources_from_binding(
event=QueryEntryAdapter.query_to_event(make_query({})),
binding=binding,
descriptor=descriptor,
)
assert resources['tools'] == [
{
'tool_name': 'langbot_mcp_read_resource',
'tool_type': 'plugin',
'description': None,
'operations': ['detail', 'call'],
'parameters': None,
'source': 'plugin',
'source_id': 'test/resource-reader',
}
]
@pytest.mark.asyncio
async def test_build_knowledge_bases_unions_config_and_policy_grants(app):
descriptor = make_descriptor(
@@ -0,0 +1,91 @@
"""Tests for generic AgentRunner resource-policy projection."""
from types import SimpleNamespace
import pytest
from langbot.pkg.agent.runner.resource_policy import ResourcePolicyProjector
def test_pipeline_projection_intersects_selected_tools_with_scoped_tools():
policy = ResourcePolicyProjector.from_runner_config(
{
'enable-all-tools': False,
'tools': ['plugin_tool', 'missing_tool', 'plugin_tool'],
'knowledge-bases': ['kb-config'],
},
resolved_model_uuids=['model-1', 'model-1'],
resolved_tool_names=['exec', 'plugin_tool', 'mcp_tool'],
resolved_kb_uuids=['kb-runtime'],
resolved_skill_names=[],
)
assert policy.allow_all_tools is False
assert policy.allowed_tool_names == ['plugin_tool']
assert policy.allowed_model_uuids == ['model-1']
assert policy.allowed_kb_uuids == ['kb-runtime']
assert policy.allowed_skill_names == []
def test_pipeline_projection_materializes_enable_all_tools_from_scoped_tools():
policy = ResourcePolicyProjector.from_runner_config(
{'enable-all-tools': True, 'tools': ['ignored']},
resolved_tool_names=['exec', 'mcp_tool'],
)
assert policy.allow_all_tools is False
assert policy.allowed_tool_names == ['exec', 'mcp_tool']
def test_pipeline_projection_keeps_sources_only_for_authorized_tools():
policy = ResourcePolicyProjector.from_runner_config(
{'enable-all-tools': False, 'tools': ['mcp_tool']},
resolved_tool_names=['plugin_tool', 'mcp_tool'],
resolved_tool_sources={
'plugin_tool': {'source': 'plugin', 'source_id': 'test/plugin'},
'mcp_tool': {'source': 'mcp', 'source_id': 'mcp-server'},
},
)
assert policy.allowed_tool_sources == {
'mcp_tool': {'source': 'mcp', 'source_id': 'mcp-server'},
}
def test_independent_agent_projection_preserves_all_tools_intent():
policy = ResourcePolicyProjector.from_runner_config({})
assert policy.allow_all_tools is True
assert policy.allowed_tool_names is None
@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}])
def test_enable_all_tools_fails_closed_for_non_boolean_values(invalid_value):
policy = ResourcePolicyProjector.from_runner_config(
{'enable-all-tools': invalid_value, 'tools': ['explicit-tool']},
)
assert policy.allow_all_tools is False
assert policy.allowed_tool_names == ['explicit-tool']
def test_independent_agent_projection_preserves_selected_tools():
policy = ResourcePolicyProjector.from_runner_config(
{'enable-all-tools': False, 'tools': ['exec', None, 'exec', 123]},
)
assert policy.allow_all_tools is False
assert policy.allowed_tool_names == ['exec']
def test_filter_tools_supports_sdk_objects_and_dictionary_tools():
policy = ResourcePolicyProjector.from_runner_config(
{'enable-all-tools': False, 'tools': ['dict-tool', 'object-tool']},
)
tools = [
{'name': 'dict-tool'},
SimpleNamespace(name='object-tool'),
SimpleNamespace(name='other-tool'),
]
assert ResourcePolicyProjector.filter_tools(tools, policy) == tools[:2]
+53 -21
View File
@@ -1,4 +1,5 @@
"""Tests for AgentRunSessionRegistry."""
from __future__ import annotations
import pytest
@@ -49,6 +50,27 @@ class TestSessionRegistryBasic:
assert 'permissions' not in result
assert '_authorized_ids' not in result
@pytest.mark.asyncio
async def test_register_keeps_host_execution_query_by_identity(self):
"""The runtime registry keeps the Host Query view in memory only."""
registry = AgentRunSessionRegistry()
execution_query = object()
await registry.register(
run_id='run_execution_query',
runner_id='plugin:test/my-runner/default',
query_id=None,
plugin_identity='test/my-runner',
resources=make_resources(),
execution_query=execution_query,
)
session = await registry.get('run_execution_query')
assert session is not None
assert session['query_id'] is None
assert session['execution_query'] is execution_query
@pytest.mark.asyncio
async def test_register_requires_plugin_identity(self):
"""Agent run sessions must always have an owning plugin identity."""
@@ -319,27 +341,36 @@ class TestSessionRegistryBasic:
available_apis={'steering_pull': True},
)
assert await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_1',
workspace_id='workspace_1',
thread_id='thread_1',
) == 'run_steering_scoped'
assert await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_2',
workspace_id='workspace_1',
thread_id='thread_1',
) is None
assert await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_1',
workspace_id='workspace_1',
thread_id='thread_2',
) is None
assert (
await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_1',
workspace_id='workspace_1',
thread_id='thread_1',
)
== 'run_steering_scoped'
)
assert (
await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_2',
workspace_id='workspace_1',
thread_id='thread_1',
)
is None
)
assert (
await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_1',
workspace_id='workspace_1',
thread_id='thread_2',
)
is None
)
@pytest.mark.asyncio
async def test_unregister_returns_pending_steering_queue(self):
@@ -554,6 +585,7 @@ class TestGlobalRegistry:
# in tests can cause UnboundLocalError due to Python scoping
# Instead, just verify the function signature
from langbot.pkg.agent.runner.session_registry import get_session_registry
assert callable(get_session_registry)
# Create a fresh instance directly to verify the class works