chore(merge): sync master into dev/4.11.x

This commit is contained in:
huanghuoguoguo
2026-07-31 19:29:38 +08:00
502 changed files with 77975 additions and 12729 deletions
+41
View File
@@ -3,6 +3,47 @@
from __future__ import annotations
import typing
from types import SimpleNamespace
from unittest.mock import AsyncMock
from langbot_plugin.entities.io.context import InstallationBinding
TEST_RUNTIME_BINDING = InstallationBinding(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
def bind_runtime_action_context(
handler,
application,
*,
plugin_identity: str = 'test/runner',
):
"""Simulate the trusted Runtime envelope used around direct action calls."""
application.workspace_service = SimpleNamespace(
get_execution_binding=AsyncMock(
return_value=SimpleNamespace(
instance_uuid=TEST_RUNTIME_BINDING.instance_uuid,
workspace_uuid=TEST_RUNTIME_BINDING.workspace_uuid,
placement_generation=TEST_RUNTIME_BINDING.placement_generation,
)
)
)
plugin_author, plugin_name = plugin_identity.split('/', 1)
handler.register_installation_binding(
TEST_RUNTIME_BINDING,
plugin_author=plugin_author,
plugin_name=plugin_name,
)
handler._current_action_context.set(TEST_RUNTIME_BINDING)
return handler
def make_resources(
@@ -350,6 +350,7 @@ def mock_query():
"""Create a mock query for testing."""
query = Mock()
query.query_id = 123
query.workspace_uuid = 'workspace-test'
query.bot_uuid = 'bot-uuid-123'
query.pipeline_uuid = 'pipeline-uuid-456'
query.launcher_type = Mock(value='person')
@@ -398,6 +399,7 @@ def mock_query_no_session():
"""Create a mock Query without session."""
query = Mock()
query.query_id = 456
query.workspace_uuid = 'workspace-test'
query.bot_uuid = 'bot-uuid-456'
query.pipeline_uuid = 'pipeline-uuid-789'
query.launcher_type = Mock(value='person')
+15 -4
View File
@@ -19,6 +19,7 @@ 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 _get_pipeline_knowledge_base_uuids
from langbot.pkg.api.http.context import ExecutionContext
# Import shared test fixtures from conftest.py
from .conftest import make_resources, make_session
@@ -69,6 +70,16 @@ class MockQuery:
self.session.launcher_id = 'group_123'
self.sender_id = 'user_001'
self.bot_uuid = 'bot_001'
self.pipeline_uuid = 'pipeline-001'
self.query_uuid = f'query-{query_id}'
self._execution_context = ExecutionContext(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
bot_uuid=self.bot_uuid,
pipeline_uuid=self.pipeline_uuid,
query_uuid=self.query_uuid,
)
self.pipeline_config = {
'ai': {
'runner': {
@@ -112,7 +123,7 @@ class MockApplication:
class FakeAgentRunnerRegistry:
async def get(self, runner_id, bound_plugins=None):
async def get(self, context, runner_id, bound_plugins=None):
return AgentRunnerDescriptor(
id=runner_id,
source='plugin',
@@ -306,19 +317,19 @@ async def test_tool_manager_get_tool_detail_returns_uniform_schema():
mgr = ToolManager.__new__(ToolManager)
async def fake_get_tool_by_name(name):
async def fake_get_tool_by_name(context, name):
return tool if name == 'search' else None
mgr.get_tool_by_name = fake_get_tool_by_name
detail = await mgr.get_tool_detail('search')
detail = await mgr.get_tool_detail('workspace-test', 'search')
assert detail == {
'name': 'search',
'description': 'Search test data',
'human_desc': 'Search public data',
'parameters': {'type': 'object', 'properties': {'q': {'type': 'string'}}},
}
assert await mgr.get_tool_detail('missing') is None
assert await mgr.get_tool_detail('workspace-test', 'missing') is None
class TestCallToolAuthorization:
@@ -17,7 +17,7 @@ from langbot_plugin.api.entities.builtin.agent_runner.page_results import (
)
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from .conftest import make_resources
from .conftest import bind_runtime_action_context, make_resources
class FakeConnection:
@@ -56,7 +56,10 @@ def _handler(db_engine, session_registry):
return True
fake_app = FakeApplication(db_engine)
return RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
return bind_runtime_action_context(
RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app),
fake_app,
)
async def _register_session(
@@ -19,6 +19,7 @@ from langbot.pkg.agent.runner.session_registry import get_session_registry
from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore
from langbot.pkg.agent.runner.interaction_store import InteractionStore
from langbot.pkg.agent.runner.persistent_state_store import reset_persistent_state_store
from langbot.pkg.api.http.context import ExecutionContext
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
@@ -28,6 +29,11 @@ from langbot_plugin.api.entities.builtin.resource import tool as resource_tool
RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
TEST_CONTEXT = ExecutionContext(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
)
class FakeLogger:
@@ -103,8 +109,15 @@ class FakeRegistry:
self.descriptor = descriptor
self.calls: list[dict] = []
async def get(self, runner_id, bound_plugins=None):
self.calls.append({'runner_id': runner_id, 'bound_plugins': bound_plugins})
async def get(self, context, runner_id, bound_plugins=None):
self.calls.append(
{
'context': context,
'runner_id': runner_id,
'bound_plugins': bound_plugins,
}
)
assert context.workspace_uuid == TEST_CONTEXT.workspace_uuid
assert runner_id == self.descriptor.id
return self.descriptor
@@ -129,7 +142,7 @@ class FakeApplication:
get_knowledge_base_by_uuid=AsyncMock(return_value=FakeKnowledgeBase('kb_001'))
)
self.skill_mgr = types.SimpleNamespace(
skills={
get_skills=lambda context: {
'demo': {
'name': 'demo',
'display_name': 'Demo Skill',
@@ -201,7 +214,7 @@ def make_query():
using_conversation=FakeConversation(),
)
return types.SimpleNamespace(
query = types.SimpleNamespace(
query_id=1001,
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id='user_001',
@@ -254,6 +267,8 @@ def make_query():
)
],
)
query._execution_context = TEST_CONTEXT
return query
def test_context_builder_includes_consumable_base64_attachments():
@@ -903,6 +918,7 @@ class TestQueryEntrySessionQueryId:
def __init__(self):
self.resolver = object.__new__(BoxService)
self.resolver._cloud_managed = False
self.materialize_session_id = None
async def materialize_inbound_attachments(self, query):
@@ -1043,7 +1059,14 @@ class TestQueryEntrySessionQueryId:
enabled=True,
)
messages = [message async for message in orchestrator.run(event, binding)]
messages = [
message
async for message in orchestrator.run(
event,
binding,
adapter_context={'_execution_context': TEST_CONTEXT},
)
]
assert len(messages) == 1
# Verify session during run has query_id=None
+34 -13
View File
@@ -7,6 +7,14 @@ import pytest
from langbot.pkg.agent.runner.registry import AgentRunnerRegistry
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.errors import RunnerNotFoundError, RunnerNotAuthorizedError
from langbot.pkg.api.http.context import ExecutionContext
TEST_CONTEXT = ExecutionContext(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
)
class FakeApplication:
@@ -31,6 +39,9 @@ class FakeApplication:
class FakePluginConnector:
is_enable_plugin = True
async def require_workspace_context(self, context):
return context
async def list_agent_runners(self, bound_plugins=None):
# Return sample runner data
return [
@@ -96,7 +107,7 @@ class TestRegistryDiscovery:
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
runners = await registry.list_runners(use_cache=False)
runners = await registry.list_runners(TEST_CONTEXT, use_cache=False)
# Should find 2 valid runners (langbot-team/LocalAgent and alice/my-agent)
assert len(runners) == 2
@@ -112,10 +123,10 @@ class TestRegistryDiscovery:
registry = AgentRunnerRegistry(ap)
# First discovery
runners1 = await registry.list_runners(use_cache=True)
runners1 = await registry.list_runners(TEST_CONTEXT, use_cache=True)
# Second call should use cache
runners2 = await registry.list_runners(use_cache=True)
runners2 = await registry.list_runners(TEST_CONTEXT, use_cache=True)
assert registry._cache is not None
assert len(runners1) == len(runners2)
@@ -127,7 +138,7 @@ class TestRegistryDiscovery:
ap.plugin_connector.is_enable_plugin = False
registry = AgentRunnerRegistry(ap)
runners = await registry.list_runners(use_cache=False)
runners = await registry.list_runners(TEST_CONTEXT, use_cache=False)
assert runners == []
@@ -143,23 +154,28 @@ class TestRegistryDiscovery:
# First: get with bound_plugins filter (should not pollute cache)
descriptor = await registry.get(
TEST_CONTEXT,
'plugin:langbot-team/LocalAgent/default',
bound_plugins=['langbot-team/LocalAgent'],
)
assert descriptor.id == 'plugin:langbot-team/LocalAgent/default'
# Cache should contain ALL runners (both langbot and alice)
assert registry._cache is not None
assert len(registry._cache) == 2 # Both runners in cache
assert 'plugin:langbot-team/LocalAgent/default' in registry._cache
assert 'plugin:alice/my-agent/custom' in registry._cache
scoped_cache = registry._cache[('instance-test', 'workspace-test', 1)]
assert len(scoped_cache) == 2
assert 'plugin:langbot-team/LocalAgent/default' in scoped_cache
assert 'plugin:alice/my-agent/custom' in scoped_cache
# Second: list_runners without filter should return ALL runners
all_runners = await registry.list_runners(bound_plugins=None, use_cache=True)
all_runners = await registry.list_runners(TEST_CONTEXT, bound_plugins=None, use_cache=True)
assert len(all_runners) == 2 # Both runners returned
# Third: list_runners with different filter should work correctly
alice_runners = await registry.list_runners(bound_plugins=['alice/my-agent'], use_cache=True)
alice_runners = await registry.list_runners(
TEST_CONTEXT,
bound_plugins=['alice/my-agent'],
use_cache=True,
)
assert len(alice_runners) == 1
assert alice_runners[0].id == 'plugin:alice/my-agent/custom'
@@ -173,7 +189,10 @@ class TestRegistryGet:
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
descriptor = await registry.get('plugin:langbot-team/LocalAgent/default')
descriptor = await registry.get(
TEST_CONTEXT,
'plugin:langbot-team/LocalAgent/default',
)
assert descriptor.id == 'plugin:langbot-team/LocalAgent/default'
assert descriptor.plugin_author == 'langbot-team'
@@ -187,7 +206,7 @@ class TestRegistryGet:
registry = AgentRunnerRegistry(ap)
with pytest.raises(RunnerNotFoundError) as exc_info:
await registry.get('plugin:notexist/unknown/default')
await registry.get(TEST_CONTEXT, 'plugin:notexist/unknown/default')
assert exc_info.value.runner_id == 'plugin:notexist/unknown/default'
@@ -199,6 +218,7 @@ class TestRegistryGet:
# Authorized - langbot plugin in bound list
descriptor = await registry.get(
TEST_CONTEXT,
'plugin:langbot-team/LocalAgent/default',
bound_plugins=['langbot-team/LocalAgent'],
)
@@ -207,6 +227,7 @@ class TestRegistryGet:
# Not authorized - plugin not in bound list
with pytest.raises(RunnerNotAuthorizedError):
await registry.get(
TEST_CONTEXT,
'plugin:alice/my-agent/custom',
bound_plugins=['langbot-team/LocalAgent'],
)
@@ -221,7 +242,7 @@ class TestRegistryMetadataForPipeline:
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
options, stages = await registry.get_runner_metadata_for_pipeline()
options, stages = await registry.get_runner_metadata_for_pipeline(TEST_CONTEXT)
# Should have options for each runner
assert len(options) == 2
@@ -12,9 +12,15 @@ 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.api.http.context import ExecutionContext
RUNNER_ID = 'plugin:test/runner/default'
TEST_CONTEXT = ExecutionContext(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
)
FULL_PERMISSIONS = {
'models': ['count_tokens', 'invoke', 'stream', 'rerank'],
'tools': ['detail', 'call'],
@@ -86,6 +92,7 @@ async def build_resources(app, query, descriptor):
agent_config = QueryEntryAdapter.config_to_agent_config(query, descriptor.id)
binding = AgentBindingResolver().resolve_one(event, [agent_config])
return await AgentResourceBuilder(app).build_resources_from_binding(
execution_context=TEST_CONTEXT,
event=event,
binding=binding,
descriptor=descriptor,
@@ -118,10 +125,12 @@ async def test_build_models_authorizes_config_declared_llm_and_rerank_models(app
'rerank': make_model(model_type='rerank', provider='rerank-provider'),
}
async def get_model_by_uuid(model_uuid):
async def get_model_by_uuid(context, model_uuid):
assert context == TEST_CONTEXT
return llm_models.get(model_uuid)
async def get_rerank_model_by_uuid(model_uuid):
async def get_rerank_model_by_uuid(context, model_uuid):
assert context == TEST_CONTEXT
return rerank_models.get(model_uuid)
app.model_mgr.get_model_by_uuid = AsyncMock(side_effect=get_model_by_uuid)
@@ -228,7 +237,8 @@ async def test_build_resources_accepts_dynamic_form_type_aliases(app):
"""Frontend DynamicForm aliases should resolve to runtime resource grants."""
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model())
async def get_kb(kb_uuid):
async def get_kb(context, kb_uuid):
assert context == TEST_CONTEXT
return SimpleNamespace(
uuid=kb_uuid,
get_name=lambda: f'name-{kb_uuid}',
@@ -322,7 +332,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, source_ref=None: {
side_effect=lambda context, name, source_ref=None: {
'qa_plugin_echo': (
'Echo test tool',
{'type': 'object', 'properties': {'text': {'type': 'string'}}},
@@ -411,6 +421,7 @@ async def test_build_tools_materializes_independent_agent_all_tools_policy(app):
)
resources = await AgentResourceBuilder(app).build_resources_from_binding(
execution_context=TEST_CONTEXT,
event=QueryEntryAdapter.query_to_event(make_query({})),
binding=binding,
descriptor=descriptor,
@@ -418,6 +429,7 @@ async def test_build_tools_materializes_independent_agent_all_tools_policy(app):
assert [tool['tool_name'] for tool in resources['tools']] == ['exec', 'plugin_tool']
app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
TEST_CONTEXT,
include_skill_authoring=True,
include_mcp_resource_tools=True,
)
@@ -442,6 +454,7 @@ async def test_build_tools_denies_mcp_resource_tools_when_agent_reads_disabled(a
)
resources = await AgentResourceBuilder(app).build_resources_from_binding(
execution_context=TEST_CONTEXT,
event=QueryEntryAdapter.query_to_event(make_query({})),
binding=binding,
descriptor=descriptor,
@@ -471,6 +484,7 @@ async def test_build_tools_keeps_plugin_using_synthetic_mcp_tool_name_when_reads
)
resources = await AgentResourceBuilder(app).build_resources_from_binding(
execution_context=TEST_CONTEXT,
event=QueryEntryAdapter.query_to_event(make_query({})),
binding=binding,
descriptor=descriptor,
@@ -502,7 +516,8 @@ async def test_build_knowledge_bases_unions_config_and_policy_grants(app):
variables={'_knowledge_base_uuids': ['kb_policy']},
)
async def get_kb(kb_uuid):
async def get_kb(context, kb_uuid):
assert context == TEST_CONTEXT
return SimpleNamespace(
uuid=kb_uuid,
get_name=lambda: f'name-{kb_uuid}',
@@ -25,7 +25,7 @@ from langbot_plugin.api.entities.builtin.agent_runner.run_ledger import (
)
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from .conftest import make_resources
from .conftest import bind_runtime_action_context, make_resources
class FakeConnection:
@@ -72,17 +72,32 @@ class FakeRunnerRegistry:
self.runners = runners
self.calls = []
async def list_runners(self, *, bound_plugins=None, use_cache=True):
self.calls.append({'bound_plugins': bound_plugins, 'use_cache': use_cache})
async def list_runners(self, context, *, bound_plugins=None, use_cache=True):
self.calls.append(
{
'workspace_uuid': context.workspace_uuid,
'bound_plugins': bound_plugins,
'use_cache': use_cache,
}
)
return self.runners
def _handler(db_engine, admin_plugins=None, runner_registry=None):
def _handler(
db_engine,
admin_plugins=None,
runner_registry=None,
plugin_identity='test/runner',
):
async def fake_disconnect():
return True
fake_app = FakeApplication(db_engine, admin_plugins=admin_plugins, runner_registry=runner_registry)
return RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
return bind_runtime_action_context(
RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app),
fake_app,
plugin_identity=plugin_identity,
)
async def _register_session(
@@ -505,6 +520,7 @@ async def test_agent_run_admin_can_list_runner_registry_without_run_id(db_engine
}
],
runner_registry=runner_registry,
plugin_identity='langbot/control',
)
runner_list = handler.actions['runner_list']
@@ -519,6 +535,7 @@ async def test_agent_run_admin_can_list_runner_registry_without_run_id(db_engine
assert result.data['items'][0]['id'] == 'plugin:test/runner/default'
assert runner_registry.calls == [
{
'workspace_uuid': 'workspace-test',
'bound_plugins': ['test/runner'],
'use_cache': True,
}
@@ -602,6 +619,7 @@ async def test_agent_run_admin_can_get_and_page_cross_scope_without_run_id(db_en
'permissions': ['agent_run:admin'],
}
],
plugin_identity='langbot/control',
)
run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value]
run_events_page = handler.actions[PluginToRuntimeAction.RUN_EVENTS_PAGE.value]
@@ -720,7 +738,7 @@ async def test_configured_admin_identity_cannot_be_spoofed_with_other_run_sessio
)
assert result.code != 0
assert 'mismatch' in result.message.lower()
assert 'does not match' in result.message.lower()
@pytest.mark.asyncio
@@ -834,6 +852,7 @@ async def test_runtime_admin_can_register_list_and_claim_without_run_id(db_engin
'permissions': ['runtime:admin'],
}
],
plugin_identity='langbot/control',
)
runtime_register = handler.actions[PluginToRuntimeAction.RUNTIME_REGISTER.value]
runtime_list = handler.actions[PluginToRuntimeAction.RUNTIME_LIST.value]
@@ -917,6 +936,7 @@ async def test_runtime_admin_can_reconcile_without_run_id(db_engine):
'permissions': ['runtime:admin'],
}
],
plugin_identity='langbot/control',
)
runtime_reconcile = handler.actions['runtime_reconcile']
+20 -7
View File
@@ -25,11 +25,11 @@ from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
from langbot.pkg.agent.runner.persistent_state_store import PersistentStateStore, reset_persistent_state_store
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
from langbot.pkg.plugin.handler import RuntimeConnectionHandler as HostRuntimeConnectionHandler
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
# Import shared test fixtures
from .conftest import make_resources
from .conftest import bind_runtime_action_context, make_resources
class FakeConnection:
@@ -48,6 +48,14 @@ class FakeApplication:
self.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
class RuntimeConnectionHandler(HostRuntimeConnectionHandler):
"""Host handler with the trusted Runtime envelope installed for direct calls."""
def __init__(self, connection, disconnect, application):
super().__init__(connection, disconnect, application)
bind_runtime_action_context(self, application)
@pytest.fixture
def session_registry():
"""Create a fresh session registry for each test."""
@@ -126,8 +134,13 @@ class TestStateAPIHandlerAuthorization:
assert 'not found' in result.message.lower()
@pytest.mark.asyncio
async def test_state_get_missing_caller_plugin_identity_returns_error(self, session_registry, db_engine, persistent_store):
"""STATE_GET: missing caller_plugin_identity when session has plugin_identity returns error."""
async def test_state_get_uses_installation_identity_when_payload_omits_caller(
self,
session_registry,
db_engine,
persistent_store,
):
"""STATE_GET derives caller identity from the trusted installation binding."""
fake_app = FakeApplication(db_engine)
fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
@@ -157,8 +170,8 @@ class TestStateAPIHandlerAuthorization:
'key': 'test_key',
})
assert result.code != 0
assert 'caller_plugin_identity is required' in result.message
assert result.code == 0
assert result.data == {'value': None}
await session_registry.unregister('run_test_missing_identity')
@@ -195,7 +208,7 @@ class TestStateAPIHandlerAuthorization:
})
assert result.code != 0
assert 'mismatch' in result.message.lower()
assert 'does not match' in result.message.lower()
await session_registry.unregister('run_test_mismatch')