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
@@ -1,5 +1,7 @@
# 事件路由与编排 # 事件路由与编排
> Implementation update (2026-09-08): the EventListener observer-broadcast proposal below is superseded by [Event processors](09-event-processors.md). Legacy EventListener hooks run only inside Pipeline. New EBA handlers use explicitly created and bound EventProcessor instances, a third peer processor type alongside Agent and Pipeline.
> 状态:当前实施模型(2026-07-12)。本文以 Pipeline / Agent 平级并存为准,不再保留早期 `pipeline / agent / webhook / plugin` 四种 Handler 草案。 > 状态:当前实施模型(2026-07-12)。本文以 Pipeline / Agent 平级并存为准,不再保留早期 `pipeline / agent / webhook / plugin` 四种 Handler 草案。
## 1. 路由边界 ## 1. 路由边界
+2
View File
@@ -1,5 +1,7 @@
# 插件 SDK 改造 # 插件 SDK 改造
> Implementation update (2026-09-08): the EventListener observer-broadcast proposal below is superseded by [Event processors](09-event-processors.md). Legacy EventListener hooks run only inside Pipeline. New EBA handlers use explicitly created and bound EventProcessor instances, a third peer processor type alongside Agent and Pipeline.
## 1. 概述 ## 1. 概述
插件 SDK 需要配合 EBA 架构进行以下改造: 插件 SDK 需要配合 EBA 架构进行以下改造:
@@ -1,5 +1,7 @@
# EBA 分阶段实施计划 # EBA 分阶段实施计划
> Implementation update (2026-09-08): the EventListener observer-broadcast proposal below is superseded by [Event processors](09-event-processors.md). Legacy EventListener hooks run only inside Pipeline. New EBA handlers use explicitly created and bound EventProcessor instances, a third peer processor type alongside Agent and Pipeline.
> 更新:2026-09-05。P0P4 的主要实现已落入 `dev/4.11.x`,P5 仍需按当前版本验收;下文工作项用于维护实现边界,不表示全部待开发。文件名沿用早期设计,但这里的“迁移”仅指代码架构逐步接入 EBA,不代表 LangBot 3.x 数据库或配置升级。当前提交、定向测试及发布缺口见 [STATUS.md](../agent-runner-pluginization/STATUS.md)。 > 更新:2026-09-05。P0P4 的主要实现已落入 `dev/4.11.x`,P5 仍需按当前版本验收;下文工作项用于维护实现边界,不表示全部待开发。文件名沿用早期设计,但这里的“迁移”仅指代码架构逐步接入 EBA,不代表 LangBot 3.x 数据库或配置升级。当前提交、定向测试及发布缺口见 [STATUS.md](../agent-runner-pluginization/STATUS.md)。
## 1. 发布边界 ## 1. 发布边界
@@ -1,5 +1,7 @@
# Agent 与 Pipeline 统一编排(产品最终形态) # Agent 与 Pipeline 统一编排(产品最终形态)
> Implementation update (2026-09-08): the EventListener observer-broadcast proposal below is superseded by [Event processors](09-event-processors.md). Legacy EventListener hooks run only inside Pipeline. New EBA handlers use explicitly created and bound EventProcessor instances, a third peer processor type alongside Agent and Pipeline.
> **状态**:历史方向稿(2026-06-12);2026-09-05 标记归档用途。本文的示意 schema、5.0 发布火车、SDK 0.5.0aX 配套与多租户“预留”描述不再作为实施合同。当前 4.11 产品形态见 [08-agent-page-and-event-orchestration.md](./08-agent-page-and-event-orchestration.md),协议见 [PROTOCOL_V1.md](../agent-runner-pluginization/PROTOCOL_V1.md),已完成与剩余事项见 [STATUS.md](../agent-runner-pluginization/STATUS.md)。保留正文仅用于解释早期设计取舍。 > **状态**:历史方向稿(2026-06-12);2026-09-05 标记归档用途。本文的示意 schema、5.0 发布火车、SDK 0.5.0aX 配套与多租户“预留”描述不再作为实施合同。当前 4.11 产品形态见 [08-agent-page-and-event-orchestration.md](./08-agent-page-and-event-orchestration.md),协议见 [PROTOCOL_V1.md](../agent-runner-pluginization/PROTOCOL_V1.md),已完成与剩余事项见 [STATUS.md](../agent-runner-pluginization/STATUS.md)。保留正文仅用于解释早期设计取舍。
> >
> 本文档修订 [00-overview.md](./00-overview.md) §3.4 与 [04-event-routing.md](./04-event-routing.md) 中"四种 Handler"的编排模型:**所有编排目标统一进入处理器选择与事件绑定界面,但独立 Agent 与现有 Pipeline 保持不同类型**。事件路由的匹配机制、数据迁移策略、WebUI 交互骨架等内容仍以 04 为准,仅 handler 分类法被本文档取代。 > 本文档修订 [00-overview.md](./00-overview.md) §3.4 与 [04-event-routing.md](./04-event-routing.md) 中"四种 Handler"的编排模型:**所有编排目标统一进入处理器选择与事件绑定界面,但独立 Agent 与现有 Pipeline 保持不同类型**。事件路由的匹配机制、数据迁移策略、WebUI 交互骨架等内容仍以 04 为准,仅 handler 分类法被本文档取代。
@@ -1,5 +1,7 @@
# 处理器页面与事件编排产品设计 # 处理器页面与事件编排产品设计
> Implementation update (2026-09-08): the EventListener observer-broadcast proposal below is superseded by [Event processors](09-event-processors.md). Legacy EventListener hooks run only inside Pipeline. New EBA handlers use explicitly created and bound EventProcessor instances, a third peer processor type alongside Agent and Pipeline.
> 状态:当前实现说明(2026-09-05),对应 `dev/4.11.x`。P0P3 已集成;发布验收见 [STATUS.md](../agent-runner-pluginization/STATUS.md)。 > 状态:当前实现说明(2026-09-05),对应 `dev/4.11.x`。P0P3 已集成;发布验收见 [STATUS.md](../agent-runner-pluginization/STATUS.md)。
> >
> 本文档修订 [07-agent-orchestration.md](./07-agent-orchestration.md) 中“Agent 替代 Pipeline”的表述。当前产品形态保留两种长期并存的同级处理器:**Agent** 与 **Pipeline**。处理器页面只是共享入口,不改变二者各自的持久化模型和执行语义。 > 本文档修订 [07-agent-orchestration.md](./07-agent-orchestration.md) 中“Agent 替代 Pipeline”的表述。当前产品形态保留两种长期并存的同级处理器:**Agent** 与 **Pipeline**。处理器页面只是共享入口,不改变二者各自的持久化模型和执行语义。
+70 -5
View File
@@ -1,7 +1,9 @@
# Event processors and Pipeline plugin compatibility # Event processors and Pipeline plugin compatibility
Status: product and implementation design, 2026-09-07. The EventProcessor Status: implemented in the 4.11 development branches of LangBot and the Plugin SDK,
component, routing target, and UI described below are not implemented yet. 2026-09-08. The Host uv configuration pins the matching SDK commit. Deploy both
revisions together; older SDK releases do not contain this component. Switch the
development source pin to a published SDK release before a stable PyPI release.
This design supersedes the automatic EBA EventListener observer broadcast in This design supersedes the automatic EBA EventListener observer broadcast in
the earlier EBA documents. Existing Pipeline plugin behavior remains supported. the earlier EBA documents. Existing Pipeline plugin behavior remains supported.
@@ -64,7 +66,8 @@ EventListener participates in Pipeline hook dispatch.
Retain the familiar authoring shape: Retain the familiar authoring shape:
```python ```python
# Illustrative API contract; these classes are not available yet. from langbot_plugin.api.definition.components.event_processor import EventProcessor, EventProcessorContext
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
class WelcomeProcessor(EventProcessor): class WelcomeProcessor(EventProcessor):
async def initialize(self): async def initialize(self):
await super().initialize() await super().initialize()
@@ -105,8 +108,7 @@ of supported events to deliver. One package may supply multiple components, and
multiple instances may use the same component with independent configuration. multiple instances may use the same component with independent configuration.
Extend the existing single-target route arbitration with `event_processor`. Extend the existing single-target route arbitration with `event_processor`.
Remove automatic EBA broadcasts to installed EventListeners when this route is There is no automatic EBA broadcast to installed EventListeners. Keep Pipeline hook dispatch inside the Pipeline path. Existing observer
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 plugins must explicitly adopt the new component and be bound by the user; do not
create subscriptions during migration. create subscriptions during migration.
@@ -149,3 +151,66 @@ history remain separate; all declared EBA events retain their fields; unavailabl
components fail visibly; and legacy plugins keep the documented Pipeline hook components fail visibly; and legacy plugins keep the documented Pipeline hook
order and behavior. Unit tests alone do not establish a successful live plugin order and behavior. Unit tests alone do not establish a successful live plugin
installation or platform delivery. installation or platform delivery.
## Implemented transport and APIs
`lbp comp EventProcessor` scaffolds a component in `components/event_processor`.
Its manifest uses `kind: EventProcessor` and `spec.events`, for example
`[group.member_joined]`. `spec.config` defines instance parameters. A component
that calls `ctx.reply()` declares `spec.permissions.tools: [detail, call]`.
References use `event_processor:author/plugin/component`, separate from
`plugin:author/plugin/runner`. Both kinds share the existing run transport,
installation authorization, deadlines and run ledger. The trusted Host selects
the component kind; the worker invokes only that exact kind and name.
There is no model invocation in the EventProcessor base class.
`EventProcessorContext` provides `event`, `run_id`, `config`, `api`, `log()` and
`reply()`. `api` is the existing run-scoped Host proxy. Use `ctx.config` for instance
parameters; plugin installation configuration remains separate. Handlers may
register the `EBAEvent` base class as a catch-all. An exact typed handler takes
precedence over that fallback. Multiple handlers for the same type run in their
registration order, within one invocation.
HTTP instance management uses `/api/v1/agents` with `kind: event_processor`.
Metadata at `/api/v1/agents/_/metadata` lists installed `event_processors`.
Creation accepts `component_ref` and `parameters`; the Host derives the supported
event patterns from the component. Bot bindings use `target_type: event_processor`
and the created instance UUID as `target_id`.
- `GET /api/v1/agents/{id}/runs?before_id=...` lists this instance's runs.
- `GET /api/v1/agents/{id}/runs/{run_id}/events?after_sequence=...` pages its logs
and action results. A run from another instance or Workspace is rejected.
- The corresponding MCP tools are `get_processor_metadata`, `list_processor_runs`
and `get_processor_run_events`, alongside processor CRUD.
- `/api/v1/agents/{id}/debug` accepts a full typed EBA event in `data`. Platform
actions use Mock; other authorized tools retain their configured behavior.
The detail page polls run updates, keeps payload details collapsed and separates
logs from platform delivery. Completed handlers produce no synthetic reply text.
## Verification (2026-09-08)
- SDK API, scaffolding and Plugin Runtime suites: 684 passed.
- Host runner, service, controller, MCP and adapter regression suites: 971 passed.
- Pipeline and registry regression suites: 243 passed, one environment-dependent skip.
- Frontend unit suite: 74 passed; TypeScript and changed-file lint checks passed.
- Real packaged-plugin tests cover installation, component-kind separation,
invocation, mock platform delivery, instance isolation and persisted logs.
- Authenticated Edge testing created an instance and a loopback OneBot bot, saved
a member-join binding, injected one native notice and received exactly one
`send_group_msg` response. The detail page displayed the completed run,
localized action name, destination and returned message ID. No external IM
account or live model was involved.
- Browser regression covers expanding long payloads, reaching the last log and
pagination without duplication. Eight unrelated existing browser failures were
reproduced against the pre-change commit; the full suite is not green.
- Repository-wide lint also retains the pre-existing duplicate `send_image_msg`
in the WeCom customer-service library and existing formatting failures outside
this change. The i18n check has the same pre-existing diagnostics as its baseline.
Native Agent interaction-resumption is not enabled for EventProcessor bindings;
its input contract is the platform EBA event collection, not a synthetic Agent
continuation. Plugins should handle platform events through their typed handlers.
+5 -1
View File
@@ -69,7 +69,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2", "langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0", "chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.5.3", "langbot-plugin==0.5.5",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2", "matrix-nio>=0.25.2",
@@ -229,3 +229,7 @@ skip-magic-trailing-comma = false
# Like Black, automatically detect the appropriate line ending. # Like Black, automatically detect the appropriate line ending.
line-ending = "auto" line-ending = "auto"
[tool.uv.sources]
# Development contract: update to the matching SDK release before publishing.
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "23011398160cedcd4ef090054692bc2a4b34ee9f" }
+12 -1
View File
@@ -65,7 +65,9 @@ The tools wrap the LangBot service layer. Current tools (v1):
| `get_system_info` | Version, edition, instance id | | `get_system_info` | Version, edition, instance id |
| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) | | `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) |
| `list_bot_event_route_statuses` | Inspect bot event-route runtime status | | `list_bot_event_route_statuses` | Inspect bot event-route runtime status |
| `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent and Pipeline processor types | | `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent, Pipeline and Event processor types |
| `get_processor_metadata` | Discover installed EventProcessor components, schemas and supported event patterns. |
| `list_processor_runs` / `get_processor_run_events` | Read one Event processor instance run history and logs; paginate with `before_id` / `after_sequence`. |
| `debug_agent` | Execute a synthetic Agent event (`processor_uuid`, `payload`); requires `runtime.operate`. Returns final text and up to 1000 execution events (thinking, text, tool arguments/results). Platform tools use Mock; other configured tools execute normally. Optional `payload.mock`: `errors`/`results` keyed by platform tool name, `unsupported_apis` lists unavailable platform APIs. | | `debug_agent` | Execute a synthetic Agent event (`processor_uuid`, `payload`); requires `runtime.operate`. Returns final text and up to 1000 execution events (thinking, text, tool arguments/results). Platform tools use Mock; other configured tools execute normally. Optional `payload.mock`: `errors`/`results` keyed by platform tool name, `unsupported_apis` lists unavailable platform APIs. |
| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines | | `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines |
| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers | | `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers |
@@ -111,3 +113,12 @@ already have a default pipeline.
- A `403` means the key is valid but lacks the permission required by the tool. - A `403` means the key is valid but lacks the permission required by the tool.
- The global key is plaintext in config.yaml — only enable it on trusted/internal - The global key is plaintext in config.yaml — only enable it on trusted/internal
deployments and serve over HTTPS. deployments and serve over HTTPS.
## Event processors
Install the plugin, discover its component with `get_processor_metadata`, then
create a processor with `kind: "event_processor"`, `component_ref` and optional
`parameters`. Bind bot events to this instance with `target_type: "event_processor"`
and `target_id` equal to its UUID. Installation alone never activates a handler.
`debug_agent` accepts the complete typed EBA event in `payload.data` for this kind.
Legacy EventListener plugins remain in the Pipeline lifecycle.
+6 -6
View File
@@ -1,4 +1,5 @@
"""Agent runner descriptor.""" """Agent runner descriptor."""
from __future__ import annotations from __future__ import annotations
import typing import typing
@@ -44,19 +45,18 @@ class AgentRunnerDescriptor(pydantic.BaseModel):
config_schema: list[dict[str, typing.Any]] = pydantic.Field(default_factory=list) config_schema: list[dict[str, typing.Any]] = pydantic.Field(default_factory=list)
"""Configuration schema using DynamicForm format""" """Configuration schema using DynamicForm format"""
capabilities: AgentRunnerCapabilities = pydantic.Field( capabilities: AgentRunnerCapabilities = pydantic.Field(default_factory=AgentRunnerCapabilities)
default_factory=AgentRunnerCapabilities
)
"""Runner capabilities: streaming, tool_calling, knowledge_retrieval, etc.""" """Runner capabilities: streaming, tool_calling, knowledge_retrieval, etc."""
permissions: AgentRunnerPermissions = pydantic.Field( permissions: AgentRunnerPermissions = pydantic.Field(default_factory=AgentRunnerPermissions)
default_factory=AgentRunnerPermissions
)
"""Requested LangBot resource permissions.""" """Requested LangBot resource permissions."""
raw_manifest: dict[str, typing.Any] = pydantic.Field(default_factory=dict) raw_manifest: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
"""Original manifest for reference""" """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( model_config = pydantic.ConfigDict(
extra='allow', extra='allow',
) )
+2 -2
View File
@@ -160,7 +160,7 @@ class AgentConfig(pydantic.BaseModel):
agent_id: str | None = None agent_id: str | None = None
"""Host-side Agent/config identifier.""" """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.""" """Product processor kind represented by this runtime config."""
processor_id: str | None = None processor_id: str | None = None
@@ -222,7 +222,7 @@ class AgentBinding(pydantic.BaseModel):
agent_id: str | None = None agent_id: str | None = None
"""Host-side Agent/config identifier for this binding.""" """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.""" """Product processor kind selected for this binding."""
processor_id: str | None = None processor_id: str | None = None
+12 -14
View File
@@ -1,4 +1,5 @@
"""Agent runner ID parsing and formatting.""" """Agent runner ID parsing and formatting."""
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
@@ -7,6 +8,7 @@ import dataclasses
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class RunnerIdParts: class RunnerIdParts:
"""Parsed runner ID components.""" """Parsed runner ID components."""
source: str # 'plugin' (future: 'builtin') source: str # 'plugin' (future: 'builtin')
plugin_author: str plugin_author: str
plugin_name: str plugin_name: str
@@ -29,31 +31,27 @@ def parse_runner_id(runner_id: str) -> RunnerIdParts:
Raises: Raises:
ValueError: If runner_id format is invalid ValueError: If runner_id format is invalid
""" """
if runner_id.startswith('plugin:'): if runner_id.startswith(('plugin:', 'event_processor:')):
parts = runner_id[7:].split('/') source, value = runner_id.split(':', 1)
parts = value.split('/')
if len(parts) != 3: if len(parts) != 3:
raise ValueError( raise ValueError(
f'Invalid plugin runner ID format: {runner_id}. ' f'Invalid plugin runner ID format: {runner_id}. Expected: plugin:author/plugin_name/runner_name'
f'Expected: plugin:author/plugin_name/runner_name'
) )
plugin_author, plugin_name, runner_name = parts plugin_author, plugin_name, runner_name = parts
if not plugin_author or not plugin_name or not runner_name: if not plugin_author or not plugin_name or not runner_name:
raise ValueError( raise ValueError(
f'Invalid plugin runner ID: {runner_id}. ' f'Invalid plugin runner ID: {runner_id}. author, plugin_name, and runner_name must be non-empty'
f'author, plugin_name, and runner_name must be non-empty'
) )
return RunnerIdParts( return RunnerIdParts(
source='plugin', source=source,
plugin_author=plugin_author, plugin_author=plugin_author,
plugin_name=plugin_name, plugin_name=plugin_name,
runner_name=runner_name, runner_name=runner_name,
) )
else: else:
# Only plugin runner IDs are valid at the protocol boundary. # Only plugin runner IDs are valid at the protocol boundary.
raise ValueError( raise ValueError(f'Invalid runner ID format: {runner_id}. Expected: plugin:author/plugin_name/runner_name')
f'Invalid runner ID format: {runner_id}. '
f'Expected: plugin:author/plugin_name/runner_name'
)
def format_runner_id( def format_runner_id(
@@ -73,8 +71,8 @@ def format_runner_id(
Returns: Returns:
Runner ID string Runner ID string
""" """
if source == 'plugin': if source in {'plugin', 'event_processor'}:
return f'plugin:{plugin_author}/{plugin_name}/{runner_name}' return f'{source}:{plugin_author}/{plugin_name}/{runner_name}'
else: else:
raise ValueError(f'Invalid runner source: {source}') raise ValueError(f'Invalid runner source: {source}')
@@ -88,4 +86,4 @@ def is_plugin_runner_id(runner_id: str) -> bool:
Returns: Returns:
True if runner ID starts with 'plugin:' 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: 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( gen = self.ap.plugin_connector.run_agent(
plugin_author=descriptor.plugin_author, plugin_author=descriptor.plugin_author,
plugin_name=descriptor.plugin_name, plugin_name=descriptor.plugin_name,
@@ -102,6 +102,10 @@ class AgentRunOrchestrator:
bound_plugins, 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: if execution_query is None:
execution_query = build_execution_query(event, []) execution_query = build_execution_query(event, [])
# Synthetic events must expose the same trusted scope as pipeline queries. # 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', {}) manifest = runner_data.get('manifest', {})
runner_id = format_runner_id( runner_id = format_runner_id(
source='plugin', source='event_processor' if manifest.get('component_kind') == 'EventProcessor' else 'plugin',
plugin_author=plugin_author, plugin_author=plugin_author,
plugin_name=plugin_name, plugin_name=plugin_name,
runner_name=runner_name, runner_name=runner_name,
) )
typed_manifest = AgentRunnerManifest.model_validate(manifest) typed_manifest = AgentRunnerManifest.model_validate(manifest)
config_schema = [ config_schema = [item.model_dump(mode='json') for item in typed_manifest.config_schema]
item.model_dump(mode='json') for item in typed_manifest.config_schema
]
return AgentRunnerDescriptor( return AgentRunnerDescriptor(
id=runner_id, id=runner_id,
component_kind=typed_manifest.component_kind,
supported_event_patterns=typed_manifest.supported_event_patterns,
source='plugin', source='plugin',
label=typed_manifest.label, label=typed_manifest.label,
description=typed_manifest.description, description=typed_manifest.description,
@@ -152,6 +152,7 @@ class AgentRunnerRegistry:
context: TenantContext, context: TenantContext,
bound_plugins: list[str] | None = None, bound_plugins: list[str] | None = None,
use_cache: bool = True, use_cache: bool = True,
component_kind: str = 'AgentRunner',
) -> list[AgentRunnerDescriptor]: ) -> list[AgentRunnerDescriptor]:
"""List available runners. """List available runners.
@@ -169,7 +170,11 @@ class AgentRunnerRegistry:
# Filter from cache. Do not treat an empty cache as final because the # Filter from cache. Do not treat an empty cache as final because the
# plugin runtime may still be launching installed plugins when the # plugin runtime may still be launching installed plugins when the
# first metadata request arrives. # 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) # Discover fresh (always full list)
runners = await self._discover_runners() runners = await self._discover_runners()
@@ -179,7 +184,11 @@ class AgentRunnerRegistry:
self._cache[cache_key] = runners self._cache[cache_key] = runners
# Filter locally # 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( def _filter_runners_by_bound_plugins(
self, self,
@@ -233,7 +242,8 @@ class AgentRunnerRegistry:
except ValueError as e: except ValueError as e:
raise RunnerNotFoundError(runner_id) from 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) descriptor = next((item for item in runners if item.id == runner_id), None)
if descriptor is None: if descriptor is None:
# The runtime launches installed plugins asynchronously, so an # The runtime launches installed plugins asynchronously, so an
@@ -242,6 +252,7 @@ class AgentRunnerRegistry:
context, context,
bound_plugins=None, bound_plugins=None,
use_cache=False, use_cache=False,
component_kind=component_kind,
) )
descriptor = next((item for item in runners if item.id == runner_id), None) descriptor = next((item for item in runners if item.id == runner_id), None)
if descriptor is None: if descriptor is None:
@@ -10,6 +10,7 @@ from langbot_plugin.api.entities.builtin.agent_runner.result import (
MessageCompletedPayload, MessageCompletedPayload,
MessageDeltaPayload, MessageDeltaPayload,
RunCompletedPayload, RunCompletedPayload,
ProcessorLogPayload,
RunFailedPayload, RunFailedPayload,
StateUpdatedPayload, StateUpdatedPayload,
ToolCallCompletedPayload, ToolCallCompletedPayload,
@@ -34,6 +35,7 @@ STRICT_RESULT_PAYLOADS: dict[str, type[pydantic.BaseModel]] = {
'action.requested': ActionRequestedPayload, 'action.requested': ActionRequestedPayload,
'run.completed': RunCompletedPayload, 'run.completed': RunCompletedPayload,
'run.failed': RunFailedPayload, 'run.failed': RunFailedPayload,
'processor.log': ProcessorLogPayload,
} }
@@ -114,6 +116,9 @@ class AgentResultNormalizer:
if not self.validate_payload(result_type, data, descriptor): if not self.validate_payload(result_type, data, descriptor):
return None return None
if result_type == 'processor.log':
return None
if result_type == 'message.delta': if result_type == 'message.delta':
return self._normalize_message_delta(data, descriptor) return self._normalize_message_delta(data, descriptor)
@@ -93,6 +93,13 @@ class AgentRunJournal:
metadata={ metadata={
'event_type': event.event_type, 'event_type': event.event_type,
'source': event.source, '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 = query.where(AgentRun.conversation_id == conversation_id)
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) 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( query = (
skip_locked=True query.order_by(AgentRun.priority.desc(), AgentRun.id.asc()).limit(1).with_for_update(skip_locked=True)
) )
result = await session.execute(query) result = await session.execute(query)
run = result.scalars().first() run = result.scalars().first()
@@ -571,8 +571,7 @@ class RunLedgerStore:
# Filter by labels # Filter by labels
runtimes = [ runtimes = [
rt for rt in all_runtimes rt for rt in all_runtimes if all(rt.get('labels', {}).get(k) == v for k, v in labels.items())
if all(rt.get('labels', {}).get(k) == v for k, v in labels.items())
] ]
total_count = len(runtimes) total_count = len(runtimes)
@@ -636,6 +635,7 @@ class RunLedgerStore:
thread_id: str | None = None, thread_id: str | None = None,
strict_thread: bool = False, strict_thread: bool = False,
runner_id: str | None = None, runner_id: str | None = None,
binding_id: str | None = None,
) -> tuple[list[dict[str, typing.Any]], int | None, bool, int]: ) -> tuple[list[dict[str, typing.Any]], int | None, bool, int]:
"""Page runs by scope. """Page runs by scope.
@@ -652,6 +652,8 @@ class RunLedgerStore:
count_query = count_query.where(AgentRun.status.in_(statuses)) count_query = count_query.where(AgentRun.status.in_(statuses))
if runner_id is not None: if runner_id is not None:
count_query = count_query.where(AgentRun.runner_id == runner_id) 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_query = self._apply_scope_filters(count_query, bot_id, workspace_id, thread_id, strict_thread)
count_result = await session.execute(count_query) count_result = await session.execute(count_query)
total_count = count_result.scalar() or 0 total_count = count_result.scalar() or 0
@@ -664,6 +666,8 @@ class RunLedgerStore:
query = query.where(AgentRun.status.in_(statuses)) query = query.where(AgentRun.status.in_(statuses))
if runner_id is not None: if runner_id is not None:
query = query.where(AgentRun.runner_id == runner_id) 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: if before_id is not None:
query = query.where(AgentRun.id < before_id) query = query.where(AgentRun.id < before_id)
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
@@ -848,10 +852,7 @@ class RunLedgerStore:
# Count by status # Count by status
status_query = ( status_query = (
sqlalchemy.select( sqlalchemy.select(AgentRun.status, func.count(AgentRun.id).label('count'))
AgentRun.status,
func.count(AgentRun.id).label('count')
)
.where(*base_filter) .where(*base_filter)
.group_by(AgentRun.status) .group_by(AgentRun.status)
) )
@@ -873,18 +874,15 @@ class RunLedgerStore:
avg_queue_wait_seconds = None avg_queue_wait_seconds = None
# Fetch completed runs with timing data # Fetch completed runs with timing data
timing_query = ( timing_query = sqlalchemy.select(
sqlalchemy.select( AgentRun.started_at,
AgentRun.started_at, AgentRun.finished_at,
AgentRun.finished_at, AgentRun.created_at,
AgentRun.created_at, ).where(
) AgentRun.status == 'completed',
.where( AgentRun.started_at.is_not(None),
AgentRun.status == 'completed', AgentRun.finished_at.is_not(None),
AgentRun.started_at.is_not(None), *base_filter,
AgentRun.finished_at.is_not(None),
*base_filter
)
) )
timing_result = await session.execute(timing_query) timing_result = await session.execute(timing_query)
timing_rows = timing_result.all() timing_rows = timing_result.all()
@@ -899,16 +897,10 @@ class RunLedgerStore:
avg_duration_seconds = round(sum(durations) / len(durations), 2) avg_duration_seconds = round(sum(durations) / len(durations), 2)
# Queue wait time - compute in Python # Queue wait time - compute in Python
queue_query = ( queue_query = sqlalchemy.select(
sqlalchemy.select( AgentRun.created_at,
AgentRun.created_at, AgentRun.started_at,
AgentRun.started_at, ).where(AgentRun.started_at.is_not(None), *base_filter)
)
.where(
AgentRun.started_at.is_not(None),
*base_filter
)
)
queue_result = await session.execute(queue_query) queue_result = await session.execute(queue_query)
queue_rows = queue_result.all() queue_rows = queue_result.all()
@@ -957,12 +949,8 @@ class RunLedgerStore:
async with self._session_factory() as session: async with self._session_factory() as session:
# Count by status # Count by status
status_query = ( status_query = sqlalchemy.select(AgentRuntime.status, func.count(AgentRuntime.id).label('count')).group_by(
sqlalchemy.select( AgentRuntime.status
AgentRuntime.status,
func.count(AgentRuntime.id).label('count')
)
.group_by(AgentRuntime.status)
) )
status_result = await session.execute(status_query) status_result = await session.execute(status_query)
status_counts = {row.status: row.count for row in status_result} status_counts = {row.status: row.count for row in status_result}
@@ -975,9 +963,8 @@ class RunLedgerStore:
avg_heartbeat_age = None avg_heartbeat_age = None
max_heartbeat_age = None max_heartbeat_age = None
heartbeat_query = ( heartbeat_query = sqlalchemy.select(AgentRuntime.last_heartbeat_at).where(
sqlalchemy.select(AgentRuntime.last_heartbeat_at) AgentRuntime.last_heartbeat_at.is_not(None)
.where(AgentRuntime.last_heartbeat_at.is_not(None))
) )
heartbeat_result = await session.execute(heartbeat_query) heartbeat_result = await session.execute(heartbeat_query)
heartbeat_rows = heartbeat_result.all() heartbeat_rows = heartbeat_result.all()
@@ -995,16 +982,12 @@ class RunLedgerStore:
avg_heartbeat_age = round(sum(ages) / len(ages), 2) avg_heartbeat_age = round(sum(ages) / len(ages), 2)
max_heartbeat_age = round(max(ages), 2) max_heartbeat_age = round(max(ages), 2)
active_runs_query = ( active_runs_query = sqlalchemy.select(func.count(AgentRun.id)).where(
sqlalchemy.select(func.count(AgentRun.id)) AgentRun.status.in_(['running', 'claimed'])
.where(AgentRun.status.in_(['running', 'claimed']))
) )
active_runs_result = await session.execute(active_runs_query) active_runs_result = await session.execute(active_runs_query)
active_runs = active_runs_result.scalar() or 0 active_runs = active_runs_result.scalar() or 0
claimed_runs_query = ( claimed_runs_query = sqlalchemy.select(func.count(AgentRun.id)).where(AgentRun.status == 'claimed')
sqlalchemy.select(func.count(AgentRun.id))
.where(AgentRun.status == 'claimed')
)
claimed_runs_result = await session.execute(claimed_runs_query) claimed_runs_result = await session.execute(claimed_runs_query)
claimed_runs = claimed_runs_result.scalar() or 0 claimed_runs = claimed_runs_result.scalar() or 0
@@ -1048,23 +1031,10 @@ class RunLedgerStore:
AgentRun.runner_id, AgentRun.runner_id,
func.count(AgentRun.id).label('total'), func.count(AgentRun.id).label('total'),
func.sum( func.sum(
sqlalchemy.case( sqlalchemy.case((AgentRun.status.in_(['queued', 'claimed', 'running']), 1), else_=0)
(AgentRun.status.in_(['queued', 'claimed', 'running']), 1),
else_=0
)
).label('active'), ).label('active'),
func.sum( func.sum(sqlalchemy.case((AgentRun.status == 'completed', 1), else_=0)).label('completed'),
sqlalchemy.case( func.sum(sqlalchemy.case((AgentRun.status.in_(['failed', 'timeout']), 1), else_=0)).label('failed'),
(AgentRun.status == 'completed', 1),
else_=0
)
).label('completed'),
func.sum(
sqlalchemy.case(
(AgentRun.status.in_(['failed', 'timeout']), 1),
else_=0
)
).label('failed'),
) )
.where( .where(
AgentRun.created_at >= start_dt, AgentRun.created_at >= start_dt,
@@ -1087,16 +1057,18 @@ class RunLedgerStore:
failed = row.failed or 0 failed = row.failed or 0
success_rate = completed / total if total > 0 else None success_rate = completed / total if total > 0 else None
stats.append({ stats.append(
'runner_id': runner_id, {
'runner_label': None, # Would need to join with runner descriptors 'runner_id': runner_id,
'plugin_identity': None, 'runner_label': None, # Would need to join with runner descriptors
'total_runs': total, 'plugin_identity': None,
'active_runs': row.active or 0, 'total_runs': total,
'completed_runs': completed, 'active_runs': row.active or 0,
'failed_runs': failed, 'completed_runs': completed,
'success_rate': round(success_rate, 4) if success_rate is not None else None, 'failed_runs': failed,
'avg_duration_seconds': None, # Would need more complex query '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 return stats
@@ -18,6 +18,43 @@ from .agent_debug_stream import debug_stream_response
@group.group_class('agents', '/api/v1/agents') @group.group_class('agents', '/api/v1/agents')
class AgentsRouterGroup(group.RouterGroup): class AgentsRouterGroup(group.RouterGroup):
async def initialize(self) -> None: async def initialize(self) -> None:
@self.route(
'/<agent_uuid>/runs',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def processor_runs(agent_uuid: str, request_context: RequestContext):
try:
cursor = quart.request.args.get('before_id')
result = await self.ap.agent_service.get_processor_runs(
request_context,
agent_uuid,
before_id=int(cursor) if cursor else None,
)
return self.success(data=result)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<agent_uuid>/runs/<run_id>/events',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def processor_run_events(agent_uuid: str, run_id: str, request_context: RequestContext):
try:
cursor = quart.request.args.get('after_sequence')
result = await self.ap.agent_service.get_processor_run_events(
request_context,
agent_uuid,
run_id,
after_sequence=int(cursor) if cursor else None,
)
return self.success(data=result)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route( @self.route(
'/<agent_uuid>/debug/stream', '/<agent_uuid>/debug/stream',
methods=['POST'], methods=['POST'],
+130 -14
View File
@@ -40,6 +40,7 @@ from .tenant import TenantContext, require_workspace_uuid, scope_statement
AGENT_KIND_AGENT = 'agent' AGENT_KIND_AGENT = 'agent'
AGENT_KIND_PIPELINE = 'pipeline' AGENT_KIND_PIPELINE = 'pipeline'
AGENT_KIND_EVENT_PROCESSOR = 'event_processor'
PIPELINE_EVENT_PATTERNS = ['message.*'] PIPELINE_EVENT_PATTERNS = ['message.*']
AGENT_DEFAULT_EVENT_PATTERNS = ['*'] AGENT_DEFAULT_EVENT_PATTERNS = ['*']
@@ -67,7 +68,19 @@ class AgentService:
) )
except Exception as exc: except Exception as exc:
self.ap.logger.warning(f'Failed to load Agent Host tool catalog: {exc}') self.ap.logger.warning(f'Failed to load Agent Host tool catalog: {exc}')
event_processors = []
registry = getattr(self.ap, 'agent_runner_registry', None)
if registry is not None:
event_processors = [
item.model_dump(mode='json')
for item in await registry.list_runners(
context,
component_kind='EventProcessor',
use_cache=False,
)
]
return { return {
'event_processors': event_processors,
'runner_config': ai_metadata, 'runner_config': ai_metadata,
'platform_tools': platform_tool_catalog(), 'platform_tools': platform_tool_catalog(),
'host_tools': host_tools, 'host_tools': host_tools,
@@ -82,6 +95,7 @@ class AgentService:
'supported_event_patterns': PIPELINE_EVENT_PATTERNS, 'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
'message_only': True, 'message_only': True,
}, },
{'name': AGENT_KIND_EVENT_PROCESSOR, 'supported_event_patterns': ['*'], 'message_only': False},
], ],
} }
@@ -131,7 +145,7 @@ class AgentService:
non-message event envelopes. non-message event envelopes.
""" """
agent = await self.get_agent(context, agent_uuid) agent = await self.get_agent(context, agent_uuid)
if agent is None or agent.get('kind') != AGENT_KIND_AGENT: if agent is None or agent.get('kind') not in {AGENT_KIND_AGENT, AGENT_KIND_EVENT_PROCESSOR}:
raise ValueError('Agent not found') raise ValueError('Agent not found')
event_type = str(payload.get('event_type', 'message.received')).strip() event_type = str(payload.get('event_type', 'message.received')).strip()
@@ -242,8 +256,28 @@ class AgentService:
raw_ref=RawEventRef(ref_id=event_id, storage_key=None), raw_ref=RawEventRef(ref_id=event_id, storage_key=None),
data=event_data, data=event_data,
) )
if agent.get('kind') == AGENT_KIND_EVENT_PROCESSOR:
from langbot_plugin.api.entities.builtin.platform.events import parse_eba_event
typed_event = parse_eba_event({**event_data, 'type': event_type})
from ....platform.botmgr import RuntimeBot
event.actor = RuntimeBot._infer_actor_context(typed_event)
event.subject = RuntimeBot._infer_subject_context(typed_event)
target_type, target_id, target_metadata = RuntimeBot._infer_reply_target(typed_event)
if target_id is not None:
event.delivery.reply_target = {
'target_type': target_type,
'target_id': str(target_id),
**target_metadata,
}
event.data = typed_event.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'})
binding = AgentBinding( binding = AgentBinding(
binding_id=f'debug:{agent_uuid}:{runner_id}', binding_id=(
f'event_processor:{agent_uuid}'
if agent.get('kind') == AGENT_KIND_EVENT_PROCESSOR
else f'debug:{agent_uuid}:{runner_id}'
),
scope=BindingScope(scope_type='agent', scope_id=agent_uuid), scope=BindingScope(scope_type='agent', scope_id=agent_uuid),
event_types=[event_type], event_types=[event_type],
runner_id=runner_id, runner_id=runner_id,
@@ -263,7 +297,7 @@ class AgentService:
enable_interactions=False, enable_interactions=False,
), ),
agent_id=agent_uuid, agent_id=agent_uuid,
processor_type='agent', processor_type=agent.get('kind', 'agent'),
processor_id=agent_uuid, processor_id=agent_uuid,
) )
execution_context = ExecutionContext.from_request( execution_context = ExecutionContext.from_request(
@@ -282,6 +316,7 @@ class AgentService:
'tool.call.completed', 'tool.call.completed',
'run.completed', 'run.completed',
'run.failed', 'run.failed',
'processor.log',
}: }:
return return
visible_result = copy.deepcopy( visible_result = copy.deepcopy(
@@ -363,11 +398,17 @@ class AgentService:
) )
return {'uuid': pipeline_uuid, 'kind': AGENT_KIND_PIPELINE} return {'uuid': pipeline_uuid, 'kind': AGENT_KIND_PIPELINE}
if kind != AGENT_KIND_AGENT: if kind not in {AGENT_KIND_AGENT, AGENT_KIND_EVENT_PROCESSOR}:
raise ValueError(f'Unsupported agent kind: {kind}') raise ValueError(f'Unsupported agent kind: {kind}')
config = agent_data['config'] if 'config' in agent_data else await self._get_default_agent_config(context) if kind == AGENT_KIND_EVENT_PROCESSOR:
config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(config) config, runner_id, patterns = await self._prepare_event_processor(context, agent_data)
else:
config = agent_data['config'] if 'config' in agent_data else await self._get_default_agent_config(context)
config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(config)
if (runner_id or '').startswith('event_processor:'):
raise ValueError('EventProcessor components require an event processor instance')
patterns = agent_data.get('supported_event_patterns', AGENT_DEFAULT_EVENT_PATTERNS)
new_uuid = str(uuid.uuid4()) new_uuid = str(uuid.uuid4())
values = { values = {
'workspace_uuid': workspace_uuid, 'workspace_uuid': workspace_uuid,
@@ -375,17 +416,13 @@ class AgentService:
'name': agent_data.get('name') or 'New Agent', 'name': agent_data.get('name') or 'New Agent',
'description': agent_data.get('description') or '', 'description': agent_data.get('description') or '',
'emoji': agent_data.get('emoji') or '🤖', 'emoji': agent_data.get('emoji') or '🤖',
'kind': AGENT_KIND_AGENT, 'kind': kind,
'component_ref': runner_id, 'component_ref': runner_id,
'config': config, 'config': config,
'supported_event_patterns': ( 'supported_event_patterns': patterns,
agent_data['supported_event_patterns']
if 'supported_event_patterns' in agent_data
else AGENT_DEFAULT_EVENT_PATTERNS
),
} }
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values)) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values))
return {'uuid': new_uuid, 'kind': AGENT_KIND_AGENT} return {'uuid': new_uuid, 'kind': kind}
async def update_agent(self, context: TenantContext, agent_uuid: str, agent_data: dict) -> None: async def update_agent(self, context: TenantContext, agent_uuid: str, agent_data: dict) -> None:
existing_agent = await self._get_agent_row(context, agent_uuid) existing_agent = await self._get_agent_row(context, agent_uuid)
@@ -401,12 +438,19 @@ class AgentService:
for field in ('name', 'description', 'emoji', 'config', 'supported_event_patterns') for field in ('name', 'description', 'emoji', 'config', 'supported_event_patterns')
if field in agent_data if field in agent_data
} }
if existing_agent.kind == AGENT_KIND_EVENT_PROCESSOR and any(
field in agent_data for field in ('config', 'component_ref', 'parameters', 'supported_event_patterns')
):
config, runner_id, patterns = await self._prepare_event_processor(context, agent_data, existing_agent)
update_data.update(config=config, component_ref=runner_id, supported_event_patterns=patterns)
if 'config' in update_data: if 'config' in update_data:
config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(update_data['config']) config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(update_data['config'])
update_data['config'] = config update_data['config'] = config
else: else:
_, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(existing_agent.config) _, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(existing_agent.config)
update_data['component_ref'] = runner_id update_data['component_ref'] = runner_id
if existing_agent.kind == AGENT_KIND_AGENT and (runner_id or '').startswith('event_processor:'):
raise ValueError('EventProcessor components require an event processor instance')
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
scope_statement( scope_statement(
sqlalchemy.update(persistence_agent.Agent) sqlalchemy.update(persistence_agent.Agent)
@@ -438,6 +482,78 @@ class AgentService:
raise ValueError(f'Agent {agent_uuid} not found') raise ValueError(f'Agent {agent_uuid} not found')
await self.ap.pipeline_service.delete_pipeline(context, agent_uuid) await self.ap.pipeline_service.delete_pipeline(context, agent_uuid)
async def _prepare_event_processor(self, context, data, existing=None):
"""Resolve an installed component and keep its capability declaration authoritative."""
config = copy.deepcopy(data.get('config', existing.config if existing is not None else {}))
if not isinstance(config, dict):
raise ValueError('Processor configuration must be an object')
component_ref = data.get('component_ref') or (existing.component_ref if existing is not None else None)
if not isinstance(component_ref, str) or not component_ref.startswith('event_processor:'):
raise ValueError('Select an installed EventProcessor component')
try:
descriptor = await self.ap.agent_runner_registry.get(context, component_ref)
except Exception as exc:
from ....agent.runner.errors import RunnerNotFoundError
if isinstance(exc, RunnerNotFoundError):
raise ValueError('EventProcessor component is unavailable') from exc
raise
if descriptor.component_kind != 'EventProcessor' or not descriptor.supported_event_patterns:
raise ValueError('The component does not declare supported EBA events')
config['runner'] = {'id': component_ref}
parameters = data.get('parameters')
if parameters is None:
runner_config = config.get('runner_config', {})
if not isinstance(runner_config, dict):
raise ValueError('Runner configuration must be an object')
parameters = runner_config.get(component_ref)
if parameters is None:
parameters = self.ap.pipeline_service._get_default_values_from_schema(descriptor.config_schema)
if not isinstance(parameters, dict):
raise ValueError('Processor parameters must be an object')
for field in descriptor.config_schema:
if field.get('required') and parameters.get(field['name']) in (None, ''):
raise ValueError(f'Required processor parameter: {field["name"]}')
config['runner_config'] = {component_ref: parameters}
return config, component_ref, descriptor.supported_event_patterns
async def get_processor_runs(self, context, processor_id, *, before_id=None):
"""Read only this Workspace's explicitly created processor instance."""
from ....agent.runner.run_ledger_store import RunLedgerStore
processor = await self.get_agent(context, processor_id)
if processor is None or processor.get('kind') != AGENT_KIND_EVENT_PROCESSOR:
raise ValueError('Event processor not found')
store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
items, cursor, has_more, total = await store.list_runs(
workspace_id=require_workspace_uuid(context),
binding_id=f'event_processor:{processor_id}',
before_id=before_id,
)
return {'items': items, 'next_cursor': cursor, 'has_more': has_more, 'total': total}
async def get_processor_run_events(self, context, processor_id, run_id, *, after_sequence=None):
"""Authorize the parent run before exposing any trace events."""
from ....agent.runner.run_ledger_store import RunLedgerStore
processor = await self.get_agent(context, processor_id)
if processor is None or processor.get('kind') != AGENT_KIND_EVENT_PROCESSOR:
raise ValueError('Event processor not found')
store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
run = await store.get_run(run_id)
if (
run is None
or run.get('workspace_id') != require_workspace_uuid(context)
or run.get('binding_id') != f'event_processor:{processor_id}'
):
raise ValueError('Processor run not found')
items, next_cursor, _, has_more = await store.page_run_events(
run_id=run_id,
after_sequence=after_sequence,
limit=100,
)
return {'run': run, 'items': items, 'next_cursor': next_cursor, 'has_more': has_more}
async def _get_agent_rows(self, context: TenantContext) -> list[persistence_agent.Agent]: async def _get_agent_rows(self, context: TenantContext) -> list[persistence_agent.Agent]:
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
scope_statement( scope_statement(
@@ -490,7 +606,7 @@ class AgentService:
include_config: bool = False, include_config: bool = False,
) -> dict[str, typing.Any]: ) -> dict[str, typing.Any]:
item = self.ap.persistence_mgr.serialize_model(persistence_agent.Agent, agent) item = self.ap.persistence_mgr.serialize_model(persistence_agent.Agent, agent)
item['kind'] = AGENT_KIND_AGENT item['kind'] = item.get('kind') or AGENT_KIND_AGENT
supported_event_patterns = item.get('supported_event_patterns') supported_event_patterns = item.get('supported_event_patterns')
item['capability'] = { item['capability'] = {
'supported_event_patterns': ( 'supported_event_patterns': (
+6 -6
View File
@@ -149,7 +149,7 @@ class BotService:
return target_kind return target_kind
if target_type == 'discard': if target_type == 'discard':
return 'discard' return 'discard'
if target_type in {'agent', 'pipeline'}: if target_type in {'agent', 'pipeline', 'event_processor'}:
return str(target_type) return str(target_type)
return None return None
@@ -416,9 +416,9 @@ class BotService:
diagnostic_steps=diagnostic_steps, diagnostic_steps=diagnostic_steps,
) )
if target_type == 'agent': if target_type in {'agent', 'event_processor'}:
agent = await self._get_agent_entity(tenant_context, target_uuid) agent = await self._get_agent_entity(tenant_context, target_uuid)
if agent is None or getattr(agent, 'kind', 'agent') != 'agent': if agent is None or getattr(agent, 'kind', 'agent') != target_type:
return self._diagnostic_result( return self._diagnostic_result(
matched=False, matched=False,
binding=selected_binding, binding=selected_binding,
@@ -514,7 +514,7 @@ class BotService:
) )
if result.first() is None: if result.first() is None:
raise ValueError('Pipeline not found') raise ValueError('Pipeline not found')
elif target_type == 'agent': elif target_type in {'agent', 'event_processor'}:
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
scope_statement( scope_statement(
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid), sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid),
@@ -523,8 +523,8 @@ class BotService:
) )
) )
agent = result.first() agent = result.first()
if agent is None: if agent is None or agent.kind != target_type:
raise ValueError('Agent not found') raise ValueError('Processor not found')
if not self._agent_supports_event_pattern(agent.supported_event_patterns, event_pattern): if not self._agent_supports_event_pattern(agent.supported_event_patterns, event_pattern):
raise ValueError('Agent does not support this event pattern') raise ValueError('Agent does not support this event pattern')
elif target_type == 'discard': elif target_type == 'discard':
+35 -7
View File
@@ -175,44 +175,72 @@ class LangBotMCPServer:
return _dump({'ok': True}) return _dump({'ok': True})
# ----- Processors ---------------------------------------------- # # ----- Processors ---------------------------------------------- #
@mcp.tool(description='List product-level processors, including Agents and Pipelines.') @mcp.tool(description='List product-level processors, including Agents, Pipelines and Event processors.')
async def list_processors() -> str: async def list_processors() -> str:
context = _authorized(Permission.RESOURCE_VIEW) context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.agent_service.get_agents(context)) return _dump(await ap.agent_service.get_agents(context))
@mcp.tool(description='Get an Agent or Pipeline processor by UUID.') @mcp.tool(description='Get an Agent, Pipeline or Event processor by UUID.')
async def get_processor(processor_uuid: str) -> str: async def get_processor(processor_uuid: str) -> str:
context = _authorized(Permission.RESOURCE_VIEW) context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.agent_service.get_agent(context, processor_uuid)) return _dump(await ap.agent_service.get_agent(context, processor_uuid))
@mcp.tool( @mcp.tool(
description=( description=(
'Create an Agent or Pipeline processor. Set `processor_data.kind` to ' 'Create an Agent, Pipeline or Event processor. Set `processor_data.kind` to '
'`agent` or `pipeline`. Returns the new UUID and kind.' '`agent`, `pipeline` or `event_processor`. Event processors require an installed component_ref '
'from get_processor_metadata; optional parameters configure the instance. Returns UUID and kind.'
) )
) )
async def create_processor(processor_data: dict) -> str: async def create_processor(processor_data: dict) -> str:
context = _authorized(Permission.RESOURCE_MANAGE) context = _authorized(Permission.RESOURCE_MANAGE)
return _dump(await ap.agent_service.create_agent(context, processor_data)) return _dump(await ap.agent_service.create_agent(context, processor_data))
@mcp.tool(description='Update an Agent or Pipeline processor by UUID.') @mcp.tool(description='Update an Agent, Pipeline or Event processor by UUID.')
async def update_processor(processor_uuid: str, processor_data: dict) -> str: async def update_processor(processor_uuid: str, processor_data: dict) -> str:
context = _authorized(Permission.RESOURCE_MANAGE) context = _authorized(Permission.RESOURCE_MANAGE)
await ap.agent_service.update_agent(context, processor_uuid, processor_data) await ap.agent_service.update_agent(context, processor_uuid, processor_data)
return _dump({'ok': True}) return _dump({'ok': True})
@mcp.tool(description='Delete an Agent or Pipeline processor by UUID.') @mcp.tool(description='Delete an Agent, Pipeline or Event processor by UUID.')
async def delete_processor(processor_uuid: str) -> str: async def delete_processor(processor_uuid: str) -> str:
context = _authorized(Permission.RESOURCE_MANAGE) context = _authorized(Permission.RESOURCE_MANAGE)
await ap.agent_service.delete_agent(context, processor_uuid) await ap.agent_service.delete_agent(context, processor_uuid)
return _dump({'ok': True}) return _dump({'ok': True})
@mcp.tool(description='Get processor kinds and installed EventProcessor components with configuration schemas.')
async def get_processor_metadata() -> str:
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.agent_service.get_agent_metadata(context))
@mcp.tool(description='List one Event processor instance run history; use before_id to page older runs.')
async def list_processor_runs(processor_uuid: str, before_id: int | None = None) -> str:
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.agent_service.get_processor_runs(context, processor_uuid, before_id=before_id))
@mcp.tool(description='Read logs and action results for an Event processor run; page using after_sequence.')
async def get_processor_run_events(
processor_uuid: str,
run_id: str,
after_sequence: int | None = None,
) -> str:
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(
await ap.agent_service.get_processor_run_events(
context,
processor_uuid,
run_id,
after_sequence=after_sequence,
)
)
# ----- Models -------------------------------------------------- # # ----- Models -------------------------------------------------- #
@mcp.tool( @mcp.tool(
description=( description=(
'Run a synthetic event against an Agent processor without platform delivery. ' 'Run a synthetic event against an Agent or Event processor without platform delivery. '
'Returns final text and execution_events containing reported messages/thinking and tool calls. ' 'Returns final text and execution_events containing reported messages/thinking and tool calls. '
'Platform tools use mock adapters; other tools execute normally. ' 'Platform tools use mock adapters; other tools execute normally. '
'For Event processors, data contains the complete typed EBA event fields. '
'Requires runtime.operate; payload accepts event_type, text, data, conversation_id, actor, subject and ' 'Requires runtime.operate; payload accepts event_type, text, data, conversation_id, actor, subject and '
'mock (errors/results keyed by platform tool name; unsupported_apis lists unavailable platform APIs).' 'mock (errors/results keyed by platform tool name; unsupported_apis lists unavailable platform APIs).'
) )
@@ -62,12 +62,25 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
chat_id = getattr(event, 'group_id', '') chat_id = getattr(event, 'group_id', '')
group = AiocqhttpEventConverter.group_from_event(event) group = AiocqhttpEventConverter.group_from_event(event)
sender = AiocqhttpEventConverter.user_from_sender(event)
sender_data = getattr(event, 'sender', {}) or {}
role = sender_data.get('role', 'member')
membership = None
if group is not None:
membership = platform_entities.UserGroupMember(
user=sender,
group_id=group.id,
role=role if role in {'owner', 'admin', 'member'} else 'member',
display_name=sender_data.get('card') or sender.nickname,
title=sender_data.get('title'),
)
return platform_events.MessageReceivedEvent( return platform_events.MessageReceivedEvent(
type='message.received', type='message.received',
adapter_name='aiocqhttp', adapter_name='aiocqhttp',
message_id=getattr(event, 'message_id', ''), message_id=getattr(event, 'message_id', ''),
message_chain=message_chain, message_chain=message_chain,
sender=AiocqhttpEventConverter.user_from_sender(event), sender=sender,
sender_member=membership,
chat_type=chat_type, chat_type=chat_type,
chat_id=chat_id, chat_id=chat_id,
group=group, group=group,
+15 -13
View File
@@ -630,6 +630,7 @@ class RuntimeBot:
name=event.group.name, name=event.group.name,
) )
return platform_events.MessageReceivedEvent( return platform_events.MessageReceivedEvent(
legacy_event=event,
message_id=self._extract_message_id(event.message_chain), message_id=self._extract_message_id(event.message_chain),
message_chain=event.message_chain, message_chain=event.message_chain,
sender=platform_entities.User( sender=platform_entities.User(
@@ -646,6 +647,7 @@ class RuntimeBot:
) )
return platform_events.MessageReceivedEvent( return platform_events.MessageReceivedEvent(
legacy_event=event,
message_id=self._extract_message_id(event.message_chain), message_id=self._extract_message_id(event.message_chain),
message_chain=event.message_chain, message_chain=event.message_chain,
sender=platform_entities.User( sender=platform_entities.User(
@@ -805,7 +807,11 @@ class RuntimeBot:
return None return None
return AgentBinding( return AgentBinding(
binding_id=f'bot:{bot_uuid}:{event_binding.get("id") or uuid.uuid4()}', binding_id=(
f'event_processor:{agent["uuid"]}'
if agent.get('kind') == 'event_processor'
else f'bot:{bot_uuid}:{event_binding.get("id") or uuid.uuid4()}'
),
scope=BindingScope(scope_type='bot', scope_id=bot_uuid), scope=BindingScope(scope_type='bot', scope_id=bot_uuid),
event_types=[event_type], event_types=[event_type],
runner_id=runner_id, runner_id=runner_id,
@@ -820,10 +826,10 @@ class RuntimeBot:
delivery_policy=DeliveryPolicy( delivery_policy=DeliveryPolicy(
enable_streaming=False, enable_streaming=False,
enable_reply=True, enable_reply=True,
enable_interactions=True, enable_interactions=agent.get('kind') != 'event_processor',
), ),
agent_id=agent.get('uuid'), agent_id=agent.get('uuid'),
processor_type='agent', processor_type=agent.get('kind', 'agent'),
processor_id=agent.get('uuid'), processor_id=agent.get('uuid'),
) )
@@ -898,14 +904,8 @@ class RuntimeBot:
await self._handle_interaction_submission(event, adapter) await self._handle_interaction_submission(event, adapter)
return return
plugin_event = self._eba_event_to_plugin_event(event) # Legacy listeners run inside Pipeline stages. EBA handlers require an
# explicitly created and routed EventProcessor instance.
if plugin_event is not None:
try:
await self.ap.plugin_connector.emit_event(plugin_event)
except Exception:
await self.logger.error(f'Failed to dispatch platform event to plugins: {traceback.format_exc()}')
await self._dispatch_eba_event_to_processor(event, adapter) await self._dispatch_eba_event_to_processor(event, adapter)
async def _dispatch_eba_event_to_processor( async def _dispatch_eba_event_to_processor(
@@ -983,7 +983,7 @@ class RuntimeBot:
target_uuid=event_binding.get('target_uuid'), target_uuid=event_binding.get('target_uuid'),
text=f'EBA event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(), text=f'EBA event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(),
) )
if target_type != 'agent': if target_type not in {'agent', 'event_processor'}:
return await self._record_event_route_trace( return await self._record_event_route_trace(
event_type=event_type, event_type=event_type,
status='failed', status='failed',
@@ -998,7 +998,7 @@ class RuntimeBot:
target_uuid = event_binding.get('target_uuid') target_uuid = event_binding.get('target_uuid')
agent = await self.ap.agent_service.get_agent(self.execution_context, target_uuid) agent = await self.ap.agent_service.get_agent(self.execution_context, target_uuid)
if not agent or agent.get('kind') != 'agent': if not agent or agent.get('kind') != target_type:
return await self._record_event_route_trace( return await self._record_event_route_trace(
event_type=event_type, event_type=event_type,
status='failed', status='failed',
@@ -1050,6 +1050,8 @@ class RuntimeBot:
) )
envelope = self._eba_event_to_agent_envelope(event, adapter) envelope = self._eba_event_to_agent_envelope(event, adapter)
if target_type == 'event_processor':
envelope.data = event.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'})
outputs: list[provider_message.Message | provider_message.MessageChunk] = [] outputs: list[provider_message.Message | provider_message.MessageChunk] = []
try: try:
async for output in self.ap.agent_run_orchestrator.run( async for output in self.ap.agent_run_orchestrator.run(
+104 -2
View File
@@ -74,6 +74,9 @@ def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
AgentRunner: AgentRunner:
fromDirs: fromDirs:
- path: components/agent_runner/ - path: components/agent_runner/
EventProcessor:
fromDirs:
- path: components/event_processor/
pages: [] pages: []
execution: execution:
python: python:
@@ -160,6 +163,45 @@ def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
encoding='utf-8', 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: def _free_port() -> int:
"""Reserve a currently-free localhost TCP port for this E2E process.""" """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:]}' f'Runtime stderr (tail):\n{runtime_stderr[-20_000:]}'
) )
def test_host_orchestrator_runs_agent_runner_and_records_ledger( def test_host_orchestrator_runs_agent_runner_and_records_ledger(
agent_runner_client, agent_runner_client,
agent_runner_langbot_process, agent_runner_langbot_process,
@@ -455,8 +498,7 @@ def test_host_orchestrator_runs_agent_runner_and_records_ledger(
event_types = { event_types = {
row[0] row[0]
for row in conn.execute( for row in conn.execute(
'SELECT type FROM agent_run_event WHERE run_id = ' 'SELECT type FROM agent_run_event WHERE run_id = (SELECT run_id FROM agent_run WHERE event_id = ?)',
'(SELECT run_id FROM agent_run WHERE event_id = ?)',
(result['event_id'],), (result['event_id'],),
).fetchall() ).fetchall()
} }
@@ -469,3 +511,63 @@ def test_host_orchestrator_runs_agent_runner_and_records_ledger(
assert '"count": 1' in state_row[0] assert '"count": 1' in state_row[0]
finally: finally:
conn.close() 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'] == []
+28
View File
@@ -335,3 +335,31 @@ class TestDescriptorValidation:
assert descriptor.supports_streaming() is True assert descriptor.supports_streaming() is True
assert descriptor.supports_tool_calling() is False assert descriptor.supports_tool_calling() is False
assert descriptor.supports_knowledge_retrieval() 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]['runner_id'] == 'runner-a'
assert stats[0]['failed_runs'] == 1 assert stats[0]['failed_runs'] == 1
assert stats[0]['success_rate'] == 0.0 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, 'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
'message_only': True, 'message_only': True,
}, },
{'name': 'event_processor', 'supported_event_patterns': ['*'], 'message_only': False},
] ]
@@ -767,3 +768,77 @@ class TestAgentServiceCreateUpdateDelete:
WORKSPACE_UUID, WORKSPACE_UUID,
'pipeline-1', '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 pipeline_uuid == 'connection-pipeline'
assert routed is False assert routed is False
assert bot.bot_entity.use_pipeline_uuid == 'default-uuid' 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()
Generated
+3 -7
View File
@@ -2119,7 +2119,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.5.3" }, { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=23011398160cedcd4ef090054692bc2a4b34ee9f" },
{ name = "langchain", specifier = ">=1.3.9" }, { name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2185,8 +2185,8 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.5.3" version = "0.5.5"
source = { registry = "https://pypi.org/simple" } source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=23011398160cedcd4ef090054692bc2a4b34ee9f#23011398160cedcd4ef090054692bc2a4b34ee9f" }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "aiohttp" }, { name = "aiohttp" },
@@ -2206,10 +2206,6 @@ dependencies = [
{ name = "watchdog" }, { name = "watchdog" },
{ name = "websockets" }, { name = "websockets" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/55/1d/a54daa3bc699f5186b9970946c2ecf0e9cf219f77738934e04aaf5a0c20a/langbot_plugin-0.5.3.tar.gz", hash = "sha256:2324b1f7e1f55e3692e75c8b1e427ea497474b0150ec6ca83b49b5d77ec224c6", size = 472149 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/5f/ae6ed59773cc9d941fbb28b4ac07ce2a29e689d693e29ad71c40c5b39aa5/langbot_plugin-0.5.3-py3-none-any.whl", hash = "sha256:75dea1b6fb79ec6087ec3284f6698fb701d5feebf5f0318a7ae51fbd17a2f41f", size = 304559 },
]
[[package]] [[package]]
name = "langchain" name = "langchain"
+98 -80
View File
@@ -23,6 +23,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent'; import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
import EventProcessorDetailContent from './EventProcessorDetailContent';
import AgentCreateContent from './components/AgentCreateContent'; import AgentCreateContent from './components/AgentCreateContent';
import AgentDebugPanel from './components/AgentDebugPanel'; import AgentDebugPanel from './components/AgentDebugPanel';
import AgentFormComponent, { import AgentFormComponent, {
@@ -167,87 +168,104 @@ export default function AgentDetailContent({ id }: { id: string }) {
return ( return (
<> <>
<ProcessorDetailWorkbench {agent.kind === 'event_processor' ? (
key={id} <EventProcessorDetailContent
title={`${agent.emoji || '🤖'} ${agent.name}`} key={id}
titleBadge={ id={id}
supportedEventPatterns.length === 0 ? ( agent={agent}
<Badge canManage={canManage}
variant="outline" onDelete={() => setDeleteConfirmOpen(true)}
role="status" onEdit={() => setBasicInfoOpen(true)}
className="shrink-0 gap-1 rounded-full border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300" onSaved={() => {
> void httpClient
<AlertTriangle className="size-3" /> .getAgent(id)
{t('agents.noEventsConfiguredBadge')} .then((response) => setAgent(response.agent));
</Badge> void refreshPipelines();
) : undefined }}
} />
titleAction={ ) : (
canManage ? ( <ProcessorDetailWorkbench
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} /> key={id}
) : undefined title={`${agent.emoji || '🤖'} ${agent.name}`}
} titleBadge={
status={runnerStatus} supportedEventPatterns.length === 0 ? (
saveLabel={t('common.save')} <Badge
saveFormId="agent-form" variant="outline"
canSave={canManage} role="status"
isDirty={formDirty} className="shrink-0 gap-1 rounded-full border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"
isSaving={formSaving} >
headerActions={ <AlertTriangle className="size-3" />
canManage ? ( {t('agents.noEventsConfiguredBadge')}
<Button </Badge>
type="button" ) : undefined
variant="destructive" }
disabled={formSaving || deleting} titleAction={
onClick={() => setDeleteConfirmOpen(true)} canManage ? (
> <EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
<Trash2 className="size-4" /> ) : undefined
{t('common.delete')} }
</Button> status={runnerStatus}
) : undefined saveLabel={t('common.save')}
} saveFormId="agent-form"
configTitle={t('pipelines.configuration')} canSave={canManage}
configContent={ isDirty={formDirty}
<fieldset className="contents" disabled={!canManage}> isSaving={formSaving}
<AgentFormComponent headerActions={
ref={agentFormRef} canManage ? (
agentId={id} <Button
availableEventTypes={availableEventTypes} type="button"
onFinish={(updatedAgent) => { variant="destructive"
if (updatedAgent) { disabled={formSaving || deleting}
setAgent((current) => onClick={() => setDeleteConfirmOpen(true)}
current ? { ...current, ...updatedAgent } : current, >
); <Trash2 className="size-4" />
{t('common.delete')}
</Button>
) : undefined
}
configTitle={t('pipelines.configuration')}
configContent={
<fieldset className="contents" disabled={!canManage}>
<AgentFormComponent
ref={agentFormRef}
agentId={id}
availableEventTypes={availableEventTypes}
onFinish={(updatedAgent) => {
if (updatedAgent) {
setAgent((current) =>
current ? { ...current, ...updatedAgent } : current,
);
}
refreshPipelines();
}}
onDirtyChange={setFormDirty}
onSavingChange={setFormSaving}
onRunnerStatusChange={setRunnerStatus}
onSupportedEventPatternsChange={setSupportedEventPatterns}
onPlatformToolsChange={setPlatformTools}
/>
</fieldset>
}
debugTitle={canOperate ? t('agents.debugTab') : undefined}
debugDescription={t('agents.debugPlatformNotice')}
debugContent={
canOperate ? (
<AgentDebugPanel
agentId={id}
platformTools={platformTools}
hasUnsavedChanges={formDirty}
beforeRun={async () => agentFormRef.current?.save() ?? false}
onOpenRunnerConfig={() =>
agentFormRef.current?.openSection('runner_config')
} }
refreshPipelines(); supportedEventPatterns={supportedEventPatterns}
}} availableEventTypes={availableEventTypes}
onDirtyChange={setFormDirty} />
onSavingChange={setFormSaving} ) : undefined
onRunnerStatusChange={setRunnerStatus} }
onSupportedEventPatternsChange={setSupportedEventPatterns} unsavedLabel={t('pipelines.unsavedChanges')}
onPlatformToolsChange={setPlatformTools} />
/> )}
</fieldset>
}
debugTitle={canOperate ? t('agents.debugTab') : undefined}
debugDescription={t('agents.debugPlatformNotice')}
debugContent={
canOperate ? (
<AgentDebugPanel
agentId={id}
platformTools={platformTools}
hasUnsavedChanges={formDirty}
beforeRun={async () => agentFormRef.current?.save() ?? false}
onOpenRunnerConfig={() =>
agentFormRef.current?.openSection('runner_config')
}
supportedEventPatterns={supportedEventPatterns}
availableEventTypes={availableEventTypes}
/>
) : undefined
}
unsavedLabel={t('pipelines.unsavedChanges')}
/>
<EntityBasicInfoDialog <EntityBasicInfoDialog
open={basicInfoOpen} open={basicInfoOpen}
onOpenChange={setBasicInfoOpen} onOpenChange={setBasicInfoOpen}
@@ -0,0 +1,437 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { FileCode2, RefreshCw, Settings2, Trash2, Pencil } from 'lucide-react';
import { toast } from 'sonner';
import type {
Agent,
AgentPlatformTool,
EventProcessorDescriptor,
ProcessorRun,
ProcessorRunEvent,
} from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Badge } from '@/components/ui/badge';
import EventProcessorSettings from './components/EventProcessorSettings';
export default function EventProcessorDetailContent({
agent,
id,
canManage,
onDelete,
onEdit,
onSaved,
}: {
agent: Agent;
id: string;
canManage: boolean;
onDelete: () => void;
onEdit: () => void;
onSaved: () => void;
}) {
const { t } = useTranslation();
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
const toolLabels = Object.fromEntries(
platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]),
);
const [components, setComponents] = useState<EventProcessorDescriptor[]>([]);
const [componentRef, setComponentRef] = useState(agent.component_ref ?? '');
const initialParameters =
(
(agent.config?.runner_config ?? {}) as Record<
string,
Record<string, unknown>
>
)[agent.component_ref ?? ''] ?? {};
const [parameters, setParameters] = useState(initialParameters);
const [runs, setRuns] = useState<ProcessorRun[]>([]);
const [cursor, setCursor] = useState<number | null>(null);
const [selected, setSelected] = useState<ProcessorRun | null>(null);
const [events, setEvents] = useState<ProcessorRunEvent[]>([]);
const [eventCursor, setEventCursor] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pagingRuns, setPagingRuns] = useState(false);
const [pagingEvents, setPagingEvents] = useState(false);
const [configOpen, setConfigOpen] = useState(false);
const [failed, setFailed] = useState(false);
const validate = useRef<(() => Promise<boolean>) | null>(null);
const requestVersion = useRef(0);
const available = components.some((item) => item.id === agent.component_ref);
const load = useCallback(async () => {
setFailed(false);
try {
const [metadata, page] = await Promise.all([
httpClient.getAgentMetadata(),
httpClient.getProcessorRuns(id),
]);
setComponents(metadata.event_processors ?? []);
setPlatformTools(metadata.platform_tools ?? []);
setRuns(page.items);
setCursor(page.has_more ? page.next_cursor : null);
} catch {
setFailed(true);
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
void load();
}, [load]);
useEffect(
() => () => {
requestVersion.current += 1;
},
[id],
);
async function openRun(run: ProcessorRun) {
const version = ++requestVersion.current;
setSelected(run);
setEvents([]);
setEventCursor(null);
try {
const page = await httpClient.getProcessorRunEvents(id, run.run_id);
if (version !== requestVersion.current) return;
setSelected(page.run);
setEvents(page.items);
setEventCursor(page.has_more ? page.next_cursor : null);
} catch {
if (version === requestVersion.current)
toast.error(t('agents.eventProcessor.loadError'));
}
}
async function loadMoreEvents() {
if (!selected || eventCursor === null || pagingEvents) return;
setPagingEvents(true);
const runId = selected.run_id;
const version = requestVersion.current;
try {
const page = await httpClient.getProcessorRunEvents(
id,
runId,
eventCursor,
);
if (version !== requestVersion.current) return;
setEvents((current) => [...current, ...page.items]);
setEventCursor(page.has_more ? page.next_cursor : null);
} catch {
toast.error(t('agents.eventProcessor.loadError'));
} finally {
setPagingEvents(false);
}
}
async function loadMoreRuns() {
if (cursor === null || pagingRuns) return;
setPagingRuns(true);
try {
const page = await httpClient.getProcessorRuns(id, cursor);
setRuns((current) => [
...new Map(
[...current, ...page.items].map((run) => [run.run_id, run]),
).values(),
]);
setCursor(page.has_more ? page.next_cursor : null);
} catch {
toast.error(t('agents.eventProcessor.loadError'));
} finally {
setPagingRuns(false);
}
}
useEffect(() => {
let cancelled = false;
let busy = false;
const timer = window.setInterval(async () => {
if (busy || document.hidden) return;
busy = true;
try {
const page = await httpClient.getProcessorRuns(id);
if (cancelled) return;
setRuns((current) =>
[
...new Map(
[...current, ...page.items].map((run) => [run.run_id, run]),
).values(),
].sort((a, b) => b.created_at - a.created_at),
);
if (
selected &&
!['completed', 'failed', 'cancelled'].includes(selected.status) &&
eventCursor === null
) {
const trace = await httpClient.getProcessorRunEvents(
id,
selected.run_id,
events.at(-1)?.sequence,
);
if (cancelled) return;
setSelected(trace.run);
setEvents((current) => [
...new Map(
[...current, ...trace.items].map((event) => [
event.sequence,
event,
]),
).values(),
]);
setEventCursor(trace.has_more ? trace.next_cursor : null);
}
} catch {
/* Keep existing records visible across transient refresh failures. */
} finally {
busy = false;
}
}, 3000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [id, selected, eventCursor, events]);
async function save() {
if (!componentRef || !((await validate.current?.()) ?? true)) return;
setSaving(true);
try {
await httpClient.updateAgent(id, {
component_ref: componentRef,
config: {
...agent.config,
runner: { id: componentRef },
runner_config: { [componentRef]: parameters },
},
});
toast.success(t('agents.saveSuccess'));
onSaved();
setConfigOpen(false);
await load();
} catch {
toast.error(t('agents.saveError'));
} finally {
setSaving(false);
}
}
function payload(value: unknown) {
return (
<pre className="mt-2 whitespace-pre-wrap break-all rounded-md bg-muted/50 p-3 text-xs">
{JSON.stringify(value, null, 2)}
</pre>
);
}
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<header className="flex flex-wrap items-center gap-3">
<FileCode2 className="size-6" />
<h1 className="text-2xl font-semibold">{agent.name}</h1>
{canManage && (
<Button
variant="ghost"
size="icon"
onClick={onEdit}
aria-label={t('common.edit')}
>
<Pencil className="size-4" />
</Button>
)}
<Badge variant="outline">{t('agents.eventProcessor.type')}</Badge>
{!loading && !failed && !available && (
<Badge variant="destructive">
{t('agents.eventProcessor.unavailable')}
</Badge>
)}
<div className="ml-auto flex gap-2">
<Button
variant="outline"
onClick={() => {
void load();
if (selected) void openRun(selected);
}}
aria-label={t('agents.eventProcessor.refresh')}
>
<RefreshCw className="size-4" />
</Button>
{canManage && (
<>
<Button
variant="outline"
onClick={() => setConfigOpen(!configOpen)}
>
<Settings2 className="size-4" />
{t('pipelines.configuration')}
</Button>
<Button variant="destructive" onClick={onDelete}>
<Trash2 className="size-4" />
{t('common.delete')}
</Button>
</>
)}
</div>
</header>
<p className="shrink-0 break-all text-xs text-muted-foreground">
{agent.component_ref}
</p>
{configOpen && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto rounded-xl border p-4">
<EventProcessorSettings
components={components}
value={componentRef}
parameters={parameters}
onChange={(value) => {
setComponentRef(value);
setParameters({});
validate.current = null;
}}
onParametersChange={setParameters}
onValidate={(fn) => {
validate.current = fn;
}}
/>
<Button
className="mt-4"
disabled={
saving || !components.some((item) => item.id === componentRef)
}
onClick={() => void save()}
>
{t('common.save')}
</Button>
</div>
)}
{failed && (
<p role="alert" className="text-destructive">
{t('agents.eventProcessor.loadError')}
</p>
)}
<div className="grid min-h-0 flex-1 gap-4 md:grid-cols-[minmax(240px,0.7fr)_minmax(0,1.3fr)]">
<section className="min-h-0 overflow-y-auto rounded-xl border p-4">
<h2 className="mb-3 font-semibold">
{t('agents.eventProcessor.runs')}
</h2>
{loading ? (
<p>{t('common.loading')}</p>
) : runs.length === 0 && !failed ? (
<div className="space-y-3 text-sm text-muted-foreground">
<p>{t('agents.eventProcessor.noRuns')}</p>
<Link className="text-primary underline" to="/home/bots">
{t('agents.eventProcessor.bindBot')}
</Link>
</div>
) : (
runs.map((run) => (
<button
key={run.run_id}
onClick={() => void openRun(run)}
className={`mb-2 block w-full rounded-lg border p-3 text-left text-sm ${selected?.run_id === run.run_id ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'}`}
>
<span className="block break-all font-medium">
{run.metadata.event_type}
</span>
<span className="mt-1 flex flex-wrap justify-between gap-1 text-xs text-muted-foreground">
<span>
{new Date(run.created_at * 1000).toLocaleString()}
</span>
<span>
{t(`agents.eventProcessor.status_${run.status}`, {
defaultValue: run.status,
})}
</span>
</span>
</button>
))
)}
{cursor !== null && (
<Button
variant="ghost"
disabled={pagingRuns}
onClick={() => void loadMoreRuns()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
)}
</section>
<section className="min-h-0 overflow-y-auto rounded-xl border p-4">
<h2 className="mb-3 font-semibold">
{t('agents.eventProcessor.trace')}
</h2>
{!selected ? (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.selectRun')}
</p>
) : (
<div className="space-y-3">
<details className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">
{t('agents.eventProcessor.input')}
</summary>
{payload(selected.metadata.input_event)}
</details>
{selected.metadata.delivery != null && (
<details className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">
{t('agents.eventProcessor.destination')}
</summary>
{payload(selected.metadata.delivery)}
</details>
)}
{events.map((event) =>
event.type === 'processor.log' ? (
<div
key={event.sequence}
className="rounded-lg bg-muted/40 p-3 text-sm"
>
<span className="mr-2 text-xs text-muted-foreground">
{String(event.data.level)}
</span>
<span className="whitespace-pre-wrap break-words">
{String(event.data.text)}
</span>
</div>
) : (
<details
key={event.sequence}
className="rounded-lg border p-3"
>
<summary className="cursor-pointer break-all text-sm font-medium">
{t(
`agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`,
{ defaultValue: event.type },
)}
{typeof event.data.tool_name === 'string' && (
<span className="ml-2 text-muted-foreground">
{toolLabels[event.data.tool_name] ||
event.data.tool_name}
</span>
)}
</summary>
{payload(event.data)}
</details>
),
)}
{selected.status === 'failed' && selected.status_reason && (
<p className="break-words text-sm text-destructive">
{selected.status_reason}
</p>
)}
{eventCursor !== null && (
<Button
variant="ghost"
disabled={pagingEvents}
onClick={() => void loadMoreEvents()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
)}
</div>
)}
</section>
</div>
</div>
);
}
@@ -1,12 +1,13 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Bot, Workflow } from 'lucide-react'; import { Bot, Workflow, FileCode2 } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { AgentKind } from '@/app/infra/entities/api'; import { AgentKind, EventProcessorDescriptor } from '@/app/infra/entities/api';
import EventProcessorSettings from './EventProcessorSettings';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { import {
@@ -35,6 +36,23 @@ export default function AgentCreateContent({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [kind, setKind] = useState<AgentKind>('agent'); const [kind, setKind] = useState<AgentKind>('agent');
const [components, setComponents] = useState<EventProcessorDescriptor[]>([]);
const [componentRef, setComponentRef] = useState('');
const [parameters, setParameters] = useState<Record<string, unknown>>({});
const validateParameters = useRef<(() => Promise<boolean>) | null>(null);
useEffect(() => {
if (kind !== 'event_processor') return;
let cancelled = false;
httpClient
.getAgentMetadata()
.then((metadata) => {
if (!cancelled) setComponents(metadata.event_processors ?? []);
})
.catch(() => toast.error(t('agents.eventProcessor.loadError')));
return () => {
cancelled = true;
};
}, [kind, t]);
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(1, { message: t('agents.nameRequired') }), name: z.string().min(1, { message: t('agents.nameRequired') }),
description: z.string().optional(), description: z.string().optional(),
@@ -51,8 +69,14 @@ export default function AgentCreateContent({
}); });
function handleKindChange(nextKind: AgentKind) { function handleKindChange(nextKind: AgentKind) {
const previousDefaultEmoji = kind === 'pipeline' ? '⚙️' : '🤖'; const previousDefaultEmoji =
const nextDefaultEmoji = nextKind === 'pipeline' ? '⚙️' : '🤖'; kind === 'pipeline' ? '⚙️' : kind === 'event_processor' ? '⚡' : '🤖';
const nextDefaultEmoji =
nextKind === 'pipeline'
? '⚙️'
: nextKind === 'event_processor'
? '⚡'
: '🤖';
setKind(nextKind); setKind(nextKind);
const currentEmoji = form.getValues('emoji'); const currentEmoji = form.getValues('emoji');
if (!currentEmoji || currentEmoji === previousDefaultEmoji) { if (!currentEmoji || currentEmoji === previousDefaultEmoji) {
@@ -60,10 +84,24 @@ export default function AgentCreateContent({
} }
} }
function handleSubmit(values: FormValues) { async function handleSubmit(values: FormValues) {
if (
kind === 'event_processor' &&
(!componentRef || !((await validateParameters.current?.()) ?? true))
)
return;
httpClient httpClient
.createAgent({ .createAgent({
kind, kind,
...(kind === 'event_processor'
? {
component_ref: componentRef,
config: {
runner: { id: componentRef },
runner_config: { [componentRef]: parameters },
},
}
: {}),
name: values.name, name: values.name,
description: values.description ?? '', description: values.description ?? '',
emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'), emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'),
@@ -90,13 +128,26 @@ export default function AgentCreateContent({
title: t('agents.pipelineType'), title: t('agents.pipelineType'),
description: t('agents.pipelineTypeDescription'), description: t('agents.pipelineTypeDescription'),
}, },
{
kind: 'event_processor' as const,
icon: FileCode2,
title: t('agents.eventProcessor.type'),
description: t('agents.eventProcessor.description'),
},
]; ];
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0"> <div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('agents.create')}</h1> <h1 className="text-xl font-semibold">{t('agents.create')}</h1>
<Button type="submit" form="agent-create-form"> <Button
type="submit"
form="agent-create-form"
disabled={
form.formState.isSubmitting ||
(kind === 'event_processor' && !componentRef)
}
>
{t('common.submit')} {t('common.submit')}
</Button> </Button>
</div> </div>
@@ -158,6 +209,22 @@ export default function AgentCreateContent({
</ToggleGroup> </ToggleGroup>
</section> </section>
{kind === 'event_processor' && (
<EventProcessorSettings
components={components}
value={componentRef}
parameters={parameters}
onChange={(value) => {
setComponentRef(value);
setParameters({});
validateParameters.current = null;
}}
onParametersChange={setParameters}
onValidate={(validate) => {
validateParameters.current = validate;
}}
/>
)}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('agents.basicInfo')}</CardTitle> <CardTitle>{t('agents.basicInfo')}</CardTitle>
@@ -0,0 +1,91 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import type { EventProcessorDescriptor } from '@/app/infra/entities/api';
import { extractI18nObject } from '@/i18n/I18nProvider';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
export default function EventProcessorSettings({
components,
value,
parameters,
onChange,
onParametersChange,
onValidate,
}: {
components: EventProcessorDescriptor[];
value: string;
parameters: Record<string, unknown>;
onChange: (value: string) => void;
onParametersChange: (value: Record<string, unknown>) => void;
onValidate?: (validate: () => Promise<boolean>) => void;
}) {
const { t } = useTranslation();
const selected = components.find((item) => item.id === value);
return (
<div className="space-y-4">
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="event-processor-component"
>
{t('agents.eventProcessor.component')}
</label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger id="event-processor-component">
<SelectValue
placeholder={t('agents.eventProcessor.selectComponent')}
/>
</SelectTrigger>
<SelectContent>
{value && !selected && (
<SelectItem value={value}>
{t('agents.eventProcessor.unavailable')}
</SelectItem>
)}
{components.map((component) => (
<SelectItem key={component.id} value={component.id}>
{extractI18nObject({
en_US: component.id,
zh_Hans: component.id,
...component.label,
})}{' '}
· {component.plugin_author}/{component.plugin_name}
</SelectItem>
))}
</SelectContent>
</Select>
{components.length === 0 && (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.noComponents')}{' '}
<Link className="text-primary underline" to="/home/plugins">
{t('agents.eventProcessor.installPlugin')}
</Link>
</p>
)}
</div>
{selected && (
<p className="break-words text-xs text-muted-foreground">
{selected.supported_event_patterns.join(' · ')}
</p>
)}
{selected && selected.config_schema.length > 0 && (
<DynamicFormComponent
key={value}
itemConfigList={selected.config_schema}
initialValues={parameters}
onSubmit={(values) =>
onParametersChange(values as Record<string, unknown>)
}
onValidate={onValidate}
/>
)}
</div>
);
}
@@ -374,5 +374,29 @@ function PipelineDiagram() {
} }
export default function ProcessorTypeDiagram({ kind }: { kind: AgentKind }) { export default function ProcessorTypeDiagram({ kind }: { kind: AgentKind }) {
const { t } = useTranslation();
if (kind === 'event_processor')
return (
<div className="flex h-full flex-col justify-center gap-6 rounded-xl border bg-muted/20 p-8">
<h3 className="text-lg font-semibold">
{t('agents.eventProcessor.type')}
</h3>
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.description')}
</p>
{['input', 'component', 'trace'].map((step, index) => (
<div
key={step}
className="flex items-center gap-3 rounded-lg border bg-background p-4"
>
<span className="text-primary">{index + 1}</span>
<span>{t(`agents.eventProcessor.${step}`)}</span>
</div>
))}
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.activation')}
</p>
</div>
);
return kind === 'agent' ? <AgentDiagram /> : <PipelineDiagram />; return kind === 'agent' ? <AgentDiagram /> : <PipelineDiagram />;
} }
@@ -75,7 +75,12 @@ const getFormSchema = (t: (key: string) => string) =>
z.object({ z.object({
id: z.string().optional(), id: z.string().optional(),
event_pattern: z.string(), event_pattern: z.string(),
target_type: z.enum(['agent', 'pipeline', 'discard']), target_type: z.enum([
'agent',
'pipeline',
'event_processor',
'discard',
]),
target_uuid: z.string(), target_uuid: z.string(),
filters: z.array(z.record(z.string(), z.any())).optional(), filters: z.array(z.record(z.string(), z.any())).optional(),
priority: z.number(), priority: z.number(),
@@ -35,6 +35,7 @@ import {
UserMinus, UserMinus,
UserPlus, UserPlus,
Workflow, Workflow,
FileCode2,
XCircle, XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -518,6 +519,11 @@ function TargetCombobox({
const pipelines = pipelineAllowed const pipelines = pipelineAllowed
? agentOptions.filter((a) => a.kind === 'pipeline') ? agentOptions.filter((a) => a.kind === 'pipeline')
: []; : [];
const eventProcessors = agentOptions.filter(
(item) =>
item.kind === 'event_processor' &&
agentSupportsEventPattern(item, binding.event_pattern),
);
function currentLabel() { function currentLabel() {
if (targetType === 'discard') if (targetType === 'discard')
@@ -531,7 +537,9 @@ function TargetCombobox({
if (agent) if (agent)
return ( return (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
{agent.kind === 'pipeline' ? ( {agent.kind === 'event_processor' ? (
<FileCode2 className="size-3.5" />
) : agent.kind === 'pipeline' ? (
<Workflow className="size-3.5" /> <Workflow className="size-3.5" />
) : ( ) : (
<Bot className="size-3.5" /> <Bot className="size-3.5" />
@@ -585,6 +593,26 @@ function TargetCombobox({
))} ))}
</CommandGroup> </CommandGroup>
)} )}
{eventProcessors.length > 0 && (
<CommandGroup heading={t('agents.eventProcessor.type')}>
{eventProcessors.map((item) => (
<CommandItem
key={item.uuid}
value={`event_processor:${item.uuid}:${item.name}`}
onSelect={() =>
select(encodeTarget('event_processor', item.uuid || ''))
}
>
<FileCode2 className="mr-2 size-3.5 shrink-0" />
<span className="truncate">{targetLabel(item)}</span>
{current ===
encodeTarget('event_processor', item.uuid || '') && (
<Check className="ml-auto size-3.5 shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
)}
{pipelines.length > 0 && ( {pipelines.length > 0 && (
<CommandGroup heading={t('bots.targetPipeline')}> <CommandGroup heading={t('bots.targetPipeline')}>
{pipelines.map((a) => ( {pipelines.map((a) => (
@@ -730,13 +730,16 @@ function NavItems({
const showAgentGroupHeaders = const showAgentGroupHeaders =
isAgents && !inPopover && sidebarData.agentsGroupByKind; isAgents && !inPopover && sidebarData.agentsGroupByKind;
const agentGroupOrder: Array<'agent' | 'pipeline'> = [ const agentGroupOrder: Array<
'agent', 'agent' | 'pipeline' | 'event_processor'
'pipeline', > = ['agent', 'pipeline', 'event_processor'];
]; const agentGroupLabelKey: Record<
const agentGroupLabelKey: Record<'agent' | 'pipeline', string> = { 'agent' | 'pipeline' | 'event_processor',
string
> = {
agent: 'agents.kindBadgeAgent', agent: 'agents.kindBadgeAgent',
pipeline: 'agents.kindBadgePipeline', pipeline: 'agents.kindBadgePipeline',
event_processor: 'agents.eventProcessor.type',
}; };
const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [ const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [
@@ -889,12 +892,16 @@ function NavItems({
<span <span
className="ml-auto flex shrink-0 items-center text-muted-foreground" className="ml-auto flex shrink-0 items-center text-muted-foreground"
title={ title={
item.kind === 'pipeline' item.kind === 'event_processor'
? t('agents.kindBadgePipeline') ? t('agents.eventProcessor.type')
: t('agents.kindBadgeAgent') : item.kind === 'pipeline'
? t('agents.kindBadgePipeline')
: t('agents.kindBadgeAgent')
} }
> >
{item.kind === 'pipeline' ? ( {item.kind === 'event_processor' ? (
<span className="text-xs"></span>
) : item.kind === 'pipeline' ? (
<Workflow className="size-3.5" /> <Workflow className="size-3.5" />
) : ( ) : (
<Bot className="size-3.5" /> <Bot className="size-3.5" />
@@ -31,7 +31,7 @@ export interface SidebarEntityItem {
// Set when this item appears in the unified extensions list // Set when this item appears in the unified extensions list
extensionType?: 'plugin' | 'mcp' | 'skill'; extensionType?: 'plugin' | 'mcp' | 'skill';
// Agent-specific: distinguishes Agent processors from Pipelines // Agent-specific: distinguishes Agent processors from Pipelines
kind?: 'agent' | 'pipeline'; kind?: 'agent' | 'pipeline' | 'event_processor';
} }
// Plugin page registered by a plugin // Plugin page registered by a plugin
+39 -2
View File
@@ -162,7 +162,43 @@ export interface ApiRespPipelines {
pipelines: Pipeline[]; pipelines: Pipeline[];
} }
export type AgentKind = 'agent' | 'pipeline'; export type AgentKind = 'agent' | 'pipeline' | 'event_processor';
export interface EventProcessorDescriptor {
id: string;
label: Record<string, string>;
plugin_author: string;
plugin_name: string;
config_schema: import('../form/dynamic').IDynamicFormItemSchema[];
supported_event_patterns: string[];
}
export interface ProcessorRun {
run_id: string;
status: string;
status_reason?: string;
created_at: number;
metadata: { event_type?: string; input_event?: unknown; delivery?: unknown };
}
export interface ProcessorRunEvent {
sequence: number;
type: string;
data: Record<string, unknown>;
}
export interface ProcessorRunPage {
items: ProcessorRun[];
next_cursor: number | null;
has_more: boolean;
}
export interface ProcessorRunEventPage {
run: ProcessorRun;
items: ProcessorRunEvent[];
next_cursor: number | null;
has_more: boolean;
}
export interface AgentCapability { export interface AgentCapability {
supported_event_patterns: string[]; supported_event_patterns: string[];
@@ -192,6 +228,7 @@ export interface ApiRespAgent {
} }
export interface GetAgentMetadataResponseData { export interface GetAgentMetadataResponseData {
event_processors?: EventProcessorDescriptor[];
runner_config?: PipelineConfigTab; runner_config?: PipelineConfigTab;
platform_tools: AgentPlatformTool[]; platform_tools: AgentPlatformTool[];
host_tools?: PluginTool[] | null; host_tools?: PluginTool[] | null;
@@ -274,7 +311,7 @@ export interface Bot {
export interface EventBinding { export interface EventBinding {
id?: string; id?: string;
event_pattern: string; event_pattern: string;
target_type: 'agent' | 'pipeline' | 'discard'; target_type: AgentKind | 'discard';
target_uuid: string; target_uuid: string;
filters?: Array<Record<string, unknown>>; filters?: Array<Record<string, unknown>>;
priority: number; priority: number;
+19
View File
@@ -268,6 +268,25 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/agents/_/metadata'); return this.get('/api/v1/agents/_/metadata');
} }
public getProcessorRuns(
uuid: string,
beforeId?: number,
): Promise<import('../entities/api').ProcessorRunPage> {
return this.get(
`/api/v1/agents/${encodeURIComponent(uuid)}/runs${beforeId === undefined ? '' : `?before_id=${beforeId}`}`,
);
}
public getProcessorRunEvents(
uuid: string,
runId: string,
afterSequence?: number,
): Promise<import('../entities/api').ProcessorRunEventPage> {
return this.get(
`/api/v1/agents/${encodeURIComponent(uuid)}/runs/${encodeURIComponent(runId)}/events${afterSequence === undefined ? '' : `?after_sequence=${afterSequence}`}`,
);
}
public createAgent(agent: Agent): Promise<{ uuid: string; kind: string }> { public createAgent(agent: Agent): Promise<{ uuid: string; kind: string }> {
return this.post('/api/v1/agents', agent); return this.post('/api/v1/agents', agent);
} }
+36 -7
View File
@@ -395,20 +395,19 @@ const enUS = {
commonScenarios: 'Common scenarios', commonScenarios: 'Common scenarios',
dragEventRoute: 'Drag route {{index}}', dragEventRoute: 'Drag route {{index}}',
behaviorReplyMessages: 'Reply to messages', behaviorReplyMessages: 'Reply to messages',
behaviorReplyMessagesDescription: behaviorReplyMessagesDescription: 'Send incoming messages to a processor.',
'Send incoming messages to an Agent or Pipeline.',
behaviorWelcomeMembers: 'Welcome new members', behaviorWelcomeMembers: 'Welcome new members',
behaviorWelcomeMembersDescription: behaviorWelcomeMembersDescription:
'Run an Agent when someone joins a group.', 'Run a processor when someone joins a group.',
behaviorHandleDepartures: 'Handle member departures', behaviorHandleDepartures: 'Handle member departures',
behaviorHandleDeparturesDescription: behaviorHandleDeparturesDescription:
'Run an Agent when someone leaves or is removed.', 'Run a processor when someone leaves or is removed.',
behaviorReviewFriendRequests: 'Review friend requests', behaviorReviewFriendRequests: 'Review friend requests',
behaviorReviewFriendRequestsDescription: behaviorReviewFriendRequestsDescription:
'Let an Agent decide how to handle a new request.', 'Send new friend requests to a processor.',
behaviorHandleModeration: 'Handle moderation events', behaviorHandleModeration: 'Handle moderation events',
behaviorHandleModerationDescription: behaviorHandleModerationDescription:
'Run an Agent when a group member is restricted.', 'Run a processor when a group member is restricted.',
behaviorCustom: 'Configure another event', behaviorCustom: 'Configure another event',
behaviorCustomDescription: behaviorCustomDescription:
'Add a route and choose from every event supported by this adapter.', 'Add a route and choose from every event supported by this adapter.',
@@ -704,6 +703,36 @@ const enUS = {
}, },
}, },
agents: { agents: {
eventProcessor: {
type: 'Event processor',
description: 'Process platform events using plugin code.',
component: 'Plugin component',
selectComponent: 'Select an event processor',
unavailable: 'Component unavailable',
noComponents: 'No event processor components installed.',
installPlugin: 'Install a plugin',
loadError: 'Unable to load processor details.',
refresh: 'Refresh',
runs: 'Runs',
noRuns: 'No runs yet. Bind a Bot event to start.',
bindBot: 'Bind Bot events',
trace: 'Logs and message flow',
selectRun: 'Select a run to view details.',
input: 'Incoming event',
destination: 'Delivery destination',
loadMore: 'Load more',
activation: 'Install a plugin, create an instance, then bind Bot events.',
status_pending: 'Pending',
status_running: 'Running',
status_completed: 'Completed',
status_failed: 'Failed',
status_cancelled: 'Cancelled',
status_queued: 'Queued',
trace_run_completed: 'Run completed',
trace_run_failed: 'Run failed',
trace_tool_call_started: 'Action started',
trace_tool_call_completed: 'Action result',
},
debugData: { debugData: {
title: 'Event data', title: 'Event data',
form: 'Common fields', form: 'Common fields',
@@ -735,7 +764,7 @@ const enUS = {
description: 'Create reusable processors and use them in bot event routing', description: 'Create reusable processors and use them in bot event routing',
create: 'Create Processor', create: 'Create Processor',
editAgent: 'Edit Agent', editAgent: 'Edit Agent',
selectFromSidebar: 'Select an Agent or Pipeline from the sidebar', selectFromSidebar: 'Select a processor from the sidebar',
agentType: 'Agent', agentType: 'Agent',
agentTypeDescription: agentTypeDescription:
'Use a runner to handle messages, group members, friends, feedback, and other platform events. Best for scenarios that need autonomous decisions, tool use, or non-message events.', 'Use a runner to handle messages, group members, friends, feedback, and other platform events. Best for scenarios that need autonomous decisions, tool use, or non-message events.',
+32 -1
View File
@@ -506,6 +506,37 @@ const esES = {
}, },
}, },
agents: { agents: {
eventProcessor: {
type: 'Procesador de eventos',
description: 'Procesa eventos con código del plugin.',
component: 'Componente del plugin',
selectComponent: 'Seleccionar un procesador',
unavailable: 'Componente no disponible',
noComponents: 'No hay componentes de eventos instalados.',
installPlugin: 'Instalar un plugin',
loadError: 'No se pudieron cargar los detalles.',
refresh: 'Actualizar',
runs: 'Ejecuciones',
noRuns: 'Sin ejecuciones. Vincula eventos de un Bot para empezar.',
bindBot: 'Vincular eventos del Bot',
trace: 'Registros y flujo de mensajes',
selectRun: 'Selecciona una ejecución para ver los detalles.',
input: 'Evento recibido',
destination: 'Destino de entrega',
loadMore: 'Cargar más',
activation:
'Instala un plugin, crea una instancia y vincula eventos del Bot.',
status_pending: 'Pendiente',
status_running: 'En ejecución',
status_completed: 'Completado',
status_failed: 'Error',
status_cancelled: 'Cancelado',
status_queued: 'En cola',
trace_run_completed: 'Ejecución completada',
trace_run_failed: 'Ejecución fallida',
trace_tool_call_started: 'Acción iniciada',
trace_tool_call_completed: 'Resultado de la acción',
},
debugData: { debugData: {
title: 'Datos del evento', title: 'Datos del evento',
form: 'Campos comunes', form: 'Campos comunes',
@@ -538,7 +569,7 @@ const esES = {
'Crea procesadores reutilizables y úsalos en el enrutamiento de eventos del bot', 'Crea procesadores reutilizables y úsalos en el enrutamiento de eventos del bot',
create: 'Crear procesador', create: 'Crear procesador',
editAgent: 'Editar Agent', editAgent: 'Editar Agent',
selectFromSidebar: 'Selecciona un Agent o Pipeline desde la barra lateral', selectFromSidebar: 'Selecciona un procesador en la barra lateral',
agentType: 'Agent', agentType: 'Agent',
agentTypeDescription: agentTypeDescription:
'Usa un runner para procesar mensajes, miembros de grupo, amigos, retroalimentación y otros eventos de plataforma.', 'Usa un runner para procesar mensajes, miembros de grupo, amigos, retroalimentación y otros eventos de plataforma.',
+37 -7
View File
@@ -402,19 +402,19 @@ const jaJP = {
dragEventRoute: 'ルート {{index}} をドラッグ', dragEventRoute: 'ルート {{index}} をドラッグ',
behaviorReplyMessages: '受信メッセージに返信', behaviorReplyMessages: '受信メッセージに返信',
behaviorReplyMessagesDescription: behaviorReplyMessagesDescription:
'受信メッセージを Agent または Pipeline で処理します。', '受信メッセージをプロセッサーに渡します。',
behaviorWelcomeMembers: '新しいメンバーを歓迎', behaviorWelcomeMembers: '新しいメンバーを歓迎',
behaviorWelcomeMembersDescription: behaviorWelcomeMembersDescription:
'メンバーがグループに参加したときに Agent を実行します。', 'グループへの参加時にプロセッサーを実行します。',
behaviorHandleDepartures: 'メンバーの退出を処理', behaviorHandleDepartures: 'メンバーの退出を処理',
behaviorHandleDeparturesDescription: behaviorHandleDeparturesDescription:
'メンバーが退出または削除されたときに Agent を実行します。', 'グループからの退出時にプロセッサーを実行します。',
behaviorReviewFriendRequests: '友だち申請を確認', behaviorReviewFriendRequests: '友だち申請を確認',
behaviorReviewFriendRequestsDescription: behaviorReviewFriendRequestsDescription:
'新しい申請の処理方法を Agent に判断させます。', '新しい友達リクエストをプロセッサーに渡します。',
behaviorHandleModeration: 'モデレーションイベントを処理', behaviorHandleModeration: 'モデレーションイベントを処理',
behaviorHandleModerationDescription: behaviorHandleModerationDescription:
'グループメンバー制限されたときに Agent を実行します。', 'グループメンバー制限時にプロセッサーを実行します。',
behaviorCustom: '別のイベントを設定', behaviorCustom: '別のイベントを設定',
behaviorCustomDescription: behaviorCustomDescription:
'ルートを追加し、このアダプターが対応する全イベントから選択します。', 'ルートを追加し、このアダプターが対応する全イベントから選択します。',
@@ -716,6 +716,37 @@ const jaJP = {
}, },
}, },
agents: { agents: {
eventProcessor: {
type: 'イベントプロセッサー',
description: 'プラグインのコードでイベントを処理します。',
component: 'プラグインコンポーネント',
selectComponent: 'イベントプロセッサーを選択',
unavailable: 'コンポーネントを利用できません',
noComponents: 'イベントプロセッサーがインストールされていません。',
installPlugin: 'プラグインをインストール',
loadError: '詳細を読み込めません。',
refresh: '更新',
runs: '実行履歴',
noRuns: '実行履歴はありません。Bot イベントを紐付けて開始します。',
bindBot: 'Bot イベントを紐付ける',
trace: 'ログとメッセージの流れ',
selectRun: '実行履歴を選択して詳細を表示します。',
input: '受信イベント',
destination: '送信先',
loadMore: 'さらに読み込む',
activation:
'プラグインをインストールし、インスタンスを作成して Bot イベントを紐付けます。',
status_pending: '待機中',
status_running: '実行中',
status_completed: '完了',
status_failed: '失敗',
status_cancelled: 'キャンセル済み',
status_queued: 'キュー待ち',
trace_run_completed: '実行完了',
trace_run_failed: '実行失敗',
trace_tool_call_started: 'アクション開始',
trace_tool_call_completed: 'アクション結果',
},
debugData: { debugData: {
title: 'イベントデータ', title: 'イベントデータ',
form: '基本項目', form: '基本項目',
@@ -772,8 +803,7 @@ const jaJP = {
'再利用可能なプロセッサーを作成し、ボットのイベントルーティングで使用します', '再利用可能なプロセッサーを作成し、ボットのイベントルーティングで使用します',
create: 'プロセッサーを作成', create: 'プロセッサーを作成',
editAgent: 'Agent を編集', editAgent: 'Agent を編集',
selectFromSidebar: selectFromSidebar: 'サイドバーからプロセッサーを選択',
'サイドバーから Agent または Pipeline を選択してください',
agentType: 'Agent', agentType: 'Agent',
agentTypeDescription: agentTypeDescription:
'Runner を使ってメッセージ、グループメンバー、友だち、フィードバックなどのプラットフォームイベントを処理します。自律的な判断、ツール利用、メッセージ以外のイベント対応が必要な場合に適しています。', 'Runner を使ってメッセージ、グループメンバー、友だち、フィードバックなどのプラットフォームイベントを処理します。自律的な判断、ツール利用、メッセージ以外のイベント対応が必要な場合に適しています。',
+32 -1
View File
@@ -503,6 +503,37 @@ const ruRU = {
}, },
}, },
agents: { agents: {
eventProcessor: {
type: 'Обработчик событий',
description: 'Обрабатывает события кодом плагина.',
component: 'Компонент плагина',
selectComponent: 'Выберите обработчик',
unavailable: 'Компонент недоступен',
noComponents: 'Компоненты обработки событий не установлены.',
installPlugin: 'Установить плагин',
loadError: 'Не удалось загрузить данные.',
refresh: 'Обновить',
runs: 'Запуски',
noRuns: 'Запусков пока нет. Привяжите события бота.',
bindBot: 'Привязать события бота',
trace: 'Журнал и поток сообщений',
selectRun: 'Выберите запуск для просмотра.',
input: 'Входящее событие',
destination: 'Получатель',
loadMore: 'Загрузить ещё',
activation:
'Установите плагин, создайте экземпляр и привяжите события бота.',
status_pending: 'Ожидание',
status_running: 'Выполняется',
status_completed: 'Завершено',
status_failed: 'Ошибка',
status_cancelled: 'Отменено',
status_queued: 'В очереди',
trace_run_completed: 'Выполнение завершено',
trace_run_failed: 'Ошибка выполнения',
trace_tool_call_started: 'Действие начато',
trace_tool_call_completed: 'Результат действия',
},
debugData: { debugData: {
title: 'Данные события', title: 'Данные события',
form: 'Основные поля', form: 'Основные поля',
@@ -535,7 +566,7 @@ const ruRU = {
'Создавайте переиспользуемые обработчики и используйте их в маршрутизации событий бота', 'Создавайте переиспользуемые обработчики и используйте их в маршрутизации событий бота',
create: 'Создать обработчик', create: 'Создать обработчик',
editAgent: 'Редактировать Agent', editAgent: 'Редактировать Agent',
selectFromSidebar: 'Выберите Agent или Pipeline на боковой панели', selectFromSidebar: 'Выберите обработчик на боковой панели',
agentType: 'Agent', agentType: 'Agent',
agentTypeDescription: agentTypeDescription:
'Используйте runner для обработки сообщений, участников групп, друзей, обратной связи и других событий платформы.', 'Используйте runner для обработки сообщений, участников групп, друзей, обратной связи и других событий платформы.',
+31 -1
View File
@@ -490,6 +490,36 @@ const thTH = {
}, },
}, },
agents: { agents: {
eventProcessor: {
type: 'ตัวประมวลผลเหตุการณ์',
description: 'ประมวลผลเหตุการณ์ด้วยโค้ดปลั๊กอิน',
component: 'ส่วนประกอบปลั๊กอิน',
selectComponent: 'เลือกตัวประมวลผลเหตุการณ์',
unavailable: 'ส่วนประกอบไม่พร้อมใช้งาน',
noComponents: 'ยังไม่ได้ติดตั้งส่วนประกอบประมวลผลเหตุการณ์',
installPlugin: 'ติดตั้งปลั๊กอิน',
loadError: 'ไม่สามารถโหลดรายละเอียดได้',
refresh: 'รีเฟรช',
runs: 'ประวัติการทำงาน',
noRuns: 'ยังไม่มีการทำงาน เชื่อมโยงเหตุการณ์บอทเพื่อเริ่มต้น',
bindBot: 'เชื่อมโยงเหตุการณ์บอท',
trace: 'บันทึกและเส้นทางข้อความ',
selectRun: 'เลือกการทำงานเพื่อดูรายละเอียด',
input: 'เหตุการณ์ขาเข้า',
destination: 'ปลายทางการส่ง',
loadMore: 'โหลดเพิ่มเติม',
activation: 'ติดตั้งปลั๊กอิน สร้างอินสแตนซ์ แล้วเชื่อมโยงเหตุการณ์บอท',
status_pending: 'รอดำเนินการ',
status_running: 'กำลังทำงาน',
status_completed: 'เสร็จสิ้น',
status_failed: 'ล้มเหลว',
status_cancelled: 'ยกเลิกแล้ว',
status_queued: 'อยู่ในคิว',
trace_run_completed: 'ทำงานเสร็จสิ้น',
trace_run_failed: 'การทำงานล้มเหลว',
trace_tool_call_started: 'เริ่มดำเนินการ',
trace_tool_call_completed: 'ผลการดำเนินการ',
},
debugData: { debugData: {
title: 'ข้อมูลเหตุการณ์', title: 'ข้อมูลเหตุการณ์',
form: 'ฟิลด์ทั่วไป', form: 'ฟิลด์ทั่วไป',
@@ -521,7 +551,7 @@ const thTH = {
description: 'สร้างตัวประมวลผลที่ใช้ซ้ำได้และใช้ในเส้นทางเหตุการณ์ของบอท', description: 'สร้างตัวประมวลผลที่ใช้ซ้ำได้และใช้ในเส้นทางเหตุการณ์ของบอท',
create: 'สร้างตัวประมวลผล', create: 'สร้างตัวประมวลผล',
editAgent: 'แก้ไข Agent', editAgent: 'แก้ไข Agent',
selectFromSidebar: 'เลือก Agent หรือ Pipeline จากแถบด้านข้าง', selectFromSidebar: 'เลือกตัวประมวลผลจากแถบด้านข้าง',
agentType: 'Agent', agentType: 'Agent',
agentTypeDescription: agentTypeDescription:
'ใช้ runner เพื่อประมวลผลข้อความ สมาชิกกลุ่ม เพื่อน ฟีดแบ็ก และเหตุการณ์แพลตฟอร์มอื่นๆ', 'ใช้ runner เพื่อประมวลผลข้อความ สมาชิกกลุ่ม เพื่อน ฟีดแบ็ก และเหตุการณ์แพลตฟอร์มอื่นๆ',
+31 -1
View File
@@ -499,6 +499,36 @@ const viVN = {
}, },
}, },
agents: { agents: {
eventProcessor: {
type: 'Bộ xử lý sự kiện',
description: 'Xử lý sự kiện bằng mã plugin.',
component: 'Thành phần plugin',
selectComponent: 'Chọn bộ xử lý sự kiện',
unavailable: 'Thành phần không khả dụng',
noComponents: 'Chưa cài thành phần xử lý sự kiện.',
installPlugin: 'Cài plugin',
loadError: 'Không thể tải chi tiết.',
refresh: 'Làm mới',
runs: 'Lịch sử chạy',
noRuns: 'Chưa có lần chạy nào. Liên kết sự kiện Bot để bắt đầu.',
bindBot: 'Liên kết sự kiện Bot',
trace: 'Nhật ký và luồng tin nhắn',
selectRun: 'Chọn một lần chạy để xem chi tiết.',
input: 'Sự kiện đầu vào',
destination: 'Đích gửi',
loadMore: 'Tải thêm',
activation: 'Cài plugin, tạo phiên bản rồi liên kết sự kiện Bot.',
status_pending: 'Đang chờ',
status_running: 'Đang chạy',
status_completed: 'Hoàn tất',
status_failed: 'Thất bại',
status_cancelled: 'Đã hủy',
status_queued: 'Trong hàng đợi',
trace_run_completed: 'Chạy hoàn tất',
trace_run_failed: 'Chạy thất bại',
trace_tool_call_started: 'Bắt đầu hành động',
trace_tool_call_completed: 'Kết quả hành động',
},
debugData: { debugData: {
title: 'Dữ liệu sự kiện', title: 'Dữ liệu sự kiện',
form: 'Trường thường dùng', form: 'Trường thường dùng',
@@ -531,7 +561,7 @@ const viVN = {
'Tạo bộ xử lý có thể tái sử dụng và dùng chúng trong định tuyến sự kiện của bot', 'Tạo bộ xử lý có thể tái sử dụng và dùng chúng trong định tuyến sự kiện của bot',
create: 'Tạo bộ xử lý', create: 'Tạo bộ xử lý',
editAgent: 'Chỉnh sửa Agent', editAgent: 'Chỉnh sửa Agent',
selectFromSidebar: 'Chọn một Agent hoặc Pipeline từ thanh bên', selectFromSidebar: 'Chọn bộ xử lý từ thanh bên',
agentType: 'Agent', agentType: 'Agent',
agentTypeDescription: agentTypeDescription:
'Dùng runner để xử lý tin nhắn, thành viên nhóm, bạn bè, phản hồi và các sự kiện nền tảng khác.', 'Dùng runner để xử lý tin nhắn, thành viên nhóm, bạn bè, phản hồi và các sự kiện nền tảng khác.',
+36 -8
View File
@@ -378,17 +378,15 @@ const zhHans = {
commonScenarios: '常用场景', commonScenarios: '常用场景',
dragEventRoute: '拖动第 {{index}} 条路由', dragEventRoute: '拖动第 {{index}} 条路由',
behaviorReplyMessages: '回复收到的消息', behaviorReplyMessages: '回复收到的消息',
behaviorReplyMessagesDescription: behaviorReplyMessagesDescription: '把收到的消息交给处理器。',
'把收到的消息交给 Agent 或 Pipeline 处理。',
behaviorWelcomeMembers: '欢迎新成员', behaviorWelcomeMembers: '欢迎新成员',
behaviorWelcomeMembersDescription: '有人加入群组时运行 Agent。', behaviorWelcomeMembersDescription: '有人加入群组时运行处理器。',
behaviorHandleDepartures: '处理成员离群', behaviorHandleDepartures: '处理成员离群',
behaviorHandleDeparturesDescription: '有人离开或被移出群组时运行 Agent。', behaviorHandleDeparturesDescription: '有人离开或被移出群组时运行处理器。',
behaviorReviewFriendRequests: '审核好友请求', behaviorReviewFriendRequests: '审核好友请求',
behaviorReviewFriendRequestsDescription: behaviorReviewFriendRequestsDescription: '把新的好友请求交给处理器。',
'让 Agent 决定如何处理新的好友请求。',
behaviorHandleModeration: '处理群管理事件', behaviorHandleModeration: '处理群管理事件',
behaviorHandleModerationDescription: '群成员受到限制时运行 Agent。', behaviorHandleModerationDescription: '群成员受到限制时运行处理器。',
behaviorCustom: '配置其他事件', behaviorCustom: '配置其他事件',
behaviorCustomDescription: '添加路由,并从此适配器支持的全部事件中选择。', behaviorCustomDescription: '添加路由,并从此适配器支持的全部事件中选择。',
eventPattern: '事件', eventPattern: '事件',
@@ -670,6 +668,36 @@ const zhHans = {
}, },
}, },
agents: { agents: {
eventProcessor: {
type: '事件处理器',
description: '通过插件代码处理平台事件。',
component: '插件组件',
selectComponent: '选择事件处理器组件',
unavailable: '组件不可用',
noComponents: '尚未安装事件处理器组件。',
installPlugin: '安装插件',
loadError: '无法加载处理器详情。',
refresh: '刷新',
runs: '运行记录',
noRuns: '暂无运行记录,绑定机器人事件后开始处理。',
bindBot: '绑定机器人事件',
trace: '日志与消息流向',
selectRun: '选择一条运行记录查看详情。',
input: '传入事件',
destination: '投递目标',
loadMore: '加载更多',
activation: '安装插件,创建实例,再绑定机器人事件。',
status_pending: '待执行',
status_running: '运行中',
status_completed: '已完成',
status_failed: '失败',
status_cancelled: '已取消',
status_queued: '排队中',
trace_run_completed: '运行完成',
trace_run_failed: '运行失败',
trace_tool_call_started: '开始执行动作',
trace_tool_call_completed: '动作结果',
},
debugData: { debugData: {
title: '事件数据', title: '事件数据',
form: '常用字段', form: '常用字段',
@@ -701,7 +729,7 @@ const zhHans = {
description: '创建可复用的处理器,并在机器人事件路由中使用', description: '创建可复用的处理器,并在机器人事件路由中使用',
create: '创建处理器', create: '创建处理器',
editAgent: '编辑 Agent', editAgent: '编辑 Agent',
selectFromSidebar: '从侧边栏选择一个 Agent 或 Pipeline', selectFromSidebar: '从侧边栏选择一个处理器',
agentType: 'Agent', agentType: 'Agent',
agentTypeDescription: agentTypeDescription:
'通过运行器处理消息、群成员、好友、反馈等平台事件。适合需要自主判断、调用工具或响应非消息事件的场景。', '通过运行器处理消息、群成员、好友、反馈等平台事件。适合需要自主判断、调用工具或响应非消息事件的场景。',
+31 -1
View File
@@ -474,6 +474,36 @@ const zhHant = {
}, },
}, },
agents: { agents: {
eventProcessor: {
type: '事件處理器',
description: '透過外掛程式碼處理平台事件。',
component: '外掛元件',
selectComponent: '選擇事件處理器元件',
unavailable: '元件無法使用',
noComponents: '尚未安裝事件處理器元件。',
installPlugin: '安裝外掛',
loadError: '無法載入處理器詳情。',
refresh: '重新整理',
runs: '執行記錄',
noRuns: '尚無執行記錄,綁定機器人事件後開始處理。',
bindBot: '綁定機器人事件',
trace: '日誌與訊息流向',
selectRun: '選擇一筆執行記錄查看詳情。',
input: '傳入事件',
destination: '傳送目標',
loadMore: '載入更多',
activation: '安裝外掛、建立實例,再綁定機器人事件。',
status_pending: '待執行',
status_running: '執行中',
status_completed: '已完成',
status_failed: '失敗',
status_cancelled: '已取消',
status_queued: '排隊中',
trace_run_completed: '執行完成',
trace_run_failed: '執行失敗',
trace_tool_call_started: '開始執行動作',
trace_tool_call_completed: '動作結果',
},
debugData: { debugData: {
title: '事件資料', title: '事件資料',
form: '常用欄位', form: '常用欄位',
@@ -505,7 +535,7 @@ const zhHant = {
description: '建立可重用的處理器,並在機器人事件路由中使用', description: '建立可重用的處理器,並在機器人事件路由中使用',
create: '建立處理器', create: '建立處理器',
editAgent: '編輯 Agent', editAgent: '編輯 Agent',
selectFromSidebar: '從側邊欄選擇一個 Agent 或 Pipeline', selectFromSidebar: '從側邊欄選擇一個處理器',
agentType: 'Agent', agentType: 'Agent',
agentTypeDescription: '透過執行器處理訊息、群成員、好友、回饋等平台事件。', agentTypeDescription: '透過執行器處理訊息、群成員、好友、回饋等平台事件。',
pipelineType: '流程線', pipelineType: '流程線',
+2 -2
View File
@@ -252,7 +252,7 @@ test.describe('frontend CRUD smoke flows', () => {
await expect(page).toHaveURL(/\/home\/agents$/); await expect(page).toHaveURL(/\/home\/agents$/);
await expect( await expect(
page.getByText('Select an Agent or Pipeline from the sidebar'), page.getByText('Select a processor from the sidebar'),
).toBeVisible(); ).toBeVisible();
}); });
@@ -1430,7 +1430,7 @@ test.describe('empty states', () => {
await page.goto('/home/agents'); await page.goto('/home/agents');
await expect( await expect(
page.getByText('Select an Agent or Pipeline from the sidebar'), page.getByText('Select a processor from the sidebar'),
).toBeVisible(); ).toBeVisible();
}); });
+115
View File
@@ -0,0 +1,115 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
test('event processor shows isolated logs, paginates and scrolls expanded payloads', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const ref = 'event_processor:qa/welcome/default';
const processor = {
uuid: 'processor-qa',
kind: 'event_processor',
name: 'Welcome processor',
component_ref: ref,
supported_event_patterns: ['group.member_joined'],
config: { runner: { id: ref }, runner_config: { [ref]: {} } },
};
const run = {
run_id: 'run-one',
status: 'completed',
status_reason: 'stop',
created_at: 1788000000,
metadata: {
event_type: 'group.member_joined',
input_event: { member: { id: 'one' } },
delivery: {
reply_target: { target_type: 'group', target_id: 'test-group' },
},
},
};
const cursors: string[] = [];
await page.route('**/api/v1/agents**', async (route) => {
const url = new URL(route.request().url());
let data: unknown;
if (url.pathname.endsWith('/_/metadata')) {
data = {
kinds: [],
event_processors: [
{
id: ref,
label: { en_US: 'Welcome' },
supported_event_patterns: ['group.member_joined'],
config_schema: [],
plugin_author: 'qa',
plugin_name: 'welcome',
},
],
};
} else if (url.pathname.endsWith('/runs/run-one/events')) {
cursors.push(url.searchParams.get('after_sequence') ?? '');
data = {
run,
items: url.searchParams.has('after_sequence')
? [
{
sequence: 101,
type: 'processor.log',
data: { level: 'info', text: 'Final log after pagination' },
},
]
: [
{
sequence: 1,
type: 'processor.log',
data: { level: 'info', text: 'Member received' },
},
{
sequence: 100,
type: 'tool.call.completed',
data: {
result: Array.from(
{ length: 100 },
(_, i) => `Payload line ${i}`,
),
},
},
],
has_more: !url.searchParams.has('after_sequence'),
next_cursor: url.searchParams.has('after_sequence') ? null : 100,
};
} else if (url.pathname.endsWith('/runs')) {
data = { items: [run], has_more: false, next_cursor: null, total: 1 };
} else if (url.pathname.endsWith('/processor-qa')) {
data = { agent: processor };
} else {
data = { agents: [processor] };
}
await route.fulfill({ json: { code: 0, data } });
});
await page.goto('/home/agents?id=processor-qa');
await expect(
page.getByRole('heading', { name: 'Welcome processor' }),
).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Logs and message flow' }),
).toBeVisible();
await expect(page.getByText('stop', { exact: true })).toHaveCount(0);
await page
.getByRole('button')
.filter({ hasText: 'group.member_joined' })
.click();
await expect(
page.getByText('Member received', { exact: true }),
).toBeVisible();
await expect(
page.getByText('Payload line 99', { exact: false }),
).toBeHidden();
await page.locator('summary').filter({ hasText: 'Action result' }).click();
await page.getByRole('button', { name: 'Load more', exact: true }).click();
await page.getByText('Final log after pagination').scrollIntoViewIfNeeded();
await expect(page.getByText('Final log after pagination')).toBeInViewport();
expect(cursors).toEqual(['', '100']);
await expect(
page.getByRole('button', { name: 'Load more', exact: true }),
).toHaveCount(0);
});