feat(runner): unify plugin execution across agents and event processors

This commit is contained in:
RockChinQ
2026-09-10 18:04:38 +08:00
parent 8903a40c41
commit f24a7c9bb2
223 changed files with 4091 additions and 3068 deletions
@@ -1,4 +1,4 @@
"""Tests for current AgentRunner config resolution."""
"""Tests for current Runner config resolution."""
from __future__ import annotations
@@ -1,4 +1,4 @@
"""Tests for persisted AgentRunner config templates."""
"""Tests for persisted Runner config templates."""
from __future__ import annotations
@@ -1,25 +1,27 @@
"""Tests for ContextAccess.state determination in AgentRunContextBuilder.
"""Tests for ContextAccess.state determination in RunnerContextBuilder.
Tests focus on:
- Event-first mode: state=True when enable_state=True and state_scopes non-empty
- Event-first mode: state=False when enable_state=False
- Legacy Query mode: state=False (no persistent state API)
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock
from langbot.pkg.agent.runner.context_builder import AgentRunContextBuilder
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.context_builder import RunnerContextBuilder
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope, StatePolicy
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.event import ActorContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
class MockApplication:
"""Mock Application for testing."""
def __init__(self):
self.logger = MagicMock()
self.persistence_mgr = MagicMock()
@@ -28,8 +30,8 @@ class MockApplication:
def make_descriptor(
permissions: dict | None = None,
) -> AgentRunnerDescriptor:
return AgentRunnerDescriptor(
) -> RunnerDescriptor:
return RunnerDescriptor(
id='plugin:test/runner/default',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -91,7 +93,7 @@ class TestContextAccessStateDetermination:
),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call to _build_context_access
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -112,7 +114,7 @@ class TestContextAccessStateDetermination:
),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -133,7 +135,7 @@ class TestContextAccessStateDetermination:
),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -144,7 +146,7 @@ class TestContextAccessStateDetermination:
@pytest.mark.asyncio
async def test_no_binding_sets_state_false(self, mock_app, mock_event, mock_descriptor):
"""ContextAccess.state=False when no binding is provided."""
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call without binding
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding=None)
@@ -180,7 +182,7 @@ class TestContextAccessStateDetermination:
),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -201,7 +203,7 @@ class TestContextAccessStateDetermination:
),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -277,7 +279,7 @@ class TestContextAccessOtherAPIs:
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -301,7 +303,7 @@ class TestContextAccessOtherAPIs:
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -324,7 +326,7 @@ class TestContextAccessOtherAPIs:
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -350,7 +352,7 @@ class TestContextAccessOtherAPIs:
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -1,4 +1,4 @@
"""Test that LangBot context builder output validates against SDK AgentRunContext."""
"""Test that LangBot context builder output validates against SDK RunnerContext."""
from __future__ import annotations
@@ -7,26 +7,26 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, AsyncMock, patch
# SDK imports for validation
from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext
from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.context_access import ContextAccess
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
from langbot_plugin.api.entities.builtin.runner.context import RunnerContext
from langbot_plugin.api.entities.builtin.runner.event import AgentEventContext
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.context_access import ContextAccess
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.runner.runtime import AgentRuntimeContext
# LangBot imports
from langbot.pkg.agent.runner.context_builder import (
AgentRunContextBuilder,
RunnerContextBuilder,
AgentResources as BuilderResources,
)
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope
from langbot.pkg.core import app
class TestContextValidation:
"""Test that context builder output validates against SDK AgentRunContext."""
"""Test that context builder output validates against SDK RunnerContext."""
def _make_mock_app(self):
"""Create a mock application."""
@@ -40,9 +40,9 @@ class TestContextValidation:
def _make_event_envelope(self) -> AgentEventEnvelope:
"""Create a test event envelope."""
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput as EventInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.event import ActorContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput as EventInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
return AgentEventEnvelope(
event_id='evt_1',
@@ -90,7 +90,7 @@ class TestContextValidation:
def _make_descriptor(self):
"""Create a mock runner descriptor."""
return AgentRunnerDescriptor(
return RunnerDescriptor(
id='plugin:test/plugin/runner',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -106,9 +106,9 @@ class TestContextValidation:
@pytest.mark.asyncio
async def test_build_context_from_event_validates(self):
"""Test that build_context_from_event output validates against SDK AgentRunContext."""
"""Test that build_context_from_event output validates against SDK RunnerContext."""
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
event = self._make_event_envelope()
binding = self._make_binding()
@@ -136,9 +136,9 @@ class TestContextValidation:
resources=resources,
)
# Validate it can be parsed by SDK AgentRunContext
# Validate it can be parsed by SDK RunnerContext
# This will raise ValidationError if invalid
validated = AgentRunContext.model_validate(context_dict)
validated = RunnerContext.model_validate(context_dict)
# Verify required fields
assert validated.run_id is not None
@@ -179,13 +179,13 @@ class TestContextValidation:
@pytest.mark.asyncio
async def test_build_context_preserves_interaction_protocol_fields(self):
"""Validated submissions and delivery capabilities survive the final context projection."""
from langbot_plugin.api.entities.builtin.agent_runner.interaction import (
from langbot_plugin.api.entities.builtin.runner.interaction import (
InteractionDeliveryCapabilities,
InteractionSubmission,
)
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
event = self._make_event_envelope()
event.event_type = 'interaction.submitted'
event.input.interaction = InteractionSubmission(
@@ -211,7 +211,7 @@ class TestContextValidation:
resources=self._make_resources(),
)
validated = AgentRunContext.model_validate(context_dict)
validated = RunnerContext.model_validate(context_dict)
assert validated.input.interaction is not None
assert validated.input.interaction.interaction_id == 'form-1'
assert validated.input.interaction.values == {'comment': 'looks good'}
@@ -228,7 +228,7 @@ class TestContextValidation:
model_entity=SimpleNamespace(context_length=128000),
)
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
event = self._make_event_envelope()
binding = self._make_binding()
@@ -281,7 +281,7 @@ class TestContextValidation:
model_entity=SimpleNamespace(context_length=None),
)
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
resources = self._make_resources()
resources['models'] = [
{
@@ -304,12 +304,12 @@ class TestContextValidation:
@pytest.mark.asyncio
async def test_build_context_preserves_subject_data_for_non_message_events(self):
"""Non-message EBA events keep subject.data instead of relying on message text."""
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext, SubjectContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput as EventInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.event import ActorContext, SubjectContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput as EventInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
event = AgentEventEnvelope(
event_id='evt_recall_1',
event_type='message.recalled',
@@ -353,7 +353,7 @@ class TestContextValidation:
resources=resources,
)
validated = AgentRunContext.model_validate(context_dict)
validated = RunnerContext.model_validate(context_dict)
assert validated.event.event_type == 'message.recalled'
assert validated.input.text is None
@@ -366,7 +366,7 @@ class TestContextValidation:
async def test_build_context_from_event_has_no_legacy_top_level_fields(self):
"""Test that build_context_from_event does NOT have top-level messages/prompt/params."""
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
event = self._make_event_envelope()
binding = self._make_binding()
@@ -409,7 +409,7 @@ class TestContextValidation:
async def test_build_context_from_event_event_is_not_none(self):
"""Test that event field is NOT None in Protocol v1."""
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
event = self._make_event_envelope()
binding = self._make_binding()
@@ -440,14 +440,14 @@ class TestContextValidation:
assert context_dict.get('event') is not None, 'event is REQUIRED for Protocol v1'
# Validate
validated = AgentRunContext.model_validate(context_dict)
validated = RunnerContext.model_validate(context_dict)
assert validated.event is not None
@pytest.mark.asyncio
async def test_build_context_from_event_delivery_is_not_none(self):
"""Test that delivery field is NOT None in Protocol v1."""
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
event = self._make_event_envelope()
binding = self._make_binding()
@@ -478,5 +478,5 @@ class TestContextValidation:
assert context_dict.get('delivery') is not None, 'delivery is REQUIRED for Protocol v1'
# Validate
validated = AgentRunContext.model_validate(context_dict)
validated = RunnerContext.model_validate(context_dict)
assert validated.delivery is not None
@@ -3,7 +3,7 @@
Tests cover:
1. Query -> AgentEventEnvelope conversion
2. Current config -> AgentConfig projection and single-binding resolution
3. AgentRunContext not inlining full history by default
3. RunnerContext not inlining full history by default
4. LangBot Host not defining context-window controls
5. Event-first run() entry point
"""
@@ -14,14 +14,14 @@ import pytest
from unittest.mock import Mock
# Import SDK entities
from langbot_plugin.api.entities.builtin.agent_runner.event import (
from langbot_plugin.api.entities.builtin.runner.event import (
AgentEventContext,
)
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger
from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext
from langbot_plugin.api.entities.builtin.agent_runner.result import (
AgentRunResult,
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.trigger import AgentTrigger
from langbot_plugin.api.entities.builtin.runner.context import RunnerContext
from langbot_plugin.api.entities.builtin.runner.result import (
RunnerResult,
)
# Import LangBot host models
@@ -263,8 +263,8 @@ class TestQueryConfigToAgentConfig:
AgentBindingResolver().resolve_one(event, [first, second])
class TestAgentRunContextProtocolV1:
"""Test AgentRunContext Protocol v1 behavior."""
class TestRunnerContextProtocolV1:
"""Test RunnerContext Protocol v1 behavior."""
def test_sdk_context_event_required(self):
"""Test that event is required in Protocol v1 context."""
@@ -275,11 +275,11 @@ class TestAgentRunContextProtocolV1:
source='platform',
)
input = AgentInput(text='Hello')
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.runner.runtime import AgentRuntimeContext
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
ctx = AgentRunContext(
ctx = RunnerContext(
run_id='run_1',
trigger=trigger,
event=event,
@@ -293,7 +293,7 @@ class TestAgentRunContextProtocolV1:
assert ctx.event.event_type == 'message.received'
def test_sdk_context_has_no_history_message_fields(self):
"""AgentRunContext should not expose inline history message fields."""
"""RunnerContext should not expose inline history message fields."""
trigger = AgentTrigger(type='message.received')
event = AgentEventContext(
event_id='evt_1',
@@ -301,11 +301,11 @@ class TestAgentRunContextProtocolV1:
source='platform',
)
input = AgentInput(text='Hello')
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.runner.runtime import AgentRuntimeContext
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
ctx = AgentRunContext(
ctx = RunnerContext(
run_id='run_1',
trigger=trigger,
event=event,
@@ -315,8 +315,8 @@ class TestAgentRunContextProtocolV1:
runtime=AgentRuntimeContext(),
)
assert 'messages' not in AgentRunContext.model_fields
assert 'bootstrap' not in AgentRunContext.model_fields
assert 'messages' not in RunnerContext.model_fields
assert 'bootstrap' not in RunnerContext.model_fields
assert not hasattr(ctx, 'bootstrap')
@@ -324,20 +324,20 @@ class TestHostManagedHistoryNotInProtocol:
"""Test that Host-managed history payloads are not in Protocol v1."""
def test_messages_not_in_sdk_context_top_level(self):
"""AgentRunContext should not expose top-level history messages."""
ctx_fields = AgentRunContext.model_fields.keys()
"""RunnerContext should not expose top-level history messages."""
ctx_fields = RunnerContext.model_fields.keys()
assert 'messages' not in ctx_fields
class TestSDKResultProtocolV1:
"""Test SDK AgentRunResult for Protocol v1."""
"""Test SDK RunnerResult for Protocol v1."""
def test_result_requires_run_id(self):
"""Test result requires run_id for Protocol v1."""
from langbot_plugin.api.entities.builtin.provider.message import Message
result = AgentRunResult.message_completed(
result = RunnerResult.message_completed(
run_id='run_1',
message=Message(role='assistant', content='Hello'),
)
@@ -17,11 +17,11 @@ from langbot.pkg.agent.runner.host_models import (
from langbot.pkg.agent.runner.event_log_store import EventLogStore
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
from langbot.pkg.agent.runner.session_registry import get_session_registry
from langbot_plugin.api.entities.builtin.agent_runner.event import (
from langbot_plugin.api.entities.builtin.runner.event import (
ActorContext,
)
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
def make_event_envelope(
@@ -1,11 +1,11 @@
"""Tests for Host-only AgentRunner tool execution context."""
"""Tests for Host-only Runner 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.runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.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
@@ -129,7 +129,7 @@ def test_prepare_box_scope_overwrites_untrusted_existing_scope():
assert json.loads(variables['_host_box_scope'])['target_id'] == 'user-1'
def test_project_mcp_resource_config_uses_independent_agent_runner_settings():
def test_project_mcp_resource_config_uses_independent_runner_settings():
query = pipeline_query.Query.model_construct(variables={})
attachments = [
{
+20 -20
View File
@@ -7,7 +7,7 @@ Tests focus on:
- RETRIEVE_KNOWLEDGE_BASE authorization
Authorization paths:
1. AgentRunner calls: has run_id, validates against session_registry
1. Runner calls: has run_id, validates against session_registry
2. Regular plugin calls: no run_id, unscoped plugin action path
"""
@@ -16,7 +16,7 @@ from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, MagicMock
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
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
@@ -122,9 +122,9 @@ class MockApplication:
self.persistence_mgr.execute_async = AsyncMock(return_value=MagicMock(first=lambda: None))
class FakeAgentRunnerRegistry:
class FakeRunnerRegistry:
async def get(self, context, runner_id, bound_plugins=None):
return AgentRunnerDescriptor(
return RunnerDescriptor(
id=runner_id,
source='plugin',
label={'en_US': 'Test Runner'},
@@ -161,7 +161,7 @@ class TestPipelineKnowledgeBaseScope:
@pytest.mark.asyncio
async def test_uses_runner_schema_when_query_scope_not_preprocessed(self):
app = MockApplication()
app.agent_runner_registry = FakeAgentRunnerRegistry()
app.runner_registry = FakeRunnerRegistry()
query = MockQuery()
query.variables = {}
@@ -458,15 +458,15 @@ class TestRetrieveKnowledgeBaseAuthorization:
class TestAuthorizationPathDifferentiation:
"""Tests that verify AgentRunner vs regular plugin call differentiation."""
"""Tests that verify Runner vs regular plugin call differentiation."""
@pytest.mark.asyncio
async def test_agent_runner_path_with_run_id(self):
"""AgentRunner calls provide run_id and use session_registry."""
async def test_runner_path_with_run_id(self):
"""Runner calls provide run_id and use session_registry."""
registry = AgentRunSessionRegistry()
# AgentRunner call has run_id
run_id = 'run_agent_123'
# Runner call has run_id
run_id = 'run_runner_123'
# Register session with resources
await registry.register(
@@ -548,7 +548,7 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
Fix: Now uses RunnerConfigResolver.resolve_runner_id first, then resolve_runner_config.
"""
def test_retrieve_kb_fix_local_agent_runner(self):
def test_retrieve_kb_fix_local_runner(self):
"""Fix should work for local-agent runner."""
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
@@ -830,8 +830,8 @@ class TestHandlerActionAuthorization:
await registry.unregister(run_id)
class TestSDKAgentRunAPIProxyFieldConsistency:
"""Tests for SDK AgentRunAPIProxy field name consistency with Host handler.
class TestSDKRunnerAPIProxyFieldConsistency:
"""Tests for SDK RunnerAPIProxy field name consistency with Host handler.
These tests verify that SDK sends field names that match what Host handler reads.
"""
@@ -901,7 +901,7 @@ class TestSDKAgentRunAPIProxyFieldConsistency:
class TestNoRunIdBackwardCompatPath:
"""Tests for unscoped plugin action path when no run_id is provided.
Regular plugins (non-AgentRunner) don't have run_id and should
Regular plugins (non-Runner) don't have run_id and should
have unrestricted access to certain APIs.
"""
@@ -1182,14 +1182,14 @@ class TestResourceTypeValidation:
class TestBypassPrevention:
"""Tests to ensure AgentRunAPIProxy cannot bypass authorization."""
"""Tests to ensure RunnerAPIProxy cannot bypass authorization."""
@pytest.mark.asyncio
async def test_cannot_bypass_via_unrestricted_retrieve_knowledge(self):
"""Cannot bypass KB authorization via unrestricted RETRIEVE_KNOWLEDGE action."""
# AgentRunAPIProxy uses RETRIEVE_KNOWLEDGE_BASE (with run_id)
# RunnerAPIProxy uses RETRIEVE_KNOWLEDGE_BASE (with run_id)
# RETRIEVE_KNOWLEDGE is unrestricted and separate
# AgentRunner should NOT use RETRIEVE_KNOWLEDGE to bypass authorization
# Runner should NOT use RETRIEVE_KNOWLEDGE to bypass authorization
registry = AgentRunSessionRegistry()
resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}])
@@ -1207,8 +1207,8 @@ class TestBypassPrevention:
# kb_002 is not authorized
assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_002') is False
# If AgentRunner tried to use RETRIEVE_KNOWLEDGE (unrestricted),
# it would bypass authorization - but AgentRunAPIProxy correctly uses
# If Runner tried to use RETRIEVE_KNOWLEDGE (unrestricted),
# it would bypass authorization - but RunnerAPIProxy correctly uses
# RETRIE_KNOWLEDGE_BASE which requires authorization
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
@@ -2006,7 +2006,7 @@ class TestCallerPluginIdentityValidation:
class TestBackwardCompatStorageNoRunId:
"""Tests for unscoped storage actions without run_id.
Regular plugins (non-AgentRunner) don't have run_id and should
Regular plugins (non-Runner) don't have run_id and should
have unrestricted access to storage APIs.
"""
@@ -1,4 +1,5 @@
"""Tests for AgentRunner history/event pull API authorization."""
"""Tests for Runner history/event pull API authorization."""
from __future__ import annotations
from unittest.mock import MagicMock
@@ -11,7 +12,7 @@ from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
from langbot.pkg.entity.persistence import event_log as event_log_model
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
from langbot_plugin.api.entities.builtin.agent_runner.page_results import (
from langbot_plugin.api.entities.builtin.runner.page_results import (
AgentEventRecord,
EventPage,
)
@@ -92,10 +93,12 @@ async def test_history_page_requires_runtime_capability(session_registry, db_eng
handler = _handler(db_engine, session_registry)
history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value]
result = await history_page({
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
})
result = await history_page(
{
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'not authorized' in result.message.lower()
@@ -107,11 +110,13 @@ async def test_history_page_rejects_cross_conversation(session_registry, db_engi
handler = _handler(db_engine, session_registry)
history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value]
result = await history_page({
'run_id': 'run_1',
'conversation_id': 'conv_other',
'caller_plugin_identity': 'test/runner',
})
result = await history_page(
{
'run_id': 'run_1',
'conversation_id': 'conv_other',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'not accessible' in result.message.lower()
@@ -123,12 +128,14 @@ async def test_history_search_rejects_filter_conversation_override(session_regis
handler = _handler(db_engine, session_registry)
history_search = handler.actions[PluginToRuntimeAction.HISTORY_SEARCH.value]
result = await history_search({
'run_id': 'run_1',
'query': 'hello',
'filters': {'conversation_id': 'conv_other'},
'caller_plugin_identity': 'test/runner',
})
result = await history_search(
{
'run_id': 'run_1',
'query': 'hello',
'filters': {'conversation_id': 'conv_other'},
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'not accessible' in result.message.lower()
@@ -140,10 +147,12 @@ async def test_event_page_requires_runtime_capability(session_registry, db_engin
handler = _handler(db_engine, session_registry)
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
result = await event_page({
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
})
result = await event_page(
{
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'not authorized' in result.message.lower()
@@ -155,11 +164,13 @@ async def test_event_page_rejects_cross_conversation(session_registry, db_engine
handler = _handler(db_engine, session_registry)
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
result = await event_page({
'run_id': 'run_1',
'conversation_id': 'conv_other',
'caller_plugin_identity': 'test/runner',
})
result = await event_page(
{
'run_id': 'run_1',
'conversation_id': 'conv_other',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'not accessible' in result.message.lower()
@@ -184,11 +195,13 @@ async def test_event_get_returns_sdk_record_projection(session_registry, db_engi
handler = _handler(db_engine, session_registry)
event_get = handler.actions[PluginToRuntimeAction.EVENT_GET.value]
result = await event_get({
'run_id': 'run_1',
'event_id': event_id,
'caller_plugin_identity': 'test/runner',
})
result = await event_get(
{
'run_id': 'run_1',
'event_id': event_id,
'caller_plugin_identity': 'test/runner',
}
)
assert result.code == 0
AgentEventRecord.model_validate(result.data)
@@ -216,10 +229,12 @@ async def test_event_page_returns_sdk_page_projection(session_registry, db_engin
handler = _handler(db_engine, session_registry)
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
result = await event_page({
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
})
result = await event_page(
{
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code == 0
page = EventPage.model_validate(result.data)
@@ -272,10 +287,12 @@ async def test_history_page_filters_run_scope_thread_and_bot(session_registry, d
handler = _handler(db_engine, session_registry)
history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value]
result = await history_page({
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
})
result = await history_page(
{
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code == 0
assert [item['content'] for item in result.data['items']] == ['visible']
@@ -317,10 +334,12 @@ async def test_event_page_filters_run_scope_thread_and_bot(session_registry, db_
handler = _handler(db_engine, session_registry)
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
result = await event_page({
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
})
result = await event_page(
{
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code == 0
assert [item['event_id'] for item in result.data['items']] == ['evt_visible']
@@ -8,10 +8,10 @@ import time
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.interaction import (
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.event import ActorContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.interaction import (
InteractionDeliveryCapabilities,
InteractionSubmission,
)
@@ -10,7 +10,7 @@ from unittest.mock import AsyncMock
import pytest
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.errors import RunnerExecutionError
from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
@@ -84,7 +84,7 @@ class FakePluginConnector:
self.contexts: list[dict] = []
self.sessions_during_run: list[dict | None] = []
async def run_agent(self, plugin_author, plugin_name, runner_name, context):
async def run_runner(self, plugin_author, plugin_name, runner_name, context):
self.calls.append(
{
'plugin_author': plugin_author,
@@ -105,7 +105,7 @@ class FakePluginConnector:
class FakeRegistry:
def __init__(self, descriptor: AgentRunnerDescriptor):
def __init__(self, descriptor: RunnerDescriptor):
self.descriptor = descriptor
self.calls: list[dict] = []
@@ -162,8 +162,8 @@ class FakeConversation:
create_time = datetime.datetime(2026, 5, 15, 12, 0, 0)
def make_descriptor() -> AgentRunnerDescriptor:
return AgentRunnerDescriptor(
def make_descriptor() -> RunnerDescriptor:
return RunnerDescriptor(
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
@@ -798,7 +798,7 @@ async def test_unconsumed_steering_audit_does_not_persist_pinned_context(clean_a
self.started = asyncio.Event()
self.release = asyncio.Event()
async def run_agent(self, plugin_author, plugin_name, runner_name, context):
async def run_runner(self, plugin_author, plugin_name, runner_name, context):
self.calls.append(
{
'plugin_author': plugin_author,
@@ -998,8 +998,8 @@ class TestQueryEntrySessionQueryId:
DeliveryPolicy,
ResourcePolicy,
)
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
db_engine = clean_agent_state
descriptor = make_descriptor()
@@ -2,7 +2,7 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot_plugin.api.entities.builtin.agent_runner import (
from langbot_plugin.api.entities.builtin.runner import (
ActorContext,
AgentInput,
DeliveryContext,
+39 -37
View File
@@ -6,8 +6,8 @@ from unittest.mock import AsyncMock
import pytest
from langbot.pkg.agent.runner.registry import AgentRunnerRegistry
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.registry import RunnerRegistry
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.errors import RunnerNotFoundError, RunnerNotAuthorizedError
from langbot.pkg.api.http.context import ExecutionContext
@@ -44,7 +44,7 @@ class FakeApplication:
async def require_workspace_context(self, context):
return context
async def list_agent_runners(self, bound_plugins=None):
async def list_runners(self, bound_plugins=None):
# Return sample runner data
return [
{
@@ -90,7 +90,7 @@ class FakeApplication:
'plugin_name': 'missing-name',
'runner_name': 'default',
'manifest': {
'kind': 'AgentRunner',
'kind': 'Runner',
'metadata': {}, # No name
'spec': {},
},
@@ -107,7 +107,7 @@ class TestRegistryDiscovery:
async def test_discover_valid_runners(self):
"""Discover valid runners from plugin runtime."""
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
runners = await registry.list_runners(TEST_CONTEXT, use_cache=False)
@@ -122,7 +122,7 @@ class TestRegistryDiscovery:
async def test_discover_caches_results(self):
"""Discovery should cache results."""
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
# First discovery
runners1 = await registry.list_runners(TEST_CONTEXT, use_cache=True)
@@ -138,7 +138,7 @@ class TestRegistryDiscovery:
"""Discovery returns empty when plugin system disabled."""
ap = FakeApplication()
ap.plugin_connector.is_enable_plugin = False
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
runners = await registry.list_runners(TEST_CONTEXT, use_cache=False)
@@ -152,7 +152,7 @@ class TestRegistryDiscovery:
so subsequent list_runners(bound_plugins=None) should return all runners.
"""
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
# First: get with bound_plugins filter (should not pollute cache)
descriptor = await registry.get(
@@ -189,7 +189,7 @@ class TestRegistryGet:
async def test_get_existing_runner(self):
"""Get existing runner by ID."""
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
descriptor = await registry.get(
TEST_CONTEXT,
@@ -205,7 +205,7 @@ class TestRegistryGet:
async def test_get_nonexistent_runner(self):
"""Get nonexistent runner raises RunnerNotFoundError."""
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
with pytest.raises(RunnerNotFoundError) as exc_info:
await registry.get(TEST_CONTEXT, 'plugin:notexist/unknown/default')
@@ -216,10 +216,10 @@ class TestRegistryGet:
async def test_get_refreshes_partial_startup_cache_on_miss(self):
"""A runner initialized after early discovery should become available."""
ap = FakeApplication()
ap.plugin_connector.list_agent_runners = AsyncMock(
side_effect=ap.plugin_connector.list_agent_runners,
ap.plugin_connector.list_runners = AsyncMock(
side_effect=ap.plugin_connector.list_runners,
)
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
await registry.list_runners(TEST_CONTEXT)
cache = registry._cache[('instance-test', 'workspace-test', 1)]
@@ -231,13 +231,13 @@ class TestRegistryGet:
)
assert descriptor.id == 'plugin:alice/my-agent/custom'
assert ap.plugin_connector.list_agent_runners.await_count == 2
assert ap.plugin_connector.list_runners.await_count == 2
@pytest.mark.asyncio
async def test_get_runner_with_bound_plugins_filter(self):
"""Get runner with bound plugins authorization."""
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
# Authorized - langbot plugin in bound list
descriptor = await registry.get(
@@ -263,7 +263,7 @@ class TestRegistryMetadataForPipeline:
async def test_get_metadata_options_and_stages(self):
"""Get metadata options and stages for pipeline UI."""
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
options, stages = await registry.get_runner_metadata_for_pipeline(TEST_CONTEXT)
@@ -284,10 +284,10 @@ class TestRegistryMetadataForPipeline:
async def test_metadata_refreshes_partial_startup_cache(self):
"""Pipeline metadata should not preserve an early partial discovery."""
ap = FakeApplication()
ap.plugin_connector.list_agent_runners = AsyncMock(
side_effect=ap.plugin_connector.list_agent_runners,
ap.plugin_connector.list_runners = AsyncMock(
side_effect=ap.plugin_connector.list_runners,
)
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
await registry.list_runners(TEST_CONTEXT)
cache = registry._cache[('instance-test', 'workspace-test', 1)]
@@ -299,7 +299,7 @@ class TestRegistryMetadataForPipeline:
'plugin:langbot-team/LocalAgent/default',
'plugin:alice/my-agent/custom',
}
assert ap.plugin_connector.list_agent_runners.await_count == 2
assert ap.plugin_connector.list_runners.await_count == 2
class TestDescriptorValidation:
@@ -307,7 +307,7 @@ class TestDescriptorValidation:
def test_validate_runner_descriptor(self):
"""Validate correctly built descriptor."""
descriptor = AgentRunnerDescriptor(
descriptor = RunnerDescriptor(
id='plugin:test/my-runner/default',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -318,11 +318,11 @@ class TestDescriptorValidation:
assert descriptor.id == 'plugin:test/my-runner/default'
assert descriptor.get_plugin_id() == 'test/my-runner'
assert 'protocol_version' not in AgentRunnerDescriptor.model_fields
assert 'protocol_version' not in RunnerDescriptor.model_fields
def test_descriptor_capabilities(self):
"""Descriptor capability helper methods."""
descriptor = AgentRunnerDescriptor(
descriptor = RunnerDescriptor(
id='plugin:test/my-runner/default',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -338,28 +338,30 @@ class TestDescriptorValidation:
@pytest.mark.asyncio
async def test_registry_separates_processor_kinds_with_same_plugin_component_name():
async def test_registry_filters_usages_without_splitting_component_identity():
ap = FakeApplication()
entries = []
for kind, prefix in [('AgentRunner', 'plugin'), ('EventProcessor', 'event_processor')]:
for name, usages in [('agent', ['agent']), ('events', ['event']), ('both', ['agent', 'event'])]:
entries.append(
{
'plugin_author': 'test',
'plugin_name': 'both',
'runner_name': 'default',
'plugin_name': 'runners',
'runner_name': name,
'manifest': {
'id': f'{prefix}:test/both/default',
'name': 'default',
'component_kind': kind,
'label': {'en_US': kind},
'id': f'plugin:test/runners/{name}',
'name': name,
'component_kind': 'Runner',
'usages': usages,
'label': {'en_US': name},
'supported_event_patterns': ['group.member_joined'],
},
}
)
ap.plugin_connector.list_agent_runners = AsyncMock(return_value=entries)
registry = AgentRunnerRegistry(ap)
ap.plugin_connector.list_runners = AsyncMock(return_value=entries)
registry = RunnerRegistry(ap)
agents = await registry.list_runners(TEST_CONTEXT)
processors = await registry.list_runners(TEST_CONTEXT, component_kind='EventProcessor')
assert [item.id for item in agents] == ['plugin:test/both/default']
assert [item.id for item in processors] == ['event_processor:test/both/default']
assert (await registry.get(TEST_CONTEXT, processors[0].id)).component_kind == 'EventProcessor'
processors = await registry.list_runners(TEST_CONTEXT, usage='event')
assert [item.runner_name for item in agents] == ['agent', 'both']
assert [item.runner_name for item in processors] == ['events', 'both']
assert agents[-1].id == processors[-1].id
assert (await registry.get(TEST_CONTEXT, processors[0].id)).usages == ['event']
+23 -14
View File
@@ -7,9 +7,9 @@ from uuid import uuid4
import pytest
from langbot_plugin.api.entities.builtin.platform import events, entities, message
from langbot_plugin.api.entities.builtin.agent_runner.context_access import ContextAPICapabilities
from langbot_plugin.api.proxies.agent_run import AgentRunAPIProxy
from langbot_plugin.api.proxies.agent_run.common import PermissionDeniedError
from langbot_plugin.api.entities.builtin.runner.context_access import ContextAPICapabilities
from langbot_plugin.api.proxies.runner import RunnerAPIProxy
from langbot_plugin.api.proxies.runner.common import PermissionDeniedError
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from langbot.pkg.agent.runner.reply_stream import ReplyStreamRequest, ReplyStreamSession
@@ -50,7 +50,7 @@ def request(key, operation='update', text='hello'):
def proxy_for(session, *, allowed=True, advertised=True):
if not hasattr(AgentRunAPIProxy, 'reply_stream'):
if not hasattr(RunnerAPIProxy, 'reply_stream'):
pytest.skip('SDK does not provide the optional streaming reply API')
context = SimpleNamespace(
run_id='run-1',
@@ -73,7 +73,7 @@ def proxy_for(session, *, allowed=True, advertised=True):
}
transport = SimpleNamespace(call_action=AsyncMock(side_effect=action))
return AgentRunAPIProxy(context, transport), transport
return RunnerAPIProxy(context, transport), transport
@pytest.mark.parametrize(
@@ -211,11 +211,11 @@ async def test_streams_are_isolated_by_run_and_bounded():
async def test_event_processor_uses_shared_sdk_api_and_emits_one_trace_for_the_stream():
from unittest.mock import Mock
from langbot_plugin.api.definition.components.event_processor import EventProcessor
from langbot_plugin.api.definition.components.runner import Runner, RunnerContext
session, adapter, incoming = make_session()
api, _ = proxy_for(session)
processor = EventProcessor()
processor = Runner()
processor.get_run_api = Mock(return_value=api)
@processor.handler(events.MessageReceivedEvent)
@@ -224,14 +224,23 @@ async def test_event_processor_uses_shared_sdk_api_and_emits_one_trace_for_the_s
await stream.update('one')
await stream.update('one two')
context = SimpleNamespace(
run_id='run-1',
config={},
event=SimpleNamespace(
data=incoming.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'})
),
context = RunnerContext.model_validate(
{
'run_id': 'run-1',
'trigger': {'type': incoming.type},
'event': {
'event_id': 'one',
'event_type': incoming.type,
'source': 'test',
'data': incoming.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'}),
},
'input': {},
'delivery': {'surface': 'test'},
'resources': {},
'runtime': {},
}
)
results = [result async for result in processor.run(context)]
results = [result async for result in processor.invoke(context)]
assert [r.type for r in results] == ['tool.call.started', 'tool.call.completed', 'run.completed']
assert results[1].data['result']['text'] == 'one two'
assert adapter.reply_message_chunk.await_count == 3
@@ -6,9 +6,9 @@ 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_plugin.api.entities.builtin.runner import AgentInput, DeliveryContext
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
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
@@ -37,8 +37,8 @@ def make_descriptor(
config_schema: list[dict] | None = None,
capabilities: dict | None = None,
permissions: dict | None = None,
) -> AgentRunnerDescriptor:
return AgentRunnerDescriptor(
) -> RunnerDescriptor:
return RunnerDescriptor(
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Test Runner'},
@@ -1,4 +1,4 @@
"""Tests for generic AgentRunner resource-policy projection."""
"""Tests for generic Runner resource-policy projection."""
from types import SimpleNamespace
@@ -5,7 +5,7 @@ from __future__ import annotations
import pytest
from langbot.pkg.agent.runner.result_normalizer import AgentResultNormalizer
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.errors import RunnerExecutionError, RunnerProtocolError
from langbot_plugin.api.entities.builtin.provider import message as provider_message
@@ -36,7 +36,7 @@ class FakeApplication:
def make_descriptor():
"""Create a test descriptor."""
return AgentRunnerDescriptor(
return RunnerDescriptor(
id='plugin:langbot-team/LocalAgent/default',
source='plugin',
label={'en_US': 'Local Agent', 'zh_Hans': '内置 Agent'},
@@ -1,4 +1,4 @@
"""Tests for AgentRunner run ledger pull API authorization."""
"""Tests for Runner run ledger pull API authorization."""
from __future__ import annotations
@@ -17,7 +17,7 @@ from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
from langbot.pkg.entity.persistence import agent_run as agent_run_model
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
from langbot_plugin.api.entities.builtin.agent_runner.run_ledger import (
from langbot_plugin.api.entities.builtin.runner.run_ledger import (
AgentRun,
AgentRunEvent,
RunEventPage,
@@ -37,10 +37,10 @@ class FakeApplication:
self.logger = MagicMock()
self.persistence_mgr = MagicMock()
self.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
self.agent_runner_registry = runner_registry
self.runner_registry = runner_registry
self.instance_config = SimpleNamespace(
data={
'agent_runner': {
'runner': {
'admin_plugins': admin_plugins or [],
}
}
+131 -93
View File
@@ -17,6 +17,7 @@ Authorization rules:
- enable_state must be True
- scope must be in state_scopes
"""
from __future__ import annotations
import pytest
@@ -34,11 +35,13 @@ from .conftest import bind_runtime_action_context, make_resources
class FakeConnection:
"""Fake connection for testing."""
pass
class FakeApplication:
"""Fake Application for testing."""
def __init__(self, db_engine=None):
self.logger = MagicMock()
self.logger.debug = MagicMock()
@@ -77,10 +80,10 @@ async def persistent_store(db_engine):
store = PersistentStateStore(db_engine)
# Create the table
from langbot.pkg.entity.persistence.agent_runner_state import AgentRunnerState
from langbot.pkg.entity.persistence.runner_state import RunnerState
async with db_engine.begin() as conn:
await conn.run_sync(AgentRunnerState.__table__.create, checkfirst=True)
await conn.run_sync(RunnerState.__table__.create, checkfirst=True)
yield store
reset_persistent_state_store()
@@ -124,11 +127,13 @@ class TestStateAPIHandlerAuthorization:
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
# Call with non-existent run_id
result = await state_get_handler({
'run_id': 'nonexistent_run',
'scope': 'conversation',
'key': 'test_key',
})
result = await state_get_handler(
{
'run_id': 'nonexistent_run',
'scope': 'conversation',
'key': 'test_key',
}
)
assert result.code != 0
assert 'not found' in result.message.lower()
@@ -164,11 +169,13 @@ class TestStateAPIHandlerAuthorization:
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
# Call without caller_plugin_identity
result = await state_get_handler({
'run_id': 'run_test_missing_identity',
'scope': 'conversation',
'key': 'test_key',
})
result = await state_get_handler(
{
'run_id': 'run_test_missing_identity',
'scope': 'conversation',
'key': 'test_key',
}
)
assert result.code == 0
assert result.data == {'value': None}
@@ -176,7 +183,9 @@ class TestStateAPIHandlerAuthorization:
await session_registry.unregister('run_test_missing_identity')
@pytest.mark.asyncio
async def test_state_get_caller_identity_mismatch_returns_error(self, session_registry, db_engine, persistent_store):
async def test_state_get_caller_identity_mismatch_returns_error(
self, session_registry, db_engine, persistent_store
):
"""STATE_GET: caller_plugin_identity mismatch returns error."""
fake_app = FakeApplication(db_engine)
fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
@@ -200,12 +209,14 @@ class TestStateAPIHandlerAuthorization:
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
# Call with wrong caller_plugin_identity
result = await state_get_handler({
'run_id': 'run_test_mismatch',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'other/plugin',
})
result = await state_get_handler(
{
'run_id': 'run_test_mismatch',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'other/plugin',
}
)
assert result.code != 0
assert 'does not match' in result.message.lower()
@@ -236,12 +247,14 @@ class TestStateAPIHandlerAuthorization:
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
result = await state_get_handler({
'run_id': 'run_test_disabled',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
})
result = await state_get_handler(
{
'run_id': 'run_test_disabled',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'disabled' in result.message.lower()
@@ -262,7 +275,10 @@ class TestStateAPIHandlerAuthorization:
resources=make_resources(),
available_apis={'state': True},
state_policy={'enable_state': True, 'state_scopes': ['conversation']},
state_context={'scope_keys': {'conversation': 'conv_key', 'actor': 'actor_key'}, 'binding_identity': 'binding_1'},
state_context={
'scope_keys': {'conversation': 'conv_key', 'actor': 'actor_key'},
'binding_identity': 'binding_1',
},
)
async def fake_disconnect():
@@ -273,12 +289,14 @@ class TestStateAPIHandlerAuthorization:
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
# Request 'actor' scope which is not in state_scopes
result = await state_get_handler({
'run_id': 'run_test_scope_disabled',
'scope': 'actor',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
})
result = await state_get_handler(
{
'run_id': 'run_test_scope_disabled',
'scope': 'actor',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'not enabled' in result.message.lower() or 'scope' in result.message.lower()
@@ -309,12 +327,14 @@ class TestStateAPIHandlerAuthorization:
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
result = await state_get_handler({
'run_id': 'run_test_no_scope_key',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
})
result = await state_get_handler(
{
'run_id': 'run_test_no_scope_key',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'not available' in result.message.lower()
@@ -360,9 +380,11 @@ class TestStateAPIFullFlowWithRealDB:
session = await session_registry.get('run_full_flow')
assert session is not None
state_ctx = session['authorization']['state_context']
assert state_ctx is not None, f"state_context is None. Session keys: {list(session.keys())}"
assert 'scope_keys' in state_ctx, f"scope_keys not in state_context: {state_ctx}"
assert 'conversation' in state_ctx['scope_keys'], f"conversation not in scope_keys: {state_ctx['scope_keys']}"
assert state_ctx is not None, f'state_context is None. Session keys: {list(session.keys())}'
assert 'scope_keys' in state_ctx, f'scope_keys not in state_context: {state_ctx}'
assert 'conversation' in state_ctx['scope_keys'], (
f'conversation not in scope_keys: {state_ctx["scope_keys"]}'
)
# Get handlers (actions dict is keyed by action value string)
state_set_handler = handler.actions[PluginToRuntimeAction.STATE_SET.value]
@@ -371,57 +393,67 @@ class TestStateAPIFullFlowWithRealDB:
state_delete_handler = handler.actions[PluginToRuntimeAction.STATE_DELETE.value]
# 1. STATE_SET
set_result = await state_set_handler({
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'value': {'data': 'test_value'},
'caller_plugin_identity': 'test/runner',
})
set_result = await state_set_handler(
{
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'value': {'data': 'test_value'},
'caller_plugin_identity': 'test/runner',
}
)
assert set_result.code == 0
assert set_result.data.get('success') is True
# 2. STATE_GET
get_result = await state_get_handler({
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'caller_plugin_identity': 'test/runner',
})
get_result = await state_get_handler(
{
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'caller_plugin_identity': 'test/runner',
}
)
assert get_result.code == 0
assert get_result.data.get('value') == {'data': 'test_value'}
# 3. STATE_LIST
list_result = await state_list_handler({
'run_id': 'run_full_flow',
'scope': 'conversation',
'prefix': 'external.',
'caller_plugin_identity': 'test/runner',
})
list_result = await state_list_handler(
{
'run_id': 'run_full_flow',
'scope': 'conversation',
'prefix': 'external.',
'caller_plugin_identity': 'test/runner',
}
)
assert list_result.code == 0
keys = list_result.data.get('keys', [])
assert 'external.test_key' in keys
# 4. STATE_DELETE
delete_result = await state_delete_handler({
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'caller_plugin_identity': 'test/runner',
})
delete_result = await state_delete_handler(
{
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'caller_plugin_identity': 'test/runner',
}
)
assert delete_result.code == 0
# 5. Verify deleted
get_after_delete = await state_get_handler({
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'caller_plugin_identity': 'test/runner',
})
get_after_delete = await state_get_handler(
{
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'caller_plugin_identity': 'test/runner',
}
)
assert get_after_delete.code == 0
assert get_after_delete.data.get('value') is None
@@ -433,7 +465,9 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
"""Tests verifying handlers read state_policy/state_context from authorization snapshot."""
@pytest.mark.asyncio
async def test_state_handler_reads_state_policy_from_authorization(self, session_registry, db_engine, persistent_store):
async def test_state_handler_reads_state_policy_from_authorization(
self, session_registry, db_engine, persistent_store
):
"""Handler reads state_policy from session['authorization'], not resources."""
fake_app = FakeApplication(db_engine)
fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
@@ -454,7 +488,7 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
session = await session_registry.get('run_policy_top_level')
assert session is not None
resources = session['authorization']['resources']
assert 'state_policy' not in resources, "resources should NOT contain state_policy"
assert 'state_policy' not in resources, 'resources should NOT contain state_policy'
async def fake_disconnect():
return True
@@ -464,12 +498,14 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
# Should fail because enable_state=False in authorization.state_policy
result = await state_get_handler({
'run_id': 'run_policy_top_level',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
})
result = await state_get_handler(
{
'run_id': 'run_policy_top_level',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'disabled' in result.message.lower()
@@ -477,7 +513,9 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
await session_registry.unregister('run_policy_top_level')
@pytest.mark.asyncio
async def test_state_handler_reads_state_context_from_authorization(self, session_registry, db_engine, persistent_store):
async def test_state_handler_reads_state_context_from_authorization(
self, session_registry, db_engine, persistent_store
):
"""Handler reads state_context from session['authorization'], not resources."""
fake_app = FakeApplication(db_engine)
fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
@@ -498,7 +536,7 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
session = await session_registry.get('run_context_top_level')
assert session is not None
resources = session['authorization']['resources']
assert 'state_context' not in resources, "resources should NOT contain state_context"
assert 'state_context' not in resources, 'resources should NOT contain state_context'
async def fake_disconnect():
return True
@@ -508,13 +546,15 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
state_set_handler = handler.actions[PluginToRuntimeAction.STATE_SET.value]
# Should use scope_key from authorization.state_context.scope_keys.conversation
result = await state_set_handler({
'run_id': 'run_context_top_level',
'scope': 'conversation',
'key': 'test_key',
'value': 'test_value',
'caller_plugin_identity': 'test/runner',
})
result = await state_set_handler(
{
'run_id': 'run_context_top_level',
'scope': 'conversation',
'key': 'test_key',
'value': 'test_value',
'caller_plugin_identity': 'test/runner',
}
)
# Should succeed - scope_key was found in state_context
assert result.code == 0
@@ -546,10 +586,8 @@ class TestResourcesDoesNotContainStateMetadata:
# Verify resources is nested under authorization and is clean.
assert 'resources' not in session
session_resources = session['authorization']['resources']
assert 'state_policy' not in session_resources, \
"authorization['resources'] should NOT contain state_policy"
assert 'state_context' not in session_resources, \
"authorization['resources'] should NOT contain state_context"
assert 'state_policy' not in session_resources, "authorization['resources'] should NOT contain state_policy"
assert 'state_context' not in session_resources, "authorization['resources'] should NOT contain state_context"
assert 'state_policy' in session['authorization']
assert 'state_context' in session['authorization']
+12 -11
View File
@@ -1,4 +1,5 @@
"""Tests for persistent AgentRunner state store."""
"""Tests for persistent Runner state store."""
from __future__ import annotations
import asyncio
@@ -8,7 +9,7 @@ import tempfile
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.host_models import BindingScope, StatePolicy
from langbot.pkg.agent.runner.persistent_state_store import PersistentStateStore
from langbot.pkg.agent.runner.state_scope import (
@@ -21,9 +22,9 @@ from langbot.pkg.agent.runner.state_scope import (
)
def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> AgentRunnerDescriptor:
def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> RunnerDescriptor:
"""Create a test descriptor."""
return AgentRunnerDescriptor(
return RunnerDescriptor(
id=runner_id,
source='plugin',
label={'en_US': 'Test Runner'},
@@ -36,6 +37,7 @@ def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> AgentRu
class FakeActorContext:
"""Fake actor context for event testing."""
def __init__(self, actor_type: str = 'user', actor_id: str = 'user_123', actor_name: str = 'Test User'):
self.actor_type = actor_type
self.actor_id = actor_id
@@ -44,6 +46,7 @@ class FakeActorContext:
class FakeSubjectContext:
"""Fake subject context for event testing."""
def __init__(self, subject_type: str = 'message', subject_id: str = 'msg_001', data: dict | None = None):
self.subject_type = subject_type
self.subject_id = subject_id
@@ -52,6 +55,7 @@ class FakeSubjectContext:
class FakeEventEnvelope:
"""Fake event envelope for testing event-first state."""
def __init__(
self,
event_id: str = 'evt_001',
@@ -78,6 +82,7 @@ class FakeEventEnvelope:
class FakeBinding:
"""Fake binding for testing state."""
def __init__(
self,
binding_id: str = 'binding_001',
@@ -119,10 +124,7 @@ class TestStateScopeHelpers:
thread_id='thread_001',
)
keys = {
scope: build_state_scope_key(scope, event, binding, descriptor)
for scope in VALID_STATE_SCOPES
}
keys = {scope: build_state_scope_key(scope, event, binding, descriptor) for scope in VALID_STATE_SCOPES}
assert keys['conversation'].startswith('conversation:v2:')
assert keys['actor'].startswith('actor:v2:')
@@ -168,6 +170,7 @@ class TestPersistentStateStore:
engine = create_async_engine(f'sqlite+aiosqlite:///{db_path}', echo=False)
from langbot.pkg.entity.persistence.base import Base
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -366,9 +369,7 @@ class TestPersistentStateStore:
event = FakeEventEnvelope(conversation_id='conv_001')
binding = FakeBinding()
await persistent_store.apply_update_from_event(
event, binding, descriptor, 'conversation', 'key', 'value', None
)
await persistent_store.apply_update_from_event(event, binding, descriptor, 'conversation', 'key', 'value', None)
snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor)
assert snapshot['conversation']['key'] == 'value'