feat(agent): add event-aware tool permissions

This commit is contained in:
Hyu
2026-09-04 15:37:41 +08:00
parent 7c64756203
commit a4d36aa2db
31 changed files with 2519 additions and 485 deletions
@@ -0,0 +1,219 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot_plugin.api.entities.builtin.agent_runner import (
ActorContext,
AgentInput,
DeliveryContext,
RawEventRef,
SubjectContext,
)
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
from langbot.pkg.agent.runner.platform_tools import (
build_platform_tool_resources,
execute_platform_tool,
freeze_platform_context,
resolve_agent_platform_tool_names,
)
def _event(event_type: str = 'friend.request_received') -> AgentEventEnvelope:
return AgentEventEnvelope(
event_id='event-1',
event_type=event_type,
source='platform',
bot_id='bot-1',
input=AgentInput(text='event'),
actor=ActorContext(actor_type='user', actor_id='user-1'),
subject=SubjectContext(subject_type='group', subject_id='group-1'),
delivery=DeliveryContext(
surface='platform',
reply_target={
'target_type': 'group',
'target_id': 'group-1',
'group_id': 'group-1',
'message_id': 'message-1',
},
platform_capabilities={
'adapter': 'FakeAdapter',
'supported_apis': [
'send_message',
'approve_friend_request',
'get_group_info',
],
},
),
raw_ref=RawEventRef(ref_id='request-fallback'),
data={'request_id': 'request-1'},
)
def test_platform_resources_intersect_selection_adapter_and_event() -> None:
resources, capabilities = build_platform_tool_resources(
_event(),
[
'event_reply',
'event_respond_friend_request',
'event_kick_member',
'platform_get_group_info',
'unknown_tool',
],
['detail', 'call'],
)
assert {item['tool_name'] for item in resources} == {
'event_reply',
'event_respond_friend_request',
'platform_get_group_info',
}
assert all(item['source'] == 'platform' for item in resources)
assert capabilities['authorized_tools'] == [item['tool_name'] for item in resources]
assert {item['reason'] for item in capabilities['unavailable_tools']} == {
'adapter_api_unsupported',
'unknown_tool',
}
def test_platform_resources_require_runner_call_permission() -> None:
resources, capabilities = build_platform_tool_resources(
_event(),
['event_reply'],
['detail'],
)
assert resources == []
assert capabilities['unavailable_tools'] == [{'name': 'event_reply', 'reason': 'runner_call_permission_missing'}]
def test_agent_platform_tools_are_resolved_for_the_current_event() -> None:
selected = resolve_agent_platform_tool_names(
{
'allowed_platform_tools': ['platform_get_user_info', 'event_reply'],
'event_tool_permissions': {
'message.*': ['event_reply'],
'group.member.joined': ['event_get_actor'],
'group.*': ['event_get_group', 'unknown_tool'],
},
},
'group.member.joined',
)
assert selected == [
'platform_get_user_info',
'event_reply',
'event_get_actor',
'event_get_group',
'event_get_group_member',
]
def test_agent_event_tools_are_automatic_without_permission_configuration() -> None:
assert resolve_agent_platform_tool_names(
{'allowed_platform_tools': ['event_reply', 'platform_get_user_info']},
'friend.request_received',
) == [
'platform_get_user_info',
'event_reply',
'event_get_actor',
'event_respond_friend_request',
]
def test_request_action_does_not_treat_host_event_ref_as_platform_request_id() -> None:
event = _event()
event.data = {}
resources, capabilities = build_platform_tool_resources(
event,
['event_respond_friend_request'],
['detail', 'call'],
)
assert resources == []
assert capabilities['unavailable_tools'] == [
{'name': 'event_respond_friend_request', 'reason': 'event_target_unavailable'}
]
@pytest.mark.asyncio
async def test_event_action_execution_uses_frozen_target_and_current_bot() -> None:
adapter = SimpleNamespace(
get_supported_apis=lambda: ['approve_friend_request'],
approve_friend_request=AsyncMock(return_value=None),
)
platform_mgr = SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter)))
ap = SimpleNamespace(platform_mgr=platform_mgr)
event = _event()
session = {
'authorization': {
'bot_id': 'bot-1',
'platform_context': freeze_platform_context(event),
}
}
execution_context = object()
await execute_platform_tool(
ap,
execution_context,
session,
'event_respond_friend_request',
{'approve': False, 'remark': 'not now'},
)
platform_mgr.get_bot_by_uuid.assert_awaited_once_with(execution_context, 'bot-1')
adapter.approve_friend_request.assert_awaited_once_with(
request_id='request-1',
approve=False,
remark='not now',
)
@pytest.mark.asyncio
async def test_event_reply_builds_message_chain_for_the_frozen_target() -> None:
adapter = SimpleNamespace(
get_supported_apis=lambda: ['send_message'],
send_message=AsyncMock(return_value=None),
)
ap = SimpleNamespace(
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter)))
)
session = {
'authorization': {
'bot_id': 'bot-1',
'platform_context': freeze_platform_context(_event()),
}
}
await execute_platform_tool(ap, object(), session, 'event_reply', {'text': 'hello'})
call = adapter.send_message.await_args
assert call.kwargs['target_type'] == 'group'
assert call.kwargs['target_id'] == 'group-1'
assert isinstance(call.kwargs['message'], platform_message.MessageChain)
assert call.kwargs['message'][0].text == 'hello'
@pytest.mark.asyncio
async def test_platform_action_rejects_parameters_outside_the_declared_schema() -> None:
adapter = SimpleNamespace(
get_supported_apis=lambda: ['get_group_info'],
get_group_info=AsyncMock(),
)
ap = SimpleNamespace(
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter)))
)
session = {'authorization': {'bot_id': 'bot-1', 'platform_context': {}}}
with pytest.raises(ValueError, match='Unexpected parameters'):
await execute_platform_tool(
ap,
object(),
session,
'platform_get_group_info',
{'group_id': 'group-1', 'raw_action': 'unsafe'},
)
adapter.get_group_info.assert_not_awaited()
@@ -6,12 +6,13 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.api.entities.builtin.agent_runner import AgentInput, DeliveryContext
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
from langbot.pkg.agent.runner.host_models import AgentBinding, AgentEventEnvelope, BindingScope, ResourcePolicy
from langbot.pkg.api.http.context import ExecutionContext
@@ -201,6 +202,41 @@ async def test_build_models_from_config_without_manifest_acl(app):
assert resources['models'] == []
@pytest.mark.asyncio
async def test_platform_tools_are_not_claimed_when_runner_disables_tool_calling(app):
event = AgentEventEnvelope(
event_id='event-platform-disabled',
event_type='message.received',
source='platform',
bot_id='bot-1',
input=AgentInput(text='hello'),
delivery=DeliveryContext(
surface='platform',
reply_target={'target_type': 'person', 'target_id': 'user-1'},
platform_capabilities={'supported_apis': ['send_message']},
),
)
binding = AgentBinding(
binding_id='binding-platform-disabled',
scope=BindingScope(scope_type='global'),
runner_id=RUNNER_ID,
resource_policy=ResourcePolicy(allowed_platform_tool_names=['event_reply']),
)
resources = await AgentResourceBuilder(app).build_resources_from_binding(
execution_context=TEST_CONTEXT,
event=event,
binding=binding,
descriptor=make_descriptor(capabilities={'tool_calling': False}),
)
assert resources['tools'] == []
assert resources['platform_capabilities']['authorized_tools'] == []
assert resources['platform_capabilities']['unavailable_tools'] == [
{'name': 'event_reply', 'reason': 'runner_call_permission_missing'}
]
@pytest.mark.asyncio
async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
"""Config-selected model references are projected regardless of method granularity."""
+37 -1
View File
@@ -53,10 +53,24 @@ def test_pipeline_projection_keeps_sources_only_for_authorized_tools():
def test_independent_agent_projection_preserves_all_tools_intent():
policy = ResourcePolicyProjector.from_runner_config({})
policy = ResourcePolicyProjector.from_runner_config(
{}, allowed_platform_tool_names=['event_reply', '', 'event_reply']
)
assert policy.allow_all_tools is True
assert policy.allowed_tool_names is None
assert policy.allowed_platform_tool_names == ['event_reply']
def test_runner_config_cannot_self_grant_platform_tools():
policy = ResourcePolicyProjector.from_runner_config(
{
'platform-tools': ['platform_send_message'],
'allowed_platform_tools': ['platform_delete_message'],
}
)
assert policy.allowed_platform_tool_names == []
@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}])
@@ -78,6 +92,28 @@ def test_independent_agent_projection_preserves_selected_tools():
assert policy.allowed_tool_names == ['exec']
def test_agent_level_host_tool_policy_overrides_runner_tool_defaults():
policy = ResourcePolicyProjector.from_runner_config(
{'enable-all-tools': True, 'tools': ['runner-tool']},
allowed_host_tool_names=['exec', 'mcp_tool', 'exec'],
override_runner_tools=True,
)
assert policy.allow_all_tools is False
assert policy.allowed_tool_names == ['exec', 'mcp_tool']
def test_agent_level_empty_host_tool_policy_fails_closed():
policy = ResourcePolicyProjector.from_runner_config(
{'enable-all-tools': True},
allowed_host_tool_names=[],
override_runner_tools=True,
)
assert policy.allow_all_tools is False
assert policy.allowed_tool_names == []
def test_filter_tools_supports_sdk_objects_and_dictionary_tools():
policy = ResourcePolicyProjector.from_runner_config(
{'enable-all-tools': False, 'tools': ['dict-tool', 'object-tool']},
@@ -46,7 +46,7 @@ def _agent_row(
'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0},
'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}},
},
supported_event_patterns=supported_event_patterns or ['*'],
supported_event_patterns=(supported_event_patterns if supported_event_patterns is not None else ['*']),
created_at=dt.datetime(2026, 1, 1, 9, 0, 0),
updated_at=updated_at or dt.datetime(2026, 1, 1, 10, 0, 0),
)
@@ -96,6 +96,7 @@ def _make_app():
_get_default_values_from_schema=Mock(return_value={}),
)
app.agent_runner_registry = None
app.tool_mgr = None
app.logger = Mock()
return app
@@ -107,11 +108,32 @@ class TestAgentServiceMetadata:
app.pipeline_service.get_pipeline_metadata = AsyncMock(
return_value=[{'name': 'trigger'}, ai_metadata, {'name': 'output'}]
)
host_tools = [
{
'name': 'exec',
'source': 'builtin',
'source_name': 'LangBot',
},
{
'name': 'weather',
'source': 'mcp',
'source_name': 'weather-server',
},
]
app.tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=host_tools))
metadata = await AgentService(app).get_agent_metadata(WORKSPACE_UUID)
app.pipeline_service.get_pipeline_metadata.assert_awaited_once_with(WORKSPACE_UUID)
assert metadata['runner_config'] == ai_metadata
assert any(tool['name'] == 'event_reply' for tool in metadata['platform_tools'])
assert all(tool['name'] != 'call_platform_api' for tool in metadata['platform_tools'])
assert metadata['host_tools'] == host_tools
app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
WORKSPACE_UUID,
include_skill_authoring=True,
include_mcp_resource_tools=True,
)
assert metadata['kinds'] == [
{
'name': AGENT_KIND_AGENT,
@@ -129,6 +151,13 @@ class TestAgentServiceMetadata:
class TestAgentServiceDebug:
async def test_debug_agent_runs_configured_runner_with_synthetic_event(self):
app = _make_app()
agent_config = _agent_row().config
agent_config['allowed_platform_tools'] = ['platform_get_user_info']
agent_config['event_tool_permissions'] = {
'message.*': ['event_reply'],
'group.member.joined': ['event_get_actor'],
}
agent_config['allowed_tools'] = ['exec', 'weather']
async def run_agent(event, binding, adapter_context):
yield SimpleNamespace(
@@ -144,7 +173,7 @@ class TestAgentServiceDebug:
'uuid': 'agent-1',
'kind': AGENT_KIND_AGENT,
'supported_event_patterns': ['*'],
'config': _agent_row().config,
'config': agent_config,
}
)
context = SimpleNamespace(
@@ -181,6 +210,15 @@ class TestAgentServiceDebug:
assert event.data == {'member_id': 'user-1'}
assert binding.agent_id == 'agent-1'
assert binding.runner_id == 'plugin:test/runner/default'
assert binding.resource_policy.allowed_platform_tool_names == [
'platform_get_user_info',
'event_reply',
'event_get_actor',
'event_get_group',
'event_get_group_member',
]
assert binding.resource_policy.allow_all_tools is False
assert binding.resource_policy.allowed_tool_names == ['exec', 'weather']
assert (
app.agent_run_orchestrator.run.call_args.kwargs['adapter_context']['_execution_context'].workspace_uuid
== WORKSPACE_UUID
@@ -435,6 +473,25 @@ class TestAgentServiceCreateUpdateDelete:
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
assert insert_values['component_ref'] is None
async def test_create_agent_preserves_explicit_empty_event_scope(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
await AgentService(app).create_agent(
WORKSPACE_UUID,
{
'name': 'Dormant Agent',
'supported_event_patterns': [],
'config': {
'runner': {'id': ''},
'runner_config': {},
},
},
)
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
assert insert_values['supported_event_patterns'] == []
async def test_update_agent_rejects_malformed_4x_runner_config_before_write(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=_agent_row(agent_uuid='agent-1')))
@@ -486,7 +543,7 @@ class TestAgentServiceCreateUpdateDelete:
assert update_values == {
'name': 'Updated Agent',
'config': new_config,
'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS,
'supported_event_patterns': [],
'component_ref': 'plugin:test/new-runner/default',
}
@@ -340,6 +340,12 @@ class TestEBAEventBindings:
bot.bot_entity = SimpleNamespace(event_bindings=bindings)
return bot
def test_empty_agent_event_scope_matches_nothing(self):
from langbot.pkg.platform.botmgr import RuntimeBot
assert RuntimeBot._agent_supports_event_type([], 'message.received') is False
assert RuntimeBot._agent_supports_event_type(None, 'message.received') is True
def test_resolve_eba_event_binding_uses_enabled_pattern_filters_priority_and_order(self):
"""The selected binding is the first matching highest-priority binding."""
bot = self._make_bot(
@@ -408,6 +414,12 @@ class TestEBAEventBindings:
'component_ref': 'plugin:test/fallback/default',
'config': {
'runner': {'id': 'plugin:test/runner/default'},
'allowed_platform_tools': ['platform_get_user_info'],
'event_tool_permissions': {
'message.*': ['event_reply'],
'platform.member.joined': ['event_get_actor'],
},
'allowed_tools': ['exec', 'mcp_tool'],
'runner_config': {
'plugin:test/runner/default': {
'temperature': 0.2,
@@ -428,8 +440,12 @@ class TestEBAEventBindings:
assert binding.event_types == ['platform.member.joined']
assert binding.runner_id == 'plugin:test/runner/default'
assert binding.runner_config == {'temperature': 0.2, 'max_tokens': 1000}
assert binding.resource_policy.allow_all_tools is True
assert binding.resource_policy.allowed_tool_names is None
assert binding.resource_policy.allow_all_tools is False
assert binding.resource_policy.allowed_tool_names == ['exec', 'mcp_tool']
assert binding.resource_policy.allowed_platform_tool_names == [
'platform_get_user_info',
'event_get_actor',
]
assert binding.delivery_policy.enable_streaming is False
assert binding.delivery_policy.enable_reply is True
assert binding.delivery_policy.enable_interactions is True