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