feat(processors): add explicitly bound plugin event processors

This commit is contained in:
RockChinQ
2026-09-08 00:43:35 +08:00
parent 812eb09ee4
commit 237fa6545d
50 changed files with 1968 additions and 289 deletions
+6 -6
View File
@@ -1,4 +1,5 @@
"""Agent runner descriptor."""
from __future__ import annotations
import typing
@@ -44,19 +45,18 @@ class AgentRunnerDescriptor(pydantic.BaseModel):
config_schema: list[dict[str, typing.Any]] = pydantic.Field(default_factory=list)
"""Configuration schema using DynamicForm format"""
capabilities: AgentRunnerCapabilities = pydantic.Field(
default_factory=AgentRunnerCapabilities
)
capabilities: AgentRunnerCapabilities = pydantic.Field(default_factory=AgentRunnerCapabilities)
"""Runner capabilities: streaming, tool_calling, knowledge_retrieval, etc."""
permissions: AgentRunnerPermissions = pydantic.Field(
default_factory=AgentRunnerPermissions
)
permissions: AgentRunnerPermissions = pydantic.Field(default_factory=AgentRunnerPermissions)
"""Requested LangBot resource permissions."""
raw_manifest: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
"""Original manifest for reference"""
component_kind: typing.Literal['AgentRunner', 'EventProcessor'] = 'AgentRunner'
supported_event_patterns: list[str] = pydantic.Field(default_factory=lambda: ['*'])
model_config = pydantic.ConfigDict(
extra='allow',
)
+2 -2
View File
@@ -160,7 +160,7 @@ class AgentConfig(pydantic.BaseModel):
agent_id: str | None = None
"""Host-side Agent/config identifier."""
processor_type: typing.Literal['agent', 'pipeline'] = 'agent'
processor_type: typing.Literal['agent', 'pipeline', 'event_processor'] = 'agent'
"""Product processor kind represented by this runtime config."""
processor_id: str | None = None
@@ -222,7 +222,7 @@ class AgentBinding(pydantic.BaseModel):
agent_id: str | None = None
"""Host-side Agent/config identifier for this binding."""
processor_type: typing.Literal['agent', 'pipeline'] = 'agent'
processor_type: typing.Literal['agent', 'pipeline', 'event_processor'] = 'agent'
"""Product processor kind selected for this binding."""
processor_id: str | None = None
+12 -14
View File
@@ -1,4 +1,5 @@
"""Agent runner ID parsing and formatting."""
from __future__ import annotations
import dataclasses
@@ -7,6 +8,7 @@ import dataclasses
@dataclasses.dataclass(frozen=True)
class RunnerIdParts:
"""Parsed runner ID components."""
source: str # 'plugin' (future: 'builtin')
plugin_author: str
plugin_name: str
@@ -29,31 +31,27 @@ def parse_runner_id(runner_id: str) -> RunnerIdParts:
Raises:
ValueError: If runner_id format is invalid
"""
if runner_id.startswith('plugin:'):
parts = runner_id[7:].split('/')
if runner_id.startswith(('plugin:', 'event_processor:')):
source, value = runner_id.split(':', 1)
parts = value.split('/')
if len(parts) != 3:
raise ValueError(
f'Invalid plugin runner ID format: {runner_id}. '
f'Expected: plugin:author/plugin_name/runner_name'
f'Invalid plugin runner ID format: {runner_id}. Expected: plugin:author/plugin_name/runner_name'
)
plugin_author, plugin_name, runner_name = parts
if not plugin_author or not plugin_name or not runner_name:
raise ValueError(
f'Invalid plugin runner ID: {runner_id}. '
f'author, plugin_name, and runner_name must be non-empty'
f'Invalid plugin runner ID: {runner_id}. author, plugin_name, and runner_name must be non-empty'
)
return RunnerIdParts(
source='plugin',
source=source,
plugin_author=plugin_author,
plugin_name=plugin_name,
runner_name=runner_name,
)
else:
# Only plugin runner IDs are valid at the protocol boundary.
raise ValueError(
f'Invalid runner ID format: {runner_id}. '
f'Expected: plugin:author/plugin_name/runner_name'
)
raise ValueError(f'Invalid runner ID format: {runner_id}. Expected: plugin:author/plugin_name/runner_name')
def format_runner_id(
@@ -73,8 +71,8 @@ def format_runner_id(
Returns:
Runner ID string
"""
if source == 'plugin':
return f'plugin:{plugin_author}/{plugin_name}/{runner_name}'
if source in {'plugin', 'event_processor'}:
return f'{source}:{plugin_author}/{plugin_name}/{runner_name}'
else:
raise ValueError(f'Invalid runner source: {source}')
@@ -88,4 +86,4 @@ def is_plugin_runner_id(runner_id: str) -> bool:
Returns:
True if runner ID starts with 'plugin:'
"""
return runner_id.startswith('plugin:')
return runner_id.startswith(('plugin:', 'event_processor:'))
+11
View File
@@ -41,6 +41,17 @@ class AgentRunnerInvoker:
)
try:
if descriptor.component_kind == 'EventProcessor':
context = {
**context,
'runtime': {
**context['runtime'],
'metadata': {
**context['runtime'].get('metadata', {}),
'component_kind': 'EventProcessor',
},
},
}
gen = self.ap.plugin_connector.run_agent(
plugin_author=descriptor.plugin_author,
plugin_name=descriptor.plugin_name,
@@ -102,6 +102,10 @@ class AgentRunOrchestrator:
bound_plugins,
)
expected_kind = 'EventProcessor' if binding.processor_type == 'event_processor' else 'AgentRunner'
if descriptor.component_kind != expected_kind:
raise ValueError('Processor kind does not match the selected plugin component')
if execution_query is None:
execution_query = build_execution_query(event, [])
# Synthetic events must expose the same trusted scope as pipeline queries.
+18 -7
View File
@@ -110,19 +110,19 @@ class AgentRunnerRegistry:
manifest = runner_data.get('manifest', {})
runner_id = format_runner_id(
source='plugin',
source='event_processor' if manifest.get('component_kind') == 'EventProcessor' else 'plugin',
plugin_author=plugin_author,
plugin_name=plugin_name,
runner_name=runner_name,
)
typed_manifest = AgentRunnerManifest.model_validate(manifest)
config_schema = [
item.model_dump(mode='json') for item in typed_manifest.config_schema
]
config_schema = [item.model_dump(mode='json') for item in typed_manifest.config_schema]
return AgentRunnerDescriptor(
id=runner_id,
component_kind=typed_manifest.component_kind,
supported_event_patterns=typed_manifest.supported_event_patterns,
source='plugin',
label=typed_manifest.label,
description=typed_manifest.description,
@@ -152,6 +152,7 @@ class AgentRunnerRegistry:
context: TenantContext,
bound_plugins: list[str] | None = None,
use_cache: bool = True,
component_kind: str = 'AgentRunner',
) -> list[AgentRunnerDescriptor]:
"""List available runners.
@@ -169,7 +170,11 @@ class AgentRunnerRegistry:
# Filter from cache. Do not treat an empty cache as final because the
# plugin runtime may still be launching installed plugins when the
# first metadata request arrives.
return self._filter_runners_by_bound_plugins(cached, bound_plugins)
return [
r
for r in self._filter_runners_by_bound_plugins(cached, bound_plugins)
if r.component_kind == component_kind
]
# Discover fresh (always full list)
runners = await self._discover_runners()
@@ -179,7 +184,11 @@ class AgentRunnerRegistry:
self._cache[cache_key] = runners
# Filter locally
return self._filter_runners_by_bound_plugins(runners, bound_plugins)
return [
r
for r in self._filter_runners_by_bound_plugins(runners, bound_plugins)
if r.component_kind == component_kind
]
def _filter_runners_by_bound_plugins(
self,
@@ -233,7 +242,8 @@ class AgentRunnerRegistry:
except ValueError as e:
raise RunnerNotFoundError(runner_id) from e
runners = await self.list_runners(context, bound_plugins=None)
component_kind = 'EventProcessor' if runner_id.startswith('event_processor:') else 'AgentRunner'
runners = await self.list_runners(context, bound_plugins=None, component_kind=component_kind)
descriptor = next((item for item in runners if item.id == runner_id), None)
if descriptor is None:
# The runtime launches installed plugins asynchronously, so an
@@ -242,6 +252,7 @@ class AgentRunnerRegistry:
context,
bound_plugins=None,
use_cache=False,
component_kind=component_kind,
)
descriptor = next((item for item in runners if item.id == runner_id), None)
if descriptor is None:
@@ -10,6 +10,7 @@ from langbot_plugin.api.entities.builtin.agent_runner.result import (
MessageCompletedPayload,
MessageDeltaPayload,
RunCompletedPayload,
ProcessorLogPayload,
RunFailedPayload,
StateUpdatedPayload,
ToolCallCompletedPayload,
@@ -34,6 +35,7 @@ STRICT_RESULT_PAYLOADS: dict[str, type[pydantic.BaseModel]] = {
'action.requested': ActionRequestedPayload,
'run.completed': RunCompletedPayload,
'run.failed': RunFailedPayload,
'processor.log': ProcessorLogPayload,
}
@@ -114,6 +116,9 @@ class AgentResultNormalizer:
if not self.validate_payload(result_type, data, descriptor):
return None
if result_type == 'processor.log':
return None
if result_type == 'message.delta':
return self._normalize_message_delta(data, descriptor)
@@ -93,6 +93,13 @@ class AgentRunJournal:
metadata={
'event_type': event.event_type,
'source': event.source,
'processor_id': binding.processor_id,
'processor_type': binding.processor_type,
**(
{'input_event': event.data, 'delivery': event.delivery.model_dump(mode='json')}
if binding.processor_type == 'event_processor'
else {}
),
},
)
@@ -188,8 +188,8 @@ class RunLedgerStore:
query = query.where(AgentRun.conversation_id == conversation_id)
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
query = query.order_by(AgentRun.priority.desc(), AgentRun.id.asc()).limit(1).with_for_update(
skip_locked=True
query = (
query.order_by(AgentRun.priority.desc(), AgentRun.id.asc()).limit(1).with_for_update(skip_locked=True)
)
result = await session.execute(query)
run = result.scalars().first()
@@ -571,8 +571,7 @@ class RunLedgerStore:
# Filter by labels
runtimes = [
rt for rt in all_runtimes
if all(rt.get('labels', {}).get(k) == v for k, v in labels.items())
rt for rt in all_runtimes if all(rt.get('labels', {}).get(k) == v for k, v in labels.items())
]
total_count = len(runtimes)
@@ -636,6 +635,7 @@ class RunLedgerStore:
thread_id: str | None = None,
strict_thread: bool = False,
runner_id: str | None = None,
binding_id: str | None = None,
) -> tuple[list[dict[str, typing.Any]], int | None, bool, int]:
"""Page runs by scope.
@@ -652,6 +652,8 @@ class RunLedgerStore:
count_query = count_query.where(AgentRun.status.in_(statuses))
if runner_id is not None:
count_query = count_query.where(AgentRun.runner_id == runner_id)
if binding_id is not None:
count_query = count_query.where(AgentRun.binding_id == binding_id)
count_query = self._apply_scope_filters(count_query, bot_id, workspace_id, thread_id, strict_thread)
count_result = await session.execute(count_query)
total_count = count_result.scalar() or 0
@@ -664,6 +666,8 @@ class RunLedgerStore:
query = query.where(AgentRun.status.in_(statuses))
if runner_id is not None:
query = query.where(AgentRun.runner_id == runner_id)
if binding_id is not None:
query = query.where(AgentRun.binding_id == binding_id)
if before_id is not None:
query = query.where(AgentRun.id < before_id)
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
@@ -848,10 +852,7 @@ class RunLedgerStore:
# Count by status
status_query = (
sqlalchemy.select(
AgentRun.status,
func.count(AgentRun.id).label('count')
)
sqlalchemy.select(AgentRun.status, func.count(AgentRun.id).label('count'))
.where(*base_filter)
.group_by(AgentRun.status)
)
@@ -873,18 +874,15 @@ class RunLedgerStore:
avg_queue_wait_seconds = None
# Fetch completed runs with timing data
timing_query = (
sqlalchemy.select(
AgentRun.started_at,
AgentRun.finished_at,
AgentRun.created_at,
)
.where(
AgentRun.status == 'completed',
AgentRun.started_at.is_not(None),
AgentRun.finished_at.is_not(None),
*base_filter
)
timing_query = sqlalchemy.select(
AgentRun.started_at,
AgentRun.finished_at,
AgentRun.created_at,
).where(
AgentRun.status == 'completed',
AgentRun.started_at.is_not(None),
AgentRun.finished_at.is_not(None),
*base_filter,
)
timing_result = await session.execute(timing_query)
timing_rows = timing_result.all()
@@ -899,16 +897,10 @@ class RunLedgerStore:
avg_duration_seconds = round(sum(durations) / len(durations), 2)
# Queue wait time - compute in Python
queue_query = (
sqlalchemy.select(
AgentRun.created_at,
AgentRun.started_at,
)
.where(
AgentRun.started_at.is_not(None),
*base_filter
)
)
queue_query = sqlalchemy.select(
AgentRun.created_at,
AgentRun.started_at,
).where(AgentRun.started_at.is_not(None), *base_filter)
queue_result = await session.execute(queue_query)
queue_rows = queue_result.all()
@@ -957,12 +949,8 @@ class RunLedgerStore:
async with self._session_factory() as session:
# Count by status
status_query = (
sqlalchemy.select(
AgentRuntime.status,
func.count(AgentRuntime.id).label('count')
)
.group_by(AgentRuntime.status)
status_query = sqlalchemy.select(AgentRuntime.status, func.count(AgentRuntime.id).label('count')).group_by(
AgentRuntime.status
)
status_result = await session.execute(status_query)
status_counts = {row.status: row.count for row in status_result}
@@ -975,9 +963,8 @@ class RunLedgerStore:
avg_heartbeat_age = None
max_heartbeat_age = None
heartbeat_query = (
sqlalchemy.select(AgentRuntime.last_heartbeat_at)
.where(AgentRuntime.last_heartbeat_at.is_not(None))
heartbeat_query = sqlalchemy.select(AgentRuntime.last_heartbeat_at).where(
AgentRuntime.last_heartbeat_at.is_not(None)
)
heartbeat_result = await session.execute(heartbeat_query)
heartbeat_rows = heartbeat_result.all()
@@ -995,16 +982,12 @@ class RunLedgerStore:
avg_heartbeat_age = round(sum(ages) / len(ages), 2)
max_heartbeat_age = round(max(ages), 2)
active_runs_query = (
sqlalchemy.select(func.count(AgentRun.id))
.where(AgentRun.status.in_(['running', 'claimed']))
active_runs_query = sqlalchemy.select(func.count(AgentRun.id)).where(
AgentRun.status.in_(['running', 'claimed'])
)
active_runs_result = await session.execute(active_runs_query)
active_runs = active_runs_result.scalar() or 0
claimed_runs_query = (
sqlalchemy.select(func.count(AgentRun.id))
.where(AgentRun.status == 'claimed')
)
claimed_runs_query = sqlalchemy.select(func.count(AgentRun.id)).where(AgentRun.status == 'claimed')
claimed_runs_result = await session.execute(claimed_runs_query)
claimed_runs = claimed_runs_result.scalar() or 0
@@ -1048,23 +1031,10 @@ class RunLedgerStore:
AgentRun.runner_id,
func.count(AgentRun.id).label('total'),
func.sum(
sqlalchemy.case(
(AgentRun.status.in_(['queued', 'claimed', 'running']), 1),
else_=0
)
sqlalchemy.case((AgentRun.status.in_(['queued', 'claimed', 'running']), 1), else_=0)
).label('active'),
func.sum(
sqlalchemy.case(
(AgentRun.status == 'completed', 1),
else_=0
)
).label('completed'),
func.sum(
sqlalchemy.case(
(AgentRun.status.in_(['failed', 'timeout']), 1),
else_=0
)
).label('failed'),
func.sum(sqlalchemy.case((AgentRun.status == 'completed', 1), else_=0)).label('completed'),
func.sum(sqlalchemy.case((AgentRun.status.in_(['failed', 'timeout']), 1), else_=0)).label('failed'),
)
.where(
AgentRun.created_at >= start_dt,
@@ -1087,16 +1057,18 @@ class RunLedgerStore:
failed = row.failed or 0
success_rate = completed / total if total > 0 else None
stats.append({
'runner_id': runner_id,
'runner_label': None, # Would need to join with runner descriptors
'plugin_identity': None,
'total_runs': total,
'active_runs': row.active or 0,
'completed_runs': completed,
'failed_runs': failed,
'success_rate': round(success_rate, 4) if success_rate is not None else None,
'avg_duration_seconds': None, # Would need more complex query
})
stats.append(
{
'runner_id': runner_id,
'runner_label': None, # Would need to join with runner descriptors
'plugin_identity': None,
'total_runs': total,
'active_runs': row.active or 0,
'completed_runs': completed,
'failed_runs': failed,
'success_rate': round(success_rate, 4) if success_rate is not None else None,
'avg_duration_seconds': None, # Would need more complex query
}
)
return stats