diff --git a/docs/event-based-agents/08-agent-page-and-event-orchestration.md b/docs/event-based-agents/08-agent-page-and-event-orchestration.md new file mode 100644 index 000000000..a9330d3ff --- /dev/null +++ b/docs/event-based-agents/08-agent-page-and-event-orchestration.md @@ -0,0 +1,180 @@ +# Agent 页面与事件编排产品设计 + +> 状态:实施稿(2026-06-23) +> +> 本文档修订 [07-agent-orchestration.md](./07-agent-orchestration.md) 中“Agent 替代 Pipeline”的表述。当前产品形态应保留两种同级处理单元:**Agent 编排**与**Pipeline**。Agent 页面是统一入口,但不是把 Pipeline 消除。 + +## 1. 产品边界 + +LangBot 的处理逻辑分成两种同级形态: + +| 形态 | 定位 | 可处理事件 | 典型用户 | +| --- | --- | --- | --- | +| Agent 编排 | 面向 EBA 的事件优先处理单元,承载 AgentRunner / 外部 runner / 后续工作流 | `message.*`、`group.*`、`friend.*`、`bot.*`、`feedback.*`、`platform.*` 等 | 希望按事件类型配置不同智能处理逻辑的用户 | +| Pipeline | 现有无代码消息流水线与向后兼容形态 | 仅 `message.*`,首版等价于 `message.received` | 已有 Pipeline 用户、只需要消息处理的用户 | + +Agent 页面负责统一管理这两种处理单元: + +- 创建时选择 **Agent 编排** 或 **Pipeline**; +- 列表中清晰标注类型与事件能力; +- Pipeline 的编辑、调试、监控继续复用现有能力; +- Agent 编排保存 runner 配置与事件能力,并通过 Bot 事件绑定进入运行时执行。 + +## 2. 信息架构 + +### 2.1 Agent 页面 + +路径:`/home/agents` + +职责: + +1. 展示所有可被事件绑定的处理单元,包括 Agent 编排与 Pipeline。 +2. 创建时先选择类型: + - Agent 编排:创建一条 Agent 配置对象,默认支持所有 EBA 事件; + - Pipeline:创建现有 legacy pipeline,只能处理消息事件。 +3. 编辑时按类型进入不同表单: + - Pipeline:沿用原 Pipeline 配置页,包括 AI、触发、安全、输出、扩展、Debug、Monitoring; + - Agent 编排:配置基础信息、runner、runner config 和事件能力。 + +`/home/pipelines` 保留为兼容路由,但新导航入口使用 `/home/agents`。 + +### 2.2 Bot 的事件编排 + +Bot 上维护“事件 -> 处理单元”的绑定规则: + +```text +Bot + └─ EventBinding[] + ├─ event_pattern: message.received / group.member_joined / group.* / * + ├─ target_type: agent / pipeline / discard + ├─ target_uuid: Agent UUID 或 Pipeline UUID + ├─ filters: 事件字段过滤条件 + ├─ priority: 数字越大越优先 + └─ enabled +``` + +Pipeline 只能被绑定到 `message.*`。如果用户选择非消息事件,目标选择器不展示 Pipeline。 + +## 3. 持久化模型 + +### 3.1 Agent 编排实例 + +新增 `agents` 表,只保存 Agent 编排形态。Pipeline 继续保存在 `legacy_pipelines`。 + +```python +class Agent(Base): + uuid: str + name: str + description: str + emoji: str + kind: str # 首版固定为 "agent" + component_ref: str # runner id / workflow id / future external ref + config: dict # runner 与 runner_config + enabled: bool + supported_event_patterns: list[str] + created_at: datetime + updated_at: datetime +``` + +Agent 聚合 API 把 `agents` 与 `legacy_pipelines` 投影成同一个前端列表: + +```json +{ + "uuid": "...", + "name": "...", + "kind": "agent | pipeline", + "capability": { + "supported_event_patterns": ["*"], + "message_only": false + } +} +``` + +Pipeline 投影时固定: + +```json +{ + "kind": "pipeline", + "capability": { + "supported_event_patterns": ["message.*"], + "message_only": true + } +} +``` + +### 3.2 Bot 事件绑定 + +Bot 新增 `event_bindings` JSON 字段,首版作为轻量配置面。后续当 EventRouter 查询、审计和多作用域规则稳定后,再拆成独立表。 + +```json +[ + { + "id": "uuid", + "event_pattern": "group.member_joined", + "target_type": "agent", + "target_uuid": "...", + "filters": [], + "priority": 100, + "enabled": true, + "description": "Welcome new group members" + } +] +``` + +## 4. 匹配规则 + +事件模式支持三层: + +1. 精确匹配:`group.member_joined` +2. 命名空间通配:`group.*` +3. 全局通配:`*` + +优先级: + +1. `enabled = true` +2. event pattern 命中 +3. filters 全部命中 +4. `priority` 数值高者优先 +5. 同优先级按列表顺序 + +## 5. 兼容策略 + +1. 现有 `legacy_pipelines` 不迁移、不改语义。 +2. 现有 Bot 的 `use_pipeline_uuid` 仍作为消息事件默认 Pipeline。 +3. 现有 `pipeline_routing_rules` 仍只作用于消息事件。 +4. 新增 `event_bindings` 是 EBA 事件编排配置,允许 Pipeline 目标但只限 `message.*`。 +5. `/api/v1/pipelines` 继续存在;新增 `/api/v1/agents` 作为聚合入口。 + +## 6. 分阶段落地 + +### P0:产品入口统一 + +- 新增 `/home/agents`。 +- 侧边栏显示“Agent”,但列表包含 Agent 编排与 Pipeline。 +- 创建时选择 Agent 编排或 Pipeline。 +- `/home/pipelines` 保留兼容。 + +### P1:配置模型落地 + +- 新增 `agents` 表与 `/api/v1/agents`。 +- Agent 编排可保存 runner 与 runner_config。 +- Pipeline 继续使用原 Pipeline 表单与 API。 + +### P2:事件编排配置面 + +- Bot 表单新增事件编排编辑器。 +- 读取 adapter manifest 的 `supported_events` 生成事件选项。 +- 根据事件类型过滤可选目标:Pipeline 仅在 `message.*` 可选。 + +### P3:EventRouter 执行接入 + +- EBA 事件先广播插件 observer。 +- 然后按 `event_bindings` 的事件模式、filters、priority 和顺序选择 Agent 编排。 +- 消息事件继续优先保留现有 Pipeline / MessageAggregator 兼容路径。 +- 非消息事件只调用 Agent 编排,不调用 Pipeline;AgentRunner 输出有平台 reply target 时会投递回平台。 + +## 7. 不做的事 + +- 不把 Pipeline 改名成 Agent,也不删除 Pipeline 的配置模型。 +- 不把非消息事件伪装成用户文本塞入 Pipeline。 +- 不在首版做多 Agent 串并联;需要多步骤处理时留给后续 workflow。 diff --git a/skills/skills.index.json b/skills/skills.index.json index d56a84822..a1c374024 100644 --- a/skills/skills.index.json +++ b/skills/skills.index.json @@ -64,7 +64,7 @@ { "directory": "langbot-mcp-ops", "name": "langbot-mcp-ops", - "description": "Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on \"langbot mcp\", \"manage langbot via mcp\", \"langbot /mcp\", \"langbot mcp server\".", + "description": "Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, agents, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on \"langbot mcp\", \"manage langbot via mcp\", \"langbot /mcp\", \"langbot mcp server\".", "references": [], "cases": [], "case_summaries": [], @@ -133,6 +133,7 @@ "references/pipeline-debug-chat.md", "references/plugin-e2e-smoke.md", "references/sandbox-skill-authoring.md", + "references/skill-all-tool-acceptance.md", "references/troubleshooting.md", "references/web-ui-testing.md" ], @@ -170,6 +171,7 @@ "qa-plugin-smoke-live-install", "sandbox-skill-authoring-e2e", "sandbox-skill-authoring-edit-existing-e2e", + "skill-discovery-via-mcp-gateway", "webui-login-state" ], "case_summaries": [ @@ -1033,6 +1035,31 @@ "filesystem" ] }, + { + "id": "skill-discovery-via-mcp-gateway", + "title": "External harness discovers LangBot skills via langbot_list_assets (all-tool model)", + "mode": "agent-browser", + "area": "sandbox", + "type": "regression", + "priority": "p2", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "skills", + "mcp-gateway", + "acp-agent-runner", + "all-tool-model", + "tools" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "backend_log" + ] + }, { "id": "webui-login-state", "title": "Configured frontend opens with authenticated LangBot WebUI state", diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index 94f25a3a4..c9bfb9f0d 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -1,6 +1,6 @@ --- name: langbot-mcp-ops -description: Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on "langbot mcp", "manage langbot via mcp", "langbot /mcp", "langbot mcp server". +description: Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, agents, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on "langbot mcp", "manage langbot via mcp", "langbot /mcp", "langbot mcp server". --- # LangBot MCP Operations @@ -58,6 +58,7 @@ The tools wrap the LangBot service layer. Current tools (v1): | --- | --- | | `get_system_info` | Version, edition, instance id | | `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) | +| `list_agents` / `get_agent` / `create_agent` / `update_agent` / `delete_agent` | Manage the Agent product surface, including Agent orchestrations and 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_knowledge_bases` / `get_knowledge_base` / `retrieve_knowledge_base` | RAG knowledge bases (incl. semantic search) | diff --git a/src/langbot/pkg/api/http/controller/groups/agents.py b/src/langbot/pkg/api/http/controller/groups/agents.py new file mode 100644 index 000000000..9cdbbd7f3 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/agents.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import quart + +from .. import group + + +@group.group_class('agents', '/api/v1/agents') +class AgentsRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + if quart.request.method == 'GET': + sort_by = quart.request.args.get('sort_by', 'updated_at') + sort_order = quart.request.args.get('sort_order', 'DESC') + return self.success(data={'agents': await self.ap.agent_service.get_agents(sort_by, sort_order)}) + + json_data = await quart.request.json + created = await self.ap.agent_service.create_agent(json_data) + return self.success(data=created) + + @self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + return self.success(data=await self.ap.agent_service.get_agent_metadata()) + + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(agent_uuid: str) -> str: + if quart.request.method == 'GET': + agent = await self.ap.agent_service.get_agent(agent_uuid) + if agent is None: + return self.http_status(404, -1, 'agent not found') + return self.success(data={'agent': agent}) + + if quart.request.method == 'PUT': + json_data = await quart.request.json + await self.ap.agent_service.update_agent(agent_uuid, json_data) + return self.success() + + await self.ap.agent_service.delete_agent(agent_uuid) + return self.success() diff --git a/src/langbot/pkg/api/http/service/agent.py b/src/langbot/pkg/api/http/service/agent.py new file mode 100644 index 000000000..916770dcc --- /dev/null +++ b/src/langbot/pkg/api/http/service/agent.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import datetime +import uuid +import typing + +import sqlalchemy + +from ....core import app +from ....entity.persistence import agent as persistence_agent + + +AGENT_KIND_AGENT = 'agent' +AGENT_KIND_PIPELINE = 'pipeline' +PIPELINE_EVENT_PATTERNS = ['message.*'] +AGENT_DEFAULT_EVENT_PATTERNS = ['*'] + + +class AgentService: + """Unified product surface for Agent orchestration instances and Pipelines.""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_agent_metadata(self) -> dict[str, typing.Any]: + """Return metadata needed by Agent forms.""" + pipeline_metadata = await self.ap.pipeline_service.get_pipeline_metadata() + ai_metadata = next((item for item in pipeline_metadata if item.get('name') == 'ai'), None) + return { + 'runner_config': ai_metadata, + 'kinds': [ + { + 'name': AGENT_KIND_AGENT, + 'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS, + 'message_only': False, + }, + { + 'name': AGENT_KIND_PIPELINE, + 'supported_event_patterns': PIPELINE_EVENT_PATTERNS, + 'message_only': True, + }, + ], + } + + async def get_agents(self, sort_by: str = 'updated_at', sort_order: str = 'DESC') -> list[dict]: + agents = await self._get_agent_rows() + pipelines = await self.ap.pipeline_service.get_pipelines(sort_by='updated_at', sort_order='DESC') + + items = [self._agent_to_product_item(agent) for agent in agents] + items.extend(self._pipeline_to_product_item(pipeline) for pipeline in pipelines) + + reverse = sort_order == 'DESC' + sort_key = sort_by if sort_by in {'created_at', 'updated_at'} else 'updated_at' + return sorted(items, key=lambda item: self._parse_sort_time(item.get(sort_key)), reverse=reverse) + + async def get_agent(self, agent_uuid: str) -> dict | None: + agent = await self._get_agent_row(agent_uuid) + if agent is not None: + return self._agent_to_product_item(agent, include_config=True) + + pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid) + if pipeline is not None: + return self._pipeline_to_product_item(pipeline, include_config=True) + + return None + + async def create_agent(self, agent_data: dict) -> dict[str, str]: + kind = agent_data.get('kind') or AGENT_KIND_AGENT + if kind == AGENT_KIND_PIPELINE: + pipeline_uuid = await self.ap.pipeline_service.create_pipeline( + { + 'name': agent_data.get('name') or 'New Pipeline', + 'description': agent_data.get('description') or '', + 'emoji': agent_data.get('emoji') or '⚙️', + 'config': {}, + } + ) + return {'uuid': pipeline_uuid, 'kind': AGENT_KIND_PIPELINE} + + if kind != AGENT_KIND_AGENT: + raise ValueError(f'Unsupported agent kind: {kind}') + + config = agent_data.get('config') or await self._get_default_agent_config() + runner_id = self._resolve_runner_id(config) + new_uuid = str(uuid.uuid4()) + values = { + 'uuid': new_uuid, + 'name': agent_data.get('name') or 'New Agent', + 'description': agent_data.get('description') or '', + 'emoji': agent_data.get('emoji') or '🤖', + 'kind': AGENT_KIND_AGENT, + 'component_ref': agent_data.get('component_ref') or runner_id, + 'config': config, + 'enabled': agent_data.get('enabled', True), + 'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS, + } + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values)) + return {'uuid': new_uuid, 'kind': AGENT_KIND_AGENT} + + async def update_agent(self, agent_uuid: str, agent_data: dict) -> None: + existing_agent = await self._get_agent_row(agent_uuid) + if existing_agent is None: + pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid) + if pipeline is None: + raise ValueError(f'Agent {agent_uuid} not found') + await self.ap.pipeline_service.update_pipeline(agent_uuid, agent_data) + return + + update_data = agent_data.copy() + for protected_field in ('uuid', 'kind', 'created_at', 'updated_at', 'capability'): + update_data.pop(protected_field, None) + if 'config' in update_data: + update_data['component_ref'] = update_data.get('component_ref') or self._resolve_runner_id( + update_data['config'] + ) + if 'supported_event_patterns' in update_data and not update_data['supported_event_patterns']: + update_data['supported_event_patterns'] = AGENT_DEFAULT_EVENT_PATTERNS + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_agent.Agent) + .where(persistence_agent.Agent.uuid == agent_uuid) + .values(**update_data) + ) + + async def delete_agent(self, agent_uuid: str) -> None: + existing_agent = await self._get_agent_row(agent_uuid) + if existing_agent is not None: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid) + ) + return + + pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid) + if pipeline is None: + raise ValueError(f'Agent {agent_uuid} not found') + await self.ap.pipeline_service.delete_pipeline(agent_uuid) + + async def _get_agent_rows(self) -> list[persistence_agent.Agent]: + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_agent.Agent)) + return list(result.all()) + + async def _get_agent_row(self, agent_uuid: str) -> persistence_agent.Agent | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid) + ) + return result.first() + + async def _get_default_agent_config(self) -> dict[str, typing.Any]: + runners = [] + if getattr(self.ap, 'agent_runner_registry', None) is not None: + try: + runners = await self.ap.agent_runner_registry.list_runners(bound_plugins=None) + except Exception as e: + if getattr(self.ap, 'logger', None): + self.ap.logger.warning(f'Failed to load plugin agent runners for default agent config: {e}') + + if not runners: + return {'runner': {'id': '', 'expire-time': 0}, 'runner_config': {}} + + selected_runner = runners[0] + return { + 'runner': {'id': selected_runner.id, 'expire-time': 0}, + 'runner_config': { + selected_runner.id: self.ap.pipeline_service._get_default_values_from_schema( + selected_runner.config_schema + ) + }, + } + + @staticmethod + def _resolve_runner_id(config: dict[str, typing.Any]) -> str | None: + runner = config.get('runner') if isinstance(config, dict) else None + if isinstance(runner, dict): + runner_id = runner.get('id') + if runner_id: + return runner_id + return None + + def _agent_to_product_item( + self, + agent: persistence_agent.Agent, + include_config: bool = False, + ) -> dict[str, typing.Any]: + item = self.ap.persistence_mgr.serialize_model(persistence_agent.Agent, agent) + item['kind'] = AGENT_KIND_AGENT + item['capability'] = { + 'supported_event_patterns': item.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS, + 'message_only': False, + } + if not include_config: + item.pop('config', None) + return item + + @staticmethod + def _pipeline_to_product_item(pipeline: dict, include_config: bool = False) -> dict[str, typing.Any]: + item = pipeline.copy() + item['kind'] = AGENT_KIND_PIPELINE + item['component_ref'] = 'pipeline' + item['enabled'] = True + item['supported_event_patterns'] = PIPELINE_EVENT_PATTERNS + item['capability'] = { + 'supported_event_patterns': PIPELINE_EVENT_PATTERNS, + 'message_only': True, + } + if not include_config: + item.pop('config', None) + return item + + @staticmethod + def _parse_sort_time(value: typing.Any) -> datetime.datetime: + if isinstance(value, datetime.datetime): + return value + if isinstance(value, str): + try: + return datetime.datetime.fromisoformat(value) + except ValueError: + return datetime.datetime.min + return datetime.datetime.min diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 0bacd70ac..b497f88a9 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -6,6 +6,7 @@ import typing from ....core import app from ....discover import engine +from ....entity.persistence import agent as persistence_agent from ....entity.persistence import bot as persistence_bot from ....entity.persistence import pipeline as persistence_pipeline @@ -36,6 +37,84 @@ class BotService: return True return False + @staticmethod + def _is_message_event_pattern(event_pattern: str) -> bool: + return event_pattern == 'message.*' or event_pattern.startswith('message.') + + @staticmethod + def _event_pattern_covers(supported_pattern: str, binding_pattern: str) -> bool: + if supported_pattern == '*': + return True + if supported_pattern == binding_pattern: + return True + if binding_pattern == '*': + return False + if supported_pattern.endswith('.*'): + namespace = supported_pattern[:-2] + return binding_pattern == f'{namespace}.*' or binding_pattern.startswith(f'{namespace}.') + return False + + @classmethod + def _agent_supports_event_pattern(cls, supported_patterns: list[str] | None, event_pattern: str) -> bool: + patterns = supported_patterns or ['*'] + return any(cls._event_pattern_covers(pattern, event_pattern) for pattern in patterns) + + async def _normalize_event_bindings(self, bindings: list[dict] | None) -> list[dict]: + """Validate and normalize Bot event bindings.""" + if not bindings: + return [] + + normalized: list[dict] = [] + for index, raw_binding in enumerate(bindings): + if not isinstance(raw_binding, dict): + continue + + event_pattern = str(raw_binding.get('event_pattern') or '').strip() + target_type = str(raw_binding.get('target_type') or '').strip() + target_uuid = str(raw_binding.get('target_uuid') or '').strip() + if not event_pattern or not target_type: + continue + + if target_type == 'pipeline': + if not self._is_message_event_pattern(event_pattern): + raise ValueError('Pipeline can only be bound to message events') + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline.uuid).where( + persistence_pipeline.LegacyPipeline.uuid == target_uuid + ) + ) + if result.first() is None: + raise ValueError('Pipeline not found') + elif target_type == 'agent': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid) + ) + agent = result.first() + if agent is None: + raise ValueError('Agent not found') + if not self._agent_supports_event_pattern(agent.supported_event_patterns, event_pattern): + raise ValueError('Agent does not support this event pattern') + elif target_type == 'discard': + target_uuid = '' + else: + raise ValueError(f'Unsupported event binding target type: {target_type}') + + normalized.append( + { + 'id': raw_binding.get('id') or str(uuid.uuid4()), + 'event_pattern': event_pattern, + 'target_type': target_type, + 'target_uuid': target_uuid, + 'filters': raw_binding.get('filters') or [], + 'priority': int(raw_binding.get('priority') or 0), + 'enabled': bool(raw_binding.get('enabled', True)), + 'description': raw_binding.get('description') or '', + 'order': index, + } + ) + + return normalized + async def get_bots(self, include_secret: bool = True) -> list[dict]: """获取所有机器人""" result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot)) @@ -137,6 +216,9 @@ class BotService: if 'uuid' in update_data: del update_data['uuid'] + if 'event_bindings' in update_data: + update_data['event_bindings'] = await self._normalize_event_bindings(update_data.get('event_bindings')) + # set use_pipeline_name if 'use_pipeline_uuid' in update_data: result = await self.ap.persistence_mgr.execute_async( diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 95630bbaf..8e07882d5 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -28,7 +28,7 @@ if typing.TYPE_CHECKING: INSTRUCTIONS = """\ This MCP server manages a LangBot instance. LangBot is an LLM-native instant -messaging bot platform. Use these tools to inspect and manage bots, pipelines, +messaging bot platform. Use these tools to inspect and manage bots, agents, pipelines, models, knowledge bases, MCP servers, and skills. Authentication uses a LangBot API key (web-UI-created `lbk_...` key or the @@ -141,6 +141,34 @@ class LangBotMCPServer: await ap.pipeline_service.delete_pipeline(pipeline_uuid) return _dump({'ok': True}) + # ----- Agents -------------------------------------------------- # + @mcp.tool(description='List product-level Agents, including Agent orchestrations and Pipelines.') + async def list_agents() -> str: + return _dump(await ap.agent_service.get_agents()) + + @mcp.tool(description='Get a product-level Agent or Pipeline by UUID.') + async def get_agent(agent_uuid: str) -> str: + return _dump(await ap.agent_service.get_agent(agent_uuid)) + + @mcp.tool( + description=( + 'Create an Agent orchestration or Pipeline. `agent_data` matches ' + 'POST /api/v1/agents; set kind to `agent` or `pipeline`. Returns the new UUID and kind.' + ) + ) + async def create_agent(agent_data: dict) -> str: + return _dump(await ap.agent_service.create_agent(agent_data)) + + @mcp.tool(description='Update an Agent orchestration or Pipeline by UUID.') + async def update_agent(agent_uuid: str, agent_data: dict) -> str: + await ap.agent_service.update_agent(agent_uuid, agent_data) + return _dump({'ok': True}) + + @mcp.tool(description='Delete an Agent orchestration or Pipeline by UUID.') + async def delete_agent(agent_uuid: str) -> str: + await ap.agent_service.delete_agent(agent_uuid) + return _dump({'ok': True}) + # ----- Models -------------------------------------------------- # @mcp.tool(description='List all configured LLM models. Secrets are redacted.') async def list_llm_models() -> str: diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 6dffe9dc7..6cbd970dd 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -27,6 +27,7 @@ from ..api.http.service import space as space_service from ..api.http.service import model as model_service from ..api.http.service import provider as provider_service from ..api.http.service import pipeline as pipeline_service +from ..api.http.service import agent as agent_service from ..api.http.service import bot as bot_service from ..api.http.service import knowledge as knowledge_service from ..api.http.service import mcp as mcp_service @@ -147,6 +148,8 @@ class Application: pipeline_service: pipeline_service.PipelineService = None + agent_service: agent_service.AgentService = None + bot_service: bot_service.BotService = None knowledge_service: knowledge_service.KnowledgeService = None diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 092bed5fd..7d8c89f29 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -23,6 +23,7 @@ from ...api.http.service import space as space_service from ...api.http.service import model as model_service from ...api.http.service import provider as provider_service from ...api.http.service import pipeline as pipeline_service +from ...api.http.service import agent as agent_service from ...api.http.service import bot as bot_service from ...api.http.service import knowledge as knowledge_service from ...api.http.service import mcp as mcp_service @@ -75,6 +76,9 @@ class BuildAppStage(stage.BootingStage): pipeline_service_inst = pipeline_service.PipelineService(ap) ap.pipeline_service = pipeline_service_inst + agent_service_inst = agent_service.AgentService(ap) + ap.agent_service = agent_service_inst + bot_service_inst = bot_service.BotService(ap) ap.bot_service = bot_service_inst diff --git a/src/langbot/pkg/entity/persistence/agent.py b/src/langbot/pkg/entity/persistence/agent.py new file mode 100644 index 000000000..dc80fc23e --- /dev/null +++ b/src/langbot/pkg/entity/persistence/agent.py @@ -0,0 +1,26 @@ +import sqlalchemy + +from .base import Base + + +class Agent(Base): + """Product-level Agent orchestration instance.""" + + __tablename__ = 'agents' + + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, default='') + emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='🤖') + kind = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='agent') + component_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) + enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True) + supported_event_patterns = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=['*']) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/src/langbot/pkg/entity/persistence/bot.py b/src/langbot/pkg/entity/persistence/bot.py index c3fa295f7..ee6bc7a8c 100644 --- a/src/langbot/pkg/entity/persistence/bot.py +++ b/src/langbot/pkg/entity/persistence/bot.py @@ -17,6 +17,7 @@ class Bot(Base): use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]') + event_bindings = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]') created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, diff --git a/src/langbot/pkg/persistence/alembic/env.py b/src/langbot/pkg/persistence/alembic/env.py index a6295ff7f..403c6ae51 100644 --- a/src/langbot/pkg/persistence/alembic/env.py +++ b/src/langbot/pkg/persistence/alembic/env.py @@ -16,6 +16,7 @@ from langbot.pkg.entity.persistence.base import Base # Import all ORM models so they are registered with Base.metadata # This is required for autogenerate to detect model changes from langbot.pkg.entity.persistence import ( + agent, # noqa: F401 agent_run, # noqa: F401 agent_runner_state, # noqa: F401 apikey, # noqa: F401 diff --git a/src/langbot/pkg/persistence/alembic/versions/0007_merge_agent_mcp_heads.py b/src/langbot/pkg/persistence/alembic/versions/0007_merge_agent_mcp_heads.py new file mode 100644 index 000000000..9490624f5 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0007_merge_agent_mcp_heads.py @@ -0,0 +1,20 @@ +"""Merge agent runner and MCP migration heads. + +Revision ID: 0007_merge_agent_mcp_heads +Revises: 8d3a1f2c4b6e, 0006_normalize_mcp_remote_mode +Create Date: 2026-06-23 +""" + +# revision identifiers, used by Alembic. +revision = '0007_merge_agent_mcp_heads' +down_revision = ('8d3a1f2c4b6e', '0006_normalize_mcp_remote_mode') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/src/langbot/pkg/persistence/alembic/versions/0008_agent_product_surface.py b/src/langbot/pkg/persistence/alembic/versions/0008_agent_product_surface.py new file mode 100644 index 000000000..a855a65bb --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0008_agent_product_surface.py @@ -0,0 +1,66 @@ +"""Add Agent product surface tables. + +Revision ID: 0008_agent_product_surface +Revises: 0007_merge_agent_mcp_heads +Create Date: 2026-06-23 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = '0008_agent_product_surface' +down_revision = '0007_merge_agent_mcp_heads' +branch_labels = None +depends_on = None + + +def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: + if not _table_exists(inspector, table_name): + return False + return any(column['name'] == column_name for column in inspector.get_columns(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _table_exists(inspector, 'agents'): + op.create_table( + 'agents', + sa.Column('uuid', sa.String(length=255), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.String(length=255), nullable=False, server_default=''), + sa.Column('emoji', sa.String(length=10), nullable=True), + sa.Column('kind', sa.String(length=50), nullable=False, server_default='agent'), + sa.Column('component_ref', sa.String(length=255), nullable=True), + sa.Column('config', sa.JSON(), nullable=False, server_default='{}'), + sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column('supported_event_patterns', sa.JSON(), nullable=False, server_default='["*"]'), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint('uuid'), + sa.UniqueConstraint('uuid'), + ) + + if _table_exists(inspector, 'bots') and not _column_exists(inspector, 'bots', 'event_bindings'): + with op.batch_alter_table('bots') as batch_op: + batch_op.add_column(sa.Column('event_bindings', sa.JSON(), nullable=False, server_default='[]')) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _table_exists(inspector, 'bots') and _column_exists(inspector, 'bots', 'event_bindings'): + with op.batch_alter_table('bots') as batch_op: + batch_op.drop_column('event_bindings') + + if _table_exists(inspector, 'agents'): + op.drop_table('agents') diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 4805892ad..e5f88de2e 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -3,7 +3,10 @@ from __future__ import annotations import asyncio import json import re +import time import traceback +import typing +import uuid import sqlalchemy from ..core import app, entities as core_entities, taskmgr @@ -14,14 +17,30 @@ from ..entity.persistence import bot as persistence_bot from ..entity.persistence import pipeline as persistence_pipeline from ..entity.errors import platform as platform_errors +from ..agent.runner.host_models import ( + AgentBinding, + AgentEventEnvelope, + BindingScope, + DeliveryPolicy, + ResourcePolicy, + StatePolicy, +) from .logger import EventLogger import langbot_plugin.api.entities.builtin.provider.session as provider_session +import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.events as plugin_events import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + ActorContext, + SubjectContext, + RawEventRef, +) +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext class RuntimeBot: @@ -77,6 +96,7 @@ class RuntimeBot: PIPELINE_DISCARD = '__discard__' PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded' + EVENT_DATA_MAX_STRING_BYTES = 512 @staticmethod def _eba_event_to_plugin_event(event: platform_events.EBAEvent) -> plugin_events.BaseEventModel | None: @@ -103,6 +123,572 @@ class RuntimeBot: return None + @staticmethod + def _match_event_pattern(event_type: str, pattern: str) -> bool: + if not event_type or not pattern: + return False + if pattern == '*': + return True + if pattern.endswith('.*'): + return event_type.startswith(f'{pattern[:-2]}.') + return event_type == pattern + + @classmethod + def _is_message_event_type(cls, event_type: str) -> bool: + return cls._match_event_pattern(event_type, 'message.*') + + @classmethod + def _agent_supports_event_type( + cls, + supported_patterns: list[str] | None, + event_type: str, + ) -> bool: + return any(cls._match_event_pattern(event_type, pattern) for pattern in (supported_patterns or ['*'])) + + @staticmethod + def _get_nested_value(data: dict[str, typing.Any], path: str) -> typing.Any: + current: typing.Any = data + for key in path.split('.'): + if isinstance(current, dict): + current = current.get(key) + else: + current = getattr(current, key, None) + if current is None: + return None + return current + + @classmethod + def _match_event_filter( + cls, + event_data: dict[str, typing.Any], + event_filter: dict[str, typing.Any], + ) -> bool: + field = str(event_filter.get('field') or event_filter.get('path') or '').strip() + if not field: + return True + + operator = str(event_filter.get('operator') or 'eq') + expected = event_filter.get('value') + actual = cls._get_nested_value(event_data, field) + + if operator == 'eq': + return actual == expected + if operator == 'neq': + return actual != expected + if operator == 'contains': + if isinstance(actual, (list, tuple, set)): + return expected in actual + return str(expected) in str(actual or '') + if operator == 'not_contains': + if isinstance(actual, (list, tuple, set)): + return expected not in actual + return str(expected) not in str(actual or '') + if operator == 'starts_with': + return str(actual or '').startswith(str(expected)) + if operator == 'regex': + try: + return bool(re.search(str(expected), str(actual or ''))) + except re.error: + return False + return False + + @classmethod + def _match_event_filters( + cls, + event: platform_events.EBAEvent, + filters: typing.Any, + ) -> bool: + if not filters: + return True + if not isinstance(filters, list): + return False + + event_data = cls._safe_model_dump(event) + return all( + cls._match_event_filter(event_data, event_filter) + for event_filter in filters + if isinstance(event_filter, dict) + ) + + def _resolve_eba_event_binding( + self, + event: platform_events.EBAEvent, + event_type: str, + ) -> dict[str, typing.Any] | None: + """Resolve the highest priority Bot event binding for a platform event.""" + raw_bindings = self.bot_entity.event_bindings or [] + if isinstance(raw_bindings, str): + try: + raw_bindings = json.loads(raw_bindings) + except json.JSONDecodeError: + raw_bindings = [] + if not isinstance(raw_bindings, list): + return None + + matched: list[tuple[int, int, dict[str, typing.Any]]] = [] + for index, binding in enumerate(raw_bindings): + if not isinstance(binding, dict) or not binding.get('enabled', True): + continue + + event_pattern = str(binding.get('event_pattern') or '') + if not self._match_event_pattern(event_type, event_pattern): + continue + if not self._match_event_filters(event, binding.get('filters')): + continue + + priority = int(binding.get('priority') or 0) + order = int(binding.get('order', index)) + matched.append((priority, -order, binding)) + + if not matched: + return None + + matched.sort(key=lambda item: (item[0], item[1]), reverse=True) + return matched[0][2] + + @staticmethod + def _safe_model_dump(model: typing.Any) -> dict[str, typing.Any]: + if model is None: + return {} + if hasattr(model, 'model_dump'): + try: + return model.model_dump(mode='json') + except TypeError: + try: + return model.model_dump() + except Exception: + return {} + except Exception: + return {} + if isinstance(model, dict): + return model + return {} + + @classmethod + def _compact_event_data(cls, event: platform_events.EBAEvent) -> dict[str, typing.Any]: + raw_event_data = cls._safe_model_dump(event) + compact: dict[str, typing.Any] = {} + for key, value in raw_event_data.items(): + if key == 'source_platform_object' or key.startswith('_'): + continue + if value is None or isinstance(value, (bool, int, float)): + compact[key] = value + continue + if isinstance(value, str): + if len(value.encode('utf-8')) <= cls.EVENT_DATA_MAX_STRING_BYTES: + compact[key] = value + continue + if isinstance(value, (list, dict)): + try: + encoded = json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + continue + if len(encoded.encode('utf-8')) <= cls.EVENT_DATA_MAX_STRING_BYTES: + compact[key] = value + return compact + + @staticmethod + def _get_entity_id(entity: typing.Any) -> str | None: + entity_id = getattr(entity, 'id', None) + if entity_id is None and isinstance(entity, dict): + entity_id = entity.get('id') + if entity_id is None or entity_id == '': + return None + return str(entity_id) + + @staticmethod + def _get_entity_name(entity: typing.Any) -> str | None: + if entity is None: + return None + if hasattr(entity, 'get_name'): + try: + name = entity.get_name() + if name: + return str(name) + except Exception: + pass + for attr in ('nickname', 'member_name', 'name', 'display_name'): + value = getattr(entity, attr, None) + if value: + return str(value) + if isinstance(entity, dict): + for attr in ('nickname', 'member_name', 'name', 'display_name'): + value = entity.get(attr) + if value: + return str(value) + return None + + @classmethod + def _infer_actor_context(cls, event: platform_events.EBAEvent) -> ActorContext | None: + actor = getattr(event, 'sender', None) or getattr(event, 'member', None) or getattr(event, 'user', None) + actor_id = cls._get_entity_id(actor) + actor_name = cls._get_entity_name(actor) + + if actor_id is None: + user_id = getattr(event, 'user_id', None) + if user_id: + actor_id = str(user_id) + + if actor_id is None: + return None + + return ActorContext( + actor_type='user', + actor_id=actor_id, + actor_name=actor_name, + metadata={}, + ) + + @classmethod + def _infer_subject_context(cls, event: platform_events.EBAEvent) -> SubjectContext: + group = getattr(event, 'group', None) + if group is not None: + group_id = cls._get_entity_id(group) + return SubjectContext( + subject_type='group', + subject_id=group_id, + data={'group_name': cls._get_entity_name(group)}, + ) + + message_id = getattr(event, 'message_id', None) + if message_id: + return SubjectContext( + subject_type='message', + subject_id=str(message_id), + data={}, + ) + + feedback_id = getattr(event, 'feedback_id', None) + if feedback_id: + return SubjectContext( + subject_type='feedback', + subject_id=str(feedback_id), + data={'message_id': getattr(event, 'message_id', None)}, + ) + + action = getattr(event, 'action', None) + if action: + return SubjectContext( + subject_type='platform_action', + subject_id=str(action), + data={}, + ) + + return SubjectContext( + subject_type='event', + subject_id=getattr(event, 'type', None), + data={}, + ) + + @staticmethod + def _session_to_reply_target(session_id: str | None) -> tuple[str | None, str | None]: + if not session_id or '_' not in session_id: + return None, None + target_type, target_id = session_id.split('_', 1) + if target_type == 'person': + target_type = 'person' + elif target_type == 'group': + target_type = 'group' + else: + return None, None + return target_type, target_id or None + + @classmethod + def _infer_reply_target( + cls, + event: platform_events.EBAEvent, + ) -> tuple[str | None, str | None, dict[str, typing.Any]]: + metadata: dict[str, typing.Any] = {} + group = getattr(event, 'group', None) + group_id = cls._get_entity_id(group) + if group_id: + metadata['group_id'] = group_id + return 'group', group_id, metadata + + chat_id = getattr(event, 'chat_id', None) + chat_type = getattr(event, 'chat_type', None) + chat_type_value = getattr(chat_type, 'value', chat_type) + if chat_id: + metadata['chat_id'] = str(chat_id) + if chat_type_value == 'group': + return 'group', str(chat_id), metadata + return 'person', str(chat_id), metadata + + session_target_type, session_target_id = cls._session_to_reply_target(getattr(event, 'session_id', None)) + if session_target_type and session_target_id: + return session_target_type, session_target_id, metadata + + raw_data = getattr(event, 'data', None) + if isinstance(raw_data, dict): + target_type = raw_data.get('target_type') or raw_data.get('chat_type') + target_id = ( + raw_data.get('target_id') + or raw_data.get('chat_id') + or raw_data.get('group_id') + or raw_data.get('user_id') + ) + if target_type and target_id: + return str(target_type), str(target_id), metadata + + return None, None, metadata + + @classmethod + def _build_agent_input(cls, event: platform_events.EBAEvent) -> AgentInput: + text = None + contents: list[dict[str, typing.Any]] = [] + + message_chain = getattr(event, 'message_chain', None) + if message_chain: + text_parts: list[str] = [] + try: + for component in message_chain: + if isinstance(component, platform_message.Plain): + text_parts.append(component.text) + elif isinstance(component, platform_message.Image): + if component.url: + contents.append({'type': 'image_url', 'image_url': {'url': component.url}}) + elif component.base64: + contents.append({'type': 'image_base64', 'image_base64': component.base64}) + except TypeError: + text_parts.append(str(message_chain)) + text = ''.join(text_parts) or str(message_chain) + + if text is None: + feedback_content = getattr(event, 'feedback_content', None) + if feedback_content: + text = str(feedback_content) + elif getattr(event, 'action', None): + text = str(getattr(event, 'action')) + else: + text = str(getattr(event, 'type', 'event')) + + if text: + contents.insert(0, {'type': 'text', 'text': text}) + + return AgentInput( + text=text, + contents=contents, + attachments=[], + ) + + def _eba_event_to_agent_envelope( + self, + event: platform_events.EBAEvent, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ) -> AgentEventEnvelope: + event_type = getattr(event, 'type', None) or event.__class__.__name__ + event_time = getattr(event, 'timestamp', None) or time.time() + event_id = ( + getattr(event, 'message_id', None) or getattr(event, 'feedback_id', None) or f'{event_type}:{uuid.uuid4()}' + ) + target_type, target_id, target_metadata = self._infer_reply_target(event) + + conversation_id = None + if target_type and target_id: + conversation_id = f'{target_type}_{target_id}' + elif getattr(event, 'session_id', None): + conversation_id = str(getattr(event, 'session_id')) + + return AgentEventEnvelope( + event_id=f'platform:{self.bot_entity.uuid}:{event_id}', + event_type=event_type, + event_time=int(event_time) if isinstance(event_time, (int, float)) else None, + source='platform', + source_event_type=event_type, + bot_id=self.bot_entity.uuid, + workspace_id=None, + conversation_id=conversation_id, + thread_id=None, + actor=self._infer_actor_context(event), + subject=self._infer_subject_context(event), + input=self._build_agent_input(event), + delivery=DeliveryContext( + surface='platform', + reply_target={ + 'target_type': target_type, + 'target_id': target_id, + 'message_id': getattr(event, 'message_id', None), + **target_metadata, + }, + supports_streaming=False, + supports_edit=False, + supports_reaction=False, + platform_capabilities={ + 'adapter': adapter.__class__.__name__, + 'event_type': event_type, + }, + ), + raw_ref=RawEventRef(ref_id=str(event_id), storage_key=None), + data=self._compact_event_data(event), + ) + + @staticmethod + def _agent_product_to_binding( + agent: dict[str, typing.Any], + event_binding: dict[str, typing.Any], + event_type: str, + bot_uuid: str, + ) -> AgentBinding | None: + config = agent.get('config') if isinstance(agent, dict) else None + if not isinstance(config, dict): + return None + + runner = config.get('runner') + runner_id = None + if isinstance(runner, dict): + runner_id = runner.get('id') + runner_id = runner_id or agent.get('component_ref') + if not runner_id: + return None + + runner_config_map = config.get('runner_config') + runner_config = {} + if isinstance(runner_config_map, dict): + runner_config = runner_config_map.get(runner_id) or {} + + return AgentBinding( + binding_id=f'bot:{bot_uuid}:{event_binding.get("id") or uuid.uuid4()}', + scope=BindingScope(scope_type='bot', scope_id=bot_uuid), + event_types=[event_type], + runner_id=runner_id, + runner_config=runner_config, + resource_policy=ResourcePolicy(), + state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']), + delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True), + enabled=True, + agent_id=agent.get('uuid'), + ) + + @staticmethod + def _provider_content_to_text(content: typing.Any) -> str: + if content is None: + return '' + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + item_data = item.model_dump(mode='json') if hasattr(item, 'model_dump') else item + if isinstance(item_data, dict): + if item_data.get('type') == 'text' and item_data.get('text') is not None: + parts.append(str(item_data.get('text'))) + elif item_data.get('text') is not None: + parts.append(str(item_data.get('text'))) + elif item_data is not None: + parts.append(str(item_data)) + return ''.join(parts) + return str(content) + + @classmethod + def _provider_output_to_text(cls, result: provider_message.Message | provider_message.MessageChunk) -> str: + if getattr(result, 'all_content', None): + return str(getattr(result, 'all_content')) + return cls._provider_content_to_text(getattr(result, 'content', None)) + + async def _deliver_agent_outputs( + self, + envelope: AgentEventEnvelope, + outputs: list[provider_message.Message | provider_message.MessageChunk], + ) -> None: + if not outputs or not envelope.delivery.reply_target: + return + + reply_target = envelope.delivery.reply_target + target_type = reply_target.get('target_type') + target_id = reply_target.get('target_id') + if not target_type or not target_id: + return + + final_text = '' + for output in outputs: + output_text = self._provider_output_to_text(output) + if isinstance(output, provider_message.Message): + final_text = output_text or final_text + elif output_text: + final_text = output_text + + if not final_text: + return + + await self.adapter.send_message( + str(target_type), + str(target_id), + platform_message.MessageChain([platform_message.Plain(text=final_text)]), + ) + + async def _dispatch_eba_event_to_agent( + self, + event: platform_events.EBAEvent, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ) -> None: + event_type = getattr(event, 'type', None) or event.__class__.__name__ + + event_binding = self._resolve_eba_event_binding(event, event_type) + if event_binding is None: + if isinstance(event, platform_events.MessageReceivedEvent): + await self._dispatch_eba_message_to_pipeline(event, adapter) + return + + target_type = event_binding.get('target_type') + if target_type == 'discard': + if isinstance(event, platform_events.MessageReceivedEvent): + await self._dispatch_eba_message_to_pipeline( + event, + adapter, + pipeline_uuid=self.PIPELINE_DISCARD, + routed_by_event_binding=True, + ) + return + await self.logger.info(f'EBA event {event_type} discarded by event binding') + return + if target_type == 'pipeline': + if not self._is_message_event_type(event_type): + await self.logger.warning(f'EBA event {event_type} ignored Pipeline target for non-message event') + return + await self._dispatch_eba_message_to_pipeline( + event, + adapter, + pipeline_uuid=event_binding.get('target_uuid'), + routed_by_event_binding=True, + ) + return + if target_type != 'agent': + await self.logger.warning(f'EBA event {event_type} ignored unsupported target type {target_type}') + return + + target_uuid = event_binding.get('target_uuid') + agent = await self.ap.agent_service.get_agent(target_uuid) + if not agent or agent.get('kind') != 'agent': + await self.logger.warning(f'EBA event {event_type} target agent not found: {target_uuid}') + return + if not agent.get('enabled', True): + await self.logger.info(f'EBA event {event_type} target agent disabled: {target_uuid}') + return + if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type): + await self.logger.info(f'EBA event {event_type} target agent does not support this event: {target_uuid}') + return + + binding = self._agent_product_to_binding(agent, event_binding, event_type, self.bot_entity.uuid) + if binding is None: + await self.logger.warning(f'EBA event {event_type} target agent has no runner: {target_uuid}') + return + + envelope = self._eba_event_to_agent_envelope(event, adapter) + outputs: list[provider_message.Message | provider_message.MessageChunk] = [] + try: + async for output in self.ap.agent_run_orchestrator.run(envelope, binding): + outputs.append(output) + except Exception: + await self.logger.error(f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}') + return + + try: + await self._deliver_agent_outputs(envelope, outputs) + except Exception: + await self.logger.error( + f'Failed to deliver Agent output for EBA event {event_type}: {traceback.format_exc()}' + ) + def resolve_pipeline_uuid( self, launcher_type: str, @@ -222,128 +808,115 @@ class RuntimeBot: except Exception as e: await self.logger.error(f'Failed to record discarded message: {e}') + async def _handle_legacy_message_event( + self, + event: platform_events.FriendMessage | platform_events.GroupMessage, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + pipeline_uuid_override: str | None = None, + routed_by_event_binding: bool = False, + ) -> None: + is_group_message = isinstance(event, platform_events.GroupMessage) + launcher_kind = 'group' if is_group_message else 'person' + launcher_type = ( + provider_session.LauncherTypes.GROUP if is_group_message else provider_session.LauncherTypes.PERSON + ) + launcher_id = event.group.id if is_group_message else event.sender.id + sender_id = event.sender.id + + image_components = [ + component for component in event.message_chain if isinstance(component, platform_message.Image) + ] + + await self.logger.info( + f'{event.message_chain}', + images=image_components, + message_session_id=f'{launcher_kind}_{launcher_id}', + ) + + skip_pipeline = False + if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher: + if is_group_message: + skip_pipeline = await self.ap.webhook_pusher.push_group_message( + event, self.bot_entity.uuid, adapter.__class__.__name__ + ) + else: + skip_pipeline = await self.ap.webhook_pusher.push_person_message( + event, self.bot_entity.uuid, adapter.__class__.__name__ + ) + + if skip_pipeline: + await self.logger.info(f'Pipeline skipped for {launcher_kind} message due to webhook response') + return + + if hasattr(adapter, 'get_launcher_id'): + custom_launcher_id = adapter.get_launcher_id(event) + if custom_launcher_id: + launcher_id = custom_launcher_id + + if pipeline_uuid_override is None: + message_text = str(event.message_chain) + element_types = [comp.type for comp in event.message_chain] + pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid( + launcher_kind, launcher_id, message_text, element_types + ) + else: + pipeline_uuid = pipeline_uuid_override + routed_by_rule = routed_by_event_binding + + if pipeline_uuid == self.PIPELINE_DISCARD: + await self.logger.info(f'{launcher_kind.title()} message discarded by routing rule') + await self._record_discarded_message( + launcher_type, + launcher_id, + sender_id, + event, + event.message_chain, + ) + return + + await self.ap.msg_aggregator.add_message( + bot_uuid=self.bot_entity.uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=sender_id, + message_event=event, + message_chain=event.message_chain, + adapter=adapter, + pipeline_uuid=pipeline_uuid, + routed_by_rule=routed_by_rule, + ) + + async def _dispatch_eba_message_to_pipeline( + self, + event: platform_events.EBAEvent, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + pipeline_uuid: str | None = None, + routed_by_event_binding: bool = False, + ) -> None: + if not isinstance(event, platform_events.MessageReceivedEvent): + event_type = getattr(event, 'type', None) or event.__class__.__name__ + await self.logger.warning(f'EBA event {event_type} cannot be dispatched to legacy Pipeline') + return + + await self._handle_legacy_message_event( + event.to_legacy_event(), + adapter, + pipeline_uuid_override=pipeline_uuid, + routed_by_event_binding=routed_by_event_binding, + ) + async def initialize(self): async def on_friend_message( event: platform_events.FriendMessage, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, ): - image_components = [ - component for component in event.message_chain if isinstance(component, platform_message.Image) - ] - - await self.logger.info( - f'{event.message_chain}', - images=image_components, - message_session_id=f'person_{event.sender.id}', - ) - - # Push to webhooks and check if pipeline should be skipped - skip_pipeline = False - if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher: - skip_pipeline = await self.ap.webhook_pusher.push_person_message( - event, self.bot_entity.uuid, adapter.__class__.__name__ - ) - - # Only add to query pool if no webhook requested to skip pipeline - if not skip_pipeline: - launcher_id = event.sender.id - - if hasattr(adapter, 'get_launcher_id'): - custom_launcher_id = adapter.get_launcher_id(event) - if custom_launcher_id: - launcher_id = custom_launcher_id - - message_text = str(event.message_chain) - element_types = [comp.type for comp in event.message_chain] - pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid( - 'person', launcher_id, message_text, element_types - ) - - if pipeline_uuid == self.PIPELINE_DISCARD: - await self.logger.info('Person message discarded by routing rule') - await self._record_discarded_message( - provider_session.LauncherTypes.PERSON, - launcher_id, - event.sender.id, - event, - event.message_chain, - ) - return - - await self.ap.msg_aggregator.add_message( - bot_uuid=self.bot_entity.uuid, - launcher_type=provider_session.LauncherTypes.PERSON, - launcher_id=launcher_id, - sender_id=event.sender.id, - message_event=event, - message_chain=event.message_chain, - adapter=adapter, - pipeline_uuid=pipeline_uuid, - routed_by_rule=routed_by_rule, - ) - else: - await self.logger.info('Pipeline skipped for person message due to webhook response') + await self._handle_legacy_message_event(event, adapter) async def on_group_message( event: platform_events.GroupMessage, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, ): - image_components = [ - component for component in event.message_chain if isinstance(component, platform_message.Image) - ] - - await self.logger.info( - f'{event.message_chain}', - images=image_components, - message_session_id=f'group_{event.group.id}', - ) - - # Push to webhooks and check if pipeline should be skipped - skip_pipeline = False - if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher: - skip_pipeline = await self.ap.webhook_pusher.push_group_message( - event, self.bot_entity.uuid, adapter.__class__.__name__ - ) - - # Only add to query pool if no webhook requested to skip pipeline - if not skip_pipeline: - launcher_id = event.group.id - - if hasattr(adapter, 'get_launcher_id'): - custom_launcher_id = adapter.get_launcher_id(event) - if custom_launcher_id: - launcher_id = custom_launcher_id - - message_text = str(event.message_chain) - element_types = [comp.type for comp in event.message_chain] - pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid( - 'group', launcher_id, message_text, element_types - ) - - if pipeline_uuid == self.PIPELINE_DISCARD: - await self.logger.info('Group message discarded by routing rule') - await self._record_discarded_message( - provider_session.LauncherTypes.GROUP, - launcher_id, - event.sender.id, - event, - event.message_chain, - ) - return - - await self.ap.msg_aggregator.add_message( - bot_uuid=self.bot_entity.uuid, - launcher_type=provider_session.LauncherTypes.GROUP, - launcher_id=launcher_id, - sender_id=event.sender.id, - message_event=event, - message_chain=event.message_chain, - adapter=adapter, - pipeline_uuid=pipeline_uuid, - routed_by_rule=routed_by_rule, - ) - else: - await self.logger.info('Pipeline skipped for group message due to webhook response') + await self._handle_legacy_message_event(event, adapter) self.adapter.register_listener(platform_events.FriendMessage, on_friend_message) self.adapter.register_listener(platform_events.GroupMessage, on_group_message) @@ -398,13 +971,14 @@ class RuntimeBot: ): event.bot_uuid = self.bot_entity.uuid plugin_event = self._eba_event_to_plugin_event(event) - if plugin_event is None: - return - try: - await self.ap.plugin_connector.emit_event(plugin_event) - except Exception: - await self.logger.error(f'Failed to dispatch EBA event to plugins: {traceback.format_exc()}') + 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 EBA event to plugins: {traceback.format_exc()}') + + await self._dispatch_eba_event_to_agent(event, adapter) self.adapter.register_listener(platform_events.EBAEvent, on_eba_event) diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx new file mode 100644 index 000000000..60c077ad5 --- /dev/null +++ b/web/src/app/home/agents/AgentDetailContent.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { Agent } from '@/app/infra/entities/api'; +import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; +import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent'; +import AgentCreateContent from './components/AgentCreateContent'; +import AgentFormComponent from './components/AgentFormComponent'; + +export default function AgentDetailContent({ id }: { id: string }) { + const isCreateMode = id === 'new'; + const navigate = useNavigate(); + const { t } = useTranslation(); + const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData(); + const [agent, setAgent] = useState(null); + const [loading, setLoading] = useState(!isCreateMode); + const [formDirty, setFormDirty] = useState(false); + + useEffect(() => { + if (isCreateMode) { + setDetailEntityName(t('agents.create')); + return () => setDetailEntityName(null); + } + + const sidebarItem = pipelines.find((p) => p.id === id); + setDetailEntityName(sidebarItem?.name ?? id); + return () => setDetailEntityName(null); + }, [id, isCreateMode, pipelines, setDetailEntityName, t]); + + useEffect(() => { + if (isCreateMode) return; + let cancelled = false; + setLoading(true); + httpClient + .getAgent(id) + .then((resp) => { + if (!cancelled) setAgent(resp.agent); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [id, isCreateMode]); + + if (isCreateMode) { + return ( + { + refreshPipelines(); + navigate(`/home/agents?id=${encodeURIComponent(newAgentId)}`); + }} + /> + ); + } + + if (loading || !agent) { + return ( +
+ {t('common.loading')} +
+ ); + } + + if (agent.kind === 'pipeline') { + return ; + } + + return ( +
+
+

{t('agents.editAgent')}

+ +
+ +
+ { + refreshPipelines(); + setFormDirty(false); + }} + onDeleted={() => { + refreshPipelines(); + navigate('/home/agents'); + }} + onDirtyChange={setFormDirty} + /> +
+
+ ); +} diff --git a/web/src/app/home/agents/components/AgentCreateContent.tsx b/web/src/app/home/agents/components/AgentCreateContent.tsx new file mode 100644 index 000000000..fe379e125 --- /dev/null +++ b/web/src/app/home/agents/components/AgentCreateContent.tsx @@ -0,0 +1,214 @@ +import { useState } from 'react'; +import type React from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { Bot, Workflow } from 'lucide-react'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { AgentKind } from '@/app/infra/entities/api'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import EmojiPicker from '@/components/ui/emoji-picker'; + +export default function AgentCreateContent({ + onCreated, +}: { + onCreated: (agentId: string) => void; +}) { + const { t } = useTranslation(); + const [kind, setKind] = useState('agent'); + const formSchema = z.object({ + name: z.string().min(1, { message: t('agents.nameRequired') }), + description: z.string().optional(), + emoji: z.string().optional(), + }); + type FormValues = z.infer; + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + description: '', + emoji: '🤖', + }, + }); + + function handleKindChange(nextKind: AgentKind) { + setKind(nextKind); + if (!form.getValues('emoji')) { + form.setValue('emoji', nextKind === 'pipeline' ? '⚙️' : '🤖'); + } + } + + function handleSubmit(values: FormValues) { + httpClient + .createAgent({ + kind, + name: values.name, + description: values.description ?? '', + emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'), + }) + .then((resp) => { + toast.success(t('agents.createSuccess')); + onCreated(resp.uuid); + }) + .catch((err) => { + toast.error(t('agents.createError') + err.msg); + }); + } + + const typeOptions: Array<{ + kind: AgentKind; + icon: React.ElementType; + title: string; + description: string; + badge: string; + }> = [ + { + kind: 'agent', + icon: Bot, + title: t('agents.agentOrchestration'), + description: t('agents.agentOrchestrationDescription'), + badge: t('agents.allEvents'), + }, + { + kind: 'pipeline', + icon: Workflow, + title: t('agents.pipelineType'), + description: t('agents.pipelineTypeDescription'), + badge: t('agents.messageEventsOnly'), + }, + ]; + + return ( +
+
+

{t('agents.create')}

+ +
+ +
+
+
+ {typeOptions.map((option) => { + const Icon = option.icon; + const selected = kind === option.kind; + return ( + + ); + })} +
+ + + + {t('agents.basicInfo')} + + {t('agents.basicInfoDescription')} + + + +
+ +
+ ( + + + {t('common.name')} + * + + + + + + + )} + /> + ( + + {t('common.icon')} + + + + + + )} + /> +
+ + ( + + {t('common.description')} + + + + + + )} + /> + + +
+
+
+
+
+ ); +} diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx new file mode 100644 index 000000000..abfabfc75 --- /dev/null +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -0,0 +1,509 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type React from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { Brain, FileJson2, Info, Power, Trash2 } from 'lucide-react'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { Agent } from '@/app/infra/entities/api'; +import { + PipelineConfigStage, + PipelineConfigTab, +} from '@/app/infra/entities/pipeline'; +import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; +import { extractI18nObject } from '@/i18n/I18nProvider'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import EmojiPicker from '@/components/ui/emoji-picker'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; + +interface AgentFormComponentProps { + agentId: string; + onFinish: () => void; + onDeleted: () => void; + onDirtyChange?: (dirty: boolean) => void; +} + +interface SectionItem { + label: string; + name: 'basic' | 'runner' | 'events'; + icon: React.ElementType; +} + +export default function AgentFormComponent({ + agentId, + onFinish, + onDeleted, + onDirtyChange, +}: AgentFormComponentProps) { + const { t } = useTranslation(); + const [activeSection, setActiveSection] = + useState('basic'); + const [runnerConfigSchema, setRunnerConfigSchema] = + useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const formSchema = z.object({ + basic: z.object({ + name: z.string().min(1, { message: t('agents.nameRequired') }), + description: z.string().optional(), + emoji: z.string().optional(), + enabled: z.boolean().optional(), + }), + runner: z.record(z.string(), z.any()), + runner_config: z.record(z.string(), z.any()), + supported_event_patterns_text: z.string(), + }); + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + basic: { + name: '', + description: '', + emoji: '🤖', + enabled: true, + }, + runner: {}, + runner_config: {}, + supported_event_patterns_text: '*', + }, + }); + + const savedSnapshotRef = useRef(''); + const initializedStagesRef = useRef>(new Set()); + const watchedValues = form.watch(); + const hasUnsavedChanges = useMemo(() => { + if (!savedSnapshotRef.current) return false; + return JSON.stringify(watchedValues) !== savedSnapshotRef.current; + }, [watchedValues]); + + useEffect(() => { + onDirtyChange?.(hasUnsavedChanges); + }, [hasUnsavedChanges, onDirtyChange]); + + useEffect(() => { + let cancelled = false; + Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)]) + .then(([metadata, resp]) => { + if (cancelled) return; + setRunnerConfigSchema(metadata.runner_config ?? null); + const agent = resp.agent; + const config = (agent.config ?? {}) as Record; + const loadedValues: FormValues = { + basic: { + name: agent.name ?? '', + description: agent.description ?? '', + emoji: agent.emoji || '🤖', + enabled: agent.enabled ?? true, + }, + runner: (config.runner as Record) ?? {}, + runner_config: + (config.runner_config as Record) ?? {}, + supported_event_patterns_text: ( + agent.supported_event_patterns ?? + agent.capability?.supported_event_patterns ?? ['*'] + ).join('\n'), + }; + form.reset(loadedValues); + savedSnapshotRef.current = JSON.stringify(loadedValues); + initializedStagesRef.current.clear(); + }) + .catch((err) => { + toast.error(t('agents.loadError') + err.msg); + }); + return () => { + cancelled = true; + }; + }, [agentId, form, t]); + + const sections: SectionItem[] = [ + { label: t('agents.basicInfo'), name: 'basic', icon: Info }, + { label: t('agents.runnerSettings'), name: 'runner', icon: Brain }, + { label: t('agents.eventCapability'), name: 'events', icon: FileJson2 }, + ]; + + const currentRunner = (form.watch('runner') as Record)?.id; + + function updateSnapshotIfInitial(stageKey: string) { + if (!initializedStagesRef.current.has(stageKey)) { + initializedStagesRef.current.add(stageKey); + if (!hasUnsavedChanges) { + savedSnapshotRef.current = JSON.stringify(form.getValues()); + } + } + } + + function handleDynamicFormEmit( + formName: 'runner' | 'runner_config', + stageName: string, + values: object, + ) { + if (formName === 'runner') { + form.setValue('runner', values, { shouldDirty: true }); + updateSnapshotIfInitial(`runner.${stageName}`); + return; + } + + const currentRunnerConfigs = + (form.getValues('runner_config') as Record) || {}; + form.setValue( + 'runner_config', + { + ...currentRunnerConfigs, + [stageName]: values, + }, + { shouldDirty: true }, + ); + updateSnapshotIfInitial(`runner_config.${stageName}`); + } + + function renderDynamicStage(stage: PipelineConfigStage) { + const isRunnerSelector = stage.name === 'runner'; + if (!isRunnerSelector && stage.name !== currentRunner) return null; + + const initialValues = isRunnerSelector + ? (form.watch('runner') as Record) || {} + : ((form.watch('runner_config') as Record) || {})[ + stage.name + ] || {}; + + return ( + + + {extractI18nObject(stage.label)} + {stage.description && ( + + {extractI18nObject(stage.description)} + + )} + + + + handleDynamicFormEmit( + isRunnerSelector ? 'runner' : 'runner_config', + stage.name, + values, + ) + } + /> + + + ); + } + + function normalizeEventPatterns(value: string): string[] { + const patterns = value + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean); + return patterns.length > 0 ? patterns : ['*']; + } + + function handleSubmit(values: FormValues) { + const runner = values.runner || {}; + const agent: Partial = { + name: values.basic.name, + description: values.basic.description ?? '', + emoji: values.basic.emoji, + enabled: values.basic.enabled ?? true, + component_ref: (runner.id as string) || null, + supported_event_patterns: normalizeEventPatterns( + values.supported_event_patterns_text, + ), + config: { + runner, + runner_config: values.runner_config ?? {}, + }, + }; + + httpClient + .updateAgent(agentId, agent) + .then(() => { + const snapshotValues = form.getValues(); + savedSnapshotRef.current = JSON.stringify(snapshotValues); + onFinish(); + toast.success(t('agents.saveSuccess')); + }) + .catch((err) => { + toast.error(t('agents.saveError') + err.msg); + }); + } + + function confirmDelete() { + httpClient + .deleteAgent(agentId) + .then(() => { + toast.success(t('agents.deleteSuccess')); + setShowDeleteConfirm(false); + onDeleted(); + }) + .catch((err) => { + toast.error(t('agents.deleteError') + err.msg); + }); + } + + return ( + <> +
+
+ +
+ + +
+ {activeSection === 'basic' && ( +
+ + + {t('agents.basicInfo')} + + {t('agents.basicInfoDescription')} + + + +
+ ( + + + {t('common.name')} + * + + + + + + + )} + /> + ( + + {t('common.icon')} + + + + + + )} + /> +
+ + ( + + {t('common.description')} + + + + + + )} + /> + + ( + +
+ + + {t('agents.enabled')} + + + {t('agents.enabledDescription')} + +
+ + + +
+ )} + /> +
+
+ + + + + {t('agents.dangerZone')} + + + {t('agents.dangerZoneDescription')} + + + +
+
+

+ {t('agents.deleteAgentAction')} +

+

+ {t('agents.deleteAgentHint')} +

+
+ +
+
+
+
+ )} + + {activeSection === 'runner' && ( +
+ {runnerConfigSchema?.stages.map((stage) => + renderDynamicStage(stage), + )} + {!runnerConfigSchema && ( + + + {t('agents.runnerSettings')} + + {t('agents.noRunnerMetadata')} + + + + )} +
+ )} + + {activeSection === 'events' && ( + + + {t('agents.eventCapability')} + + {t('agents.eventCapabilityDescription')} + + + + ( + + {t('agents.supportedEvents')} + +