fix(pipeline): apply returned plugin event contexts

This commit is contained in:
RockChinQ
2026-09-07 23:51:34 +08:00
parent 5d0657b1db
commit 812eb09ee4
8 changed files with 329 additions and 23 deletions
+2
View File
@@ -1,5 +1,7 @@
# Event Based Agents 架构设计总览
> Product revision (2026-09-07): [Event processors and Pipeline plugin compatibility](./09-event-processors.md) defines an explicitly bound EventProcessor alongside Pipeline and Agent. It supersedes the automatic EBA observer product model below; the new component and UI are planned, not yet implemented.
> 当前状态(2026-09-05):平台事件、Bot `event_bindings`、独立 Agent、Pipeline / Agent 平级路由及 WebUI 已集成到 `dev/4.11.x`。实现入口为 `pkg/platform/botmgr.py::RuntimeBot` 与 `pkg/agent/runner/`。下文“当前架构的局限性”“现有架构”描述改造前背景;EventBus / EventRouter 图表示职责划分,不表示存在同名独立服务。当前实现和验收以 [STATUS.md](../agent-runner-pluginization/STATUS.md) 为准,平台动作使用[授权工具](../agent-runner-pluginization/PLATFORM_ACTION_TOOLS.md)。
## 1. 背景与动机
@@ -0,0 +1,151 @@
# Event processors and Pipeline plugin compatibility
Status: product and implementation design, 2026-09-07. The EventProcessor
component, routing target, and UI described below are not implemented yet.
This design supersedes the automatic EBA EventListener observer broadcast in
the earlier EBA documents. Existing Pipeline plugin behavior remains supported.
## Product boundary
Three processor types appear together in the Processors area:
| Product | Implementation | Event entry | Flow ownership |
| --- | --- | --- | --- |
| Pipeline | Existing Pipeline stages and configuration | Received messages | Pipeline stages, including legacy plugin hooks |
| Agent | A configured plugin AgentRunner | Supported EBA events | The selected runner |
| Event processor | A configured plugin EventProcessor | Supported EBA events | Plugin Python handlers |
Use **Event processor** as the product label and **EventProcessor** as the SDK
component name. The localized description should explain that the plugin defines
the processing logic. It must not suggest an LLM, prompt, or visual workflow is
required.
An installed component is a reusable implementation. A processor instance is a
user-created configuration of that component. A Bot event binding selects an
instance, not an installed plugin package directly.
## Legacy EventListener contract
EventListener remains a Pipeline extension. Existing plugins retain their import
paths, handler registration syntax, event classes, Query-based APIs, and Pipeline
plugin selection behavior. No conversion of installed listeners into standalone
processor instances takes place.
Compatibility must cover execution behavior, not just successful deserialization:
| Hook | Required behavior |
| --- | --- |
| PersonMessageReceived / GroupMessageReceived | Read the returned EventContext before later stages; retain message edits and default prevention |
| PromptPreProcessing | Preserve timing and apply returned default_prompt and prompt |
| PersonNormalMessageReceived / GroupNormalMessageReceived | Preserve user_message_alter, default prevention, and replacement replies |
| PersonCommandSent / GroupCommandSent | Preserve command stage timing, default prevention, and replacement replies |
| NormalMessageResponded | Preserve response-stage timing, default prevention, and replacement message chains, including streaming behavior |
| All hooks | Preserve plugin ordering, prevent_postorder across installations, bound-plugin filtering, Query identity, and Workspace scope |
RPC responses are new Python objects. Host code must consume returned values
rather than assume mutations reached the original Query by object identity.
Host-only references, including the active Query and raw adapter message, must
remain available for legacy reply APIs without being exposed in serialized
plugin events.
Preserve source event fields across EBA-to-legacy conversion, including group
member permissions, bot group permissions, and member titles. Missing platform
information must be distinguished from fields that were dropped during conversion.
Direct Agent and Event processor execution must not synthesize Pipeline lifecycle
hooks. Those hooks describe actual Pipeline stages.
## EventProcessor SDK contract
Introduce a distinct component kind instead of changing what an existing
EventListener manifest means. One package may contain both kinds; only the legacy
EventListener participates in Pipeline hook dispatch.
Retain the familiar authoring shape:
```python
# Illustrative API contract; these classes are not available yet.
class WelcomeProcessor(EventProcessor):
async def initialize(self):
await super().initialize()
@self.handler(MemberJoinedEvent)
async def on_join(ctx: EventProcessorContext):
await ctx.reply(f"Hello, {ctx.event.member.nickname}")
```
Handlers receive typed EBA events directly. Do not maintain a second, incomplete
mapping into plugin-only EBA wrapper classes. Preserve complete public event
fields; compact log previews must not become the execution payload. Include the
generic platform-specific event contract for adapter-specific events.
The context belongs to one invocation and exposes the event, processor/run
identifiers, instance configuration, logging, and authorized Host APIs. It has no
fabricated Pipeline Query. Reuse Host run tracking, deadlines, installation
authority, platform capabilities, and delivery records where appropriate.
A handler returning normally completes its invocation. There is no implicit LLM
loop, automatic second processor, or hidden retry of side effects. An exception
marks the run failed and retains the associated log. New processing handlers do
not use prevent_default to control another processor; routing has already chosen
the current processor. The legacy methods keep their existing Pipeline meaning.
## Activation and routing
The activation sequence is explicit:
1. Install a plugin containing an EventProcessor component.
2. Create an Event processor in the Processors area.
3. Select its plugin component and enter any component-defined configuration.
4. Bind a Bot event to that processor instance in the existing event routing UI.
Installation and processor creation alone do not subscribe to Bot events.
The component declares the event types it handles; Bot bindings select the subset
of supported events to deliver. One package may supply multiple components, and
multiple instances may use the same component with independent configuration.
Extend the existing single-target route arbitration with `event_processor`.
Remove automatic EBA broadcasts to installed EventListeners when this route is
ready. Keep Pipeline hook dispatch inside the Pipeline path. Existing observer
plugins must explicitly adopt the new component and be bound by the user; do not
create subscriptions during migration.
Validate component availability, event compatibility, Workspace ownership, and
instance identity at creation/update and again at invocation. A disabled or
unavailable plugin leaves the instance visible with an actionable unavailable
status. It must not silently fall back to Agent or Pipeline.
## Compact UI
Creation adds a third type next to Agent and Pipeline, followed by a component
selector and basic instance information. Show configuration fields only when the
component declares them. If no component is installed, show a relevant plugin
installation entry point; installing still does not create a binding.
The detail page prioritizes a single run list. Selecting a run shows a chronological
trace of the incoming event, handler logs, outgoing actions/messages, and outcome.
Keep payloads and error details collapsed until expanded. Distinguish attempted
delivery from confirmed delivery and display the actual destination.
Place component identity, availability, bindings, and configuration in a compact
secondary area. Do not add a prompt editor, model selector, or flow designer.
The plugin implements the processing flow in code.
## Delivery sequence and acceptance
1. Repair and regression-test Pipeline EventContext handling and legacy payload
conversion independently of the new processor feature.
2. Add the SDK component, context, manifest/scaffolding, and explicit invocation
contract; verify registration, event coverage, and process isolation.
3. Add Host instance management, event routing, execution tracking, and matching
HTTP/MCP/skill surfaces. Turn off automatic EBA observer dispatch in this step.
4. Add creation, binding, availability, logs, and delivery trace UI with i18n.
5. Exercise a real packaged plugin through installation, explicit instance
creation, Bot binding, invocation, logging, and reply delivery.
Acceptance must prove that installation alone invokes no handlers; one matching
binding invokes exactly the chosen component; instance configuration and run
history remain separate; all declared EBA events retain their fields; unavailable
components fail visibly; and legacy plugins keep the documented Pipeline hook
order and behavior. Unit tests alone do not establish a successful live plugin
installation or platform delivery.
+11 -19
View File
@@ -122,20 +122,14 @@ class RuntimePipeline:
self.placement_generation = self.execution_context.placement_generation
# Extract bound plugins and MCP servers from extensions_preferences
extensions_prefs = normalize_extension_preferences(
pipeline_entity.extensions_preferences
)
extensions_prefs = normalize_extension_preferences(pipeline_entity.extensions_preferences)
self.enable_all_plugins = extensions_prefs['enable_all_plugins'] is True
self.enable_all_mcp_servers = (
extensions_prefs['enable_all_mcp_servers'] is True
)
self.enable_all_mcp_servers = extensions_prefs['enable_all_mcp_servers'] is True
pipeline_config = pipeline_entity.config or {}
runner_config: dict[str, typing.Any] = {}
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
if runner_id:
resolved = RunnerConfigResolver.resolve_runner_config(
pipeline_config, runner_id
)
resolved = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
if isinstance(resolved, dict):
runner_config = resolved
self.mcp_resource_attachments = runner_config.get(
@@ -154,10 +148,7 @@ class RuntimePipeline:
# None indicates to use all available plugins
self.bound_plugins = None
else:
self.bound_plugins = [
f'{plugin["author"]}/{plugin["name"]}'
for plugin in extensions_prefs['plugins']
]
self.bound_plugins = [f'{plugin["author"]}/{plugin["name"]}' for plugin in extensions_prefs['plugins']]
if self.enable_all_mcp_servers:
# None indicates to use all available MCP servers
@@ -385,9 +376,7 @@ class RuntimePipeline:
# Get runner name from pipeline config
runner_name = None
if query.pipeline_config:
runner_name = RunnerConfigResolver.resolve_runner_id(
query.pipeline_config
)
runner_name = RunnerConfigResolver.resolve_runner_id(query.pipeline_config)
# Record query start and store message_id
message_id = ''
@@ -442,6 +431,11 @@ class RuntimePipeline:
)
return
# The Runtime returns a deserialized event, not the original Query.
# Carry plugin message edits into the following Pipeline stages.
query.message_chain = event_ctx.event.message_chain
query.message_event.message_chain = query.message_chain
self.ap.logger.debug(f'Processing query {query.query_id}')
await self._execute_from_stage(0, query)
@@ -669,9 +663,7 @@ class PipelineManager:
stage_containers: list[StageInstContainer] = []
for stage_name in pipeline_entity.stages:
if stage_name not in self.stage_dict:
self.ap.logger.warning(
f'Pipeline stage {stage_name} is not registered; skipping'
)
self.ap.logger.warning(f'Pipeline stage {stage_name} is not registered; skipping')
continue
stage_containers.append(StageInstContainer(inst_name=stage_name, inst=self.stage_dict[stage_name](self.ap)))
+4
View File
@@ -2114,6 +2114,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
event_ctx = context.EventContext.model_validate(result['event_context'])
emitted_plugins.extend(result.get('emitted_plugins', []))
response_sources.extend(result.get('response_sources', []))
if event_ctx.is_prevented_postorder():
break
if query is not None:
event_ctx.event.query = query
event_ctx._emitted_plugins = emitted_plugins
event_ctx._response_sources = response_sources
+46 -4
View File
@@ -229,6 +229,7 @@ async def test_remove_pipeline(mock_app):
@pytest.mark.asyncio
async def test_runtime_pipeline_execute(mock_app, sample_query):
"""Test runtime pipeline execution with real Pydantic models."""
sample_query.query_id = 1
pipelinemgr = get_pipelinemgr_module()
stage = get_stage_module()
persistence_pipeline = get_persistence_pipeline_module()
@@ -266,18 +267,59 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
)
# Mock plugin connector
event_ctx = Mock()
event_ctx.is_prevented_default = Mock(return_value=False)
mock_app.plugin_connector.emit_event = AsyncMock(return_value=event_ctx)
from langbot_plugin.api.entities.context import EventContext
async def return_event_context(event, bound_plugins):
return EventContext.model_validate(EventContext.from_event(event).model_dump())
mock_app.plugin_connector.emit_event = AsyncMock(side_effect=return_event_context)
# Execute pipeline
await runtime_pipeline.run(sample_query)
# Verify stage was called
mock_stage.process.assert_called_once()
assert mock_stage.process.call_count == 1, mock_app.logger.error.call_args_list
mock_app.query_pool.remove_query.assert_awaited_once_with(sample_query)
@pytest.mark.asyncio
async def test_received_event_edits_reach_pipeline_stages(mock_app, sample_query):
"""Read edits from the returned RPC context before running message stages."""
from langbot_plugin.api.entities.context import EventContext
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
sample_query.query_id = 1
pipeline_entity = SimpleNamespace(
name='Compatibility test',
uuid='test-pipeline-uuid',
workspace_uuid='test-workspace',
config=sample_query.pipeline_config,
extensions_preferences={'plugins': []},
)
runtime_pipeline = get_pipelinemgr_module().RuntimePipeline(
mock_app,
pipeline_entity,
[],
_context('test-pipeline-uuid'),
)
observed = []
async def plugin_edit(event, bound_plugins):
ctx = EventContext.model_validate(EventContext.from_event(event).model_dump())
ctx.event.message_chain = MessageChain([Plain(text='edited by plugin')])
return ctx
async def capture_stage(index, query):
observed.append((str(query.message_chain), str(query.message_event.message_chain)))
mock_app.plugin_connector.emit_event = AsyncMock(side_effect=plugin_edit)
runtime_pipeline._execute_from_stage = AsyncMock(side_effect=capture_stage)
await runtime_pipeline.run(sample_query)
assert observed == [('edited by plugin', 'edited by plugin')], mock_app.logger.error.call_args_list
@pytest.mark.asyncio
async def test_runtime_pipeline_rejects_stale_generation_before_side_effects(
mock_app,
+35
View File
@@ -76,6 +76,41 @@ def make_session(
class TestPreProcessorNormalText:
"""Tests for normal text message preprocessing."""
@pytest.mark.asyncio
async def test_returned_plugin_prompt_edits_are_applied(self):
"""Prompt hooks retain both edits across the serialized Runtime boundary."""
from langbot_plugin.api.entities.context import EventContext
from langbot_plugin.api.entities.builtin.provider.message import Message
app = FakeApp()
app.sess_mgr.get_session = AsyncMock(return_value=make_session())
conversation = Mock()
conversation.prompt = Mock(messages=[])
conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
conversation.messages = []
conversation.uuid = None
app.sess_mgr.get_conversation = AsyncMock(return_value=conversation)
model = Mock()
model.model_entity = Mock(uuid='test-model', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=model)
observed_hooks = []
async def edit_prompts(event, bound_plugins):
observed_hooks.append(event.event_name)
ctx = EventContext.model_validate(EventContext.from_event(event).model_dump())
ctx.event.default_prompt = [Message(role='system', content='plugin system prompt')]
ctx.event.prompt = [Message(role='assistant', content='plugin history')]
return ctx
app.plugin_connector.emit_event = AsyncMock(side_effect=edit_prompts)
query = text_query('hello')
await get_preproc_module().PreProcessor(app).process(query, 'PreProcessor')
assert observed_hooks == ['PromptPreProcessing']
assert query.prompt.messages[0].content == 'plugin system prompt'
assert query.messages[0].content == 'plugin history'
@pytest.mark.asyncio
async def test_normal_text_continues(self):
"""Normal text message should continue pipeline."""
+43
View File
@@ -228,6 +228,49 @@ class TestResponseWrapperPlugin:
class TestResponseWrapperAssistant:
"""Tests for assistant response wrapping."""
@pytest.mark.asyncio
@pytest.mark.parametrize('streaming', [False, True])
@pytest.mark.parametrize('prevent_default', [False, True])
async def test_returned_response_context_controls_delivery(self, streaming, prevent_default):
"""Legacy response hooks can replace or block the actual outgoing chain."""
from langbot_plugin.api.entities.context import EventContext
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
app = FakeApp()
app.sess_mgr.get_session = AsyncMock(return_value=make_session())
observed_hooks = []
async def edit_response(event, bound_plugins):
observed_hooks.append(event.event_name)
ctx = EventContext.model_validate(EventContext.from_event(event).model_dump())
ctx.event.reply_message_chain = platform_message.MessageChain(
[
platform_message.Plain(text='plugin reply'),
]
)
if prevent_default:
ctx.prevent_default()
return ctx
app.plugin_connector.emit_event = AsyncMock(side_effect=edit_response)
stage = get_wrapper_module().ResponseWrapper(app)
query = text_query('hello')
query.pipeline_config = make_wrapper_config()
message_class = MessageChunk if streaming else Message
query.resp_messages = [message_class(role='assistant', content='model reply')]
query.resp_message_chain = []
await stage.initialize(query.pipeline_config)
results = [result async for result in stage.process(query, 'ResponseWrapper')]
assert observed_hooks == ['NormalMessageResponded']
if prevent_default:
assert results[0].result_type == get_entities_module().ResultType.INTERRUPT
assert query.resp_message_chain == []
else:
assert results[0].result_type == get_entities_module().ResultType.CONTINUE
assert [str(chain) for chain in query.resp_message_chain] == ['plugin reply']
@pytest.mark.asyncio
async def test_assistant_content_response(self):
"""Assistant with content should emit event and wrap."""
@@ -239,6 +239,43 @@ class TestListPlugins:
class TestPluginDiagnostics:
@pytest.mark.asyncio
async def test_prevent_postorder_stops_later_installations_and_restores_query(self):
from langbot_plugin.api.entities.events import PersonMessageReceived
connector = create_mock_connector()
query = text_query('hello')
event = PersonMessageReceived(
query=query,
launcher_type=query.launcher_type.value,
launcher_id=query.launcher_id,
sender_id=query.sender_id,
message_event=query.message_event,
message_chain=query.message_chain,
)
second_binding = TEST_INSTALLATION_BINDING.model_copy(
update={
'installation_uuid': '00000000-0000-4000-8000-000000000002',
}
)
connector._operation_bindings = AsyncMock(return_value=[TEST_INSTALLATION_BINDING, second_binding])
async def stop_following_plugins(event_context, include_plugins=None):
event_context['is_prevent_postorder'] = True
return {'event_context': event_context, 'emitted_plugins': ['first']}
runtime_handler = configure_handler(connector, Mock())
runtime_handler.emit_event = AsyncMock(side_effect=stop_following_plugins)
returned = await connector.emit_event(event)
runtime_handler.emit_event.assert_awaited_once()
assert returned.is_prevented_postorder()
assert not returned.is_prevented_default()
assert returned.event.query is query
assert 'query' not in returned.event.model_dump()
assert returned._emitted_plugins == ['first']
@pytest.mark.asyncio
async def test_emit_event_preserves_response_sources(self):
connector = create_mock_connector()