mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-08 02:27:14 +00:00
feat(processors): add explicitly bound plugin event processors
This commit is contained in:
@@ -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 草案。
|
||||
|
||||
## 1. 路由边界
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 插件 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. 概述
|
||||
|
||||
插件 SDK 需要配合 EBA 架构进行以下改造:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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。P0–P4 的主要实现已落入 `dev/4.11.x`,P5 仍需按当前版本验收;下文工作项用于维护实现边界,不表示全部待开发。文件名沿用早期设计,但这里的“迁移”仅指代码架构逐步接入 EBA,不代表 LangBot 3.x 数据库或配置升级。当前提交、定向测试及发布缺口见 [STATUS.md](../agent-runner-pluginization/STATUS.md)。
|
||||
|
||||
## 1. 发布边界
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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)。保留正文仅用于解释早期设计取舍。
|
||||
>
|
||||
> 本文档修订 [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`。P0–P3 已集成;发布验收见 [STATUS.md](../agent-runner-pluginization/STATUS.md)。
|
||||
>
|
||||
> 本文档修订 [07-agent-orchestration.md](./07-agent-orchestration.md) 中“Agent 替代 Pipeline”的表述。当前产品形态保留两种长期并存的同级处理器:**Agent** 与 **Pipeline**。处理器页面只是共享入口,不改变二者各自的持久化模型和执行语义。
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Event processors and Pipeline plugin compatibility
|
||||
|
||||
Status: product and implementation design, 2026-09-07. The EventProcessor
|
||||
component, routing target, and UI described below are not implemented yet.
|
||||
Status: implemented in the 4.11 development branches of LangBot and the Plugin SDK,
|
||||
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
|
||||
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:
|
||||
|
||||
```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):
|
||||
async def initialize(self):
|
||||
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.
|
||||
|
||||
Extend the existing single-target route arbitration with `event_processor`.
|
||||
Remove automatic EBA broadcasts to installed EventListeners when this route is
|
||||
ready. Keep Pipeline hook dispatch inside the Pipeline path. Existing observer
|
||||
There is no automatic EBA broadcast to installed EventListeners. Keep Pipeline hook dispatch inside the Pipeline path. Existing observer
|
||||
plugins must explicitly adopt the new component and be bound by the user; do not
|
||||
create subscriptions during migration.
|
||||
|
||||
@@ -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
|
||||
order and behavior. Unit tests alone do not establish a successful live plugin
|
||||
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
@@ -69,7 +69,7 @@ dependencies = [
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<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",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
@@ -229,3 +229,7 @@ skip-magic-trailing-comma = false
|
||||
|
||||
# Like Black, automatically detect the appropriate line ending.
|
||||
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" }
|
||||
|
||||
@@ -65,7 +65,9 @@ The tools wrap the LangBot service layer. Current tools (v1):
|
||||
| `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_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. |
|
||||
| `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 |
|
||||
@@ -111,3 +113,12 @@ already have a default pipeline.
|
||||
- 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
|
||||
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.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Agent runner descriptor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
@@ -44,19 +45,18 @@ class AgentRunnerDescriptor(pydantic.BaseModel):
|
||||
config_schema: list[dict[str, typing.Any]] = pydantic.Field(default_factory=list)
|
||||
"""Configuration schema using DynamicForm format"""
|
||||
|
||||
capabilities: AgentRunnerCapabilities = pydantic.Field(
|
||||
default_factory=AgentRunnerCapabilities
|
||||
)
|
||||
capabilities: AgentRunnerCapabilities = pydantic.Field(default_factory=AgentRunnerCapabilities)
|
||||
"""Runner capabilities: streaming, tool_calling, knowledge_retrieval, etc."""
|
||||
|
||||
permissions: AgentRunnerPermissions = pydantic.Field(
|
||||
default_factory=AgentRunnerPermissions
|
||||
)
|
||||
permissions: AgentRunnerPermissions = pydantic.Field(default_factory=AgentRunnerPermissions)
|
||||
"""Requested LangBot resource permissions."""
|
||||
|
||||
raw_manifest: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
||||
"""Original manifest for reference"""
|
||||
|
||||
component_kind: typing.Literal['AgentRunner', 'EventProcessor'] = 'AgentRunner'
|
||||
supported_event_patterns: list[str] = pydantic.Field(default_factory=lambda: ['*'])
|
||||
|
||||
model_config = pydantic.ConfigDict(
|
||||
extra='allow',
|
||||
)
|
||||
|
||||
@@ -160,7 +160,7 @@ class AgentConfig(pydantic.BaseModel):
|
||||
agent_id: str | None = None
|
||||
"""Host-side Agent/config identifier."""
|
||||
|
||||
processor_type: typing.Literal['agent', 'pipeline'] = 'agent'
|
||||
processor_type: typing.Literal['agent', 'pipeline', 'event_processor'] = 'agent'
|
||||
"""Product processor kind represented by this runtime config."""
|
||||
|
||||
processor_id: str | None = None
|
||||
@@ -222,7 +222,7 @@ class AgentBinding(pydantic.BaseModel):
|
||||
agent_id: str | None = None
|
||||
"""Host-side Agent/config identifier for this binding."""
|
||||
|
||||
processor_type: typing.Literal['agent', 'pipeline'] = 'agent'
|
||||
processor_type: typing.Literal['agent', 'pipeline', 'event_processor'] = 'agent'
|
||||
"""Product processor kind selected for this binding."""
|
||||
|
||||
processor_id: str | None = None
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Agent runner ID parsing and formatting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
@@ -7,6 +8,7 @@ import dataclasses
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RunnerIdParts:
|
||||
"""Parsed runner ID components."""
|
||||
|
||||
source: str # 'plugin' (future: 'builtin')
|
||||
plugin_author: str
|
||||
plugin_name: str
|
||||
@@ -29,31 +31,27 @@ def parse_runner_id(runner_id: str) -> RunnerIdParts:
|
||||
Raises:
|
||||
ValueError: If runner_id format is invalid
|
||||
"""
|
||||
if runner_id.startswith('plugin:'):
|
||||
parts = runner_id[7:].split('/')
|
||||
if runner_id.startswith(('plugin:', 'event_processor:')):
|
||||
source, value = runner_id.split(':', 1)
|
||||
parts = value.split('/')
|
||||
if len(parts) != 3:
|
||||
raise ValueError(
|
||||
f'Invalid plugin runner ID format: {runner_id}. '
|
||||
f'Expected: plugin:author/plugin_name/runner_name'
|
||||
f'Invalid plugin runner ID format: {runner_id}. Expected: plugin:author/plugin_name/runner_name'
|
||||
)
|
||||
plugin_author, plugin_name, runner_name = parts
|
||||
if not plugin_author or not plugin_name or not runner_name:
|
||||
raise ValueError(
|
||||
f'Invalid plugin runner ID: {runner_id}. '
|
||||
f'author, plugin_name, and runner_name must be non-empty'
|
||||
f'Invalid plugin runner ID: {runner_id}. author, plugin_name, and runner_name must be non-empty'
|
||||
)
|
||||
return RunnerIdParts(
|
||||
source='plugin',
|
||||
source=source,
|
||||
plugin_author=plugin_author,
|
||||
plugin_name=plugin_name,
|
||||
runner_name=runner_name,
|
||||
)
|
||||
else:
|
||||
# Only plugin runner IDs are valid at the protocol boundary.
|
||||
raise ValueError(
|
||||
f'Invalid runner ID format: {runner_id}. '
|
||||
f'Expected: plugin:author/plugin_name/runner_name'
|
||||
)
|
||||
raise ValueError(f'Invalid runner ID format: {runner_id}. Expected: plugin:author/plugin_name/runner_name')
|
||||
|
||||
|
||||
def format_runner_id(
|
||||
@@ -73,8 +71,8 @@ def format_runner_id(
|
||||
Returns:
|
||||
Runner ID string
|
||||
"""
|
||||
if source == 'plugin':
|
||||
return f'plugin:{plugin_author}/{plugin_name}/{runner_name}'
|
||||
if source in {'plugin', 'event_processor'}:
|
||||
return f'{source}:{plugin_author}/{plugin_name}/{runner_name}'
|
||||
else:
|
||||
raise ValueError(f'Invalid runner source: {source}')
|
||||
|
||||
@@ -88,4 +86,4 @@ def is_plugin_runner_id(runner_id: str) -> bool:
|
||||
Returns:
|
||||
True if runner ID starts with 'plugin:'
|
||||
"""
|
||||
return runner_id.startswith('plugin:')
|
||||
return runner_id.startswith(('plugin:', 'event_processor:'))
|
||||
|
||||
@@ -41,6 +41,17 @@ class AgentRunnerInvoker:
|
||||
)
|
||||
|
||||
try:
|
||||
if descriptor.component_kind == 'EventProcessor':
|
||||
context = {
|
||||
**context,
|
||||
'runtime': {
|
||||
**context['runtime'],
|
||||
'metadata': {
|
||||
**context['runtime'].get('metadata', {}),
|
||||
'component_kind': 'EventProcessor',
|
||||
},
|
||||
},
|
||||
}
|
||||
gen = self.ap.plugin_connector.run_agent(
|
||||
plugin_author=descriptor.plugin_author,
|
||||
plugin_name=descriptor.plugin_name,
|
||||
|
||||
@@ -102,6 +102,10 @@ class AgentRunOrchestrator:
|
||||
bound_plugins,
|
||||
)
|
||||
|
||||
expected_kind = 'EventProcessor' if binding.processor_type == 'event_processor' else 'AgentRunner'
|
||||
if descriptor.component_kind != expected_kind:
|
||||
raise ValueError('Processor kind does not match the selected plugin component')
|
||||
|
||||
if execution_query is None:
|
||||
execution_query = build_execution_query(event, [])
|
||||
# Synthetic events must expose the same trusted scope as pipeline queries.
|
||||
|
||||
@@ -110,19 +110,19 @@ class AgentRunnerRegistry:
|
||||
|
||||
manifest = runner_data.get('manifest', {})
|
||||
runner_id = format_runner_id(
|
||||
source='plugin',
|
||||
source='event_processor' if manifest.get('component_kind') == 'EventProcessor' else 'plugin',
|
||||
plugin_author=plugin_author,
|
||||
plugin_name=plugin_name,
|
||||
runner_name=runner_name,
|
||||
)
|
||||
|
||||
typed_manifest = AgentRunnerManifest.model_validate(manifest)
|
||||
config_schema = [
|
||||
item.model_dump(mode='json') for item in typed_manifest.config_schema
|
||||
]
|
||||
config_schema = [item.model_dump(mode='json') for item in typed_manifest.config_schema]
|
||||
|
||||
return AgentRunnerDescriptor(
|
||||
id=runner_id,
|
||||
component_kind=typed_manifest.component_kind,
|
||||
supported_event_patterns=typed_manifest.supported_event_patterns,
|
||||
source='plugin',
|
||||
label=typed_manifest.label,
|
||||
description=typed_manifest.description,
|
||||
@@ -152,6 +152,7 @@ class AgentRunnerRegistry:
|
||||
context: TenantContext,
|
||||
bound_plugins: list[str] | None = None,
|
||||
use_cache: bool = True,
|
||||
component_kind: str = 'AgentRunner',
|
||||
) -> list[AgentRunnerDescriptor]:
|
||||
"""List available runners.
|
||||
|
||||
@@ -169,7 +170,11 @@ class AgentRunnerRegistry:
|
||||
# Filter from cache. Do not treat an empty cache as final because the
|
||||
# plugin runtime may still be launching installed plugins when the
|
||||
# first metadata request arrives.
|
||||
return self._filter_runners_by_bound_plugins(cached, bound_plugins)
|
||||
return [
|
||||
r
|
||||
for r in self._filter_runners_by_bound_plugins(cached, bound_plugins)
|
||||
if r.component_kind == component_kind
|
||||
]
|
||||
|
||||
# Discover fresh (always full list)
|
||||
runners = await self._discover_runners()
|
||||
@@ -179,7 +184,11 @@ class AgentRunnerRegistry:
|
||||
self._cache[cache_key] = runners
|
||||
|
||||
# Filter locally
|
||||
return self._filter_runners_by_bound_plugins(runners, bound_plugins)
|
||||
return [
|
||||
r
|
||||
for r in self._filter_runners_by_bound_plugins(runners, bound_plugins)
|
||||
if r.component_kind == component_kind
|
||||
]
|
||||
|
||||
def _filter_runners_by_bound_plugins(
|
||||
self,
|
||||
@@ -233,7 +242,8 @@ class AgentRunnerRegistry:
|
||||
except ValueError as e:
|
||||
raise RunnerNotFoundError(runner_id) from e
|
||||
|
||||
runners = await self.list_runners(context, bound_plugins=None)
|
||||
component_kind = 'EventProcessor' if runner_id.startswith('event_processor:') else 'AgentRunner'
|
||||
runners = await self.list_runners(context, bound_plugins=None, component_kind=component_kind)
|
||||
descriptor = next((item for item in runners if item.id == runner_id), None)
|
||||
if descriptor is None:
|
||||
# The runtime launches installed plugins asynchronously, so an
|
||||
@@ -242,6 +252,7 @@ class AgentRunnerRegistry:
|
||||
context,
|
||||
bound_plugins=None,
|
||||
use_cache=False,
|
||||
component_kind=component_kind,
|
||||
)
|
||||
descriptor = next((item for item in runners if item.id == runner_id), None)
|
||||
if descriptor is None:
|
||||
|
||||
@@ -10,6 +10,7 @@ from langbot_plugin.api.entities.builtin.agent_runner.result import (
|
||||
MessageCompletedPayload,
|
||||
MessageDeltaPayload,
|
||||
RunCompletedPayload,
|
||||
ProcessorLogPayload,
|
||||
RunFailedPayload,
|
||||
StateUpdatedPayload,
|
||||
ToolCallCompletedPayload,
|
||||
@@ -34,6 +35,7 @@ STRICT_RESULT_PAYLOADS: dict[str, type[pydantic.BaseModel]] = {
|
||||
'action.requested': ActionRequestedPayload,
|
||||
'run.completed': RunCompletedPayload,
|
||||
'run.failed': RunFailedPayload,
|
||||
'processor.log': ProcessorLogPayload,
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +116,9 @@ class AgentResultNormalizer:
|
||||
if not self.validate_payload(result_type, data, descriptor):
|
||||
return None
|
||||
|
||||
if result_type == 'processor.log':
|
||||
return None
|
||||
|
||||
if result_type == 'message.delta':
|
||||
return self._normalize_message_delta(data, descriptor)
|
||||
|
||||
|
||||
@@ -93,6 +93,13 @@ class AgentRunJournal:
|
||||
metadata={
|
||||
'event_type': event.event_type,
|
||||
'source': event.source,
|
||||
'processor_id': binding.processor_id,
|
||||
'processor_type': binding.processor_type,
|
||||
**(
|
||||
{'input_event': event.data, 'delivery': event.delivery.model_dump(mode='json')}
|
||||
if binding.processor_type == 'event_processor'
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -188,8 +188,8 @@ class RunLedgerStore:
|
||||
query = query.where(AgentRun.conversation_id == conversation_id)
|
||||
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
|
||||
|
||||
query = query.order_by(AgentRun.priority.desc(), AgentRun.id.asc()).limit(1).with_for_update(
|
||||
skip_locked=True
|
||||
query = (
|
||||
query.order_by(AgentRun.priority.desc(), AgentRun.id.asc()).limit(1).with_for_update(skip_locked=True)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
run = result.scalars().first()
|
||||
@@ -571,8 +571,7 @@ class RunLedgerStore:
|
||||
|
||||
# Filter by labels
|
||||
runtimes = [
|
||||
rt for rt in all_runtimes
|
||||
if all(rt.get('labels', {}).get(k) == v for k, v in labels.items())
|
||||
rt for rt in all_runtimes if all(rt.get('labels', {}).get(k) == v for k, v in labels.items())
|
||||
]
|
||||
total_count = len(runtimes)
|
||||
|
||||
@@ -636,6 +635,7 @@ class RunLedgerStore:
|
||||
thread_id: str | None = None,
|
||||
strict_thread: bool = False,
|
||||
runner_id: str | None = None,
|
||||
binding_id: str | None = None,
|
||||
) -> tuple[list[dict[str, typing.Any]], int | None, bool, int]:
|
||||
"""Page runs by scope.
|
||||
|
||||
@@ -652,6 +652,8 @@ class RunLedgerStore:
|
||||
count_query = count_query.where(AgentRun.status.in_(statuses))
|
||||
if runner_id is not None:
|
||||
count_query = count_query.where(AgentRun.runner_id == runner_id)
|
||||
if binding_id is not None:
|
||||
count_query = count_query.where(AgentRun.binding_id == binding_id)
|
||||
count_query = self._apply_scope_filters(count_query, bot_id, workspace_id, thread_id, strict_thread)
|
||||
count_result = await session.execute(count_query)
|
||||
total_count = count_result.scalar() or 0
|
||||
@@ -664,6 +666,8 @@ class RunLedgerStore:
|
||||
query = query.where(AgentRun.status.in_(statuses))
|
||||
if runner_id is not None:
|
||||
query = query.where(AgentRun.runner_id == runner_id)
|
||||
if binding_id is not None:
|
||||
query = query.where(AgentRun.binding_id == binding_id)
|
||||
if before_id is not None:
|
||||
query = query.where(AgentRun.id < before_id)
|
||||
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
|
||||
@@ -848,10 +852,7 @@ class RunLedgerStore:
|
||||
|
||||
# Count by status
|
||||
status_query = (
|
||||
sqlalchemy.select(
|
||||
AgentRun.status,
|
||||
func.count(AgentRun.id).label('count')
|
||||
)
|
||||
sqlalchemy.select(AgentRun.status, func.count(AgentRun.id).label('count'))
|
||||
.where(*base_filter)
|
||||
.group_by(AgentRun.status)
|
||||
)
|
||||
@@ -873,18 +874,15 @@ class RunLedgerStore:
|
||||
avg_queue_wait_seconds = None
|
||||
|
||||
# Fetch completed runs with timing data
|
||||
timing_query = (
|
||||
sqlalchemy.select(
|
||||
AgentRun.started_at,
|
||||
AgentRun.finished_at,
|
||||
AgentRun.created_at,
|
||||
)
|
||||
.where(
|
||||
AgentRun.status == 'completed',
|
||||
AgentRun.started_at.is_not(None),
|
||||
AgentRun.finished_at.is_not(None),
|
||||
*base_filter
|
||||
)
|
||||
timing_query = sqlalchemy.select(
|
||||
AgentRun.started_at,
|
||||
AgentRun.finished_at,
|
||||
AgentRun.created_at,
|
||||
).where(
|
||||
AgentRun.status == 'completed',
|
||||
AgentRun.started_at.is_not(None),
|
||||
AgentRun.finished_at.is_not(None),
|
||||
*base_filter,
|
||||
)
|
||||
timing_result = await session.execute(timing_query)
|
||||
timing_rows = timing_result.all()
|
||||
@@ -899,16 +897,10 @@ class RunLedgerStore:
|
||||
avg_duration_seconds = round(sum(durations) / len(durations), 2)
|
||||
|
||||
# Queue wait time - compute in Python
|
||||
queue_query = (
|
||||
sqlalchemy.select(
|
||||
AgentRun.created_at,
|
||||
AgentRun.started_at,
|
||||
)
|
||||
.where(
|
||||
AgentRun.started_at.is_not(None),
|
||||
*base_filter
|
||||
)
|
||||
)
|
||||
queue_query = sqlalchemy.select(
|
||||
AgentRun.created_at,
|
||||
AgentRun.started_at,
|
||||
).where(AgentRun.started_at.is_not(None), *base_filter)
|
||||
queue_result = await session.execute(queue_query)
|
||||
queue_rows = queue_result.all()
|
||||
|
||||
@@ -957,12 +949,8 @@ class RunLedgerStore:
|
||||
|
||||
async with self._session_factory() as session:
|
||||
# Count by status
|
||||
status_query = (
|
||||
sqlalchemy.select(
|
||||
AgentRuntime.status,
|
||||
func.count(AgentRuntime.id).label('count')
|
||||
)
|
||||
.group_by(AgentRuntime.status)
|
||||
status_query = sqlalchemy.select(AgentRuntime.status, func.count(AgentRuntime.id).label('count')).group_by(
|
||||
AgentRuntime.status
|
||||
)
|
||||
status_result = await session.execute(status_query)
|
||||
status_counts = {row.status: row.count for row in status_result}
|
||||
@@ -975,9 +963,8 @@ class RunLedgerStore:
|
||||
avg_heartbeat_age = None
|
||||
max_heartbeat_age = None
|
||||
|
||||
heartbeat_query = (
|
||||
sqlalchemy.select(AgentRuntime.last_heartbeat_at)
|
||||
.where(AgentRuntime.last_heartbeat_at.is_not(None))
|
||||
heartbeat_query = sqlalchemy.select(AgentRuntime.last_heartbeat_at).where(
|
||||
AgentRuntime.last_heartbeat_at.is_not(None)
|
||||
)
|
||||
heartbeat_result = await session.execute(heartbeat_query)
|
||||
heartbeat_rows = heartbeat_result.all()
|
||||
@@ -995,16 +982,12 @@ class RunLedgerStore:
|
||||
avg_heartbeat_age = round(sum(ages) / len(ages), 2)
|
||||
max_heartbeat_age = round(max(ages), 2)
|
||||
|
||||
active_runs_query = (
|
||||
sqlalchemy.select(func.count(AgentRun.id))
|
||||
.where(AgentRun.status.in_(['running', 'claimed']))
|
||||
active_runs_query = sqlalchemy.select(func.count(AgentRun.id)).where(
|
||||
AgentRun.status.in_(['running', 'claimed'])
|
||||
)
|
||||
active_runs_result = await session.execute(active_runs_query)
|
||||
active_runs = active_runs_result.scalar() or 0
|
||||
claimed_runs_query = (
|
||||
sqlalchemy.select(func.count(AgentRun.id))
|
||||
.where(AgentRun.status == 'claimed')
|
||||
)
|
||||
claimed_runs_query = sqlalchemy.select(func.count(AgentRun.id)).where(AgentRun.status == 'claimed')
|
||||
claimed_runs_result = await session.execute(claimed_runs_query)
|
||||
claimed_runs = claimed_runs_result.scalar() or 0
|
||||
|
||||
@@ -1048,23 +1031,10 @@ class RunLedgerStore:
|
||||
AgentRun.runner_id,
|
||||
func.count(AgentRun.id).label('total'),
|
||||
func.sum(
|
||||
sqlalchemy.case(
|
||||
(AgentRun.status.in_(['queued', 'claimed', 'running']), 1),
|
||||
else_=0
|
||||
)
|
||||
sqlalchemy.case((AgentRun.status.in_(['queued', 'claimed', 'running']), 1), else_=0)
|
||||
).label('active'),
|
||||
func.sum(
|
||||
sqlalchemy.case(
|
||||
(AgentRun.status == 'completed', 1),
|
||||
else_=0
|
||||
)
|
||||
).label('completed'),
|
||||
func.sum(
|
||||
sqlalchemy.case(
|
||||
(AgentRun.status.in_(['failed', 'timeout']), 1),
|
||||
else_=0
|
||||
)
|
||||
).label('failed'),
|
||||
func.sum(sqlalchemy.case((AgentRun.status == 'completed', 1), else_=0)).label('completed'),
|
||||
func.sum(sqlalchemy.case((AgentRun.status.in_(['failed', 'timeout']), 1), else_=0)).label('failed'),
|
||||
)
|
||||
.where(
|
||||
AgentRun.created_at >= start_dt,
|
||||
@@ -1087,16 +1057,18 @@ class RunLedgerStore:
|
||||
failed = row.failed or 0
|
||||
success_rate = completed / total if total > 0 else None
|
||||
|
||||
stats.append({
|
||||
'runner_id': runner_id,
|
||||
'runner_label': None, # Would need to join with runner descriptors
|
||||
'plugin_identity': None,
|
||||
'total_runs': total,
|
||||
'active_runs': row.active or 0,
|
||||
'completed_runs': completed,
|
||||
'failed_runs': failed,
|
||||
'success_rate': round(success_rate, 4) if success_rate is not None else None,
|
||||
'avg_duration_seconds': None, # Would need more complex query
|
||||
})
|
||||
stats.append(
|
||||
{
|
||||
'runner_id': runner_id,
|
||||
'runner_label': None, # Would need to join with runner descriptors
|
||||
'plugin_identity': None,
|
||||
'total_runs': total,
|
||||
'active_runs': row.active or 0,
|
||||
'completed_runs': completed,
|
||||
'failed_runs': failed,
|
||||
'success_rate': round(success_rate, 4) if success_rate is not None else None,
|
||||
'avg_duration_seconds': None, # Would need more complex query
|
||||
}
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
@@ -18,6 +18,43 @@ from .agent_debug_stream import debug_stream_response
|
||||
@group.group_class('agents', '/api/v1/agents')
|
||||
class AgentsRouterGroup(group.RouterGroup):
|
||||
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(
|
||||
'/<agent_uuid>/debug/stream',
|
||||
methods=['POST'],
|
||||
|
||||
@@ -40,6 +40,7 @@ from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
|
||||
AGENT_KIND_AGENT = 'agent'
|
||||
AGENT_KIND_PIPELINE = 'pipeline'
|
||||
AGENT_KIND_EVENT_PROCESSOR = 'event_processor'
|
||||
PIPELINE_EVENT_PATTERNS = ['message.*']
|
||||
AGENT_DEFAULT_EVENT_PATTERNS = ['*']
|
||||
|
||||
@@ -67,7 +68,19 @@ class AgentService:
|
||||
)
|
||||
except Exception as 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 {
|
||||
'event_processors': event_processors,
|
||||
'runner_config': ai_metadata,
|
||||
'platform_tools': platform_tool_catalog(),
|
||||
'host_tools': host_tools,
|
||||
@@ -82,6 +95,7 @@ class AgentService:
|
||||
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
|
||||
'message_only': True,
|
||||
},
|
||||
{'name': AGENT_KIND_EVENT_PROCESSOR, 'supported_event_patterns': ['*'], 'message_only': False},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -131,7 +145,7 @@ class AgentService:
|
||||
non-message event envelopes.
|
||||
"""
|
||||
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')
|
||||
|
||||
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),
|
||||
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_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),
|
||||
event_types=[event_type],
|
||||
runner_id=runner_id,
|
||||
@@ -263,7 +297,7 @@ class AgentService:
|
||||
enable_interactions=False,
|
||||
),
|
||||
agent_id=agent_uuid,
|
||||
processor_type='agent',
|
||||
processor_type=agent.get('kind', 'agent'),
|
||||
processor_id=agent_uuid,
|
||||
)
|
||||
execution_context = ExecutionContext.from_request(
|
||||
@@ -282,6 +316,7 @@ class AgentService:
|
||||
'tool.call.completed',
|
||||
'run.completed',
|
||||
'run.failed',
|
||||
'processor.log',
|
||||
}:
|
||||
return
|
||||
visible_result = copy.deepcopy(
|
||||
@@ -363,11 +398,17 @@ class AgentService:
|
||||
)
|
||||
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}')
|
||||
|
||||
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 kind == AGENT_KIND_EVENT_PROCESSOR:
|
||||
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())
|
||||
values = {
|
||||
'workspace_uuid': workspace_uuid,
|
||||
@@ -375,17 +416,13 @@ class AgentService:
|
||||
'name': agent_data.get('name') or 'New Agent',
|
||||
'description': agent_data.get('description') or '',
|
||||
'emoji': agent_data.get('emoji') or '🤖',
|
||||
'kind': AGENT_KIND_AGENT,
|
||||
'kind': kind,
|
||||
'component_ref': runner_id,
|
||||
'config': config,
|
||||
'supported_event_patterns': (
|
||||
agent_data['supported_event_patterns']
|
||||
if 'supported_event_patterns' in agent_data
|
||||
else AGENT_DEFAULT_EVENT_PATTERNS
|
||||
),
|
||||
'supported_event_patterns': patterns,
|
||||
}
|
||||
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:
|
||||
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')
|
||||
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:
|
||||
config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(update_data['config'])
|
||||
update_data['config'] = config
|
||||
else:
|
||||
_, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(existing_agent.config)
|
||||
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(
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_agent.Agent)
|
||||
@@ -438,6 +482,78 @@ class AgentService:
|
||||
raise ValueError(f'Agent {agent_uuid} not found')
|
||||
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]:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
@@ -490,7 +606,7 @@ class AgentService:
|
||||
include_config: bool = False,
|
||||
) -> dict[str, typing.Any]:
|
||||
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')
|
||||
item['capability'] = {
|
||||
'supported_event_patterns': (
|
||||
|
||||
@@ -149,7 +149,7 @@ class BotService:
|
||||
return target_kind
|
||||
if target_type == 'discard':
|
||||
return 'discard'
|
||||
if target_type in {'agent', 'pipeline'}:
|
||||
if target_type in {'agent', 'pipeline', 'event_processor'}:
|
||||
return str(target_type)
|
||||
return None
|
||||
|
||||
@@ -416,9 +416,9 @@ class BotService:
|
||||
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)
|
||||
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(
|
||||
matched=False,
|
||||
binding=selected_binding,
|
||||
@@ -514,7 +514,7 @@ class BotService:
|
||||
)
|
||||
if result.first() is None:
|
||||
raise ValueError('Pipeline not found')
|
||||
elif target_type == 'agent':
|
||||
elif target_type in {'agent', 'event_processor'}:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid),
|
||||
@@ -523,8 +523,8 @@ class BotService:
|
||||
)
|
||||
)
|
||||
agent = result.first()
|
||||
if agent is None:
|
||||
raise ValueError('Agent not found')
|
||||
if agent is None or agent.kind != target_type:
|
||||
raise ValueError('Processor not found')
|
||||
if not self._agent_supports_event_pattern(agent.supported_event_patterns, event_pattern):
|
||||
raise ValueError('Agent does not support this event pattern')
|
||||
elif target_type == 'discard':
|
||||
|
||||
@@ -175,44 +175,72 @@ class LangBotMCPServer:
|
||||
return _dump({'ok': True})
|
||||
|
||||
# ----- 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:
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
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:
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.agent_service.get_agent(context, processor_uuid))
|
||||
|
||||
@mcp.tool(
|
||||
description=(
|
||||
'Create an Agent or Pipeline processor. Set `processor_data.kind` to '
|
||||
'`agent` or `pipeline`. Returns the new UUID and kind.'
|
||||
'Create an Agent, Pipeline or Event processor. Set `processor_data.kind` to '
|
||||
'`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:
|
||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||
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:
|
||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||
await ap.agent_service.update_agent(context, processor_uuid, processor_data)
|
||||
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:
|
||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||
await ap.agent_service.delete_agent(context, processor_uuid)
|
||||
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 -------------------------------------------------- #
|
||||
@mcp.tool(
|
||||
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. '
|
||||
'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 '
|
||||
'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', '')
|
||||
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(
|
||||
type='message.received',
|
||||
adapter_name='aiocqhttp',
|
||||
message_id=getattr(event, 'message_id', ''),
|
||||
message_chain=message_chain,
|
||||
sender=AiocqhttpEventConverter.user_from_sender(event),
|
||||
sender=sender,
|
||||
sender_member=membership,
|
||||
chat_type=chat_type,
|
||||
chat_id=chat_id,
|
||||
group=group,
|
||||
|
||||
@@ -630,6 +630,7 @@ class RuntimeBot:
|
||||
name=event.group.name,
|
||||
)
|
||||
return platform_events.MessageReceivedEvent(
|
||||
legacy_event=event,
|
||||
message_id=self._extract_message_id(event.message_chain),
|
||||
message_chain=event.message_chain,
|
||||
sender=platform_entities.User(
|
||||
@@ -646,6 +647,7 @@ class RuntimeBot:
|
||||
)
|
||||
|
||||
return platform_events.MessageReceivedEvent(
|
||||
legacy_event=event,
|
||||
message_id=self._extract_message_id(event.message_chain),
|
||||
message_chain=event.message_chain,
|
||||
sender=platform_entities.User(
|
||||
@@ -805,7 +807,11 @@ class RuntimeBot:
|
||||
return None
|
||||
|
||||
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),
|
||||
event_types=[event_type],
|
||||
runner_id=runner_id,
|
||||
@@ -820,10 +826,10 @@ class RuntimeBot:
|
||||
delivery_policy=DeliveryPolicy(
|
||||
enable_streaming=False,
|
||||
enable_reply=True,
|
||||
enable_interactions=True,
|
||||
enable_interactions=agent.get('kind') != 'event_processor',
|
||||
),
|
||||
agent_id=agent.get('uuid'),
|
||||
processor_type='agent',
|
||||
processor_type=agent.get('kind', 'agent'),
|
||||
processor_id=agent.get('uuid'),
|
||||
)
|
||||
|
||||
@@ -898,14 +904,8 @@ class RuntimeBot:
|
||||
await self._handle_interaction_submission(event, adapter)
|
||||
return
|
||||
|
||||
plugin_event = self._eba_event_to_plugin_event(event)
|
||||
|
||||
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()}')
|
||||
|
||||
# Legacy listeners run inside Pipeline stages. EBA handlers require an
|
||||
# explicitly created and routed EventProcessor instance.
|
||||
await self._dispatch_eba_event_to_processor(event, adapter)
|
||||
|
||||
async def _dispatch_eba_event_to_processor(
|
||||
@@ -983,7 +983,7 @@ class RuntimeBot:
|
||||
target_uuid=event_binding.get('target_uuid'),
|
||||
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(
|
||||
event_type=event_type,
|
||||
status='failed',
|
||||
@@ -998,7 +998,7 @@ class RuntimeBot:
|
||||
|
||||
target_uuid = event_binding.get('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(
|
||||
event_type=event_type,
|
||||
status='failed',
|
||||
@@ -1050,6 +1050,8 @@ class RuntimeBot:
|
||||
)
|
||||
|
||||
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] = []
|
||||
try:
|
||||
async for output in self.ap.agent_run_orchestrator.run(
|
||||
|
||||
@@ -74,6 +74,9 @@ def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
|
||||
AgentRunner:
|
||||
fromDirs:
|
||||
- path: components/agent_runner/
|
||||
EventProcessor:
|
||||
fromDirs:
|
||||
- path: components/event_processor/
|
||||
pages: []
|
||||
execution:
|
||||
python:
|
||||
@@ -160,6 +163,45 @@ def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
processor_dir = plugin_root / 'components' / 'event_processor'
|
||||
processor_dir.mkdir(parents=True)
|
||||
(processor_dir / 'default.yaml').write_text(
|
||||
textwrap.dedent("""
|
||||
apiVersion: langbot/v1
|
||||
kind: EventProcessor
|
||||
metadata:
|
||||
name: default
|
||||
label: {en_US: Welcome processor, zh_Hans: Welcome processor}
|
||||
spec:
|
||||
events: [group.member_joined]
|
||||
config:
|
||||
- name: greeting
|
||||
type: string
|
||||
required: true
|
||||
label: {en_US: Greeting, zh_Hans: Greeting}
|
||||
default: Hello
|
||||
capabilities: {tool_calling: true}
|
||||
permissions:
|
||||
tools: [detail, call]
|
||||
execution:
|
||||
python: {path: default.py, attr: WelcomeProcessor}
|
||||
""")
|
||||
)
|
||||
(processor_dir / 'default.py').write_text(
|
||||
textwrap.dedent("""
|
||||
from langbot_plugin.api.definition.components.event_processor import EventProcessor, EventProcessorContext
|
||||
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
|
||||
|
||||
class WelcomeProcessor(EventProcessor):
|
||||
async def initialize(self):
|
||||
@self.handler(MemberJoinedEvent)
|
||||
async def handle(ctx: EventProcessorContext):
|
||||
await ctx.log('Handling ' + str(ctx.event.member.id))
|
||||
result = await ctx.reply(ctx.config['greeting'] + ', ' + (ctx.event.member.nickname or str(ctx.event.member.id)))
|
||||
await ctx.log('Reply simulated: ' + str(result.get('mock')))
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
"""Reserve a currently-free localhost TCP port for this E2E process."""
|
||||
@@ -384,6 +426,7 @@ def test_plugin_runtime_discovers_agent_runner(
|
||||
f'Runtime stderr (tail):\n{runtime_stderr[-20_000:]}'
|
||||
)
|
||||
|
||||
|
||||
def test_host_orchestrator_runs_agent_runner_and_records_ledger(
|
||||
agent_runner_client,
|
||||
agent_runner_langbot_process,
|
||||
@@ -455,8 +498,7 @@ def test_host_orchestrator_runs_agent_runner_and_records_ledger(
|
||||
event_types = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
'SELECT type FROM agent_run_event WHERE run_id = '
|
||||
'(SELECT run_id FROM agent_run WHERE event_id = ?)',
|
||||
'SELECT type FROM agent_run_event WHERE run_id = (SELECT run_id FROM agent_run WHERE event_id = ?)',
|
||||
(result['event_id'],),
|
||||
).fetchall()
|
||||
}
|
||||
@@ -469,3 +511,63 @@ def test_host_orchestrator_runs_agent_runner_and_records_ledger(
|
||||
assert '"count": 1' in state_row[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_event_processor_real_runtime_logs_actions_and_instance_isolation(
|
||||
agent_runner_client,
|
||||
agent_runner_e2e_tmpdir,
|
||||
):
|
||||
client = agent_runner_client
|
||||
token = _init_and_auth(client)
|
||||
_ensure_qa_plugin(client, token, agent_runner_e2e_tmpdir / 'agent-runner-qa.zip')
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
metadata_response = client.get('/api/v1/agents/_/metadata', headers=headers).json()
|
||||
assert metadata_response['code'] == 0, metadata_response
|
||||
metadata = metadata_response['data']
|
||||
ref = 'event_processor:e2e/agent-runner-qa/default'
|
||||
assert any(item['id'] == ref for item in metadata['event_processors']), metadata
|
||||
assert ref not in _wait_for_qa_runner(client, token)
|
||||
created = []
|
||||
for name in ('First', 'Second'):
|
||||
response = client.post(
|
||||
'/api/v1/agents',
|
||||
headers=headers,
|
||||
json={
|
||||
'kind': 'event_processor',
|
||||
'name': name,
|
||||
'component_ref': ref,
|
||||
'parameters': {'greeting': name},
|
||||
},
|
||||
).json()
|
||||
assert response['code'] == 0, response
|
||||
created.append(response['data']['uuid'])
|
||||
for processor_id in created:
|
||||
page = client.get(f'/api/v1/agents/{processor_id}/runs', headers=headers).json()
|
||||
assert page['data']['items'] == [], page
|
||||
result = client.post(
|
||||
f'/api/v1/agents/{created[0]}/debug',
|
||||
headers=headers,
|
||||
json={
|
||||
'event_type': 'group.member_joined',
|
||||
'data': {'member': {'id': 'member-1', 'nickname': 'Tester'}, 'group': {'id': 'group-1'}},
|
||||
},
|
||||
).json()
|
||||
assert result['code'] == 0, result
|
||||
logs = [event['data']['text'] for event in result['data']['execution_events'] if event['type'] == 'processor.log']
|
||||
assert logs == ['Handling member-1', 'Reply simulated: True'], result
|
||||
actions = [event for event in result['data']['execution_events'] if event['type'] == 'tool.call.completed']
|
||||
assert len(actions) == 1, result
|
||||
assert actions[0]['data']['tool_name'] == 'event_reply'
|
||||
assert actions[0]['data']['result']['mock'] is True
|
||||
assert result['data']['final_text'] == '', result
|
||||
page = client.get(f'/api/v1/agents/{created[0]}/runs', headers=headers).json()['data']
|
||||
assert len(page['items']) == 1, page
|
||||
run = page['items'][0]
|
||||
assert run['status'] == 'completed', run
|
||||
assert run['metadata']['input_event']['member']['id'] == 'member-1'
|
||||
trace = client.get(f'/api/v1/agents/{created[0]}/runs/{run["run_id"]}/events', headers=headers).json()
|
||||
assert trace['code'] == 0, trace
|
||||
assert any(item['type'] == 'processor.log' for item in trace['data']['items']), trace
|
||||
foreign = client.get(f'/api/v1/agents/{created[1]}/runs/{run["run_id"]}/events', headers=headers)
|
||||
assert foreign.status_code == 400
|
||||
assert client.get(f'/api/v1/agents/{created[1]}/runs', headers=headers).json()['data']['items'] == []
|
||||
|
||||
@@ -335,3 +335,31 @@ class TestDescriptorValidation:
|
||||
assert descriptor.supports_streaming() is True
|
||||
assert descriptor.supports_tool_calling() is False
|
||||
assert descriptor.supports_knowledge_retrieval() is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_separates_processor_kinds_with_same_plugin_component_name():
|
||||
ap = FakeApplication()
|
||||
entries = []
|
||||
for kind, prefix in [('AgentRunner', 'plugin'), ('EventProcessor', 'event_processor')]:
|
||||
entries.append(
|
||||
{
|
||||
'plugin_author': 'test',
|
||||
'plugin_name': 'both',
|
||||
'runner_name': 'default',
|
||||
'manifest': {
|
||||
'id': f'{prefix}:test/both/default',
|
||||
'name': 'default',
|
||||
'component_kind': kind,
|
||||
'label': {'en_US': kind},
|
||||
'supported_event_patterns': ['group.member_joined'],
|
||||
},
|
||||
}
|
||||
)
|
||||
ap.plugin_connector.list_agent_runners = AsyncMock(return_value=entries)
|
||||
registry = AgentRunnerRegistry(ap)
|
||||
agents = await registry.list_runners(TEST_CONTEXT)
|
||||
processors = await registry.list_runners(TEST_CONTEXT, component_kind='EventProcessor')
|
||||
assert [item.id for item in agents] == ['plugin:test/both/default']
|
||||
assert [item.id for item in processors] == ['event_processor:test/both/default']
|
||||
assert (await registry.get(TEST_CONTEXT, processors[0].id)).component_kind == 'EventProcessor'
|
||||
|
||||
@@ -428,3 +428,30 @@ async def test_runner_stats_reports_zero_success_rate_for_failed_only_runner(sto
|
||||
assert stats[0]['runner_id'] == 'runner-a'
|
||||
assert stats[0]['failed_runs'] == 1
|
||||
assert stats[0]['success_rate'] == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processor_instance_history_filters_count_and_pages(store):
|
||||
for index, (workspace, binding) in enumerate(
|
||||
[
|
||||
('one', 'processor-a'),
|
||||
('one', 'processor-b'),
|
||||
('two', 'processor-a'),
|
||||
('one', 'processor-a'),
|
||||
]
|
||||
):
|
||||
await store.create_run(
|
||||
run_id=f'run-{index}',
|
||||
event_id=f'event-{index}',
|
||||
binding_id=binding,
|
||||
runner_id='same-component',
|
||||
workspace_id=workspace,
|
||||
)
|
||||
first, cursor, more, total = await store.list_runs(workspace_id='one', binding_id='processor-a', limit=1)
|
||||
assert (total, more) == (2, True)
|
||||
assert first[0]['run_id'] == 'run-3'
|
||||
second, _, more, total = await store.list_runs(
|
||||
workspace_id='one', binding_id='processor-a', limit=1, before_id=cursor
|
||||
)
|
||||
assert (total, more) == (2, False)
|
||||
assert second[0]['run_id'] == 'run-0'
|
||||
|
||||
@@ -145,6 +145,7 @@ class TestAgentServiceMetadata:
|
||||
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
|
||||
'message_only': True,
|
||||
},
|
||||
{'name': 'event_processor', 'supported_event_patterns': ['*'], 'message_only': False},
|
||||
]
|
||||
|
||||
|
||||
@@ -767,3 +768,77 @@ class TestAgentServiceCreateUpdateDelete:
|
||||
WORKSPACE_UUID,
|
||||
'pipeline-1',
|
||||
)
|
||||
|
||||
|
||||
async def test_event_processor_creation_uses_installed_component_scope():
|
||||
app = _make_app()
|
||||
ref = 'event_processor:test/welcome/default'
|
||||
descriptor = SimpleNamespace(
|
||||
component_kind='EventProcessor',
|
||||
supported_event_patterns=['group.member_joined'],
|
||||
config_schema=[{'name': 'greeting', 'required': True}],
|
||||
)
|
||||
app.agent_runner_registry = SimpleNamespace(get=AsyncMock(return_value=descriptor))
|
||||
service = AgentService(app)
|
||||
result = await service.create_agent(
|
||||
WORKSPACE_UUID,
|
||||
{
|
||||
'kind': 'event_processor',
|
||||
'name': 'Welcome',
|
||||
'component_ref': ref,
|
||||
'parameters': {'greeting': 'Hi'},
|
||||
'supported_event_patterns': ['*'],
|
||||
},
|
||||
)
|
||||
values = _compiled_params(app.persistence_mgr.execute_async.call_args.args[0])
|
||||
assert result['kind'] == 'event_processor'
|
||||
assert values['component_ref'] == ref
|
||||
assert values['supported_event_patterns'] == ['group.member_joined']
|
||||
assert values['config']['runner_config'][ref] == {'greeting': 'Hi'}
|
||||
|
||||
|
||||
async def test_event_processor_rejects_invalid_component_and_missing_parameters():
|
||||
app = _make_app()
|
||||
service = AgentService(app)
|
||||
with pytest.raises(ValueError, match='Select an installed'):
|
||||
await service.create_agent(WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'plugin:a/b/c'})
|
||||
app.agent_runner_registry = SimpleNamespace(
|
||||
get=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
component_kind='EventProcessor',
|
||||
supported_event_patterns=['*'],
|
||||
config_schema=[{'name': 'greeting', 'required': True}],
|
||||
)
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match='Required processor parameter'):
|
||||
await service.create_agent(
|
||||
WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'event_processor:a/b/c'}
|
||||
)
|
||||
|
||||
|
||||
async def test_unavailable_event_processor_can_still_be_renamed():
|
||||
app = _make_app()
|
||||
row = _agent_row(config={'runner': {'id': 'event_processor:a/b/c'}, 'runner_config': {'event_processor:a/b/c': {}}})
|
||||
row.kind = 'event_processor'
|
||||
row.component_ref = 'event_processor:a/b/c'
|
||||
service = AgentService(app)
|
||||
service._get_agent_row = AsyncMock(return_value=row)
|
||||
await service.update_agent(WORKSPACE_UUID, row.uuid, {'name': 'Renamed'})
|
||||
assert _compiled_update_values(app.persistence_mgr.execute_async.call_args.args[0])['name'] == 'Renamed'
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'workspace,binding', [('other-workspace', 'event_processor:one'), (WORKSPACE_UUID, 'event_processor:two')]
|
||||
)
|
||||
async def test_processor_trace_rejects_other_workspace_or_instance(monkeypatch, workspace, binding):
|
||||
service = AgentService(_make_app())
|
||||
service.get_agent = AsyncMock(return_value={'kind': 'event_processor'})
|
||||
service.ap.persistence_mgr.get_db_engine = Mock()
|
||||
store = SimpleNamespace(
|
||||
get_run=AsyncMock(return_value={'workspace_id': workspace, 'binding_id': binding}), page_run_events=AsyncMock()
|
||||
)
|
||||
monkeypatch.setattr('langbot.pkg.agent.runner.run_ledger_store.RunLedgerStore', Mock(return_value=store))
|
||||
with pytest.raises(ValueError, match='Processor run not found'):
|
||||
await service.get_processor_run_events(WORKSPACE_UUID, 'one', 'run')
|
||||
store.page_run_events.assert_not_called()
|
||||
|
||||
@@ -692,3 +692,73 @@ def test_websocket_task_override_does_not_mutate_bot_default():
|
||||
assert pipeline_uuid == 'connection-pipeline'
|
||||
assert routed is False
|
||||
assert bot.bot_entity.use_pipeline_uuid == 'default-uuid'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_installed_event_processor_never_receives_unbound_events():
|
||||
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
|
||||
|
||||
bot = TestEventRouteTrace._make_bot([])
|
||||
bot.ap = SimpleNamespace(plugin_connector=SimpleNamespace(emit_event=AsyncMock()))
|
||||
bot._record_adapter_event = AsyncMock()
|
||||
await bot._handle_platform_event(MemberJoinedEvent(), Mock())
|
||||
bot.ap.plugin_connector.emit_event.assert_not_called()
|
||||
assert bot.logger.info.await_args.kwargs['metadata']['status'] == 'not_matched'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bound_event_processor_receives_one_complete_typed_event():
|
||||
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
|
||||
|
||||
bot = TestEventRouteTrace._make_bot(
|
||||
[
|
||||
{
|
||||
'id': 'binding',
|
||||
'enabled': True,
|
||||
'event_pattern': 'group.member_joined',
|
||||
'target_type': 'event_processor',
|
||||
'target_uuid': 'processor-1',
|
||||
'priority': 0,
|
||||
'order': 0,
|
||||
}
|
||||
]
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def run(envelope, binding, adapter_context=None):
|
||||
calls.append((envelope, binding))
|
||||
if False:
|
||||
yield None
|
||||
|
||||
ref = 'event_processor:test/welcome/default'
|
||||
bot.ap = SimpleNamespace(
|
||||
workspace_service=active_workspace_service(),
|
||||
agent_service=SimpleNamespace(
|
||||
get_agent=AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'processor-1',
|
||||
'kind': 'event_processor',
|
||||
'supported_event_patterns': ['group.member_joined'],
|
||||
'config': {'runner': {'id': ref}, 'runner_config': {ref: {'greeting': 'Hi'}}},
|
||||
}
|
||||
)
|
||||
),
|
||||
agent_run_orchestrator=SimpleNamespace(run=run),
|
||||
plugin_connector=SimpleNamespace(emit_event=AsyncMock()),
|
||||
)
|
||||
bot._record_adapter_event = AsyncMock()
|
||||
await bot._handle_platform_event(
|
||||
MemberJoinedEvent(
|
||||
member={'id': 'member-1'}, group={'id': 'group-1'}, source_platform_object={'private': 'opaque'}
|
||||
),
|
||||
Mock(),
|
||||
)
|
||||
assert len(calls) == 1
|
||||
envelope, binding = calls[0]
|
||||
assert binding.binding_id == 'event_processor:processor-1'
|
||||
assert binding.processor_type == 'event_processor'
|
||||
assert binding.runner_config == {'greeting': 'Hi'}
|
||||
assert envelope.data['member']['id'] == 'member-1'
|
||||
assert envelope.data['type'] == 'group.member_joined'
|
||||
assert 'source_platform_object' not in envelope.data
|
||||
bot.ap.plugin_connector.emit_event.assert_not_called()
|
||||
|
||||
@@ -2119,7 +2119,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ 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-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2185,8 +2185,8 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.5.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
version = "0.5.5"
|
||||
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=23011398160cedcd4ef090054692bc2a4b34ee9f#23011398160cedcd4ef090054692bc2a4b34ee9f" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "aiohttp" },
|
||||
@@ -2206,10 +2206,6 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ 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]]
|
||||
name = "langchain"
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
|
||||
import EventProcessorDetailContent from './EventProcessorDetailContent';
|
||||
import AgentCreateContent from './components/AgentCreateContent';
|
||||
import AgentDebugPanel from './components/AgentDebugPanel';
|
||||
import AgentFormComponent, {
|
||||
@@ -167,87 +168,104 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
title={`${agent.emoji || '🤖'} ${agent.name}`}
|
||||
titleBadge={
|
||||
supportedEventPatterns.length === 0 ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
role="status"
|
||||
className="shrink-0 gap-1 rounded-full border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<AlertTriangle className="size-3" />
|
||||
{t('agents.noEventsConfiguredBadge')}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
titleAction={
|
||||
canManage ? (
|
||||
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
|
||||
) : undefined
|
||||
}
|
||||
status={runnerStatus}
|
||||
saveLabel={t('common.save')}
|
||||
saveFormId="agent-form"
|
||||
canSave={canManage}
|
||||
isDirty={formDirty}
|
||||
isSaving={formSaving}
|
||||
headerActions={
|
||||
canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={formSaving || deleting}
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
>
|
||||
<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,
|
||||
);
|
||||
{agent.kind === 'event_processor' ? (
|
||||
<EventProcessorDetailContent
|
||||
key={id}
|
||||
id={id}
|
||||
agent={agent}
|
||||
canManage={canManage}
|
||||
onDelete={() => setDeleteConfirmOpen(true)}
|
||||
onEdit={() => setBasicInfoOpen(true)}
|
||||
onSaved={() => {
|
||||
void httpClient
|
||||
.getAgent(id)
|
||||
.then((response) => setAgent(response.agent));
|
||||
void refreshPipelines();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
title={`${agent.emoji || '🤖'} ${agent.name}`}
|
||||
titleBadge={
|
||||
supportedEventPatterns.length === 0 ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
role="status"
|
||||
className="shrink-0 gap-1 rounded-full border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<AlertTriangle className="size-3" />
|
||||
{t('agents.noEventsConfiguredBadge')}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
titleAction={
|
||||
canManage ? (
|
||||
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
|
||||
) : undefined
|
||||
}
|
||||
status={runnerStatus}
|
||||
saveLabel={t('common.save')}
|
||||
saveFormId="agent-form"
|
||||
canSave={canManage}
|
||||
isDirty={formDirty}
|
||||
isSaving={formSaving}
|
||||
headerActions={
|
||||
canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={formSaving || deleting}
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
>
|
||||
<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();
|
||||
}}
|
||||
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')
|
||||
}
|
||||
supportedEventPatterns={supportedEventPatterns}
|
||||
availableEventTypes={availableEventTypes}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||
/>
|
||||
supportedEventPatterns={supportedEventPatterns}
|
||||
availableEventTypes={availableEventTypes}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||
/>
|
||||
)}
|
||||
<EntityBasicInfoDialog
|
||||
open={basicInfoOpen}
|
||||
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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { 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 { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
@@ -35,6 +36,23 @@ export default function AgentCreateContent({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
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({
|
||||
name: z.string().min(1, { message: t('agents.nameRequired') }),
|
||||
description: z.string().optional(),
|
||||
@@ -51,8 +69,14 @@ export default function AgentCreateContent({
|
||||
});
|
||||
|
||||
function handleKindChange(nextKind: AgentKind) {
|
||||
const previousDefaultEmoji = kind === 'pipeline' ? '⚙️' : '🤖';
|
||||
const nextDefaultEmoji = nextKind === 'pipeline' ? '⚙️' : '🤖';
|
||||
const previousDefaultEmoji =
|
||||
kind === 'pipeline' ? '⚙️' : kind === 'event_processor' ? '⚡' : '🤖';
|
||||
const nextDefaultEmoji =
|
||||
nextKind === 'pipeline'
|
||||
? '⚙️'
|
||||
: nextKind === 'event_processor'
|
||||
? '⚡'
|
||||
: '🤖';
|
||||
setKind(nextKind);
|
||||
const currentEmoji = form.getValues('emoji');
|
||||
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
|
||||
.createAgent({
|
||||
kind,
|
||||
...(kind === 'event_processor'
|
||||
? {
|
||||
component_ref: componentRef,
|
||||
config: {
|
||||
runner: { id: componentRef },
|
||||
runner_config: { [componentRef]: parameters },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
name: values.name,
|
||||
description: values.description ?? '',
|
||||
emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'),
|
||||
@@ -90,13 +128,26 @@ export default function AgentCreateContent({
|
||||
title: t('agents.pipelineType'),
|
||||
description: t('agents.pipelineTypeDescription'),
|
||||
},
|
||||
{
|
||||
kind: 'event_processor' as const,
|
||||
icon: FileCode2,
|
||||
title: t('agents.eventProcessor.type'),
|
||||
description: t('agents.eventProcessor.description'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<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')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -158,6 +209,22 @@ export default function AgentCreateContent({
|
||||
</ToggleGroup>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<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 }) {
|
||||
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 />;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,12 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
event_pattern: z.string(),
|
||||
target_type: z.enum(['agent', 'pipeline', 'discard']),
|
||||
target_type: z.enum([
|
||||
'agent',
|
||||
'pipeline',
|
||||
'event_processor',
|
||||
'discard',
|
||||
]),
|
||||
target_uuid: z.string(),
|
||||
filters: z.array(z.record(z.string(), z.any())).optional(),
|
||||
priority: z.number(),
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
Workflow,
|
||||
FileCode2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -518,6 +519,11 @@ function TargetCombobox({
|
||||
const pipelines = pipelineAllowed
|
||||
? agentOptions.filter((a) => a.kind === 'pipeline')
|
||||
: [];
|
||||
const eventProcessors = agentOptions.filter(
|
||||
(item) =>
|
||||
item.kind === 'event_processor' &&
|
||||
agentSupportsEventPattern(item, binding.event_pattern),
|
||||
);
|
||||
|
||||
function currentLabel() {
|
||||
if (targetType === 'discard')
|
||||
@@ -531,7 +537,9 @@ function TargetCombobox({
|
||||
if (agent)
|
||||
return (
|
||||
<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" />
|
||||
) : (
|
||||
<Bot className="size-3.5" />
|
||||
@@ -585,6 +593,26 @@ function TargetCombobox({
|
||||
))}
|
||||
</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 && (
|
||||
<CommandGroup heading={t('bots.targetPipeline')}>
|
||||
{pipelines.map((a) => (
|
||||
|
||||
@@ -730,13 +730,16 @@ function NavItems({
|
||||
const showAgentGroupHeaders =
|
||||
isAgents && !inPopover && sidebarData.agentsGroupByKind;
|
||||
|
||||
const agentGroupOrder: Array<'agent' | 'pipeline'> = [
|
||||
'agent',
|
||||
'pipeline',
|
||||
];
|
||||
const agentGroupLabelKey: Record<'agent' | 'pipeline', string> = {
|
||||
const agentGroupOrder: Array<
|
||||
'agent' | 'pipeline' | 'event_processor'
|
||||
> = ['agent', 'pipeline', 'event_processor'];
|
||||
const agentGroupLabelKey: Record<
|
||||
'agent' | 'pipeline' | 'event_processor',
|
||||
string
|
||||
> = {
|
||||
agent: 'agents.kindBadgeAgent',
|
||||
pipeline: 'agents.kindBadgePipeline',
|
||||
event_processor: 'agents.eventProcessor.type',
|
||||
};
|
||||
|
||||
const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [
|
||||
@@ -889,12 +892,16 @@ function NavItems({
|
||||
<span
|
||||
className="ml-auto flex shrink-0 items-center text-muted-foreground"
|
||||
title={
|
||||
item.kind === 'pipeline'
|
||||
? t('agents.kindBadgePipeline')
|
||||
: t('agents.kindBadgeAgent')
|
||||
item.kind === 'event_processor'
|
||||
? t('agents.eventProcessor.type')
|
||||
: 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" />
|
||||
) : (
|
||||
<Bot className="size-3.5" />
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface SidebarEntityItem {
|
||||
// Set when this item appears in the unified extensions list
|
||||
extensionType?: 'plugin' | 'mcp' | 'skill';
|
||||
// Agent-specific: distinguishes Agent processors from Pipelines
|
||||
kind?: 'agent' | 'pipeline';
|
||||
kind?: 'agent' | 'pipeline' | 'event_processor';
|
||||
}
|
||||
|
||||
// Plugin page registered by a plugin
|
||||
|
||||
@@ -162,7 +162,43 @@ export interface ApiRespPipelines {
|
||||
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 {
|
||||
supported_event_patterns: string[];
|
||||
@@ -192,6 +228,7 @@ export interface ApiRespAgent {
|
||||
}
|
||||
|
||||
export interface GetAgentMetadataResponseData {
|
||||
event_processors?: EventProcessorDescriptor[];
|
||||
runner_config?: PipelineConfigTab;
|
||||
platform_tools: AgentPlatformTool[];
|
||||
host_tools?: PluginTool[] | null;
|
||||
@@ -274,7 +311,7 @@ export interface Bot {
|
||||
export interface EventBinding {
|
||||
id?: string;
|
||||
event_pattern: string;
|
||||
target_type: 'agent' | 'pipeline' | 'discard';
|
||||
target_type: AgentKind | 'discard';
|
||||
target_uuid: string;
|
||||
filters?: Array<Record<string, unknown>>;
|
||||
priority: number;
|
||||
|
||||
@@ -268,6 +268,25 @@ export class BackendClient extends BaseHttpClient {
|
||||
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 }> {
|
||||
return this.post('/api/v1/agents', agent);
|
||||
}
|
||||
|
||||
@@ -395,20 +395,19 @@ const enUS = {
|
||||
commonScenarios: 'Common scenarios',
|
||||
dragEventRoute: 'Drag route {{index}}',
|
||||
behaviorReplyMessages: 'Reply to messages',
|
||||
behaviorReplyMessagesDescription:
|
||||
'Send incoming messages to an Agent or Pipeline.',
|
||||
behaviorReplyMessagesDescription: 'Send incoming messages to a processor.',
|
||||
behaviorWelcomeMembers: 'Welcome new members',
|
||||
behaviorWelcomeMembersDescription:
|
||||
'Run an Agent when someone joins a group.',
|
||||
'Run a processor when someone joins a group.',
|
||||
behaviorHandleDepartures: 'Handle member departures',
|
||||
behaviorHandleDeparturesDescription:
|
||||
'Run an Agent when someone leaves or is removed.',
|
||||
'Run a processor when someone leaves or is removed.',
|
||||
behaviorReviewFriendRequests: 'Review friend requests',
|
||||
behaviorReviewFriendRequestsDescription:
|
||||
'Let an Agent decide how to handle a new request.',
|
||||
'Send new friend requests to a processor.',
|
||||
behaviorHandleModeration: 'Handle moderation events',
|
||||
behaviorHandleModerationDescription:
|
||||
'Run an Agent when a group member is restricted.',
|
||||
'Run a processor when a group member is restricted.',
|
||||
behaviorCustom: 'Configure another event',
|
||||
behaviorCustomDescription:
|
||||
'Add a route and choose from every event supported by this adapter.',
|
||||
@@ -704,6 +703,36 @@ const enUS = {
|
||||
},
|
||||
},
|
||||
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: {
|
||||
title: 'Event data',
|
||||
form: 'Common fields',
|
||||
@@ -735,7 +764,7 @@ const enUS = {
|
||||
description: 'Create reusable processors and use them in bot event routing',
|
||||
create: 'Create Processor',
|
||||
editAgent: 'Edit Agent',
|
||||
selectFromSidebar: 'Select an Agent or Pipeline from the sidebar',
|
||||
selectFromSidebar: 'Select a processor from the sidebar',
|
||||
agentType: 'Agent',
|
||||
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.',
|
||||
|
||||
@@ -506,6 +506,37 @@ const esES = {
|
||||
},
|
||||
},
|
||||
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: {
|
||||
title: 'Datos del evento',
|
||||
form: 'Campos comunes',
|
||||
@@ -538,7 +569,7 @@ const esES = {
|
||||
'Crea procesadores reutilizables y úsalos en el enrutamiento de eventos del bot',
|
||||
create: 'Crear procesador',
|
||||
editAgent: 'Editar Agent',
|
||||
selectFromSidebar: 'Selecciona un Agent o Pipeline desde la barra lateral',
|
||||
selectFromSidebar: 'Selecciona un procesador en la barra lateral',
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription:
|
||||
'Usa un runner para procesar mensajes, miembros de grupo, amigos, retroalimentación y otros eventos de plataforma.',
|
||||
|
||||
@@ -402,19 +402,19 @@ const jaJP = {
|
||||
dragEventRoute: 'ルート {{index}} をドラッグ',
|
||||
behaviorReplyMessages: '受信メッセージに返信',
|
||||
behaviorReplyMessagesDescription:
|
||||
'受信メッセージを Agent または Pipeline で処理します。',
|
||||
'受信メッセージをプロセッサーに渡します。',
|
||||
behaviorWelcomeMembers: '新しいメンバーを歓迎',
|
||||
behaviorWelcomeMembersDescription:
|
||||
'メンバーがグループに参加したときに Agent を実行します。',
|
||||
'グループへの参加時にプロセッサーを実行します。',
|
||||
behaviorHandleDepartures: 'メンバーの退出を処理',
|
||||
behaviorHandleDeparturesDescription:
|
||||
'メンバーが退出または削除されたときに Agent を実行します。',
|
||||
'グループからの退出時にプロセッサーを実行します。',
|
||||
behaviorReviewFriendRequests: '友だち申請を確認',
|
||||
behaviorReviewFriendRequestsDescription:
|
||||
'新しい申請の処理方法を Agent に判断させます。',
|
||||
'新しい友達リクエストをプロセッサーに渡します。',
|
||||
behaviorHandleModeration: 'モデレーションイベントを処理',
|
||||
behaviorHandleModerationDescription:
|
||||
'グループメンバーが制限されたときに Agent を実行します。',
|
||||
'グループメンバーの制限時にプロセッサーを実行します。',
|
||||
behaviorCustom: '別のイベントを設定',
|
||||
behaviorCustomDescription:
|
||||
'ルートを追加し、このアダプターが対応する全イベントから選択します。',
|
||||
@@ -716,6 +716,37 @@ const jaJP = {
|
||||
},
|
||||
},
|
||||
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: {
|
||||
title: 'イベントデータ',
|
||||
form: '基本項目',
|
||||
@@ -772,8 +803,7 @@ const jaJP = {
|
||||
'再利用可能なプロセッサーを作成し、ボットのイベントルーティングで使用します',
|
||||
create: 'プロセッサーを作成',
|
||||
editAgent: 'Agent を編集',
|
||||
selectFromSidebar:
|
||||
'サイドバーから Agent または Pipeline を選択してください',
|
||||
selectFromSidebar: 'サイドバーからプロセッサーを選択',
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription:
|
||||
'Runner を使ってメッセージ、グループメンバー、友だち、フィードバックなどのプラットフォームイベントを処理します。自律的な判断、ツール利用、メッセージ以外のイベント対応が必要な場合に適しています。',
|
||||
|
||||
@@ -503,6 +503,37 @@ const ruRU = {
|
||||
},
|
||||
},
|
||||
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: {
|
||||
title: 'Данные события',
|
||||
form: 'Основные поля',
|
||||
@@ -535,7 +566,7 @@ const ruRU = {
|
||||
'Создавайте переиспользуемые обработчики и используйте их в маршрутизации событий бота',
|
||||
create: 'Создать обработчик',
|
||||
editAgent: 'Редактировать Agent',
|
||||
selectFromSidebar: 'Выберите Agent или Pipeline на боковой панели',
|
||||
selectFromSidebar: 'Выберите обработчик на боковой панели',
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription:
|
||||
'Используйте runner для обработки сообщений, участников групп, друзей, обратной связи и других событий платформы.',
|
||||
|
||||
@@ -490,6 +490,36 @@ const thTH = {
|
||||
},
|
||||
},
|
||||
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: {
|
||||
title: 'ข้อมูลเหตุการณ์',
|
||||
form: 'ฟิลด์ทั่วไป',
|
||||
@@ -521,7 +551,7 @@ const thTH = {
|
||||
description: 'สร้างตัวประมวลผลที่ใช้ซ้ำได้และใช้ในเส้นทางเหตุการณ์ของบอท',
|
||||
create: 'สร้างตัวประมวลผล',
|
||||
editAgent: 'แก้ไข Agent',
|
||||
selectFromSidebar: 'เลือก Agent หรือ Pipeline จากแถบด้านข้าง',
|
||||
selectFromSidebar: 'เลือกตัวประมวลผลจากแถบด้านข้าง',
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription:
|
||||
'ใช้ runner เพื่อประมวลผลข้อความ สมาชิกกลุ่ม เพื่อน ฟีดแบ็ก และเหตุการณ์แพลตฟอร์มอื่นๆ',
|
||||
|
||||
@@ -499,6 +499,36 @@ const viVN = {
|
||||
},
|
||||
},
|
||||
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: {
|
||||
title: 'Dữ liệu sự kiện',
|
||||
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',
|
||||
create: 'Tạo bộ xử lý',
|
||||
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',
|
||||
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.',
|
||||
|
||||
@@ -378,17 +378,15 @@ const zhHans = {
|
||||
commonScenarios: '常用场景',
|
||||
dragEventRoute: '拖动第 {{index}} 条路由',
|
||||
behaviorReplyMessages: '回复收到的消息',
|
||||
behaviorReplyMessagesDescription:
|
||||
'把收到的消息交给 Agent 或 Pipeline 处理。',
|
||||
behaviorReplyMessagesDescription: '把收到的消息交给处理器。',
|
||||
behaviorWelcomeMembers: '欢迎新成员',
|
||||
behaviorWelcomeMembersDescription: '有人加入群组时运行 Agent。',
|
||||
behaviorWelcomeMembersDescription: '有人加入群组时运行处理器。',
|
||||
behaviorHandleDepartures: '处理成员离群',
|
||||
behaviorHandleDeparturesDescription: '有人离开或被移出群组时运行 Agent。',
|
||||
behaviorHandleDeparturesDescription: '有人离开或被移出群组时运行处理器。',
|
||||
behaviorReviewFriendRequests: '审核好友请求',
|
||||
behaviorReviewFriendRequestsDescription:
|
||||
'让 Agent 决定如何处理新的好友请求。',
|
||||
behaviorReviewFriendRequestsDescription: '把新的好友请求交给处理器。',
|
||||
behaviorHandleModeration: '处理群管理事件',
|
||||
behaviorHandleModerationDescription: '群成员受到限制时运行 Agent。',
|
||||
behaviorHandleModerationDescription: '群成员受到限制时运行处理器。',
|
||||
behaviorCustom: '配置其他事件',
|
||||
behaviorCustomDescription: '添加路由,并从此适配器支持的全部事件中选择。',
|
||||
eventPattern: '事件',
|
||||
@@ -670,6 +668,36 @@ const zhHans = {
|
||||
},
|
||||
},
|
||||
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: {
|
||||
title: '事件数据',
|
||||
form: '常用字段',
|
||||
@@ -701,7 +729,7 @@ const zhHans = {
|
||||
description: '创建可复用的处理器,并在机器人事件路由中使用',
|
||||
create: '创建处理器',
|
||||
editAgent: '编辑 Agent',
|
||||
selectFromSidebar: '从侧边栏选择一个 Agent 或 Pipeline',
|
||||
selectFromSidebar: '从侧边栏选择一个处理器',
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription:
|
||||
'通过运行器处理消息、群成员、好友、反馈等平台事件。适合需要自主判断、调用工具或响应非消息事件的场景。',
|
||||
|
||||
@@ -474,6 +474,36 @@ const zhHant = {
|
||||
},
|
||||
},
|
||||
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: {
|
||||
title: '事件資料',
|
||||
form: '常用欄位',
|
||||
@@ -505,7 +535,7 @@ const zhHant = {
|
||||
description: '建立可重用的處理器,並在機器人事件路由中使用',
|
||||
create: '建立處理器',
|
||||
editAgent: '編輯 Agent',
|
||||
selectFromSidebar: '從側邊欄選擇一個 Agent 或 Pipeline',
|
||||
selectFromSidebar: '從側邊欄選擇一個處理器',
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription: '透過執行器處理訊息、群成員、好友、回饋等平台事件。',
|
||||
pipelineType: '流程線',
|
||||
|
||||
@@ -252,7 +252,7 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/agents$/);
|
||||
await expect(
|
||||
page.getByText('Select an Agent or Pipeline from the sidebar'),
|
||||
page.getByText('Select a processor from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1430,7 +1430,7 @@ test.describe('empty states', () => {
|
||||
|
||||
await page.goto('/home/agents');
|
||||
await expect(
|
||||
page.getByText('Select an Agent or Pipeline from the sidebar'),
|
||||
page.getByText('Select a processor from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user