mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(processors): add explicitly bound plugin event processors
This commit is contained in:
@@ -74,6 +74,9 @@ def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
|
||||
AgentRunner:
|
||||
fromDirs:
|
||||
- path: components/agent_runner/
|
||||
EventProcessor:
|
||||
fromDirs:
|
||||
- path: components/event_processor/
|
||||
pages: []
|
||||
execution:
|
||||
python:
|
||||
@@ -160,6 +163,45 @@ def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
processor_dir = plugin_root / 'components' / 'event_processor'
|
||||
processor_dir.mkdir(parents=True)
|
||||
(processor_dir / 'default.yaml').write_text(
|
||||
textwrap.dedent("""
|
||||
apiVersion: langbot/v1
|
||||
kind: EventProcessor
|
||||
metadata:
|
||||
name: default
|
||||
label: {en_US: Welcome processor, zh_Hans: Welcome processor}
|
||||
spec:
|
||||
events: [group.member_joined]
|
||||
config:
|
||||
- name: greeting
|
||||
type: string
|
||||
required: true
|
||||
label: {en_US: Greeting, zh_Hans: Greeting}
|
||||
default: Hello
|
||||
capabilities: {tool_calling: true}
|
||||
permissions:
|
||||
tools: [detail, call]
|
||||
execution:
|
||||
python: {path: default.py, attr: WelcomeProcessor}
|
||||
""")
|
||||
)
|
||||
(processor_dir / 'default.py').write_text(
|
||||
textwrap.dedent("""
|
||||
from langbot_plugin.api.definition.components.event_processor import EventProcessor, EventProcessorContext
|
||||
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
|
||||
|
||||
class WelcomeProcessor(EventProcessor):
|
||||
async def initialize(self):
|
||||
@self.handler(MemberJoinedEvent)
|
||||
async def handle(ctx: EventProcessorContext):
|
||||
await ctx.log('Handling ' + str(ctx.event.member.id))
|
||||
result = await ctx.reply(ctx.config['greeting'] + ', ' + (ctx.event.member.nickname or str(ctx.event.member.id)))
|
||||
await ctx.log('Reply simulated: ' + str(result.get('mock')))
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
"""Reserve a currently-free localhost TCP port for this E2E process."""
|
||||
@@ -384,6 +426,7 @@ def test_plugin_runtime_discovers_agent_runner(
|
||||
f'Runtime stderr (tail):\n{runtime_stderr[-20_000:]}'
|
||||
)
|
||||
|
||||
|
||||
def test_host_orchestrator_runs_agent_runner_and_records_ledger(
|
||||
agent_runner_client,
|
||||
agent_runner_langbot_process,
|
||||
@@ -455,8 +498,7 @@ def test_host_orchestrator_runs_agent_runner_and_records_ledger(
|
||||
event_types = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
'SELECT type FROM agent_run_event WHERE run_id = '
|
||||
'(SELECT run_id FROM agent_run WHERE event_id = ?)',
|
||||
'SELECT type FROM agent_run_event WHERE run_id = (SELECT run_id FROM agent_run WHERE event_id = ?)',
|
||||
(result['event_id'],),
|
||||
).fetchall()
|
||||
}
|
||||
@@ -469,3 +511,63 @@ def test_host_orchestrator_runs_agent_runner_and_records_ledger(
|
||||
assert '"count": 1' in state_row[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_event_processor_real_runtime_logs_actions_and_instance_isolation(
|
||||
agent_runner_client,
|
||||
agent_runner_e2e_tmpdir,
|
||||
):
|
||||
client = agent_runner_client
|
||||
token = _init_and_auth(client)
|
||||
_ensure_qa_plugin(client, token, agent_runner_e2e_tmpdir / 'agent-runner-qa.zip')
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
metadata_response = client.get('/api/v1/agents/_/metadata', headers=headers).json()
|
||||
assert metadata_response['code'] == 0, metadata_response
|
||||
metadata = metadata_response['data']
|
||||
ref = 'event_processor:e2e/agent-runner-qa/default'
|
||||
assert any(item['id'] == ref for item in metadata['event_processors']), metadata
|
||||
assert ref not in _wait_for_qa_runner(client, token)
|
||||
created = []
|
||||
for name in ('First', 'Second'):
|
||||
response = client.post(
|
||||
'/api/v1/agents',
|
||||
headers=headers,
|
||||
json={
|
||||
'kind': 'event_processor',
|
||||
'name': name,
|
||||
'component_ref': ref,
|
||||
'parameters': {'greeting': name},
|
||||
},
|
||||
).json()
|
||||
assert response['code'] == 0, response
|
||||
created.append(response['data']['uuid'])
|
||||
for processor_id in created:
|
||||
page = client.get(f'/api/v1/agents/{processor_id}/runs', headers=headers).json()
|
||||
assert page['data']['items'] == [], page
|
||||
result = client.post(
|
||||
f'/api/v1/agents/{created[0]}/debug',
|
||||
headers=headers,
|
||||
json={
|
||||
'event_type': 'group.member_joined',
|
||||
'data': {'member': {'id': 'member-1', 'nickname': 'Tester'}, 'group': {'id': 'group-1'}},
|
||||
},
|
||||
).json()
|
||||
assert result['code'] == 0, result
|
||||
logs = [event['data']['text'] for event in result['data']['execution_events'] if event['type'] == 'processor.log']
|
||||
assert logs == ['Handling member-1', 'Reply simulated: True'], result
|
||||
actions = [event for event in result['data']['execution_events'] if event['type'] == 'tool.call.completed']
|
||||
assert len(actions) == 1, result
|
||||
assert actions[0]['data']['tool_name'] == 'event_reply'
|
||||
assert actions[0]['data']['result']['mock'] is True
|
||||
assert result['data']['final_text'] == '', result
|
||||
page = client.get(f'/api/v1/agents/{created[0]}/runs', headers=headers).json()['data']
|
||||
assert len(page['items']) == 1, page
|
||||
run = page['items'][0]
|
||||
assert run['status'] == 'completed', run
|
||||
assert run['metadata']['input_event']['member']['id'] == 'member-1'
|
||||
trace = client.get(f'/api/v1/agents/{created[0]}/runs/{run["run_id"]}/events', headers=headers).json()
|
||||
assert trace['code'] == 0, trace
|
||||
assert any(item['type'] == 'processor.log' for item in trace['data']['items']), trace
|
||||
foreign = client.get(f'/api/v1/agents/{created[1]}/runs/{run["run_id"]}/events', headers=headers)
|
||||
assert foreign.status_code == 400
|
||||
assert client.get(f'/api/v1/agents/{created[1]}/runs', headers=headers).json()['data']['items'] == []
|
||||
|
||||
@@ -335,3 +335,31 @@ class TestDescriptorValidation:
|
||||
assert descriptor.supports_streaming() is True
|
||||
assert descriptor.supports_tool_calling() is False
|
||||
assert descriptor.supports_knowledge_retrieval() is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_separates_processor_kinds_with_same_plugin_component_name():
|
||||
ap = FakeApplication()
|
||||
entries = []
|
||||
for kind, prefix in [('AgentRunner', 'plugin'), ('EventProcessor', 'event_processor')]:
|
||||
entries.append(
|
||||
{
|
||||
'plugin_author': 'test',
|
||||
'plugin_name': 'both',
|
||||
'runner_name': 'default',
|
||||
'manifest': {
|
||||
'id': f'{prefix}:test/both/default',
|
||||
'name': 'default',
|
||||
'component_kind': kind,
|
||||
'label': {'en_US': kind},
|
||||
'supported_event_patterns': ['group.member_joined'],
|
||||
},
|
||||
}
|
||||
)
|
||||
ap.plugin_connector.list_agent_runners = AsyncMock(return_value=entries)
|
||||
registry = AgentRunnerRegistry(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'
|
||||
|
||||
@@ -428,3 +428,30 @@ async def test_runner_stats_reports_zero_success_rate_for_failed_only_runner(sto
|
||||
assert stats[0]['runner_id'] == 'runner-a'
|
||||
assert stats[0]['failed_runs'] == 1
|
||||
assert stats[0]['success_rate'] == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processor_instance_history_filters_count_and_pages(store):
|
||||
for index, (workspace, binding) in enumerate(
|
||||
[
|
||||
('one', 'processor-a'),
|
||||
('one', 'processor-b'),
|
||||
('two', 'processor-a'),
|
||||
('one', 'processor-a'),
|
||||
]
|
||||
):
|
||||
await store.create_run(
|
||||
run_id=f'run-{index}',
|
||||
event_id=f'event-{index}',
|
||||
binding_id=binding,
|
||||
runner_id='same-component',
|
||||
workspace_id=workspace,
|
||||
)
|
||||
first, cursor, more, total = await store.list_runs(workspace_id='one', binding_id='processor-a', limit=1)
|
||||
assert (total, more) == (2, True)
|
||||
assert first[0]['run_id'] == 'run-3'
|
||||
second, _, more, total = await store.list_runs(
|
||||
workspace_id='one', binding_id='processor-a', limit=1, before_id=cursor
|
||||
)
|
||||
assert (total, more) == (2, False)
|
||||
assert second[0]['run_id'] == 'run-0'
|
||||
|
||||
@@ -145,6 +145,7 @@ class TestAgentServiceMetadata:
|
||||
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
|
||||
'message_only': True,
|
||||
},
|
||||
{'name': 'event_processor', 'supported_event_patterns': ['*'], 'message_only': False},
|
||||
]
|
||||
|
||||
|
||||
@@ -767,3 +768,77 @@ class TestAgentServiceCreateUpdateDelete:
|
||||
WORKSPACE_UUID,
|
||||
'pipeline-1',
|
||||
)
|
||||
|
||||
|
||||
async def test_event_processor_creation_uses_installed_component_scope():
|
||||
app = _make_app()
|
||||
ref = 'event_processor:test/welcome/default'
|
||||
descriptor = SimpleNamespace(
|
||||
component_kind='EventProcessor',
|
||||
supported_event_patterns=['group.member_joined'],
|
||||
config_schema=[{'name': 'greeting', 'required': True}],
|
||||
)
|
||||
app.agent_runner_registry = SimpleNamespace(get=AsyncMock(return_value=descriptor))
|
||||
service = AgentService(app)
|
||||
result = await service.create_agent(
|
||||
WORKSPACE_UUID,
|
||||
{
|
||||
'kind': 'event_processor',
|
||||
'name': 'Welcome',
|
||||
'component_ref': ref,
|
||||
'parameters': {'greeting': 'Hi'},
|
||||
'supported_event_patterns': ['*'],
|
||||
},
|
||||
)
|
||||
values = _compiled_params(app.persistence_mgr.execute_async.call_args.args[0])
|
||||
assert result['kind'] == 'event_processor'
|
||||
assert values['component_ref'] == ref
|
||||
assert values['supported_event_patterns'] == ['group.member_joined']
|
||||
assert values['config']['runner_config'][ref] == {'greeting': 'Hi'}
|
||||
|
||||
|
||||
async def test_event_processor_rejects_invalid_component_and_missing_parameters():
|
||||
app = _make_app()
|
||||
service = AgentService(app)
|
||||
with pytest.raises(ValueError, match='Select an installed'):
|
||||
await service.create_agent(WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'plugin:a/b/c'})
|
||||
app.agent_runner_registry = SimpleNamespace(
|
||||
get=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
component_kind='EventProcessor',
|
||||
supported_event_patterns=['*'],
|
||||
config_schema=[{'name': 'greeting', 'required': True}],
|
||||
)
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match='Required processor parameter'):
|
||||
await service.create_agent(
|
||||
WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'event_processor:a/b/c'}
|
||||
)
|
||||
|
||||
|
||||
async def test_unavailable_event_processor_can_still_be_renamed():
|
||||
app = _make_app()
|
||||
row = _agent_row(config={'runner': {'id': 'event_processor:a/b/c'}, 'runner_config': {'event_processor:a/b/c': {}}})
|
||||
row.kind = 'event_processor'
|
||||
row.component_ref = 'event_processor:a/b/c'
|
||||
service = AgentService(app)
|
||||
service._get_agent_row = AsyncMock(return_value=row)
|
||||
await service.update_agent(WORKSPACE_UUID, row.uuid, {'name': 'Renamed'})
|
||||
assert _compiled_update_values(app.persistence_mgr.execute_async.call_args.args[0])['name'] == 'Renamed'
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'workspace,binding', [('other-workspace', 'event_processor:one'), (WORKSPACE_UUID, 'event_processor:two')]
|
||||
)
|
||||
async def test_processor_trace_rejects_other_workspace_or_instance(monkeypatch, workspace, binding):
|
||||
service = AgentService(_make_app())
|
||||
service.get_agent = AsyncMock(return_value={'kind': 'event_processor'})
|
||||
service.ap.persistence_mgr.get_db_engine = Mock()
|
||||
store = SimpleNamespace(
|
||||
get_run=AsyncMock(return_value={'workspace_id': workspace, 'binding_id': binding}), page_run_events=AsyncMock()
|
||||
)
|
||||
monkeypatch.setattr('langbot.pkg.agent.runner.run_ledger_store.RunLedgerStore', Mock(return_value=store))
|
||||
with pytest.raises(ValueError, match='Processor run not found'):
|
||||
await service.get_processor_run_events(WORKSPACE_UUID, 'one', 'run')
|
||||
store.page_run_events.assert_not_called()
|
||||
|
||||
@@ -692,3 +692,73 @@ def test_websocket_task_override_does_not_mutate_bot_default():
|
||||
assert pipeline_uuid == 'connection-pipeline'
|
||||
assert routed is False
|
||||
assert bot.bot_entity.use_pipeline_uuid == 'default-uuid'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_installed_event_processor_never_receives_unbound_events():
|
||||
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
|
||||
|
||||
bot = TestEventRouteTrace._make_bot([])
|
||||
bot.ap = SimpleNamespace(plugin_connector=SimpleNamespace(emit_event=AsyncMock()))
|
||||
bot._record_adapter_event = AsyncMock()
|
||||
await bot._handle_platform_event(MemberJoinedEvent(), Mock())
|
||||
bot.ap.plugin_connector.emit_event.assert_not_called()
|
||||
assert bot.logger.info.await_args.kwargs['metadata']['status'] == 'not_matched'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bound_event_processor_receives_one_complete_typed_event():
|
||||
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
|
||||
|
||||
bot = TestEventRouteTrace._make_bot(
|
||||
[
|
||||
{
|
||||
'id': 'binding',
|
||||
'enabled': True,
|
||||
'event_pattern': 'group.member_joined',
|
||||
'target_type': 'event_processor',
|
||||
'target_uuid': 'processor-1',
|
||||
'priority': 0,
|
||||
'order': 0,
|
||||
}
|
||||
]
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def run(envelope, binding, adapter_context=None):
|
||||
calls.append((envelope, binding))
|
||||
if False:
|
||||
yield None
|
||||
|
||||
ref = 'event_processor:test/welcome/default'
|
||||
bot.ap = SimpleNamespace(
|
||||
workspace_service=active_workspace_service(),
|
||||
agent_service=SimpleNamespace(
|
||||
get_agent=AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'processor-1',
|
||||
'kind': 'event_processor',
|
||||
'supported_event_patterns': ['group.member_joined'],
|
||||
'config': {'runner': {'id': ref}, 'runner_config': {ref: {'greeting': 'Hi'}}},
|
||||
}
|
||||
)
|
||||
),
|
||||
agent_run_orchestrator=SimpleNamespace(run=run),
|
||||
plugin_connector=SimpleNamespace(emit_event=AsyncMock()),
|
||||
)
|
||||
bot._record_adapter_event = AsyncMock()
|
||||
await bot._handle_platform_event(
|
||||
MemberJoinedEvent(
|
||||
member={'id': 'member-1'}, group={'id': 'group-1'}, source_platform_object={'private': 'opaque'}
|
||||
),
|
||||
Mock(),
|
||||
)
|
||||
assert len(calls) == 1
|
||||
envelope, binding = calls[0]
|
||||
assert binding.binding_id == 'event_processor:processor-1'
|
||||
assert binding.processor_type == 'event_processor'
|
||||
assert binding.runner_config == {'greeting': 'Hi'}
|
||||
assert envelope.data['member']['id'] == 'member-1'
|
||||
assert envelope.data['type'] == 'group.member_joined'
|
||||
assert 'source_platform_object' not in envelope.data
|
||||
bot.ap.plugin_connector.emit_event.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user