mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-08 10:37:14 +00:00
feat(agent): add event-aware tool permissions
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
# Agent 工具权限
|
||||||
|
|
||||||
|
Agent 配置页展示同一次运行中可能投射给 AgentRunner 的完整工具目录:
|
||||||
|
|
||||||
|
- 事件级工具由 Agent 选择的事件范围自动启用。
|
||||||
|
- `allowed_platform_tools` 管理需要 Agent 自行指定目标的平台级动作。
|
||||||
|
- `allowed_tools` 管理沙盒内置工具、MCP 工具、插件工具和技能工具。
|
||||||
|
|
||||||
|
Host 会按当前 Workspace 实时解析工具来源。未安装的插件、未连接的 MCP、不可用的 Box
|
||||||
|
沙盒以及名称存在歧义的工具不会进入可选目录。旧 Agent 若尚未保存 `allowed_tools`,继续
|
||||||
|
沿用运行器原有的工具策略;一旦在配置页保存,就转为明确的顶层白名单。
|
||||||
|
|
||||||
|
Agent 不直接持有平台适配器,也不能调用任意原始平台接口。每次运行时,Host 根据当前
|
||||||
|
事件自动加入兼容的事件级工具,并加入 `allowed_platform_tools` 中选择的平台级工具,
|
||||||
|
再与 AgentRunner 权限、当前适配器声明的 API、当前事件能够安全绑定的目标取交集,
|
||||||
|
得到 `ctx.resources.tools` 中 `tool_type=platform` 的最终工具集合。
|
||||||
|
|
||||||
|
## 两类工具
|
||||||
|
|
||||||
|
- 事件级工具以 `event_` 开头。用户、群组、消息或请求标识由 Host 从当前事件冻结,
|
||||||
|
Agent 只能填写回复文本、审核结果、禁言时长等动作参数。
|
||||||
|
- 平台级工具以 `platform_` 开头。Agent 可以填写目标用户、群组或消息标识,因此权限
|
||||||
|
更宽,配置页将其与事件级工具分开展示。
|
||||||
|
|
||||||
|
当前事件级工具包括:回复当前会话、删除当前消息、查询事件发起者或相关群组/成员、
|
||||||
|
禁言/解除禁言/移出相关成员、同意或拒绝好友请求、同意或拒绝入群邀请。
|
||||||
|
|
||||||
|
当前平台级工具包括:发送/查询/删除消息,查询群组、群列表、群成员,修改群名称,
|
||||||
|
禁言/解除禁言/移出成员、退出群组,以及查询用户和好友列表。
|
||||||
|
|
||||||
|
`call_platform_api` 不在 Agent 工具目录中。平台私有透传接口必须先在 Host 中定义为
|
||||||
|
具有固定名称、JSON Schema、风险级别和授权规则的语义工具,不能让 Agent 自行传入
|
||||||
|
原始 action 名称。
|
||||||
|
|
||||||
|
## 运行时投射
|
||||||
|
|
||||||
|
```text
|
||||||
|
current event type ── compatible event tools
|
||||||
|
Agent.allowed_platform_tools ── selected platform tools
|
||||||
|
│
|
||||||
|
├─ AgentRunner capability tool_calling is enabled
|
||||||
|
├─ AgentRunner manifest permissions.tools contains call
|
||||||
|
├─ current adapter.get_supported_apis()
|
||||||
|
└─ current event type and frozen target are compatible
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ctx.resources.tools[tool_type=platform]
|
||||||
|
│
|
||||||
|
├─ Local Agent: AgentRunAPIProxy.call_tool
|
||||||
|
└─ External AgentRunner: langbot_list_assets / langbot_get_tool_detail /
|
||||||
|
langbot_call_tool (MCP Asset Gateway)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Host revalidates run_id, runner plugin identity, operation and frozen source
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
current bot adapter semantic API
|
||||||
|
```
|
||||||
|
|
||||||
|
本地和外部 AgentRunner 因此使用同一个工具名、参数 Schema 和 Host 授权快照。外部
|
||||||
|
平台不会获得适配器对象或长期凭据;MCP 网关中的 run token 和 Host 中的 run session
|
||||||
|
都只对应当前运行。
|
||||||
|
|
||||||
|
## 失败语义
|
||||||
|
|
||||||
|
- Agent 未选择当前事件:不会触发运行,也不会生成事件级工具。
|
||||||
|
- 平台级工具未在 Agent 配置中选择:不进入运行资源。
|
||||||
|
- Runner 未启用 `tool_calling` 或没有 `tools.call` 权限:所有平台动作均不可用。
|
||||||
|
- 当前适配器不声明对应 API:该工具记入 `platform_capabilities.unavailable_tools`,不投射。
|
||||||
|
- 事件类型不匹配或缺少可冻结目标:事件级工具不投射。
|
||||||
|
- 调用期间机器人下线或适配器能力变化:Host 拒绝执行并返回具体错误。
|
||||||
|
- 参数包含 Schema 之外的字段:Host 拒绝执行。
|
||||||
|
|
||||||
|
这些规则保证配置白名单不是唯一防线;真正的执行授权始终由单次运行快照和执行时检查
|
||||||
|
共同决定。
|
||||||
@@ -96,6 +96,9 @@ class ResourcePolicy(pydantic.BaseModel):
|
|||||||
allowed_tool_sources: dict[str, dict[str, str | None]] | None = None
|
allowed_tool_sources: dict[str, dict[str, str | None]] | None = None
|
||||||
"""Host-resolved implementation identity for each allowed tool name."""
|
"""Host-resolved implementation identity for each allowed tool name."""
|
||||||
|
|
||||||
|
allowed_platform_tool_names: list[str] = pydantic.Field(default_factory=list)
|
||||||
|
"""Platform and event action tools explicitly granted by the Agent owner."""
|
||||||
|
|
||||||
allow_all_tools: bool = False
|
allow_all_tools: bool = False
|
||||||
"""Whether all tools visible to the current Host scope are granted."""
|
"""Whether all tools visible to the current Host scope are granted."""
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from .interaction_manager import InteractionManager
|
|||||||
from .query_bridge import QueryRunBridge
|
from .query_bridge import QueryRunBridge
|
||||||
from .registry import AgentRunnerRegistry
|
from .registry import AgentRunnerRegistry
|
||||||
from .resource_builder import AgentResourceBuilder
|
from .resource_builder import AgentResourceBuilder
|
||||||
|
from .platform_tools import freeze_platform_context
|
||||||
from .result_normalizer import AgentResultNormalizer
|
from .result_normalizer import AgentResultNormalizer
|
||||||
from .run_journal import AgentRunJournal
|
from .run_journal import AgentRunJournal
|
||||||
from .session_registry import AgentRunSessionRegistry, get_session_registry
|
from .session_registry import AgentRunSessionRegistry, get_session_registry
|
||||||
@@ -201,6 +202,7 @@ class AgentRunOrchestrator:
|
|||||||
},
|
},
|
||||||
state_context=state_context,
|
state_context=state_context,
|
||||||
execution_query=execution_query,
|
execution_query=execution_query,
|
||||||
|
platform_context=freeze_platform_context(event),
|
||||||
)
|
)
|
||||||
|
|
||||||
event_log_id = await self.journal.write_event_log(
|
event_log_id = await self.journal.write_event_log(
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
"""Run-scoped platform and event action tools exposed to AgentRunners."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import fnmatch
|
||||||
|
import typing
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
|
||||||
|
from .host_models import AgentEventEnvelope
|
||||||
|
|
||||||
|
|
||||||
|
JsonSchema = dict[str, typing.Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlatformToolDefinition:
|
||||||
|
name: str
|
||||||
|
api: str
|
||||||
|
scope: typing.Literal['event', 'platform']
|
||||||
|
category: str
|
||||||
|
risk: typing.Literal['read', 'write', 'dangerous']
|
||||||
|
label: dict[str, str]
|
||||||
|
description: dict[str, str]
|
||||||
|
parameters: JsonSchema
|
||||||
|
event_patterns: tuple[str, ...] = ('*',)
|
||||||
|
binding: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _object_schema(properties: dict[str, JsonSchema], required: list[str] | None = None) -> JsonSchema:
|
||||||
|
schema: JsonSchema = {'type': 'object', 'properties': properties, 'additionalProperties': False}
|
||||||
|
if required:
|
||||||
|
schema['required'] = required
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
_TEXT = {'type': 'string', 'minLength': 1}
|
||||||
|
_ID = {'type': 'string', 'minLength': 1}
|
||||||
|
_TARGET_TYPE = {'type': 'string', 'enum': ['person', 'group']}
|
||||||
|
_CHAT_TYPE = {'type': 'string', 'enum': ['person', 'private', 'group']}
|
||||||
|
_APPROVE = {'type': 'boolean', 'description': 'true to accept; false to reject'}
|
||||||
|
|
||||||
|
|
||||||
|
PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = (
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'event_reply',
|
||||||
|
'send_message',
|
||||||
|
'event',
|
||||||
|
'message',
|
||||||
|
'write',
|
||||||
|
{'zh_Hans': '回复当前会话', 'en_US': 'Reply to current conversation'},
|
||||||
|
{
|
||||||
|
'zh_Hans': '向触发当前事件的会话发送文本消息。目标由 LangBot 固定,Agent 无法改写。',
|
||||||
|
'en_US': 'Send text to the conversation that triggered this run. LangBot fixes the target.',
|
||||||
|
},
|
||||||
|
_object_schema({'text': {**_TEXT, 'description': 'Reply text'}}, ['text']),
|
||||||
|
('message.*', 'friend.*', 'group.*', 'feedback.*'),
|
||||||
|
'reply_target',
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'event_delete_message',
|
||||||
|
'delete_message',
|
||||||
|
'event',
|
||||||
|
'message',
|
||||||
|
'dangerous',
|
||||||
|
{'zh_Hans': '删除当前消息', 'en_US': 'Delete current message'},
|
||||||
|
{
|
||||||
|
'zh_Hans': '删除触发当前事件的消息。消息与会话标识由 LangBot 固定。',
|
||||||
|
'en_US': 'Delete the message that triggered the run. Message and chat IDs are fixed by LangBot.',
|
||||||
|
},
|
||||||
|
_object_schema({}),
|
||||||
|
('message.received', 'message.edited'),
|
||||||
|
'current_message',
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'event_get_actor',
|
||||||
|
'get_user_info',
|
||||||
|
'event',
|
||||||
|
'identity',
|
||||||
|
'read',
|
||||||
|
{'zh_Hans': '查询事件发起者', 'en_US': 'Get event actor'},
|
||||||
|
{
|
||||||
|
'zh_Hans': '查询当前事件发起者的用户资料。用户标识由 LangBot 固定。',
|
||||||
|
'en_US': 'Read the current event actor profile. LangBot fixes the user ID.',
|
||||||
|
},
|
||||||
|
_object_schema({}),
|
||||||
|
('*',),
|
||||||
|
'actor',
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'event_get_group',
|
||||||
|
'get_group_info',
|
||||||
|
'event',
|
||||||
|
'group',
|
||||||
|
'read',
|
||||||
|
{'zh_Hans': '查询当前群组', 'en_US': 'Get current group'},
|
||||||
|
{
|
||||||
|
'zh_Hans': '查询当前事件所属群组的信息。群组标识由 LangBot 固定。',
|
||||||
|
'en_US': 'Read the group associated with the current event. LangBot fixes the group ID.',
|
||||||
|
},
|
||||||
|
_object_schema({}),
|
||||||
|
('message.*', 'group.*', 'bot.invited_to_group', 'bot.removed_from_group', 'bot.muted', 'bot.unmuted'),
|
||||||
|
'group',
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'event_get_group_member',
|
||||||
|
'get_group_member_info',
|
||||||
|
'event',
|
||||||
|
'group',
|
||||||
|
'read',
|
||||||
|
{'zh_Hans': '查询相关群成员', 'en_US': 'Get related group member'},
|
||||||
|
{
|
||||||
|
'zh_Hans': '查询当前事件发起者在当前群组中的成员信息。',
|
||||||
|
'en_US': 'Read the current actor membership in the current group.',
|
||||||
|
},
|
||||||
|
_object_schema({}),
|
||||||
|
('message.*', 'group.*'),
|
||||||
|
'group_actor',
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'event_mute_member',
|
||||||
|
'mute_member',
|
||||||
|
'event',
|
||||||
|
'moderation',
|
||||||
|
'dangerous',
|
||||||
|
{'zh_Hans': '禁言相关群成员', 'en_US': 'Mute related group member'},
|
||||||
|
{
|
||||||
|
'zh_Hans': '禁言当前事件关联的群成员,群组和成员标识由 LangBot 固定。',
|
||||||
|
'en_US': 'Mute the member related to this event. LangBot fixes group and user IDs.',
|
||||||
|
},
|
||||||
|
_object_schema(
|
||||||
|
{
|
||||||
|
'duration': {
|
||||||
|
'type': 'integer',
|
||||||
|
'minimum': 0,
|
||||||
|
'description': 'Mute duration in seconds; 0 uses the adapter default',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
('message.*', 'group.member_*'),
|
||||||
|
'group_actor',
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'event_unmute_member',
|
||||||
|
'unmute_member',
|
||||||
|
'event',
|
||||||
|
'moderation',
|
||||||
|
'dangerous',
|
||||||
|
{'zh_Hans': '解除相关群成员禁言', 'en_US': 'Unmute related group member'},
|
||||||
|
{'zh_Hans': '解除当前事件关联群成员的禁言。', 'en_US': 'Unmute the member related to this event.'},
|
||||||
|
_object_schema({}),
|
||||||
|
('message.*', 'group.member_*'),
|
||||||
|
'group_actor',
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'event_kick_member',
|
||||||
|
'kick_member',
|
||||||
|
'event',
|
||||||
|
'moderation',
|
||||||
|
'dangerous',
|
||||||
|
{'zh_Hans': '移出相关群成员', 'en_US': 'Kick related group member'},
|
||||||
|
{
|
||||||
|
'zh_Hans': '将当前事件关联的成员移出群组。',
|
||||||
|
'en_US': 'Remove the member related to this event from the group.',
|
||||||
|
},
|
||||||
|
_object_schema({}),
|
||||||
|
('message.*', 'group.member_*'),
|
||||||
|
'group_actor',
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'event_respond_friend_request',
|
||||||
|
'approve_friend_request',
|
||||||
|
'event',
|
||||||
|
'request',
|
||||||
|
'dangerous',
|
||||||
|
{'zh_Hans': '处理好友请求', 'en_US': 'Respond to friend request'},
|
||||||
|
{
|
||||||
|
'zh_Hans': '同意或拒绝触发当前事件的好友请求。请求标识由 LangBot 固定。',
|
||||||
|
'en_US': 'Accept or reject the friend request that triggered this run. LangBot fixes the request ID.',
|
||||||
|
},
|
||||||
|
_object_schema({'approve': _APPROVE, 'remark': {'type': 'string'}}, ['approve']),
|
||||||
|
('friend.request_received',),
|
||||||
|
'request',
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'event_respond_group_invite',
|
||||||
|
'approve_group_invite',
|
||||||
|
'event',
|
||||||
|
'request',
|
||||||
|
'dangerous',
|
||||||
|
{'zh_Hans': '处理入群邀请', 'en_US': 'Respond to group invite'},
|
||||||
|
{
|
||||||
|
'zh_Hans': '同意或拒绝触发当前事件的机器人入群邀请。',
|
||||||
|
'en_US': 'Accept or reject the bot group invitation that triggered this run.',
|
||||||
|
},
|
||||||
|
_object_schema({'approve': _APPROVE}, ['approve']),
|
||||||
|
('bot.invited_to_group',),
|
||||||
|
'request',
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'platform_send_message',
|
||||||
|
'send_message',
|
||||||
|
'platform',
|
||||||
|
'message',
|
||||||
|
'write',
|
||||||
|
{'zh_Hans': '发送消息', 'en_US': 'Send message'},
|
||||||
|
{
|
||||||
|
'zh_Hans': '使用当前机器人向指定用户或群组发送文本消息。',
|
||||||
|
'en_US': 'Send text to a specified person or group using the current bot.',
|
||||||
|
},
|
||||||
|
_object_schema(
|
||||||
|
{'target_type': _TARGET_TYPE, 'target_id': _ID, 'text': _TEXT}, ['target_type', 'target_id', 'text']
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'platform_get_message',
|
||||||
|
'get_message',
|
||||||
|
'platform',
|
||||||
|
'message',
|
||||||
|
'read',
|
||||||
|
{'zh_Hans': '查询消息', 'en_US': 'Get message'},
|
||||||
|
{'zh_Hans': '按会话和消息标识查询消息。', 'en_US': 'Get a message by chat and message ID.'},
|
||||||
|
_object_schema(
|
||||||
|
{'chat_type': _CHAT_TYPE, 'chat_id': _ID, 'message_id': _ID}, ['chat_type', 'chat_id', 'message_id']
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'platform_delete_message',
|
||||||
|
'delete_message',
|
||||||
|
'platform',
|
||||||
|
'message',
|
||||||
|
'dangerous',
|
||||||
|
{'zh_Hans': '删除指定消息', 'en_US': 'Delete message'},
|
||||||
|
{'zh_Hans': '按会话和消息标识删除消息。', 'en_US': 'Delete a message by chat and message ID.'},
|
||||||
|
_object_schema(
|
||||||
|
{'chat_type': _CHAT_TYPE, 'chat_id': _ID, 'message_id': _ID}, ['chat_type', 'chat_id', 'message_id']
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'platform_get_group_info',
|
||||||
|
'get_group_info',
|
||||||
|
'platform',
|
||||||
|
'group',
|
||||||
|
'read',
|
||||||
|
{'zh_Hans': '查询群组', 'en_US': 'Get group'},
|
||||||
|
{'zh_Hans': '查询指定群组的信息。', 'en_US': 'Read information about a specified group.'},
|
||||||
|
_object_schema({'group_id': _ID}, ['group_id']),
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'platform_get_group_list',
|
||||||
|
'get_group_list',
|
||||||
|
'platform',
|
||||||
|
'group',
|
||||||
|
'read',
|
||||||
|
{'zh_Hans': '列出群组', 'en_US': 'List groups'},
|
||||||
|
{'zh_Hans': '列出当前机器人加入的群组。', 'en_US': 'List groups joined by the current bot.'},
|
||||||
|
_object_schema({}),
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'platform_get_group_member_list',
|
||||||
|
'get_group_member_list',
|
||||||
|
'platform',
|
||||||
|
'group',
|
||||||
|
'read',
|
||||||
|
{'zh_Hans': '列出群成员', 'en_US': 'List group members'},
|
||||||
|
{'zh_Hans': '列出指定群组的成员。', 'en_US': 'List members of a specified group.'},
|
||||||
|
_object_schema({'group_id': _ID}, ['group_id']),
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'platform_get_group_member_info',
|
||||||
|
'get_group_member_info',
|
||||||
|
'platform',
|
||||||
|
'group',
|
||||||
|
'read',
|
||||||
|
{'zh_Hans': '查询群成员', 'en_US': 'Get group member'},
|
||||||
|
{'zh_Hans': '查询指定用户在指定群组中的成员信息。', 'en_US': 'Read a specified user membership in a group.'},
|
||||||
|
_object_schema({'group_id': _ID, 'user_id': _ID}, ['group_id', 'user_id']),
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'platform_set_group_name',
|
||||||
|
'set_group_name',
|
||||||
|
'platform',
|
||||||
|
'group',
|
||||||
|
'dangerous',
|
||||||
|
{'zh_Hans': '修改群名称', 'en_US': 'Rename group'},
|
||||||
|
{'zh_Hans': '修改指定群组的名称。', 'en_US': 'Change the name of a specified group.'},
|
||||||
|
_object_schema({'group_id': _ID, 'name': _TEXT}, ['group_id', 'name']),
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'platform_mute_member',
|
||||||
|
'mute_member',
|
||||||
|
'platform',
|
||||||
|
'moderation',
|
||||||
|
'dangerous',
|
||||||
|
{'zh_Hans': '禁言群成员', 'en_US': 'Mute group member'},
|
||||||
|
{'zh_Hans': '禁言指定群组中的指定成员。', 'en_US': 'Mute a specified member in a group.'},
|
||||||
|
_object_schema(
|
||||||
|
{'group_id': _ID, 'user_id': _ID, 'duration': {'type': 'integer', 'minimum': 0}}, ['group_id', 'user_id']
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'platform_unmute_member',
|
||||||
|
'unmute_member',
|
||||||
|
'platform',
|
||||||
|
'moderation',
|
||||||
|
'dangerous',
|
||||||
|
{'zh_Hans': '解除群成员禁言', 'en_US': 'Unmute group member'},
|
||||||
|
{'zh_Hans': '解除指定群成员的禁言。', 'en_US': 'Unmute a specified group member.'},
|
||||||
|
_object_schema({'group_id': _ID, 'user_id': _ID}, ['group_id', 'user_id']),
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'platform_kick_member',
|
||||||
|
'kick_member',
|
||||||
|
'platform',
|
||||||
|
'moderation',
|
||||||
|
'dangerous',
|
||||||
|
{'zh_Hans': '移出群成员', 'en_US': 'Kick group member'},
|
||||||
|
{'zh_Hans': '将指定成员移出指定群组。', 'en_US': 'Remove a specified member from a group.'},
|
||||||
|
_object_schema({'group_id': _ID, 'user_id': _ID}, ['group_id', 'user_id']),
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'platform_leave_group',
|
||||||
|
'leave_group',
|
||||||
|
'platform',
|
||||||
|
'moderation',
|
||||||
|
'dangerous',
|
||||||
|
{'zh_Hans': '退出群组', 'en_US': 'Leave group'},
|
||||||
|
{'zh_Hans': '让当前机器人退出指定群组。', 'en_US': 'Make the current bot leave a specified group.'},
|
||||||
|
_object_schema({'group_id': _ID}, ['group_id']),
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'platform_get_user_info',
|
||||||
|
'get_user_info',
|
||||||
|
'platform',
|
||||||
|
'identity',
|
||||||
|
'read',
|
||||||
|
{'zh_Hans': '查询用户', 'en_US': 'Get user'},
|
||||||
|
{'zh_Hans': '查询指定用户的资料。', 'en_US': 'Read a specified user profile.'},
|
||||||
|
_object_schema({'user_id': _ID}, ['user_id']),
|
||||||
|
),
|
||||||
|
PlatformToolDefinition(
|
||||||
|
'platform_get_friend_list',
|
||||||
|
'get_friend_list',
|
||||||
|
'platform',
|
||||||
|
'identity',
|
||||||
|
'read',
|
||||||
|
{'zh_Hans': '列出好友', 'en_US': 'List friends'},
|
||||||
|
{'zh_Hans': '列出当前机器人的好友。', 'en_US': 'List friends of the current bot.'},
|
||||||
|
_object_schema({}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
PLATFORM_TOOLS_BY_NAME = {definition.name: definition for definition in PLATFORM_TOOL_DEFINITIONS}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_agent_platform_tool_names(config: typing.Mapping[str, typing.Any], event_type: str) -> list[str]:
|
||||||
|
"""Resolve platform tools plus the event actions implied by this event."""
|
||||||
|
configured = [name for name in config.get('allowed_platform_tools', []) if isinstance(name, str)]
|
||||||
|
selected = [
|
||||||
|
name
|
||||||
|
for name in configured
|
||||||
|
if (definition := PLATFORM_TOOLS_BY_NAME.get(name)) is not None and definition.scope == 'platform'
|
||||||
|
]
|
||||||
|
selected.extend(
|
||||||
|
definition.name
|
||||||
|
for definition in PLATFORM_TOOL_DEFINITIONS
|
||||||
|
if definition.scope == 'event' and _event_matches(event_type, definition.event_patterns)
|
||||||
|
)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def platform_tool_catalog() -> list[dict[str, typing.Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'name': item.name,
|
||||||
|
'api': item.api,
|
||||||
|
'scope': item.scope,
|
||||||
|
'category': item.category,
|
||||||
|
'risk': item.risk,
|
||||||
|
'label': item.label,
|
||||||
|
'description': item.description,
|
||||||
|
'event_patterns': list(item.event_patterns),
|
||||||
|
'parameters': copy.deepcopy(item.parameters),
|
||||||
|
}
|
||||||
|
for item in PLATFORM_TOOL_DEFINITIONS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _event_matches(event_type: str, patterns: tuple[str, ...]) -> bool:
|
||||||
|
return any(fnmatch.fnmatchcase(event_type, pattern) for pattern in patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_binding_available(event: AgentEventEnvelope, binding: str | None) -> bool:
|
||||||
|
if binding is None:
|
||||||
|
return True
|
||||||
|
reply_target = event.delivery.reply_target or {}
|
||||||
|
if binding == 'reply_target':
|
||||||
|
return bool(reply_target.get('target_type') and reply_target.get('target_id'))
|
||||||
|
if binding == 'current_message':
|
||||||
|
return bool(
|
||||||
|
reply_target.get('target_type') and reply_target.get('target_id') and reply_target.get('message_id')
|
||||||
|
)
|
||||||
|
if binding == 'actor':
|
||||||
|
return bool(event.actor and event.actor.actor_id)
|
||||||
|
group_id = reply_target.get('group_id') or (
|
||||||
|
event.subject.subject_id if event.subject and event.subject.subject_type == 'group' else None
|
||||||
|
)
|
||||||
|
if binding == 'group':
|
||||||
|
return bool(group_id)
|
||||||
|
if binding == 'group_actor':
|
||||||
|
return bool(group_id and event.actor and event.actor.actor_id)
|
||||||
|
if binding == 'request':
|
||||||
|
# A Host event reference is only a journal identity. It is not a
|
||||||
|
# platform request token and must never be forwarded to an adapter as
|
||||||
|
# one. Request actions are therefore available only when the adapter
|
||||||
|
# event supplied its real request_id.
|
||||||
|
return bool(event.data.get('request_id'))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def build_platform_tool_resources(
|
||||||
|
event: AgentEventEnvelope, selected_names: typing.Iterable[str] | None, operations: list[str]
|
||||||
|
) -> tuple[list[dict[str, typing.Any]], dict[str, typing.Any]]:
|
||||||
|
selected = list(dict.fromkeys(selected_names or []))
|
||||||
|
supported_apis = set((event.delivery.platform_capabilities or {}).get('supported_apis') or [])
|
||||||
|
resources: list[dict[str, typing.Any]] = []
|
||||||
|
unavailable: list[dict[str, str]] = []
|
||||||
|
if 'call' not in operations:
|
||||||
|
capabilities = copy.deepcopy(event.delivery.platform_capabilities or {})
|
||||||
|
capabilities.update(
|
||||||
|
{
|
||||||
|
'authorized_tools': [],
|
||||||
|
'unavailable_tools': [{'name': name, 'reason': 'runner_call_permission_missing'} for name in selected],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return resources, capabilities
|
||||||
|
for name in selected:
|
||||||
|
definition = PLATFORM_TOOLS_BY_NAME.get(name)
|
||||||
|
reason = None
|
||||||
|
if definition is None:
|
||||||
|
reason = 'unknown_tool'
|
||||||
|
elif definition.api not in supported_apis:
|
||||||
|
reason = 'adapter_api_unsupported'
|
||||||
|
elif definition.scope == 'event' and not _event_matches(event.event_type, definition.event_patterns):
|
||||||
|
reason = 'event_incompatible'
|
||||||
|
elif definition.scope == 'event' and not _event_binding_available(event, definition.binding):
|
||||||
|
reason = 'event_target_unavailable'
|
||||||
|
if reason:
|
||||||
|
unavailable.append({'name': name, 'reason': reason})
|
||||||
|
continue
|
||||||
|
assert definition is not None
|
||||||
|
resources.append(
|
||||||
|
{
|
||||||
|
'tool_name': definition.name,
|
||||||
|
'tool_type': 'platform',
|
||||||
|
'description': definition.description.get('en_US') or definition.description.get('zh_Hans'),
|
||||||
|
'operations': list(operations),
|
||||||
|
'parameters': copy.deepcopy(definition.parameters),
|
||||||
|
'source': 'platform',
|
||||||
|
'source_id': definition.name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
capabilities = copy.deepcopy(event.delivery.platform_capabilities or {})
|
||||||
|
capabilities.update(
|
||||||
|
{'authorized_tools': [item['tool_name'] for item in resources], 'unavailable_tools': unavailable}
|
||||||
|
)
|
||||||
|
return resources, capabilities
|
||||||
|
|
||||||
|
|
||||||
|
def freeze_platform_context(event: AgentEventEnvelope) -> dict[str, typing.Any]:
|
||||||
|
return {
|
||||||
|
'event_type': event.event_type,
|
||||||
|
'data': copy.deepcopy(event.data),
|
||||||
|
'actor': event.actor.model_dump(mode='json') if event.actor else None,
|
||||||
|
'subject': event.subject.model_dump(mode='json') if event.subject else None,
|
||||||
|
'delivery': event.delivery.model_dump(mode='json'),
|
||||||
|
'raw_ref': event.raw_ref.model_dump(mode='json') if event.raw_ref else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_platform_tool_detail(session: typing.Mapping[str, typing.Any], tool_name: str) -> dict[str, typing.Any] | None:
|
||||||
|
for tool in session.get('authorization', {}).get('resources', {}).get('tools', []):
|
||||||
|
if tool.get('tool_name') == tool_name and tool.get('source') == 'platform':
|
||||||
|
return {
|
||||||
|
'name': tool_name,
|
||||||
|
'description': tool.get('description'),
|
||||||
|
'parameters': copy.deepcopy(tool.get('parameters') or _object_schema({})),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_string(parameters: dict[str, typing.Any], name: str) -> str:
|
||||||
|
value = parameters.get(name)
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise ValueError(f'{name} must be a non-empty string')
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _event_params(
|
||||||
|
definition: PlatformToolDefinition, context: dict[str, typing.Any], parameters: dict[str, typing.Any]
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
|
reply_target = (context.get('delivery') or {}).get('reply_target') or {}
|
||||||
|
actor = context.get('actor') or {}
|
||||||
|
subject = context.get('subject') or {}
|
||||||
|
data = context.get('data') or {}
|
||||||
|
group_id = reply_target.get('group_id') or (
|
||||||
|
subject.get('subject_id') if subject.get('subject_type') == 'group' else None
|
||||||
|
)
|
||||||
|
actor_id = actor.get('actor_id')
|
||||||
|
if definition.binding == 'reply_target':
|
||||||
|
return {
|
||||||
|
'target_type': reply_target.get('target_type'),
|
||||||
|
'target_id': reply_target.get('target_id'),
|
||||||
|
'text': _require_string(parameters, 'text'),
|
||||||
|
}
|
||||||
|
if definition.binding == 'current_message':
|
||||||
|
return {
|
||||||
|
'chat_type': reply_target.get('target_type'),
|
||||||
|
'chat_id': reply_target.get('target_id'),
|
||||||
|
'message_id': reply_target.get('message_id'),
|
||||||
|
}
|
||||||
|
if definition.binding == 'actor':
|
||||||
|
return {'user_id': actor_id}
|
||||||
|
if definition.binding == 'group':
|
||||||
|
return {'group_id': group_id}
|
||||||
|
if definition.binding == 'group_actor':
|
||||||
|
result = {'group_id': group_id, 'user_id': actor_id}
|
||||||
|
if definition.api == 'mute_member':
|
||||||
|
duration = parameters.get('duration', 0)
|
||||||
|
if not isinstance(duration, int) or duration < 0:
|
||||||
|
raise ValueError('duration must be a non-negative integer')
|
||||||
|
result['duration'] = duration
|
||||||
|
return result
|
||||||
|
if definition.binding == 'request':
|
||||||
|
result = {
|
||||||
|
'request_id': data.get('request_id'),
|
||||||
|
'approve': parameters.get('approve'),
|
||||||
|
}
|
||||||
|
if not isinstance(result['approve'], bool):
|
||||||
|
raise ValueError('approve must be a boolean')
|
||||||
|
if definition.api == 'approve_friend_request' and isinstance(parameters.get('remark'), str):
|
||||||
|
result['remark'] = parameters['remark']
|
||||||
|
return result
|
||||||
|
return dict(parameters)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_platform_params(
|
||||||
|
definition: PlatformToolDefinition, parameters: dict[str, typing.Any]
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
|
if not isinstance(parameters, dict):
|
||||||
|
raise ValueError('parameters must be an object')
|
||||||
|
allowed = set((definition.parameters.get('properties') or {}).keys())
|
||||||
|
extra = set(parameters) - allowed
|
||||||
|
if extra:
|
||||||
|
raise ValueError(f'Unexpected parameters: {", ".join(sorted(extra))}')
|
||||||
|
for required in definition.parameters.get('required', []):
|
||||||
|
if required not in parameters:
|
||||||
|
raise ValueError(f'{required} is required')
|
||||||
|
for name, value in parameters.items():
|
||||||
|
field = definition.parameters['properties'][name]
|
||||||
|
expected_type = field.get('type')
|
||||||
|
if expected_type == 'string' and not isinstance(value, str):
|
||||||
|
raise ValueError(f'{name} must be a string')
|
||||||
|
if expected_type == 'boolean' and not isinstance(value, bool):
|
||||||
|
raise ValueError(f'{name} must be a boolean')
|
||||||
|
if expected_type == 'integer' and (isinstance(value, bool) or not isinstance(value, int)):
|
||||||
|
raise ValueError(f'{name} must be an integer')
|
||||||
|
if isinstance(value, str) and field.get('minLength', 0) > len(value):
|
||||||
|
raise ValueError(f'{name} must be a non-empty string')
|
||||||
|
if isinstance(value, int) and 'minimum' in field and value < field['minimum']:
|
||||||
|
raise ValueError(f'{name} must be at least {field["minimum"]}')
|
||||||
|
if 'enum' in field and value not in field['enum']:
|
||||||
|
raise ValueError(f'{name} must be one of: {", ".join(field["enum"])}')
|
||||||
|
return dict(parameters)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_platform_tool(
|
||||||
|
ap: typing.Any,
|
||||||
|
execution_context: typing.Any,
|
||||||
|
session: typing.Mapping[str, typing.Any],
|
||||||
|
tool_name: str,
|
||||||
|
parameters: dict[str, typing.Any],
|
||||||
|
) -> typing.Any:
|
||||||
|
definition = PLATFORM_TOOLS_BY_NAME.get(tool_name)
|
||||||
|
if definition is None:
|
||||||
|
raise ValueError(f'Unknown platform tool: {tool_name}')
|
||||||
|
authorization = session.get('authorization', {})
|
||||||
|
bot_id = authorization.get('bot_id')
|
||||||
|
if not bot_id:
|
||||||
|
raise ValueError('This run is not associated with a platform bot')
|
||||||
|
bot = await ap.platform_mgr.get_bot_by_uuid(execution_context, bot_id)
|
||||||
|
if bot is None:
|
||||||
|
raise ValueError(f'Bot {bot_id} is not running')
|
||||||
|
if definition.api not in set(bot.adapter.get_supported_apis() or []):
|
||||||
|
raise ValueError(f'Platform API {definition.api} is no longer supported by bot {bot_id}')
|
||||||
|
api_func = getattr(bot.adapter, definition.api, None)
|
||||||
|
if not callable(api_func):
|
||||||
|
raise ValueError(f'Platform API {definition.api} is declared but not implemented')
|
||||||
|
normalized = _normalize_platform_params(definition, parameters)
|
||||||
|
if definition.scope == 'event':
|
||||||
|
normalized = _event_params(definition, authorization.get('platform_context') or {}, normalized)
|
||||||
|
if definition.api == 'send_message':
|
||||||
|
normalized = {
|
||||||
|
'target_type': _require_string(normalized, 'target_type'),
|
||||||
|
'target_id': _require_string(normalized, 'target_id'),
|
||||||
|
'message': platform_message.MessageChain(
|
||||||
|
[platform_message.Plain(text=_require_string(normalized, 'text'))]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return await api_func(**normalized)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'PLATFORM_TOOL_DEFINITIONS',
|
||||||
|
'build_platform_tool_resources',
|
||||||
|
'execute_platform_tool',
|
||||||
|
'freeze_platform_context',
|
||||||
|
'get_platform_tool_detail',
|
||||||
|
'platform_tool_catalog',
|
||||||
|
'resolve_agent_platform_tool_names',
|
||||||
|
]
|
||||||
@@ -20,6 +20,7 @@ from .host_models import AgentEventEnvelope, AgentBinding
|
|||||||
from .resource_policy import ResourcePolicyProjector
|
from .resource_policy import ResourcePolicyProjector
|
||||||
from ...provider.tools.loaders.mcp import MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE
|
from ...provider.tools.loaders.mcp import MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE
|
||||||
from ...provider.tools.toolmgr import ToolSourceRef
|
from ...provider.tools.toolmgr import ToolSourceRef
|
||||||
|
from .platform_tools import build_platform_tool_resources
|
||||||
|
|
||||||
|
|
||||||
class AgentResourceBuilder:
|
class AgentResourceBuilder:
|
||||||
@@ -86,6 +87,18 @@ class AgentResourceBuilder:
|
|||||||
descriptor,
|
descriptor,
|
||||||
runner_config,
|
runner_config,
|
||||||
)
|
)
|
||||||
|
runner_uses_host_tools = config_schema.uses_host_tools(descriptor)
|
||||||
|
platform_tools, platform_capabilities = build_platform_tool_resources(
|
||||||
|
event,
|
||||||
|
resource_policy.allowed_platform_tool_names,
|
||||||
|
(
|
||||||
|
[operation for operation in ('detail', 'call') if operation in set(manifest_perms.tools)]
|
||||||
|
if runner_uses_host_tools
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if runner_uses_host_tools:
|
||||||
|
tools.extend(platform_tools)
|
||||||
knowledge_bases = await self._build_knowledge_bases_from_binding(
|
knowledge_bases = await self._build_knowledge_bases_from_binding(
|
||||||
execution_context,
|
execution_context,
|
||||||
manifest_perms,
|
manifest_perms,
|
||||||
@@ -106,7 +119,7 @@ class AgentResourceBuilder:
|
|||||||
'knowledge_bases': knowledge_bases,
|
'knowledge_bases': knowledge_bases,
|
||||||
'skills': skills,
|
'skills': skills,
|
||||||
'storage': storage,
|
'storage': storage,
|
||||||
'platform_capabilities': {}, # Reserved for EBA
|
'platform_capabilities': platform_capabilities,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _build_models_from_binding(
|
async def _build_models_from_binding(
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ class ResourcePolicyProjector:
|
|||||||
resolved_tool_sources: typing.Mapping[str, typing.Any] | None = None,
|
resolved_tool_sources: typing.Mapping[str, typing.Any] | None = None,
|
||||||
resolved_kb_uuids: collections.abc.Iterable[typing.Any] | None = None,
|
resolved_kb_uuids: collections.abc.Iterable[typing.Any] | None = None,
|
||||||
resolved_skill_names: collections.abc.Iterable[typing.Any] | None = None,
|
resolved_skill_names: collections.abc.Iterable[typing.Any] | None = None,
|
||||||
|
allowed_platform_tool_names: collections.abc.Iterable[typing.Any] | None = None,
|
||||||
|
allowed_host_tool_names: collections.abc.Iterable[typing.Any] | None = None,
|
||||||
|
override_runner_tools: bool = False,
|
||||||
) -> ResourcePolicy:
|
) -> ResourcePolicy:
|
||||||
"""Project standard resource fields without depending on a runner ID.
|
"""Project standard resource fields without depending on a runner ID.
|
||||||
|
|
||||||
@@ -33,7 +36,10 @@ class ResourcePolicyProjector:
|
|||||||
selected_tool_names = cls.normalize_names(config.get('tools'))
|
selected_tool_names = cls.normalize_names(config.get('tools'))
|
||||||
enable_all_tools = config.get('enable-all-tools', True) is True
|
enable_all_tools = config.get('enable-all-tools', True) is True
|
||||||
|
|
||||||
if resolved_tool_names is not None:
|
if override_runner_tools:
|
||||||
|
allowed_tool_names = cls.normalize_names(allowed_host_tool_names)
|
||||||
|
allow_all_tools = False
|
||||||
|
elif resolved_tool_names is not None:
|
||||||
available_tool_names = cls.normalize_names(resolved_tool_names)
|
available_tool_names = cls.normalize_names(resolved_tool_names)
|
||||||
if enable_all_tools:
|
if enable_all_tools:
|
||||||
allowed_tool_names = available_tool_names
|
allowed_tool_names = available_tool_names
|
||||||
@@ -64,6 +70,7 @@ class ResourcePolicyProjector:
|
|||||||
allowed_model_uuids=cls.normalize_optional_names(resolved_model_uuids),
|
allowed_model_uuids=cls.normalize_optional_names(resolved_model_uuids),
|
||||||
allowed_tool_names=allowed_tool_names,
|
allowed_tool_names=allowed_tool_names,
|
||||||
allowed_tool_sources=allowed_tool_sources,
|
allowed_tool_sources=allowed_tool_sources,
|
||||||
|
allowed_platform_tool_names=cls.normalize_names(allowed_platform_tool_names),
|
||||||
allow_all_tools=allow_all_tools,
|
allow_all_tools=allow_all_tools,
|
||||||
allowed_kb_uuids=allowed_kb_uuids,
|
allowed_kb_uuids=allowed_kb_uuids,
|
||||||
allowed_skill_names=cls.normalize_optional_names(resolved_skill_names),
|
allowed_skill_names=cls.normalize_optional_names(resolved_skill_names),
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class RunAuthorizationSnapshot(typing.TypedDict):
|
|||||||
thread_id: str | None
|
thread_id: str | None
|
||||||
state_policy: dict[str, typing.Any]
|
state_policy: dict[str, typing.Any]
|
||||||
state_context: dict[str, typing.Any]
|
state_context: dict[str, typing.Any]
|
||||||
|
platform_context: dict[str, typing.Any]
|
||||||
authorized_ids: dict[str, set[str]]
|
authorized_ids: dict[str, set[str]]
|
||||||
authorized_operations: dict[str, dict[str, set[str]]]
|
authorized_operations: dict[str, dict[str, set[str]]]
|
||||||
|
|
||||||
@@ -113,6 +114,7 @@ class AgentRunSessionRegistry:
|
|||||||
state_policy: dict[str, typing.Any] | None = None,
|
state_policy: dict[str, typing.Any] | None = None,
|
||||||
state_context: dict[str, typing.Any] | None = None,
|
state_context: dict[str, typing.Any] | None = None,
|
||||||
execution_query: pipeline_query.Query | None = None,
|
execution_query: pipeline_query.Query | None = None,
|
||||||
|
platform_context: dict[str, typing.Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register a new agent run session.
|
"""Register a new agent run session.
|
||||||
|
|
||||||
@@ -155,6 +157,7 @@ class AgentRunSessionRegistry:
|
|||||||
'thread_id': thread_id,
|
'thread_id': thread_id,
|
||||||
'state_policy': copy.deepcopy(state_policy),
|
'state_policy': copy.deepcopy(state_policy),
|
||||||
'state_context': copy.deepcopy(state_context),
|
'state_context': copy.deepcopy(state_context),
|
||||||
|
'platform_context': copy.deepcopy(platform_context or {}),
|
||||||
'authorized_ids': self._build_authorized_ids(resources_snapshot),
|
'authorized_ids': self._build_authorized_ids(resources_snapshot),
|
||||||
'authorized_operations': self._build_authorized_operations(resources_snapshot),
|
'authorized_operations': self._build_authorized_operations(resources_snapshot),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ from ....agent.runner.host_models import (
|
|||||||
StatePolicy,
|
StatePolicy,
|
||||||
)
|
)
|
||||||
from ....agent.runner.resource_policy import ResourcePolicyProjector
|
from ....agent.runner.resource_policy import ResourcePolicyProjector
|
||||||
|
from ....agent.runner.platform_tools import (
|
||||||
|
platform_tool_catalog,
|
||||||
|
resolve_agent_platform_tool_names,
|
||||||
|
)
|
||||||
from ....entity.persistence import agent as persistence_agent
|
from ....entity.persistence import agent as persistence_agent
|
||||||
from ....workspace.errors import WorkspaceNotFoundError
|
from ....workspace.errors import WorkspaceNotFoundError
|
||||||
from ..context import ExecutionContext, RequestContext
|
from ..context import ExecutionContext, RequestContext
|
||||||
@@ -49,8 +53,21 @@ class AgentService:
|
|||||||
"""Return metadata needed by Agent forms."""
|
"""Return metadata needed by Agent forms."""
|
||||||
pipeline_metadata = await self.ap.pipeline_service.get_pipeline_metadata(context)
|
pipeline_metadata = await self.ap.pipeline_service.get_pipeline_metadata(context)
|
||||||
ai_metadata = next((item for item in pipeline_metadata if item.get('name') == 'ai'), None)
|
ai_metadata = next((item for item in pipeline_metadata if item.get('name') == 'ai'), None)
|
||||||
|
host_tools: list[dict[str, typing.Any]] | None = None
|
||||||
|
get_tool_catalog = getattr(getattr(self.ap, 'tool_mgr', None), 'get_resolved_tool_catalog', None)
|
||||||
|
if get_tool_catalog is not None:
|
||||||
|
try:
|
||||||
|
host_tools = await get_tool_catalog(
|
||||||
|
context,
|
||||||
|
include_skill_authoring=True,
|
||||||
|
include_mcp_resource_tools=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.ap.logger.warning(f'Failed to load Agent Host tool catalog: {exc}')
|
||||||
return {
|
return {
|
||||||
'runner_config': ai_metadata,
|
'runner_config': ai_metadata,
|
||||||
|
'platform_tools': platform_tool_catalog(),
|
||||||
|
'host_tools': host_tools,
|
||||||
'kinds': [
|
'kinds': [
|
||||||
{
|
{
|
||||||
'name': AGENT_KIND_AGENT,
|
'name': AGENT_KIND_AGENT,
|
||||||
@@ -192,7 +209,12 @@ class AgentService:
|
|||||||
event_types=[event_type],
|
event_types=[event_type],
|
||||||
runner_id=runner_id,
|
runner_id=runner_id,
|
||||||
runner_config=runner_config,
|
runner_config=runner_config,
|
||||||
resource_policy=ResourcePolicyProjector.from_runner_config(runner_config),
|
resource_policy=ResourcePolicyProjector.from_runner_config(
|
||||||
|
runner_config,
|
||||||
|
allowed_platform_tool_names=resolve_agent_platform_tool_names(config, event_type),
|
||||||
|
allowed_host_tool_names=config.get('allowed_tools'),
|
||||||
|
override_runner_tools='allowed_tools' in config,
|
||||||
|
),
|
||||||
state_policy=StatePolicy(
|
state_policy=StatePolicy(
|
||||||
state_scopes=['conversation', 'actor', 'subject', 'runner'],
|
state_scopes=['conversation', 'actor', 'subject', 'runner'],
|
||||||
),
|
),
|
||||||
@@ -292,7 +314,11 @@ class AgentService:
|
|||||||
'kind': AGENT_KIND_AGENT,
|
'kind': AGENT_KIND_AGENT,
|
||||||
'component_ref': runner_id,
|
'component_ref': runner_id,
|
||||||
'config': config,
|
'config': config,
|
||||||
'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
|
'supported_event_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': AGENT_KIND_AGENT}
|
||||||
@@ -317,9 +343,6 @@ class AgentService:
|
|||||||
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 'supported_event_patterns' in update_data and not update_data['supported_event_patterns']:
|
|
||||||
update_data['supported_event_patterns'] = AGENT_DEFAULT_EVENT_PATTERNS
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -404,8 +427,11 @@ class AgentService:
|
|||||||
) -> 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'] = AGENT_KIND_AGENT
|
||||||
|
supported_event_patterns = item.get('supported_event_patterns')
|
||||||
item['capability'] = {
|
item['capability'] = {
|
||||||
'supported_event_patterns': item.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
|
'supported_event_patterns': (
|
||||||
|
supported_event_patterns if isinstance(supported_event_patterns, list) else AGENT_DEFAULT_EVENT_PATTERNS
|
||||||
|
),
|
||||||
'message_only': False,
|
'message_only': False,
|
||||||
}
|
}
|
||||||
if not include_config:
|
if not include_config:
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class BotService:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _agent_supports_event_pattern(cls, supported_patterns: list[str] | None, event_pattern: str) -> bool:
|
def _agent_supports_event_pattern(cls, supported_patterns: list[str] | None, event_pattern: str) -> bool:
|
||||||
patterns = supported_patterns or ['*']
|
patterns = supported_patterns if isinstance(supported_patterns, list) else ['*']
|
||||||
return any(cls._event_pattern_covers(pattern, event_pattern) for pattern in patterns)
|
return any(cls._event_pattern_covers(pattern, event_pattern) for pattern in patterns)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from ..agent.runner.host_models import (
|
|||||||
StatePolicy,
|
StatePolicy,
|
||||||
)
|
)
|
||||||
from ..agent.runner.resource_policy import ResourcePolicyProjector
|
from ..agent.runner.resource_policy import ResourcePolicyProjector
|
||||||
|
from ..agent.runner.platform_tools import resolve_agent_platform_tool_names
|
||||||
from ..entity.persistence import workspace as persistence_workspace
|
from ..entity.persistence import workspace as persistence_workspace
|
||||||
|
|
||||||
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
|
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
|
||||||
@@ -165,7 +166,8 @@ class RuntimeBot:
|
|||||||
supported_patterns: list[str] | None,
|
supported_patterns: list[str] | None,
|
||||||
event_type: str,
|
event_type: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
return any(cls._match_event_pattern(event_type, pattern) for pattern in (supported_patterns or ['*']))
|
patterns = supported_patterns if isinstance(supported_patterns, list) else ['*']
|
||||||
|
return any(cls._match_event_pattern(event_type, pattern) for pattern in patterns)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_nested_value(data: dict[str, typing.Any], path: str) -> typing.Any:
|
def _get_nested_value(data: dict[str, typing.Any], path: str) -> typing.Any:
|
||||||
@@ -808,7 +810,12 @@ class RuntimeBot:
|
|||||||
event_types=[event_type],
|
event_types=[event_type],
|
||||||
runner_id=runner_id,
|
runner_id=runner_id,
|
||||||
runner_config=runner_config,
|
runner_config=runner_config,
|
||||||
resource_policy=ResourcePolicyProjector.from_runner_config(runner_config),
|
resource_policy=ResourcePolicyProjector.from_runner_config(
|
||||||
|
runner_config,
|
||||||
|
allowed_platform_tool_names=resolve_agent_platform_tool_names(config, event_type),
|
||||||
|
allowed_host_tool_names=config.get('allowed_tools'),
|
||||||
|
override_runner_tools='allowed_tools' in config,
|
||||||
|
),
|
||||||
state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']),
|
state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']),
|
||||||
delivery_policy=DeliveryPolicy(
|
delivery_policy=DeliveryPolicy(
|
||||||
enable_streaming=False,
|
enable_streaming=False,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from ..utils import constants
|
|||||||
from ..agent.runner.session_registry import get_session_registry
|
from ..agent.runner.session_registry import get_session_registry
|
||||||
from ..agent.runner.config_resolver import RunnerConfigResolver
|
from ..agent.runner.config_resolver import RunnerConfigResolver
|
||||||
from ..agent.runner import config_schema
|
from ..agent.runner import config_schema
|
||||||
|
from ..agent.runner.platform_tools import execute_platform_tool, get_platform_tool_detail
|
||||||
from ..pipeline.pool import get_query_execution_context
|
from ..pipeline.pool import get_query_execution_context
|
||||||
|
|
||||||
|
|
||||||
@@ -302,6 +303,8 @@ def _validate_frozen_tool_source_identity(
|
|||||||
MCP_TOOL_READ_RESOURCE,
|
MCP_TOOL_READ_RESOURCE,
|
||||||
}:
|
}:
|
||||||
source_ref = {'source': source, 'source_id': source_id}
|
source_ref = {'source': source, 'source_id': source_id}
|
||||||
|
elif source == 'platform' and source_id == tool_name:
|
||||||
|
source_ref = {'source': source, 'source_id': source_id}
|
||||||
|
|
||||||
if source_ref is not None:
|
if source_ref is not None:
|
||||||
return source_ref, None
|
return source_ref, None
|
||||||
@@ -1547,6 +1550,15 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
# In real implementation, you would reconstruct the full session
|
# In real implementation, you would reconstruct the full session
|
||||||
# For now, we'll call the tool manager's execute method
|
# For now, we'll call the tool manager's execute method
|
||||||
try:
|
try:
|
||||||
|
if source_ref is not None and source_ref['source'] == 'platform':
|
||||||
|
result = await execute_platform_tool(
|
||||||
|
self.ap,
|
||||||
|
self._execution_context(action_context),
|
||||||
|
session,
|
||||||
|
tool_name,
|
||||||
|
parameters,
|
||||||
|
)
|
||||||
|
return handler.ActionResponse.success(data={'result': _serialize_plugin_api_result(result)})
|
||||||
query = _resolve_action_query(
|
query = _resolve_action_query(
|
||||||
data,
|
data,
|
||||||
session,
|
session,
|
||||||
@@ -1602,6 +1614,13 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
return error
|
return error
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if source_ref is not None and source_ref['source'] == 'platform':
|
||||||
|
tool_detail = get_platform_tool_detail(session, tool_name)
|
||||||
|
if tool_detail is None:
|
||||||
|
return handler.ActionResponse.error(
|
||||||
|
message=f'Tool {tool_name} not found',
|
||||||
|
)
|
||||||
|
return handler.ActionResponse.success(data={'tool': tool_detail})
|
||||||
detail_kwargs: dict[str, Any] = {}
|
detail_kwargs: dict[str, Any] = {}
|
||||||
if source_ref is not None:
|
if source_ref is not None:
|
||||||
detail_kwargs['source_ref'] = source_ref
|
detail_kwargs['source_ref'] = source_ref
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import shutil
|
|||||||
import socket
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import textwrap
|
import textwrap
|
||||||
import time
|
import time
|
||||||
@@ -25,7 +26,6 @@ pytestmark = pytest.mark.e2e
|
|||||||
|
|
||||||
|
|
||||||
QA_RUNNER_ID = 'plugin:e2e/agent-runner-qa/default'
|
QA_RUNNER_ID = 'plugin:e2e/agent-runner-qa/default'
|
||||||
QA_PLUGIN_DIRNAME = 'e2e__agent-runner-qa'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
@pytest.fixture(scope='session')
|
||||||
@@ -198,7 +198,13 @@ def agent_runner_e2e_config_path(agent_runner_e2e_tmpdir, agent_runner_e2e_port,
|
|||||||
with open(config_path, 'w', encoding='utf-8') as f:
|
with open(config_path, 'w', encoding='utf-8') as f:
|
||||||
yaml.safe_dump(config, f, default_flow_style=False)
|
yaml.safe_dump(config, f, default_flow_style=False)
|
||||||
|
|
||||||
_write_qa_agent_runner_plugin(agent_runner_e2e_tmpdir / 'data' / 'plugins' / QA_PLUGIN_DIRNAME)
|
plugin_source = agent_runner_e2e_tmpdir / 'agent-runner-qa-package'
|
||||||
|
_write_qa_agent_runner_plugin(plugin_source)
|
||||||
|
shutil.make_archive(
|
||||||
|
str(agent_runner_e2e_tmpdir / 'agent-runner-qa'),
|
||||||
|
'zip',
|
||||||
|
root_dir=plugin_source,
|
||||||
|
)
|
||||||
return config_path
|
return config_path
|
||||||
|
|
||||||
|
|
||||||
@@ -212,7 +218,7 @@ def agent_runner_runtime_process(agent_runner_e2e_tmpdir, agent_runner_runtime_p
|
|||||||
stderr_file = open(stderr_path, 'wb')
|
stderr_file = open(stderr_path, 'wb')
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[
|
[
|
||||||
str(find_project_root() / '.venv' / 'bin' / 'python'),
|
sys.executable,
|
||||||
'-m',
|
'-m',
|
||||||
'langbot_plugin.cli.__init__',
|
'langbot_plugin.cli.__init__',
|
||||||
'rt',
|
'rt',
|
||||||
@@ -278,132 +284,180 @@ def agent_runner_client(agent_runner_e2e_port, agent_runner_langbot_process):
|
|||||||
|
|
||||||
def _init_and_auth(client: httpx.Client) -> str:
|
def _init_and_auth(client: httpx.Client) -> str:
|
||||||
"""Initialize the test admin user and return a bearer token."""
|
"""Initialize the test admin user and return a bearer token."""
|
||||||
init_resp = client.post('/api/v1/user/init', json={'user': 'admin', 'password': 'admin'})
|
credentials = {'user': 'admin@langbot.test', 'password': 'admin'}
|
||||||
|
init_resp = client.post('/api/v1/user/init', json=credentials)
|
||||||
assert init_resp.status_code == 200
|
assert init_resp.status_code == 200
|
||||||
assert init_resp.json()['code'] in [0, 1]
|
assert init_resp.json()['code'] in [0, 1]
|
||||||
|
|
||||||
auth_resp = client.post('/api/v1/user/auth', json={'user': 'admin', 'password': 'admin'})
|
auth_resp = client.post('/api/v1/user/auth', json=credentials)
|
||||||
assert auth_resp.status_code == 200
|
assert auth_resp.status_code == 200
|
||||||
payload = auth_resp.json()
|
payload = auth_resp.json()
|
||||||
assert payload['code'] == 0
|
assert payload['code'] == 0
|
||||||
return payload['data']['token']
|
return payload['data']['token']
|
||||||
|
|
||||||
|
|
||||||
def test_plugin_runtime_discovers_agent_runner(agent_runner_client, agent_runner_langbot_process):
|
def _install_qa_plugin(client: httpx.Client, token: str, package_path: Path) -> None:
|
||||||
"""Pipeline metadata should include the real runtime-discovered QA runner."""
|
"""Install the QA Runner through the same asynchronous local-upload API as the UI."""
|
||||||
token = _init_and_auth(agent_runner_client)
|
headers = {'Authorization': f'Bearer {token}'}
|
||||||
start = time.time()
|
with package_path.open('rb') as package_file:
|
||||||
while time.time() - start < 60:
|
response = client.post(
|
||||||
response = agent_runner_client.get(
|
'/api/v1/plugins/install/local',
|
||||||
|
headers=headers,
|
||||||
|
files={'file': ('agent-runner-qa.zip', package_file, 'application/zip')},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['code'] == 0, payload
|
||||||
|
task_id = payload['data']['task_id']
|
||||||
|
|
||||||
|
deadline = time.time() + 90
|
||||||
|
while time.time() < deadline:
|
||||||
|
task_response = client.get(f'/api/v1/system/tasks/{task_id}', headers=headers)
|
||||||
|
assert task_response.status_code == 200, task_response.text
|
||||||
|
task_payload = task_response.json()
|
||||||
|
assert task_payload['code'] == 0, task_payload
|
||||||
|
task = task_payload['data']
|
||||||
|
if task['runtime']['done']:
|
||||||
|
assert task['runtime']['exception'] is None, task
|
||||||
|
assert task['task_context']['metadata']['progress_percent'] == 100
|
||||||
|
return
|
||||||
|
time.sleep(1)
|
||||||
|
raise AssertionError(f'Plugin installation task {task_id} did not complete')
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_qa_runner(client: httpx.Client, token: str, timeout: float = 60) -> set[str]:
|
||||||
|
"""Return the latest Runner option set, waiting for the QA Runner when needed."""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
option_names: set[str] = set()
|
||||||
|
while time.time() < deadline:
|
||||||
|
response = client.get(
|
||||||
'/api/v1/pipelines/_/metadata',
|
'/api/v1/pipelines/_/metadata',
|
||||||
headers={'Authorization': f'Bearer {token}'},
|
headers={'Authorization': f'Bearer {token}'},
|
||||||
)
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data['code'] == 0
|
assert data['code'] == 0, data
|
||||||
metadata_groups = data['data']['configs']
|
metadata_groups = data['data']['configs']
|
||||||
ai_metadata = next(group for group in metadata_groups if group.get('name') == 'ai')
|
ai_metadata = next(group for group in metadata_groups if group.get('name') == 'ai')
|
||||||
|
|
||||||
runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner')
|
runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner')
|
||||||
runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id')
|
runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id')
|
||||||
option_names = {option['name'] for option in runner_select['options']}
|
option_names = {option['name'] for option in runner_select['options']}
|
||||||
if QA_RUNNER_ID in option_names:
|
if QA_RUNNER_ID in option_names:
|
||||||
return
|
break
|
||||||
time.sleep(2)
|
time.sleep(1)
|
||||||
|
return option_names
|
||||||
|
|
||||||
assert QA_RUNNER_ID in option_names
|
|
||||||
|
def _ensure_qa_plugin(client: httpx.Client, token: str, package_path: Path) -> None:
|
||||||
|
if QA_RUNNER_ID in _wait_for_qa_runner(client, token, timeout=2):
|
||||||
|
return
|
||||||
|
_install_qa_plugin(client, token, package_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_runtime_discovers_agent_runner(
|
||||||
|
agent_runner_client,
|
||||||
|
agent_runner_langbot_process,
|
||||||
|
agent_runner_e2e_tmpdir,
|
||||||
|
):
|
||||||
|
"""Pipeline metadata should include the real runtime-discovered QA runner."""
|
||||||
|
token = _init_and_auth(agent_runner_client)
|
||||||
|
_ensure_qa_plugin(
|
||||||
|
agent_runner_client,
|
||||||
|
token,
|
||||||
|
agent_runner_e2e_tmpdir / 'agent-runner-qa.zip',
|
||||||
|
)
|
||||||
|
option_names = _wait_for_qa_runner(agent_runner_client, token)
|
||||||
|
if QA_RUNNER_ID in option_names:
|
||||||
|
return
|
||||||
|
|
||||||
|
host_stdout, host_stderr = agent_runner_langbot_process.get_logs()
|
||||||
|
runtime_stdout = (agent_runner_e2e_tmpdir / 'plugin-runtime.stdout.log').read_text(
|
||||||
|
encoding='utf-8', errors='replace'
|
||||||
|
)
|
||||||
|
runtime_stderr = (agent_runner_e2e_tmpdir / 'plugin-runtime.stderr.log').read_text(
|
||||||
|
encoding='utf-8', errors='replace'
|
||||||
|
)
|
||||||
|
assert QA_RUNNER_ID in option_names, (
|
||||||
|
f'{QA_RUNNER_ID} was not discovered\n'
|
||||||
|
f'Host stdout (tail):\n{host_stdout[-20_000:]}\nHost stderr (tail):\n{host_stderr[-20_000:]}\n'
|
||||||
|
f'Runtime stdout (tail):\n{runtime_stdout[-20_000:]}\n'
|
||||||
|
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_e2e_config_path,
|
agent_runner_client,
|
||||||
|
agent_runner_langbot_process,
|
||||||
agent_runner_e2e_tmpdir,
|
agent_runner_e2e_tmpdir,
|
||||||
agent_runner_runtime_process,
|
|
||||||
):
|
):
|
||||||
"""The Host orchestrator should run the pluginized runner and persist run side effects."""
|
"""Create/configure/debug an Agent through HTTP and persist Runner side effects."""
|
||||||
import asyncio
|
del agent_runner_langbot_process
|
||||||
import os
|
token = _init_and_auth(agent_runner_client)
|
||||||
|
_ensure_qa_plugin(
|
||||||
from langbot.pkg.agent.runner.host_models import (
|
agent_runner_client,
|
||||||
AgentBinding,
|
token,
|
||||||
AgentEventEnvelope,
|
agent_runner_e2e_tmpdir / 'agent-runner-qa.zip',
|
||||||
BindingScope,
|
|
||||||
DeliveryPolicy,
|
|
||||||
StatePolicy,
|
|
||||||
)
|
)
|
||||||
from langbot.pkg.core import boot
|
headers = {'Authorization': f'Bearer {token}'}
|
||||||
from langbot.pkg.utils import platform as platform_utils
|
create_response = agent_runner_client.post(
|
||||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
'/api/v1/agents',
|
||||||
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext, SubjectContext
|
headers=headers,
|
||||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
json={
|
||||||
|
'kind': 'agent',
|
||||||
|
'name': 'AgentRunner E2E Agent',
|
||||||
|
'description': 'Exercises the installed QA Runner.',
|
||||||
|
'emoji': 'QA',
|
||||||
|
'supported_event_patterns': ['message.*'],
|
||||||
|
'config': {
|
||||||
|
'runner': {'id': QA_RUNNER_ID},
|
||||||
|
'runner_config': {QA_RUNNER_ID: {}},
|
||||||
|
'allowed_platform_tools': ['event_reply', 'platform_get_user_info'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 200, create_response.text
|
||||||
|
create_payload = create_response.json()
|
||||||
|
assert create_payload['code'] == 0, create_payload
|
||||||
|
agent_uuid = create_payload['data']['uuid']
|
||||||
|
|
||||||
async def _run_probe():
|
get_response = agent_runner_client.get(f'/api/v1/agents/{agent_uuid}', headers=headers)
|
||||||
previous_cwd = Path.cwd()
|
assert get_response.status_code == 200, get_response.text
|
||||||
previous_standalone_runtime = platform_utils.standalone_runtime
|
stored_agent = get_response.json()['data']['agent']
|
||||||
os.chdir(agent_runner_e2e_tmpdir)
|
assert stored_agent['config']['allowed_platform_tools'] == [
|
||||||
platform_utils.standalone_runtime = True
|
'event_reply',
|
||||||
ap = None
|
'platform_get_user_info',
|
||||||
try:
|
]
|
||||||
ap = await boot.make_app(asyncio.get_running_loop())
|
|
||||||
for _ in range(60):
|
|
||||||
handler = getattr(ap.plugin_connector, 'handler', None)
|
|
||||||
if handler is not None:
|
|
||||||
await handler.ping()
|
|
||||||
break
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
else:
|
|
||||||
raise AssertionError('Plugin runtime did not connect')
|
|
||||||
|
|
||||||
for _ in range(60):
|
debug_response = agent_runner_client.post(
|
||||||
runners = await ap.agent_runner_registry.list_runners(use_cache=False)
|
f'/api/v1/agents/{agent_uuid}/debug',
|
||||||
if any(runner.id == QA_RUNNER_ID for runner in runners):
|
headers=headers,
|
||||||
break
|
json={
|
||||||
await asyncio.sleep(1)
|
'event_type': 'message.received',
|
||||||
else:
|
'text': 'hello from orchestrator e2e',
|
||||||
raise AssertionError(f'{QA_RUNNER_ID} was not discovered')
|
'conversation_id': 'e2e-conversation',
|
||||||
|
},
|
||||||
event = AgentEventEnvelope(
|
)
|
||||||
event_id='e2e-orchestrator-event-001',
|
assert debug_response.status_code == 200, debug_response.text
|
||||||
event_type='message.received',
|
debug_payload = debug_response.json()
|
||||||
source='api',
|
assert debug_payload['code'] == 0, debug_payload
|
||||||
conversation_id='e2e-conversation',
|
result = debug_payload['data']
|
||||||
thread_id='e2e-thread',
|
assert result['final_text'] == 'e2e echo: hello from orchestrator e2e'
|
||||||
actor=ActorContext(actor_type='user', actor_id='user-001', actor_name='E2E User'),
|
assert result['outputs'][0]['role'] == 'assistant'
|
||||||
subject=SubjectContext(subject_type='chat', subject_id='chat-001'),
|
|
||||||
input=AgentInput(text='hello from orchestrator e2e'),
|
|
||||||
delivery=DeliveryContext(surface='e2e'),
|
|
||||||
)
|
|
||||||
binding = AgentBinding(
|
|
||||||
binding_id='e2e-binding',
|
|
||||||
scope=BindingScope(scope_type='global'),
|
|
||||||
runner_id=QA_RUNNER_ID,
|
|
||||||
state_policy=StatePolicy(enable_state=True, state_scopes=['conversation']),
|
|
||||||
delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True),
|
|
||||||
)
|
|
||||||
return [message async for message in ap.agent_run_orchestrator.run(event, binding)]
|
|
||||||
finally:
|
|
||||||
if ap is not None:
|
|
||||||
ap.dispose()
|
|
||||||
platform_utils.standalone_runtime = previous_standalone_runtime
|
|
||||||
os.chdir(previous_cwd)
|
|
||||||
|
|
||||||
messages = asyncio.run(_run_probe())
|
|
||||||
|
|
||||||
assert len(messages) == 1
|
|
||||||
assert messages[0].role == 'assistant'
|
|
||||||
assert messages[0].content == 'e2e echo: hello from orchestrator e2e'
|
|
||||||
|
|
||||||
db_path = agent_runner_e2e_tmpdir / 'data' / 'langbot.db'
|
db_path = agent_runner_e2e_tmpdir / 'data' / 'langbot.db'
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
try:
|
try:
|
||||||
run_row = conn.execute(
|
run_row = conn.execute(
|
||||||
"SELECT status, runner_id FROM agent_run WHERE event_id = 'e2e-orchestrator-event-001'"
|
'SELECT status, runner_id FROM agent_run WHERE event_id = ?',
|
||||||
|
(result['event_id'],),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert run_row == ('completed', QA_RUNNER_ID)
|
assert run_row == ('completed', QA_RUNNER_ID)
|
||||||
|
|
||||||
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 run_id FROM agent_run WHERE event_id = 'e2e-orchestrator-event-001')"
|
'SELECT type FROM agent_run_event WHERE run_id = '
|
||||||
|
'(SELECT run_id FROM agent_run WHERE event_id = ?)',
|
||||||
|
(result['event_id'],),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
}
|
}
|
||||||
assert {'state.updated', 'message.completed', 'run.completed'}.issubset(event_types)
|
assert {'state.updated', 'message.completed', 'run.completed'}.issubset(event_types)
|
||||||
@@ -415,59 +469,3 @@ 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_pluginized_agent_runner_executes_through_runtime(agent_runner_client, agent_runner_langbot_process):
|
|
||||||
"""The Host debug surface should invoke the QA runner through the real Plugin Runtime."""
|
|
||||||
token = _init_and_auth(agent_runner_client)
|
|
||||||
start = time.time()
|
|
||||||
while time.time() - start < 60:
|
|
||||||
metadata_response = agent_runner_client.get(
|
|
||||||
'/api/v1/pipelines/_/metadata',
|
|
||||||
headers={'Authorization': f'Bearer {token}'},
|
|
||||||
)
|
|
||||||
assert metadata_response.status_code == 200
|
|
||||||
metadata = metadata_response.json()['data']['configs']
|
|
||||||
ai_metadata = next(group for group in metadata if group.get('name') == 'ai')
|
|
||||||
runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner')
|
|
||||||
runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id')
|
|
||||||
if QA_RUNNER_ID in {option['name'] for option in runner_select['options']}:
|
|
||||||
break
|
|
||||||
time.sleep(2)
|
|
||||||
else:
|
|
||||||
pytest.fail(f'{QA_RUNNER_ID} was not discovered before run_agent')
|
|
||||||
|
|
||||||
response = agent_runner_client.post(
|
|
||||||
'/api/v1/system/debug/plugin/action',
|
|
||||||
headers={'Authorization': f'Bearer {token}'},
|
|
||||||
json={
|
|
||||||
'action': 'run_agent',
|
|
||||||
'timeout': 60,
|
|
||||||
'data': {
|
|
||||||
'plugin_author': 'e2e',
|
|
||||||
'plugin_name': 'agent-runner-qa',
|
|
||||||
'runner_name': 'default',
|
|
||||||
'context': {
|
|
||||||
'run_id': 'e2e-run-001',
|
|
||||||
'trigger': {'type': 'message.received'},
|
|
||||||
'event': {
|
|
||||||
'event_id': 'e2e-event-001',
|
|
||||||
'event_type': 'message.received',
|
|
||||||
'source': 'api',
|
|
||||||
},
|
|
||||||
'input': {'text': 'hello from real e2e'},
|
|
||||||
'delivery': {'surface': 'e2e'},
|
|
||||||
'resources': {},
|
|
||||||
'runtime': {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
payload = response.json()
|
|
||||||
assert payload['code'] == 0
|
|
||||||
result = payload['data']
|
|
||||||
assert result['type'] == 'message.completed', result
|
|
||||||
assert result['data']['message']['role'] == 'assistant'
|
|
||||||
assert result['data']['message']['content'] == 'e2e echo: hello from real e2e'
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import shutil
|
|||||||
import socket
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -30,7 +31,6 @@ pytestmark = pytest.mark.e2e
|
|||||||
LOCAL_AGENT_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
|
LOCAL_AGENT_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
|
||||||
FAKE_PROVIDER_UUID = 'e2e-fake-provider'
|
FAKE_PROVIDER_UUID = 'e2e-fake-provider'
|
||||||
FAKE_MODEL_UUID = 'e2e-fake-local-agent-model'
|
FAKE_MODEL_UUID = 'e2e-fake-local-agent-model'
|
||||||
LOCAL_AGENT_PLUGIN_DIRNAME = 'langbot__local-agent'
|
|
||||||
E2E_TOOL_NAME = 'e2e_lookup'
|
E2E_TOOL_NAME = 'e2e_lookup'
|
||||||
E2E_KB_UUID = 'e2e-kb-local-agent'
|
E2E_KB_UUID = 'e2e-kb-local-agent'
|
||||||
|
|
||||||
@@ -48,13 +48,13 @@ def _local_agent_repo() -> Path:
|
|||||||
return project_root.parent / 'langbot-local-agent'
|
return project_root.parent / 'langbot-local-agent'
|
||||||
|
|
||||||
|
|
||||||
def _copy_local_agent_plugin(tmpdir: Path) -> None:
|
def _package_local_agent_plugin(tmpdir: Path) -> Path:
|
||||||
"""Copy the sibling Local Agent plugin into the temporary LangBot data dir."""
|
"""Package the sibling Local Agent plugin for the real local-install flow."""
|
||||||
local_agent_src = _local_agent_repo()
|
local_agent_src = _local_agent_repo()
|
||||||
if not (local_agent_src / 'manifest.yaml').exists():
|
if not (local_agent_src / 'manifest.yaml').exists():
|
||||||
pytest.skip(f'local-agent repository not found at {local_agent_src}')
|
pytest.skip(f'local-agent repository not found at {local_agent_src}')
|
||||||
|
|
||||||
plugin_dst = tmpdir / 'data' / 'plugins' / LOCAL_AGENT_PLUGIN_DIRNAME
|
package_source = tmpdir / 'local-agent-package'
|
||||||
ignore = shutil.ignore_patterns(
|
ignore = shutil.ignore_patterns(
|
||||||
'.git',
|
'.git',
|
||||||
'.venv',
|
'.venv',
|
||||||
@@ -64,7 +64,16 @@ def _copy_local_agent_plugin(tmpdir: Path) -> None:
|
|||||||
'build',
|
'build',
|
||||||
'dist',
|
'dist',
|
||||||
)
|
)
|
||||||
shutil.copytree(local_agent_src, plugin_dst, ignore=ignore)
|
shutil.copytree(local_agent_src, package_source, ignore=ignore)
|
||||||
|
archive_path = Path(
|
||||||
|
shutil.make_archive(
|
||||||
|
str(tmpdir / 'langbot-local-agent'),
|
||||||
|
'zip',
|
||||||
|
root_dir=package_source,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
shutil.rmtree(package_source)
|
||||||
|
return archive_path
|
||||||
|
|
||||||
|
|
||||||
def _content_text(content: Any) -> str:
|
def _content_text(content: Any) -> str:
|
||||||
@@ -289,7 +298,7 @@ def local_agent_e2e_config_path(local_agent_e2e_tmpdir, local_agent_e2e_port, lo
|
|||||||
with open(config_path, 'w', encoding='utf-8') as f:
|
with open(config_path, 'w', encoding='utf-8') as f:
|
||||||
yaml.safe_dump(config, f, default_flow_style=False)
|
yaml.safe_dump(config, f, default_flow_style=False)
|
||||||
|
|
||||||
_copy_local_agent_plugin(local_agent_e2e_tmpdir)
|
_package_local_agent_plugin(local_agent_e2e_tmpdir)
|
||||||
return config_path
|
return config_path
|
||||||
|
|
||||||
|
|
||||||
@@ -304,7 +313,7 @@ def local_agent_runtime_process(local_agent_e2e_tmpdir, local_agent_runtime_port
|
|||||||
stderr_file = open(stderr_path, 'wb')
|
stderr_file = open(stderr_path, 'wb')
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[
|
[
|
||||||
str(find_project_root() / '.venv' / 'bin' / 'python'),
|
sys.executable,
|
||||||
'-m',
|
'-m',
|
||||||
'langbot_plugin.cli.__init__',
|
'langbot_plugin.cli.__init__',
|
||||||
'rt',
|
'rt',
|
||||||
@@ -329,14 +338,18 @@ def local_agent_runtime_process(local_agent_e2e_tmpdir, local_agent_runtime_port
|
|||||||
stderr_file.close()
|
stderr_file.close()
|
||||||
|
|
||||||
|
|
||||||
def _inject_fake_llm_model(ap) -> Any:
|
async def _inject_fake_llm_model(ap) -> Any:
|
||||||
"""Register a runtime-only fake model that supports count_tokens/invoke."""
|
"""Register a runtime-only fake model that supports count_tokens/invoke."""
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
from langbot.pkg.entity.persistence import model as persistence_model
|
from langbot.pkg.entity.persistence import model as persistence_model
|
||||||
from langbot.pkg.provider.modelmgr import requester, token
|
from langbot.pkg.provider.modelmgr import requester, token
|
||||||
from tests.unit_tests.provider.conftest import FakeProviderAPIRequester
|
from tests.unit_tests.provider.conftest import FakeProviderAPIRequester
|
||||||
|
|
||||||
|
execution_context = await ap.plugin_connector._current_execution_context()
|
||||||
provider_entity = persistence_model.ModelProvider(
|
provider_entity = persistence_model.ModelProvider(
|
||||||
uuid=FAKE_PROVIDER_UUID,
|
uuid=FAKE_PROVIDER_UUID,
|
||||||
|
workspace_uuid=execution_context.workspace_uuid,
|
||||||
name='E2E Fake Provider',
|
name='E2E Fake Provider',
|
||||||
requester='fake-requester',
|
requester='fake-requester',
|
||||||
base_url='https://fake.invalid',
|
base_url='https://fake.invalid',
|
||||||
@@ -344,26 +357,64 @@ def _inject_fake_llm_model(ap) -> Any:
|
|||||||
)
|
)
|
||||||
fake_requester = FakeProviderAPIRequester(ap, {'base_url': provider_entity.base_url})
|
fake_requester = FakeProviderAPIRequester(ap, {'base_url': provider_entity.base_url})
|
||||||
runtime_provider = requester.RuntimeProvider(
|
runtime_provider = requester.RuntimeProvider(
|
||||||
|
execution_context=execution_context,
|
||||||
provider_entity=provider_entity,
|
provider_entity=provider_entity,
|
||||||
token_mgr=token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys),
|
token_mgr=token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys),
|
||||||
requester=fake_requester,
|
requester=fake_requester,
|
||||||
)
|
)
|
||||||
|
model_entity = persistence_model.LLMModel(
|
||||||
|
uuid=FAKE_MODEL_UUID,
|
||||||
|
workspace_uuid=execution_context.workspace_uuid,
|
||||||
|
name=FAKE_MODEL_UUID,
|
||||||
|
provider_uuid=provider_entity.uuid,
|
||||||
|
abilities=['func_call'],
|
||||||
|
context_length=8192,
|
||||||
|
extra_args={},
|
||||||
|
)
|
||||||
runtime_model = requester.RuntimeLLMModel(
|
runtime_model = requester.RuntimeLLMModel(
|
||||||
model_entity=persistence_model.LLMModel(
|
model_entity=model_entity,
|
||||||
uuid=FAKE_MODEL_UUID,
|
execution_context=execution_context,
|
||||||
name=FAKE_MODEL_UUID,
|
|
||||||
provider_uuid=provider_entity.uuid,
|
|
||||||
abilities=['func_call'],
|
|
||||||
context_length=8192,
|
|
||||||
extra_args={},
|
|
||||||
),
|
|
||||||
provider=runtime_provider,
|
provider=runtime_provider,
|
||||||
)
|
)
|
||||||
ap.model_mgr.provider_dict[provider_entity.uuid] = runtime_provider
|
await ap.persistence_mgr.execute_async(
|
||||||
ap.model_mgr.llm_models.append(runtime_model)
|
sqlalchemy.insert(persistence_model.ModelProvider).values(
|
||||||
|
uuid=provider_entity.uuid,
|
||||||
|
workspace_uuid=provider_entity.workspace_uuid,
|
||||||
|
name=provider_entity.name,
|
||||||
|
requester=provider_entity.requester,
|
||||||
|
base_url=provider_entity.base_url,
|
||||||
|
api_keys=provider_entity.api_keys,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.insert(persistence_model.LLMModel).values(
|
||||||
|
uuid=model_entity.uuid,
|
||||||
|
workspace_uuid=model_entity.workspace_uuid,
|
||||||
|
name=model_entity.name,
|
||||||
|
provider_uuid=model_entity.provider_uuid,
|
||||||
|
abilities=model_entity.abilities,
|
||||||
|
context_length=model_entity.context_length,
|
||||||
|
extra_args=model_entity.extra_args,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await ap.model_mgr.cache_provider(execution_context, runtime_provider)
|
||||||
|
await ap.model_mgr.cache_llm_model(execution_context, runtime_model)
|
||||||
return fake_requester
|
return fake_requester
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_agent(ap, event, binding) -> list[Any]:
|
||||||
|
"""Execute through the trusted Workspace context used by the real Host."""
|
||||||
|
execution_context = await ap.plugin_connector._current_execution_context()
|
||||||
|
return [
|
||||||
|
message
|
||||||
|
async for message in ap.agent_run_orchestrator.run(
|
||||||
|
event,
|
||||||
|
binding,
|
||||||
|
adapter_context={'_execution_context': execution_context},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _scripted_tool_call(
|
def _scripted_tool_call(
|
||||||
tool_name: str = E2E_TOOL_NAME,
|
tool_name: str = E2E_TOOL_NAME,
|
||||||
*,
|
*,
|
||||||
@@ -392,8 +443,10 @@ def _scripted_tool_call(
|
|||||||
async def _boot_local_agent_app(tmpdir: Path):
|
async def _boot_local_agent_app(tmpdir: Path):
|
||||||
"""Boot LangBot and wait until the Local Agent runner is discoverable."""
|
"""Boot LangBot and wait until the Local Agent runner is discoverable."""
|
||||||
from langbot.pkg.core import boot
|
from langbot.pkg.core import boot
|
||||||
|
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||||
|
|
||||||
ap = await boot.make_app(asyncio.get_running_loop())
|
ap = await boot.make_app(asyncio.get_running_loop())
|
||||||
|
run_task = asyncio.create_task(ap.run(), name='local-agent-e2e-app')
|
||||||
for _ in range(60):
|
for _ in range(60):
|
||||||
handler = getattr(ap.plugin_connector, 'handler', None)
|
handler = getattr(ap.plugin_connector, 'handler', None)
|
||||||
if handler is not None:
|
if handler is not None:
|
||||||
@@ -401,17 +454,31 @@ async def _boot_local_agent_app(tmpdir: Path):
|
|||||||
break
|
break
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
else:
|
else:
|
||||||
raise AssertionError(f'Plugin runtime did not connect; tmpdir={tmpdir}')
|
runtime_stdout = (tmpdir / 'plugin-runtime.stdout.log').read_text(encoding='utf-8', errors='replace')
|
||||||
|
runtime_stderr = (tmpdir / 'plugin-runtime.stderr.log').read_text(encoding='utf-8', errors='replace')
|
||||||
|
raise AssertionError(
|
||||||
|
f'Plugin runtime did not connect; tmpdir={tmpdir}\n'
|
||||||
|
f'Runtime stdout:\n{runtime_stdout[-20_000:]}\n'
|
||||||
|
f'Runtime stderr:\n{runtime_stderr[-20_000:]}'
|
||||||
|
)
|
||||||
|
|
||||||
for _ in range(60):
|
execution_context = await ap.plugin_connector._current_execution_context()
|
||||||
runners = await ap.agent_runner_registry.list_runners(use_cache=False)
|
runners = await ap.agent_runner_registry.list_runners(execution_context, use_cache=False)
|
||||||
if any(runner.id == LOCAL_AGENT_RUNNER_ID for runner in runners):
|
if not any(runner.id == LOCAL_AGENT_RUNNER_ID for runner in runners):
|
||||||
break
|
await ap.plugin_connector.install_plugin(
|
||||||
await asyncio.sleep(1)
|
PluginInstallSource.LOCAL,
|
||||||
else:
|
{'plugin_file': (tmpdir / 'langbot-local-agent.zip').read_bytes()},
|
||||||
raise AssertionError(f'{LOCAL_AGENT_RUNNER_ID} was not discovered')
|
)
|
||||||
|
|
||||||
return ap
|
for _ in range(60):
|
||||||
|
runners = await ap.agent_runner_registry.list_runners(execution_context, use_cache=False)
|
||||||
|
if any(runner.id == LOCAL_AGENT_RUNNER_ID for runner in runners):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
else:
|
||||||
|
raise AssertionError(f'{LOCAL_AGENT_RUNNER_ID} was not discovered after installation')
|
||||||
|
|
||||||
|
return ap, run_task
|
||||||
|
|
||||||
|
|
||||||
def _run_local_agent_probe(tmpdir: Path, probe):
|
def _run_local_agent_probe(tmpdir: Path, probe):
|
||||||
@@ -424,12 +491,30 @@ def _run_local_agent_probe(tmpdir: Path, probe):
|
|||||||
os.chdir(tmpdir)
|
os.chdir(tmpdir)
|
||||||
platform_utils.standalone_runtime = True
|
platform_utils.standalone_runtime = True
|
||||||
ap = None
|
ap = None
|
||||||
|
run_task = None
|
||||||
try:
|
try:
|
||||||
ap = await _boot_local_agent_app(tmpdir)
|
ap, run_task = await _boot_local_agent_app(tmpdir)
|
||||||
return await probe(ap)
|
return await probe(ap)
|
||||||
finally:
|
finally:
|
||||||
if ap is not None:
|
if ap is not None:
|
||||||
ap.dispose()
|
import sqlalchemy
|
||||||
|
|
||||||
|
from langbot.pkg.entity.persistence import model as persistence_model
|
||||||
|
|
||||||
|
await ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(persistence_model.LLMModel).where(
|
||||||
|
persistence_model.LLMModel.uuid == FAKE_MODEL_UUID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||||
|
persistence_model.ModelProvider.uuid == FAKE_PROVIDER_UUID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await ap.shutdown()
|
||||||
|
if run_task is not None:
|
||||||
|
run_task.cancel()
|
||||||
|
await asyncio.gather(run_task, return_exceptions=True)
|
||||||
platform_utils.standalone_runtime = previous_standalone_runtime
|
platform_utils.standalone_runtime = previous_standalone_runtime
|
||||||
os.chdir(previous_cwd)
|
os.chdir(previous_cwd)
|
||||||
|
|
||||||
@@ -445,13 +530,13 @@ def test_local_agent_runner_uses_host_fake_provider_and_persists_ledger(
|
|||||||
del local_agent_e2e_config_path, local_agent_runtime_process
|
del local_agent_e2e_config_path, local_agent_runtime_process
|
||||||
|
|
||||||
async def _run_probe(ap):
|
async def _run_probe(ap):
|
||||||
fake_requester = _inject_fake_llm_model(ap)
|
fake_requester = await _inject_fake_llm_model(ap)
|
||||||
event = _event(
|
event = _event(
|
||||||
event_id='e2e-local-agent-event-001',
|
event_id='e2e-local-agent-event-001',
|
||||||
conversation_id='e2e-local-agent-conversation',
|
conversation_id='e2e-local-agent-conversation',
|
||||||
text='Say pong through the fake provider.',
|
text='Say pong through the fake provider.',
|
||||||
)
|
)
|
||||||
messages = [message async for message in ap.agent_run_orchestrator.run(event, _binding())]
|
messages = await _run_agent(ap, event, _binding())
|
||||||
return messages, list(fake_requester._count_tokens_payloads)
|
return messages, list(fake_requester._count_tokens_payloads)
|
||||||
|
|
||||||
messages, token_payloads = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe)
|
messages, token_payloads = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe)
|
||||||
@@ -507,7 +592,7 @@ def test_local_agent_runner_executes_authorized_tool_loop_through_host_action(
|
|||||||
del local_agent_e2e_config_path, local_agent_runtime_process
|
del local_agent_e2e_config_path, local_agent_runtime_process
|
||||||
|
|
||||||
async def _run_probe(ap):
|
async def _run_probe(ap):
|
||||||
fake_requester = _inject_fake_llm_model(ap)
|
fake_requester = await _inject_fake_llm_model(ap)
|
||||||
fake_requester.queue_llm_responses(
|
fake_requester.queue_llm_responses(
|
||||||
_scripted_tool_call(),
|
_scripted_tool_call(),
|
||||||
'Tool loop final answer after tool-result:alpha',
|
'Tool loop final answer after tool-result:alpha',
|
||||||
@@ -528,7 +613,7 @@ def test_local_agent_runner_executes_authorized_tool_loop_through_host_action(
|
|||||||
'tool-execution-mode': 'serial',
|
'tool-execution-mode': 'serial',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)]
|
messages = await _run_agent(ap, event, binding)
|
||||||
return messages, tool_mgr.calls, _invoke_payload_texts(fake_requester)
|
return messages, tool_mgr.calls, _invoke_payload_texts(fake_requester)
|
||||||
|
|
||||||
messages, tool_calls, invoke_payload_texts = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe)
|
messages, tool_calls, invoke_payload_texts = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe)
|
||||||
@@ -576,7 +661,7 @@ def test_local_agent_runner_retrieves_authorized_rag_context_through_host_action
|
|||||||
del local_agent_e2e_config_path, local_agent_runtime_process
|
del local_agent_e2e_config_path, local_agent_runtime_process
|
||||||
|
|
||||||
async def _run_probe(ap):
|
async def _run_probe(ap):
|
||||||
fake_requester = _inject_fake_llm_model(ap)
|
fake_requester = await _inject_fake_llm_model(ap)
|
||||||
fake_requester.queue_llm_responses('RAG final answer with RAG_SENTINEL')
|
fake_requester.queue_llm_responses('RAG final answer with RAG_SENTINEL')
|
||||||
fake_kb = _FakeKnowledgeBase()
|
fake_kb = _FakeKnowledgeBase()
|
||||||
ap.rag_mgr = _FakeRagManager(fake_kb)
|
ap.rag_mgr = _FakeRagManager(fake_kb)
|
||||||
@@ -594,7 +679,7 @@ def test_local_agent_runner_retrieves_authorized_rag_context_through_host_action
|
|||||||
'retrieval-top-k': 1,
|
'retrieval-top-k': 1,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)]
|
messages = await _run_agent(ap, event, binding)
|
||||||
return messages, fake_kb.retrieve_calls, _invoke_payload_texts(fake_requester)
|
return messages, fake_kb.retrieve_calls, _invoke_payload_texts(fake_requester)
|
||||||
|
|
||||||
messages, retrieve_calls, invoke_payload_texts = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe)
|
messages, retrieve_calls, invoke_payload_texts = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe)
|
||||||
@@ -646,7 +731,7 @@ def test_local_agent_runner_compacts_history_and_persists_checkpoint(
|
|||||||
async def _run_probe(ap):
|
async def _run_probe(ap):
|
||||||
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
|
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
|
||||||
|
|
||||||
fake_requester = _inject_fake_llm_model(ap)
|
fake_requester = await _inject_fake_llm_model(ap)
|
||||||
fake_requester.queue_llm_responses(
|
fake_requester.queue_llm_responses(
|
||||||
'SUMMARY_SENTINEL compacted older history including HIST_SENTINEL',
|
'SUMMARY_SENTINEL compacted older history including HIST_SENTINEL',
|
||||||
'Compaction final answer',
|
'Compaction final answer',
|
||||||
@@ -682,7 +767,7 @@ def test_local_agent_runner_compacts_history_and_persists_checkpoint(
|
|||||||
'context-history-fetch-limit': 20,
|
'context-history-fetch-limit': 20,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)]
|
messages = await _run_agent(ap, event, binding)
|
||||||
return messages, _invoke_payload_texts(fake_requester), fake_requester._invoke_count
|
return messages, _invoke_payload_texts(fake_requester), fake_requester._invoke_count
|
||||||
|
|
||||||
messages, invoke_payload_texts, invoke_count = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe)
|
messages, invoke_payload_texts, invoke_count = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe)
|
||||||
@@ -738,7 +823,7 @@ def test_local_agent_runner_combines_rag_compaction_and_multi_turn_tool_loop(
|
|||||||
async def _run_probe(ap):
|
async def _run_probe(ap):
|
||||||
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
|
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
|
||||||
|
|
||||||
fake_requester = _inject_fake_llm_model(ap)
|
fake_requester = await _inject_fake_llm_model(ap)
|
||||||
|
|
||||||
async def scripted_response(**kwargs):
|
async def scripted_response(**kwargs):
|
||||||
messages = kwargs['messages']
|
messages = kwargs['messages']
|
||||||
@@ -798,7 +883,7 @@ def test_local_agent_runner_combines_rag_compaction_and_multi_turn_tool_loop(
|
|||||||
'context-history-fetch-limit': 25,
|
'context-history-fetch-limit': 25,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)]
|
messages = await _run_agent(ap, event, binding)
|
||||||
return (
|
return (
|
||||||
messages,
|
messages,
|
||||||
tool_mgr.calls,
|
tool_mgr.calls,
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ class LangBotProcess:
|
|||||||
env.pop(proxy_key, None)
|
env.pop(proxy_key, None)
|
||||||
env['NO_PROXY'] = '127.0.0.1,localhost'
|
env['NO_PROXY'] = '127.0.0.1,localhost'
|
||||||
env['no_proxy'] = '127.0.0.1,localhost'
|
env['no_proxy'] = '127.0.0.1,localhost'
|
||||||
|
# The startup banner contains Unicode symbols. Force deterministic
|
||||||
|
# UTF-8 subprocess streams so Windows locales such as GBK do not crash
|
||||||
|
# before the application can bind its HTTP port.
|
||||||
|
env['PYTHONUTF8'] = '1'
|
||||||
|
env['PYTHONIOENCODING'] = 'utf-8'
|
||||||
|
|
||||||
# Set API port via environment variable
|
# Set API port via environment variable
|
||||||
env['API__PORT'] = str(self.port)
|
env['API__PORT'] = str(self.port)
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langbot_plugin.api.entities.builtin.agent_runner import (
|
||||||
|
ActorContext,
|
||||||
|
AgentInput,
|
||||||
|
DeliveryContext,
|
||||||
|
RawEventRef,
|
||||||
|
SubjectContext,
|
||||||
|
)
|
||||||
|
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||||
|
|
||||||
|
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
|
||||||
|
from langbot.pkg.agent.runner.platform_tools import (
|
||||||
|
build_platform_tool_resources,
|
||||||
|
execute_platform_tool,
|
||||||
|
freeze_platform_context,
|
||||||
|
resolve_agent_platform_tool_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event(event_type: str = 'friend.request_received') -> AgentEventEnvelope:
|
||||||
|
return AgentEventEnvelope(
|
||||||
|
event_id='event-1',
|
||||||
|
event_type=event_type,
|
||||||
|
source='platform',
|
||||||
|
bot_id='bot-1',
|
||||||
|
input=AgentInput(text='event'),
|
||||||
|
actor=ActorContext(actor_type='user', actor_id='user-1'),
|
||||||
|
subject=SubjectContext(subject_type='group', subject_id='group-1'),
|
||||||
|
delivery=DeliveryContext(
|
||||||
|
surface='platform',
|
||||||
|
reply_target={
|
||||||
|
'target_type': 'group',
|
||||||
|
'target_id': 'group-1',
|
||||||
|
'group_id': 'group-1',
|
||||||
|
'message_id': 'message-1',
|
||||||
|
},
|
||||||
|
platform_capabilities={
|
||||||
|
'adapter': 'FakeAdapter',
|
||||||
|
'supported_apis': [
|
||||||
|
'send_message',
|
||||||
|
'approve_friend_request',
|
||||||
|
'get_group_info',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
raw_ref=RawEventRef(ref_id='request-fallback'),
|
||||||
|
data={'request_id': 'request-1'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_platform_resources_intersect_selection_adapter_and_event() -> None:
|
||||||
|
resources, capabilities = build_platform_tool_resources(
|
||||||
|
_event(),
|
||||||
|
[
|
||||||
|
'event_reply',
|
||||||
|
'event_respond_friend_request',
|
||||||
|
'event_kick_member',
|
||||||
|
'platform_get_group_info',
|
||||||
|
'unknown_tool',
|
||||||
|
],
|
||||||
|
['detail', 'call'],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {item['tool_name'] for item in resources} == {
|
||||||
|
'event_reply',
|
||||||
|
'event_respond_friend_request',
|
||||||
|
'platform_get_group_info',
|
||||||
|
}
|
||||||
|
assert all(item['source'] == 'platform' for item in resources)
|
||||||
|
assert capabilities['authorized_tools'] == [item['tool_name'] for item in resources]
|
||||||
|
assert {item['reason'] for item in capabilities['unavailable_tools']} == {
|
||||||
|
'adapter_api_unsupported',
|
||||||
|
'unknown_tool',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_platform_resources_require_runner_call_permission() -> None:
|
||||||
|
resources, capabilities = build_platform_tool_resources(
|
||||||
|
_event(),
|
||||||
|
['event_reply'],
|
||||||
|
['detail'],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resources == []
|
||||||
|
assert capabilities['unavailable_tools'] == [{'name': 'event_reply', 'reason': 'runner_call_permission_missing'}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_platform_tools_are_resolved_for_the_current_event() -> None:
|
||||||
|
selected = resolve_agent_platform_tool_names(
|
||||||
|
{
|
||||||
|
'allowed_platform_tools': ['platform_get_user_info', 'event_reply'],
|
||||||
|
'event_tool_permissions': {
|
||||||
|
'message.*': ['event_reply'],
|
||||||
|
'group.member.joined': ['event_get_actor'],
|
||||||
|
'group.*': ['event_get_group', 'unknown_tool'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'group.member.joined',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert selected == [
|
||||||
|
'platform_get_user_info',
|
||||||
|
'event_reply',
|
||||||
|
'event_get_actor',
|
||||||
|
'event_get_group',
|
||||||
|
'event_get_group_member',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_event_tools_are_automatic_without_permission_configuration() -> None:
|
||||||
|
assert resolve_agent_platform_tool_names(
|
||||||
|
{'allowed_platform_tools': ['event_reply', 'platform_get_user_info']},
|
||||||
|
'friend.request_received',
|
||||||
|
) == [
|
||||||
|
'platform_get_user_info',
|
||||||
|
'event_reply',
|
||||||
|
'event_get_actor',
|
||||||
|
'event_respond_friend_request',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_action_does_not_treat_host_event_ref_as_platform_request_id() -> None:
|
||||||
|
event = _event()
|
||||||
|
event.data = {}
|
||||||
|
|
||||||
|
resources, capabilities = build_platform_tool_resources(
|
||||||
|
event,
|
||||||
|
['event_respond_friend_request'],
|
||||||
|
['detail', 'call'],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resources == []
|
||||||
|
assert capabilities['unavailable_tools'] == [
|
||||||
|
{'name': 'event_respond_friend_request', 'reason': 'event_target_unavailable'}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_action_execution_uses_frozen_target_and_current_bot() -> None:
|
||||||
|
adapter = SimpleNamespace(
|
||||||
|
get_supported_apis=lambda: ['approve_friend_request'],
|
||||||
|
approve_friend_request=AsyncMock(return_value=None),
|
||||||
|
)
|
||||||
|
platform_mgr = SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter)))
|
||||||
|
ap = SimpleNamespace(platform_mgr=platform_mgr)
|
||||||
|
event = _event()
|
||||||
|
session = {
|
||||||
|
'authorization': {
|
||||||
|
'bot_id': 'bot-1',
|
||||||
|
'platform_context': freeze_platform_context(event),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
execution_context = object()
|
||||||
|
|
||||||
|
await execute_platform_tool(
|
||||||
|
ap,
|
||||||
|
execution_context,
|
||||||
|
session,
|
||||||
|
'event_respond_friend_request',
|
||||||
|
{'approve': False, 'remark': 'not now'},
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_mgr.get_bot_by_uuid.assert_awaited_once_with(execution_context, 'bot-1')
|
||||||
|
adapter.approve_friend_request.assert_awaited_once_with(
|
||||||
|
request_id='request-1',
|
||||||
|
approve=False,
|
||||||
|
remark='not now',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_reply_builds_message_chain_for_the_frozen_target() -> None:
|
||||||
|
adapter = SimpleNamespace(
|
||||||
|
get_supported_apis=lambda: ['send_message'],
|
||||||
|
send_message=AsyncMock(return_value=None),
|
||||||
|
)
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter)))
|
||||||
|
)
|
||||||
|
session = {
|
||||||
|
'authorization': {
|
||||||
|
'bot_id': 'bot-1',
|
||||||
|
'platform_context': freeze_platform_context(_event()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await execute_platform_tool(ap, object(), session, 'event_reply', {'text': 'hello'})
|
||||||
|
|
||||||
|
call = adapter.send_message.await_args
|
||||||
|
assert call.kwargs['target_type'] == 'group'
|
||||||
|
assert call.kwargs['target_id'] == 'group-1'
|
||||||
|
assert isinstance(call.kwargs['message'], platform_message.MessageChain)
|
||||||
|
assert call.kwargs['message'][0].text == 'hello'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_platform_action_rejects_parameters_outside_the_declared_schema() -> None:
|
||||||
|
adapter = SimpleNamespace(
|
||||||
|
get_supported_apis=lambda: ['get_group_info'],
|
||||||
|
get_group_info=AsyncMock(),
|
||||||
|
)
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter)))
|
||||||
|
)
|
||||||
|
session = {'authorization': {'bot_id': 'bot-1', 'platform_context': {}}}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='Unexpected parameters'):
|
||||||
|
await execute_platform_tool(
|
||||||
|
ap,
|
||||||
|
object(),
|
||||||
|
session,
|
||||||
|
'platform_get_group_info',
|
||||||
|
{'group_id': 'group-1', 'raw_action': 'unsafe'},
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter.get_group_info.assert_not_awaited()
|
||||||
@@ -6,12 +6,13 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from langbot_plugin.api.entities.builtin.agent_runner import AgentInput, DeliveryContext
|
||||||
|
|
||||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||||
from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver
|
from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver
|
||||||
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||||
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
|
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
|
||||||
from langbot.pkg.agent.runner.host_models import AgentBinding, BindingScope, ResourcePolicy
|
from langbot.pkg.agent.runner.host_models import AgentBinding, AgentEventEnvelope, BindingScope, ResourcePolicy
|
||||||
from langbot.pkg.api.http.context import ExecutionContext
|
from langbot.pkg.api.http.context import ExecutionContext
|
||||||
|
|
||||||
|
|
||||||
@@ -201,6 +202,41 @@ async def test_build_models_from_config_without_manifest_acl(app):
|
|||||||
assert resources['models'] == []
|
assert resources['models'] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_platform_tools_are_not_claimed_when_runner_disables_tool_calling(app):
|
||||||
|
event = AgentEventEnvelope(
|
||||||
|
event_id='event-platform-disabled',
|
||||||
|
event_type='message.received',
|
||||||
|
source='platform',
|
||||||
|
bot_id='bot-1',
|
||||||
|
input=AgentInput(text='hello'),
|
||||||
|
delivery=DeliveryContext(
|
||||||
|
surface='platform',
|
||||||
|
reply_target={'target_type': 'person', 'target_id': 'user-1'},
|
||||||
|
platform_capabilities={'supported_apis': ['send_message']},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
binding = AgentBinding(
|
||||||
|
binding_id='binding-platform-disabled',
|
||||||
|
scope=BindingScope(scope_type='global'),
|
||||||
|
runner_id=RUNNER_ID,
|
||||||
|
resource_policy=ResourcePolicy(allowed_platform_tool_names=['event_reply']),
|
||||||
|
)
|
||||||
|
|
||||||
|
resources = await AgentResourceBuilder(app).build_resources_from_binding(
|
||||||
|
execution_context=TEST_CONTEXT,
|
||||||
|
event=event,
|
||||||
|
binding=binding,
|
||||||
|
descriptor=make_descriptor(capabilities={'tool_calling': False}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resources['tools'] == []
|
||||||
|
assert resources['platform_capabilities']['authorized_tools'] == []
|
||||||
|
assert resources['platform_capabilities']['unavailable_tools'] == [
|
||||||
|
{'name': 'event_reply', 'reason': 'runner_call_permission_missing'}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
|
async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
|
||||||
"""Config-selected model references are projected regardless of method granularity."""
|
"""Config-selected model references are projected regardless of method granularity."""
|
||||||
|
|||||||
@@ -53,10 +53,24 @@ def test_pipeline_projection_keeps_sources_only_for_authorized_tools():
|
|||||||
|
|
||||||
|
|
||||||
def test_independent_agent_projection_preserves_all_tools_intent():
|
def test_independent_agent_projection_preserves_all_tools_intent():
|
||||||
policy = ResourcePolicyProjector.from_runner_config({})
|
policy = ResourcePolicyProjector.from_runner_config(
|
||||||
|
{}, allowed_platform_tool_names=['event_reply', '', 'event_reply']
|
||||||
|
)
|
||||||
|
|
||||||
assert policy.allow_all_tools is True
|
assert policy.allow_all_tools is True
|
||||||
assert policy.allowed_tool_names is None
|
assert policy.allowed_tool_names is None
|
||||||
|
assert policy.allowed_platform_tool_names == ['event_reply']
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_config_cannot_self_grant_platform_tools():
|
||||||
|
policy = ResourcePolicyProjector.from_runner_config(
|
||||||
|
{
|
||||||
|
'platform-tools': ['platform_send_message'],
|
||||||
|
'allowed_platform_tools': ['platform_delete_message'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert policy.allowed_platform_tool_names == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}])
|
@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}])
|
||||||
@@ -78,6 +92,28 @@ def test_independent_agent_projection_preserves_selected_tools():
|
|||||||
assert policy.allowed_tool_names == ['exec']
|
assert policy.allowed_tool_names == ['exec']
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_level_host_tool_policy_overrides_runner_tool_defaults():
|
||||||
|
policy = ResourcePolicyProjector.from_runner_config(
|
||||||
|
{'enable-all-tools': True, 'tools': ['runner-tool']},
|
||||||
|
allowed_host_tool_names=['exec', 'mcp_tool', 'exec'],
|
||||||
|
override_runner_tools=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert policy.allow_all_tools is False
|
||||||
|
assert policy.allowed_tool_names == ['exec', 'mcp_tool']
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_level_empty_host_tool_policy_fails_closed():
|
||||||
|
policy = ResourcePolicyProjector.from_runner_config(
|
||||||
|
{'enable-all-tools': True},
|
||||||
|
allowed_host_tool_names=[],
|
||||||
|
override_runner_tools=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert policy.allow_all_tools is False
|
||||||
|
assert policy.allowed_tool_names == []
|
||||||
|
|
||||||
|
|
||||||
def test_filter_tools_supports_sdk_objects_and_dictionary_tools():
|
def test_filter_tools_supports_sdk_objects_and_dictionary_tools():
|
||||||
policy = ResourcePolicyProjector.from_runner_config(
|
policy = ResourcePolicyProjector.from_runner_config(
|
||||||
{'enable-all-tools': False, 'tools': ['dict-tool', 'object-tool']},
|
{'enable-all-tools': False, 'tools': ['dict-tool', 'object-tool']},
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def _agent_row(
|
|||||||
'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0},
|
'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0},
|
||||||
'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}},
|
'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}},
|
||||||
},
|
},
|
||||||
supported_event_patterns=supported_event_patterns or ['*'],
|
supported_event_patterns=(supported_event_patterns if supported_event_patterns is not None else ['*']),
|
||||||
created_at=dt.datetime(2026, 1, 1, 9, 0, 0),
|
created_at=dt.datetime(2026, 1, 1, 9, 0, 0),
|
||||||
updated_at=updated_at or dt.datetime(2026, 1, 1, 10, 0, 0),
|
updated_at=updated_at or dt.datetime(2026, 1, 1, 10, 0, 0),
|
||||||
)
|
)
|
||||||
@@ -96,6 +96,7 @@ def _make_app():
|
|||||||
_get_default_values_from_schema=Mock(return_value={}),
|
_get_default_values_from_schema=Mock(return_value={}),
|
||||||
)
|
)
|
||||||
app.agent_runner_registry = None
|
app.agent_runner_registry = None
|
||||||
|
app.tool_mgr = None
|
||||||
app.logger = Mock()
|
app.logger = Mock()
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@@ -107,11 +108,32 @@ class TestAgentServiceMetadata:
|
|||||||
app.pipeline_service.get_pipeline_metadata = AsyncMock(
|
app.pipeline_service.get_pipeline_metadata = AsyncMock(
|
||||||
return_value=[{'name': 'trigger'}, ai_metadata, {'name': 'output'}]
|
return_value=[{'name': 'trigger'}, ai_metadata, {'name': 'output'}]
|
||||||
)
|
)
|
||||||
|
host_tools = [
|
||||||
|
{
|
||||||
|
'name': 'exec',
|
||||||
|
'source': 'builtin',
|
||||||
|
'source_name': 'LangBot',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'weather',
|
||||||
|
'source': 'mcp',
|
||||||
|
'source_name': 'weather-server',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
app.tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=host_tools))
|
||||||
|
|
||||||
metadata = await AgentService(app).get_agent_metadata(WORKSPACE_UUID)
|
metadata = await AgentService(app).get_agent_metadata(WORKSPACE_UUID)
|
||||||
app.pipeline_service.get_pipeline_metadata.assert_awaited_once_with(WORKSPACE_UUID)
|
app.pipeline_service.get_pipeline_metadata.assert_awaited_once_with(WORKSPACE_UUID)
|
||||||
|
|
||||||
assert metadata['runner_config'] == ai_metadata
|
assert metadata['runner_config'] == ai_metadata
|
||||||
|
assert any(tool['name'] == 'event_reply' for tool in metadata['platform_tools'])
|
||||||
|
assert all(tool['name'] != 'call_platform_api' for tool in metadata['platform_tools'])
|
||||||
|
assert metadata['host_tools'] == host_tools
|
||||||
|
app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
include_skill_authoring=True,
|
||||||
|
include_mcp_resource_tools=True,
|
||||||
|
)
|
||||||
assert metadata['kinds'] == [
|
assert metadata['kinds'] == [
|
||||||
{
|
{
|
||||||
'name': AGENT_KIND_AGENT,
|
'name': AGENT_KIND_AGENT,
|
||||||
@@ -129,6 +151,13 @@ class TestAgentServiceMetadata:
|
|||||||
class TestAgentServiceDebug:
|
class TestAgentServiceDebug:
|
||||||
async def test_debug_agent_runs_configured_runner_with_synthetic_event(self):
|
async def test_debug_agent_runs_configured_runner_with_synthetic_event(self):
|
||||||
app = _make_app()
|
app = _make_app()
|
||||||
|
agent_config = _agent_row().config
|
||||||
|
agent_config['allowed_platform_tools'] = ['platform_get_user_info']
|
||||||
|
agent_config['event_tool_permissions'] = {
|
||||||
|
'message.*': ['event_reply'],
|
||||||
|
'group.member.joined': ['event_get_actor'],
|
||||||
|
}
|
||||||
|
agent_config['allowed_tools'] = ['exec', 'weather']
|
||||||
|
|
||||||
async def run_agent(event, binding, adapter_context):
|
async def run_agent(event, binding, adapter_context):
|
||||||
yield SimpleNamespace(
|
yield SimpleNamespace(
|
||||||
@@ -144,7 +173,7 @@ class TestAgentServiceDebug:
|
|||||||
'uuid': 'agent-1',
|
'uuid': 'agent-1',
|
||||||
'kind': AGENT_KIND_AGENT,
|
'kind': AGENT_KIND_AGENT,
|
||||||
'supported_event_patterns': ['*'],
|
'supported_event_patterns': ['*'],
|
||||||
'config': _agent_row().config,
|
'config': agent_config,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
context = SimpleNamespace(
|
context = SimpleNamespace(
|
||||||
@@ -181,6 +210,15 @@ class TestAgentServiceDebug:
|
|||||||
assert event.data == {'member_id': 'user-1'}
|
assert event.data == {'member_id': 'user-1'}
|
||||||
assert binding.agent_id == 'agent-1'
|
assert binding.agent_id == 'agent-1'
|
||||||
assert binding.runner_id == 'plugin:test/runner/default'
|
assert binding.runner_id == 'plugin:test/runner/default'
|
||||||
|
assert binding.resource_policy.allowed_platform_tool_names == [
|
||||||
|
'platform_get_user_info',
|
||||||
|
'event_reply',
|
||||||
|
'event_get_actor',
|
||||||
|
'event_get_group',
|
||||||
|
'event_get_group_member',
|
||||||
|
]
|
||||||
|
assert binding.resource_policy.allow_all_tools is False
|
||||||
|
assert binding.resource_policy.allowed_tool_names == ['exec', 'weather']
|
||||||
assert (
|
assert (
|
||||||
app.agent_run_orchestrator.run.call_args.kwargs['adapter_context']['_execution_context'].workspace_uuid
|
app.agent_run_orchestrator.run.call_args.kwargs['adapter_context']['_execution_context'].workspace_uuid
|
||||||
== WORKSPACE_UUID
|
== WORKSPACE_UUID
|
||||||
@@ -435,6 +473,25 @@ class TestAgentServiceCreateUpdateDelete:
|
|||||||
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||||
assert insert_values['component_ref'] is None
|
assert insert_values['component_ref'] is None
|
||||||
|
|
||||||
|
async def test_create_agent_preserves_explicit_empty_event_scope(self):
|
||||||
|
app = _make_app()
|
||||||
|
app.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
|
||||||
|
|
||||||
|
await AgentService(app).create_agent(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
{
|
||||||
|
'name': 'Dormant Agent',
|
||||||
|
'supported_event_patterns': [],
|
||||||
|
'config': {
|
||||||
|
'runner': {'id': ''},
|
||||||
|
'runner_config': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||||
|
assert insert_values['supported_event_patterns'] == []
|
||||||
|
|
||||||
async def test_update_agent_rejects_malformed_4x_runner_config_before_write(self):
|
async def test_update_agent_rejects_malformed_4x_runner_config_before_write(self):
|
||||||
app = _make_app()
|
app = _make_app()
|
||||||
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=_agent_row(agent_uuid='agent-1')))
|
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=_agent_row(agent_uuid='agent-1')))
|
||||||
@@ -486,7 +543,7 @@ class TestAgentServiceCreateUpdateDelete:
|
|||||||
assert update_values == {
|
assert update_values == {
|
||||||
'name': 'Updated Agent',
|
'name': 'Updated Agent',
|
||||||
'config': new_config,
|
'config': new_config,
|
||||||
'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS,
|
'supported_event_patterns': [],
|
||||||
'component_ref': 'plugin:test/new-runner/default',
|
'component_ref': 'plugin:test/new-runner/default',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -340,6 +340,12 @@ class TestEBAEventBindings:
|
|||||||
bot.bot_entity = SimpleNamespace(event_bindings=bindings)
|
bot.bot_entity = SimpleNamespace(event_bindings=bindings)
|
||||||
return bot
|
return bot
|
||||||
|
|
||||||
|
def test_empty_agent_event_scope_matches_nothing(self):
|
||||||
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
|
assert RuntimeBot._agent_supports_event_type([], 'message.received') is False
|
||||||
|
assert RuntimeBot._agent_supports_event_type(None, 'message.received') is True
|
||||||
|
|
||||||
def test_resolve_eba_event_binding_uses_enabled_pattern_filters_priority_and_order(self):
|
def test_resolve_eba_event_binding_uses_enabled_pattern_filters_priority_and_order(self):
|
||||||
"""The selected binding is the first matching highest-priority binding."""
|
"""The selected binding is the first matching highest-priority binding."""
|
||||||
bot = self._make_bot(
|
bot = self._make_bot(
|
||||||
@@ -408,6 +414,12 @@ class TestEBAEventBindings:
|
|||||||
'component_ref': 'plugin:test/fallback/default',
|
'component_ref': 'plugin:test/fallback/default',
|
||||||
'config': {
|
'config': {
|
||||||
'runner': {'id': 'plugin:test/runner/default'},
|
'runner': {'id': 'plugin:test/runner/default'},
|
||||||
|
'allowed_platform_tools': ['platform_get_user_info'],
|
||||||
|
'event_tool_permissions': {
|
||||||
|
'message.*': ['event_reply'],
|
||||||
|
'platform.member.joined': ['event_get_actor'],
|
||||||
|
},
|
||||||
|
'allowed_tools': ['exec', 'mcp_tool'],
|
||||||
'runner_config': {
|
'runner_config': {
|
||||||
'plugin:test/runner/default': {
|
'plugin:test/runner/default': {
|
||||||
'temperature': 0.2,
|
'temperature': 0.2,
|
||||||
@@ -428,8 +440,12 @@ class TestEBAEventBindings:
|
|||||||
assert binding.event_types == ['platform.member.joined']
|
assert binding.event_types == ['platform.member.joined']
|
||||||
assert binding.runner_id == 'plugin:test/runner/default'
|
assert binding.runner_id == 'plugin:test/runner/default'
|
||||||
assert binding.runner_config == {'temperature': 0.2, 'max_tokens': 1000}
|
assert binding.runner_config == {'temperature': 0.2, 'max_tokens': 1000}
|
||||||
assert binding.resource_policy.allow_all_tools is True
|
assert binding.resource_policy.allow_all_tools is False
|
||||||
assert binding.resource_policy.allowed_tool_names is None
|
assert binding.resource_policy.allowed_tool_names == ['exec', 'mcp_tool']
|
||||||
|
assert binding.resource_policy.allowed_platform_tool_names == [
|
||||||
|
'platform_get_user_info',
|
||||||
|
'event_get_actor',
|
||||||
|
]
|
||||||
assert binding.delivery_policy.enable_streaming is False
|
assert binding.delivery_policy.enable_streaming is False
|
||||||
assert binding.delivery_policy.enable_reply is True
|
assert binding.delivery_policy.enable_reply is True
|
||||||
assert binding.delivery_policy.enable_interactions is True
|
assert binding.delivery_policy.enable_interactions is True
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||||
import { Agent } from '@/app/infra/entities/api';
|
import { Agent } from '@/app/infra/entities/api';
|
||||||
@@ -13,6 +13,7 @@ import EntityBasicInfoDialog, {
|
|||||||
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
|
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
|
||||||
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
|
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -168,6 +169,18 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
<ProcessorDetailWorkbench
|
<ProcessorDetailWorkbench
|
||||||
key={id}
|
key={id}
|
||||||
title={`${agent.emoji || '🤖'} ${agent.name}`}
|
title={`${agent.emoji || '🤖'} ${agent.name}`}
|
||||||
|
titleBadge={
|
||||||
|
supportedEventPatterns.length === 0 ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
role="status"
|
||||||
|
className="shrink-0 gap-1 rounded-full border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-3" />
|
||||||
|
{t('agents.noEventsConfiguredBadge')}
|
||||||
|
</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
titleAction={
|
titleAction={
|
||||||
canManage ? (
|
canManage ? (
|
||||||
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
|
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ChevronDown, Search } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { AgentPlatformTool, PluginTool } from '@/app/infra/entities/api';
|
||||||
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AgentApiToolPickerProps {
|
||||||
|
platformTools: AgentPlatformTool[];
|
||||||
|
platformValue: string[];
|
||||||
|
onPlatformChange: (value: string[]) => void;
|
||||||
|
hostTools: PluginTool[];
|
||||||
|
hostValue: string[];
|
||||||
|
onHostChange: (value: string[]) => void;
|
||||||
|
platformCatalogAvailable?: boolean;
|
||||||
|
hostCatalogAvailable?: boolean;
|
||||||
|
scopes?: readonly ToolScope[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolScope = 'event' | 'platform' | 'builtin' | 'mcp' | 'plugin' | 'skill';
|
||||||
|
|
||||||
|
type ToolEntry = {
|
||||||
|
key: string;
|
||||||
|
kind: 'platform' | 'host';
|
||||||
|
name: string;
|
||||||
|
scope: ToolScope;
|
||||||
|
group: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
api?: string;
|
||||||
|
eventPatterns?: string[];
|
||||||
|
risk?: AgentPlatformTool['risk'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATFORM_CATEGORY_LABELS: Record<string, { zh: string; en: string }> = {
|
||||||
|
message: { zh: '消息', en: 'Messages' },
|
||||||
|
identity: { zh: '用户与身份', en: 'Users & identity' },
|
||||||
|
group: { zh: '群组', en: 'Groups' },
|
||||||
|
moderation: { zh: '群管理', en: 'Moderation' },
|
||||||
|
request: { zh: '请求处理', en: 'Requests' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCOPE_ORDER: ToolScope[] = [
|
||||||
|
'event',
|
||||||
|
'platform',
|
||||||
|
'builtin',
|
||||||
|
'mcp',
|
||||||
|
'plugin',
|
||||||
|
'skill',
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeHostScope(tool: PluginTool): ToolScope {
|
||||||
|
if (tool.source === 'mcp' || tool.source === 'plugin') return tool.source;
|
||||||
|
if (tool.source === 'skill') return 'skill';
|
||||||
|
return 'builtin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentApiToolPicker({
|
||||||
|
platformTools,
|
||||||
|
platformValue,
|
||||||
|
onPlatformChange,
|
||||||
|
hostTools,
|
||||||
|
hostValue,
|
||||||
|
onHostChange,
|
||||||
|
platformCatalogAvailable = true,
|
||||||
|
hostCatalogAvailable = true,
|
||||||
|
scopes = SCOPE_ORDER,
|
||||||
|
}: AgentApiToolPickerProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [activeScope, setActiveScope] = useState<ToolScope>(
|
||||||
|
scopes[0] ?? 'event',
|
||||||
|
);
|
||||||
|
const [expandedTool, setExpandedTool] = useState<string | null>(null);
|
||||||
|
const isChinese = i18n.language.startsWith('zh');
|
||||||
|
const selectedPlatform = useMemo(
|
||||||
|
() => new Set(platformValue),
|
||||||
|
[platformValue],
|
||||||
|
);
|
||||||
|
const selectedHost = useMemo(() => new Set(hostValue), [hostValue]);
|
||||||
|
|
||||||
|
const entries = useMemo<ToolEntry[]>(
|
||||||
|
() => [
|
||||||
|
...platformTools.map((tool) => ({
|
||||||
|
key: `platform:${tool.name}`,
|
||||||
|
kind: 'platform' as const,
|
||||||
|
name: tool.name,
|
||||||
|
scope: tool.scope,
|
||||||
|
group: tool.category,
|
||||||
|
label: extractI18nObject(tool.label),
|
||||||
|
description: extractI18nObject(tool.description),
|
||||||
|
parameters: tool.parameters,
|
||||||
|
api: tool.api,
|
||||||
|
eventPatterns: tool.event_patterns,
|
||||||
|
risk: tool.risk,
|
||||||
|
})),
|
||||||
|
...hostTools.map((tool) => ({
|
||||||
|
key: `host:${tool.source || 'builtin'}:${tool.source_id || ''}:${tool.name}`,
|
||||||
|
kind: 'host' as const,
|
||||||
|
name: tool.name,
|
||||||
|
scope: normalizeHostScope(tool),
|
||||||
|
group: tool.source_name || t('agents.langbotBuiltIn'),
|
||||||
|
label: tool.name,
|
||||||
|
description: tool.human_desc || tool.description || tool.name,
|
||||||
|
parameters: tool.parameters as Record<string, unknown>,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[hostTools, platformTools, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const scopeCounts = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
SCOPE_ORDER.map((scope) => [
|
||||||
|
scope,
|
||||||
|
entries.filter((tool) => tool.scope === scope).length,
|
||||||
|
]),
|
||||||
|
) as Record<ToolScope, number>,
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
const visibleScopes = SCOPE_ORDER.filter(
|
||||||
|
(scope) =>
|
||||||
|
scopes.includes(scope) && (scope !== 'skill' || scopeCounts.skill > 0),
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visibleScopes.includes(activeScope) && visibleScopes[0]) {
|
||||||
|
setActiveScope(visibleScopes[0]);
|
||||||
|
setExpandedTool(null);
|
||||||
|
}
|
||||||
|
}, [activeScope, visibleScopes]);
|
||||||
|
|
||||||
|
const filteredEntries = useMemo(() => {
|
||||||
|
const needle = query.trim().toLocaleLowerCase();
|
||||||
|
return entries.filter(
|
||||||
|
(tool) =>
|
||||||
|
tool.scope === activeScope &&
|
||||||
|
(!needle ||
|
||||||
|
[tool.name, tool.label, tool.description, tool.api, tool.group]
|
||||||
|
.join(' ')
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.includes(needle)),
|
||||||
|
);
|
||||||
|
}, [activeScope, entries, query]);
|
||||||
|
const groupedEntries = useMemo(() => {
|
||||||
|
const groups = new Map<string, ToolEntry[]>();
|
||||||
|
for (const tool of filteredEntries) {
|
||||||
|
if (!groups.has(tool.group)) groups.set(tool.group, []);
|
||||||
|
groups.get(tool.group)!.push(tool);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries());
|
||||||
|
}, [filteredEntries]);
|
||||||
|
const selectedCount = entries.filter(
|
||||||
|
(tool) =>
|
||||||
|
scopes.includes(tool.scope) &&
|
||||||
|
(tool.kind === 'platform'
|
||||||
|
? selectedPlatform.has(tool.name)
|
||||||
|
: selectedHost.has(tool.name)),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const scopeLabel = (scope: ToolScope) => {
|
||||||
|
const keys: Record<ToolScope, string> = {
|
||||||
|
event: 'agents.eventApiTools',
|
||||||
|
platform: 'agents.platformApiTools',
|
||||||
|
builtin: 'agents.sandboxTools',
|
||||||
|
mcp: 'agents.mcpTools',
|
||||||
|
plugin: 'agents.pluginTools',
|
||||||
|
skill: 'agents.skillTools',
|
||||||
|
};
|
||||||
|
return t(keys[scope]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupLabel = (group: string) => {
|
||||||
|
if (activeScope === 'event' || activeScope === 'platform') {
|
||||||
|
return isChinese
|
||||||
|
? PLATFORM_CATEGORY_LABELS[group]?.zh || group
|
||||||
|
: PLATFORM_CATEGORY_LABELS[group]?.en || group;
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTool = (tool: ToolEntry, checked: boolean) => {
|
||||||
|
if (tool.kind === 'platform') {
|
||||||
|
const next = new Set(platformValue);
|
||||||
|
if (checked) next.add(tool.name);
|
||||||
|
else next.delete(tool.name);
|
||||||
|
onPlatformChange(
|
||||||
|
platformTools
|
||||||
|
.filter((item) => next.has(item.name))
|
||||||
|
.map((item) => item.name),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = new Set(hostValue);
|
||||||
|
if (checked) next.add(tool.name);
|
||||||
|
else next.delete(tool.name);
|
||||||
|
onHostChange(
|
||||||
|
hostTools.filter((item) => next.has(item.name)).map((item) => item.name),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const catalogAvailable =
|
||||||
|
activeScope === 'event' || activeScope === 'platform'
|
||||||
|
? platformCatalogAvailable
|
||||||
|
: hostCatalogAvailable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
{visibleScopes.length === 1 ? (
|
||||||
|
<span className="pt-2 text-sm font-medium">
|
||||||
|
{scopeLabel(visibleScopes[0])}
|
||||||
|
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
||||||
|
{scopeCounts[visibleScopes[0]]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex max-w-full flex-wrap gap-1 rounded-lg bg-muted p-1">
|
||||||
|
{visibleScopes.map((scope) => (
|
||||||
|
<button
|
||||||
|
key={scope}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={activeScope === scope}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveScope(scope);
|
||||||
|
setExpandedTool(null);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-2.5 py-1.5 text-sm transition-colors active:scale-[0.98]',
|
||||||
|
activeScope === scope
|
||||||
|
? 'bg-background font-medium shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{scopeLabel(scope)}
|
||||||
|
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||||
|
{scopeCounts[scope]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 pt-2 text-xs text-muted-foreground">
|
||||||
|
{t('agents.apiToolsSelected', {
|
||||||
|
count: selectedCount,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative max-w-sm">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder={t('agents.apiToolsSearch')}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!catalogAvailable && (
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
|
{activeScope === 'event' || activeScope === 'platform'
|
||||||
|
? t('agents.apiToolsCatalogUnavailable')
|
||||||
|
: t('agents.hostToolsCatalogUnavailable')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{catalogAvailable && !filteredEntries.length && (
|
||||||
|
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t('agents.apiToolsNoResults')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{catalogAvailable && filteredEntries.length > 0 && (
|
||||||
|
<div className="overflow-hidden rounded-lg border bg-background">
|
||||||
|
{groupedEntries.map(([group, groupTools], groupIndex) => (
|
||||||
|
<section key={group} className={cn(groupIndex > 0 && 'border-t')}>
|
||||||
|
<div className="bg-muted/30 px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{groupLabel(group)}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
{groupTools.map((tool) => {
|
||||||
|
const checked =
|
||||||
|
tool.kind === 'platform'
|
||||||
|
? selectedPlatform.has(tool.name)
|
||||||
|
: selectedHost.has(tool.name);
|
||||||
|
const expanded = expandedTool === tool.key;
|
||||||
|
const parameterNames = Object.keys(
|
||||||
|
(tool.parameters.properties as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined) ?? {},
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={tool.key} className="bg-background px-3 py-2.5">
|
||||||
|
<label
|
||||||
|
className={cn(
|
||||||
|
'grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-2.5',
|
||||||
|
'cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(next) =>
|
||||||
|
setTool(tool, next === true)
|
||||||
|
}
|
||||||
|
aria-label={tool.label}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium leading-5">
|
||||||
|
{tool.label}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs leading-5 text-muted-foreground">
|
||||||
|
{tool.description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'min-w-12 pt-0.5 text-right text-xs text-muted-foreground',
|
||||||
|
tool.risk === 'dangerous' &&
|
||||||
|
'text-amber-700 dark:text-amber-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tool.risk
|
||||||
|
? t(`agents.apiToolRisk.${tool.risk}`)
|
||||||
|
: scopeLabel(tool.scope)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedTool(expanded ? null : tool.key)
|
||||||
|
}
|
||||||
|
className="ml-7 mt-0.5 inline-flex items-center gap-1 rounded px-1 py-0.5 text-xs text-muted-foreground hover:text-foreground active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{expanded
|
||||||
|
? t('agents.apiToolHideDetails')
|
||||||
|
: t('agents.apiToolDetails')}
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'size-3 transition-transform',
|
||||||
|
expanded && 'rotate-180',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="ml-8 mt-1.5 space-y-1 border-l pl-3 text-xs text-muted-foreground">
|
||||||
|
<div className="font-mono text-foreground/75">
|
||||||
|
{tool.name}
|
||||||
|
</div>
|
||||||
|
{tool.api && <div>API: {tool.api}</div>}
|
||||||
|
{tool.kind === 'host' && (
|
||||||
|
<div>
|
||||||
|
{t('agents.apiToolSource')}:{' '}
|
||||||
|
{groupLabel(tool.group)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tool.eventPatterns && (
|
||||||
|
<div>
|
||||||
|
{t('agents.apiToolEvents')}:{' '}
|
||||||
|
{tool.eventPatterns.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
{t('agents.apiToolParameters')}:{' '}
|
||||||
|
{parameterNames.length
|
||||||
|
? parameterNames.join(', ')
|
||||||
|
: t('agents.apiToolNoParameters')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,11 +3,11 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -167,11 +167,6 @@ export default function AgentDebugPanel({
|
|||||||
setEventDataText(JSON.stringify(nextPreset.data, null, 2));
|
setEventDataText(JSON.stringify(nextPreset.data, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetSession() {
|
|
||||||
sessionIdRef.current = createDebugSessionId(agentId);
|
|
||||||
setEntries([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runDebugEvent() {
|
async function runDebugEvent() {
|
||||||
if (!eventType) {
|
if (!eventType) {
|
||||||
toast.error(t('agents.debugEventTypeRequired'));
|
toast.error(t('agents.debugEventTypeRequired'));
|
||||||
@@ -368,130 +363,131 @@ export default function AgentDebugPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 space-y-3 border-t p-3">
|
<div className="shrink-0 space-y-3 border-t p-3">
|
||||||
<div className="flex items-end gap-2">
|
{supportedEventPatterns.length === 0 ? (
|
||||||
<div className="min-w-0 flex-1 space-y-1.5">
|
<Alert className="bg-amber-500/5 text-amber-800 dark:text-amber-200">
|
||||||
<Label>{t('agents.debugEventType')}</Label>
|
<AlertTriangle className="size-4" />
|
||||||
<Select value={preset} onValueChange={selectPreset}>
|
<AlertTitle>{t('agents.debugNoEventsTitle')}</AlertTitle>
|
||||||
<SelectTrigger
|
<AlertDescription>
|
||||||
className="w-full"
|
{t('agents.debugNoEventsDescription')}
|
||||||
aria-label={t('agents.debugEventType')}
|
</AlertDescription>
|
||||||
>
|
</Alert>
|
||||||
<SelectValue />
|
) : (
|
||||||
</SelectTrigger>
|
<>
|
||||||
<SelectContent className="w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
|
<div className="space-y-1.5">
|
||||||
{eventGroups.map((group) => (
|
<Label>{t('agents.debugEventType')}</Label>
|
||||||
<SelectGroup key={group.namespace}>
|
<Select value={preset} onValueChange={selectPreset}>
|
||||||
<SelectLabel>
|
<SelectTrigger
|
||||||
{eventGroupLabel(group.namespace, t)}
|
className="w-full"
|
||||||
</SelectLabel>
|
aria-label={t('agents.debugEventType')}
|
||||||
{group.patterns.map((event) => (
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
|
||||||
|
{eventGroups.map((group) => (
|
||||||
|
<SelectGroup key={group.namespace}>
|
||||||
|
<SelectLabel>
|
||||||
|
{eventGroupLabel(group.namespace, t)}
|
||||||
|
</SelectLabel>
|
||||||
|
{group.patterns.map((event) => (
|
||||||
|
<SelectItem
|
||||||
|
key={event}
|
||||||
|
value={event}
|
||||||
|
description={eventPatternDescription(event, t)}
|
||||||
|
className="py-2"
|
||||||
|
>
|
||||||
|
<EventSelectOptionContent
|
||||||
|
event={event}
|
||||||
|
label={eventPatternLabel(event, t)}
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))}
|
||||||
|
{supportsCustomEvent && (
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>{t('agents.debugCustomEvent')}</SelectLabel>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={event}
|
value="custom"
|
||||||
value={event}
|
description={t('bots.eventDescriptions.custom')}
|
||||||
description={eventPatternDescription(event, t)}
|
|
||||||
className="py-2"
|
className="py-2"
|
||||||
>
|
>
|
||||||
<EventSelectOptionContent
|
<EventSelectOptionContent
|
||||||
event={event}
|
event="custom.event"
|
||||||
label={eventPatternLabel(event, t)}
|
label={t('agents.debugCustomEvent')}
|
||||||
/>
|
/>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
</SelectGroup>
|
||||||
</SelectGroup>
|
)}
|
||||||
))}
|
</SelectContent>
|
||||||
{supportsCustomEvent && (
|
</Select>
|
||||||
<SelectGroup>
|
</div>
|
||||||
<SelectLabel>{t('agents.debugCustomEvent')}</SelectLabel>
|
|
||||||
<SelectItem
|
|
||||||
value="custom"
|
|
||||||
description={t('bots.eventDescriptions.custom')}
|
|
||||||
className="py-2"
|
|
||||||
>
|
|
||||||
<EventSelectOptionContent
|
|
||||||
event="custom.event"
|
|
||||||
label={t('agents.debugCustomEvent')}
|
|
||||||
/>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={resetSession}
|
|
||||||
title={t('agents.debugResetSession')}
|
|
||||||
>
|
|
||||||
<RotateCcw className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{preset === 'custom' && (
|
{preset === 'custom' && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="agent-debug-custom-event">
|
<Label htmlFor="agent-debug-custom-event">
|
||||||
{t('agents.debugCustomEventType')}
|
{t('agents.debugCustomEventType')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="agent-debug-custom-event"
|
id="agent-debug-custom-event"
|
||||||
value={customEventType}
|
value={customEventType}
|
||||||
onChange={(event) => setCustomEventType(event.target.value)}
|
onChange={(event) => setCustomEventType(event.target.value)}
|
||||||
placeholder="custom.event"
|
placeholder="custom.event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="agent-debug-input">
|
||||||
|
{isMessageEvent
|
||||||
|
? t('agents.debugMessageInput')
|
||||||
|
: t('agents.debugEventSummary')}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="agent-debug-input"
|
||||||
|
value={inputText}
|
||||||
|
onChange={(event) => setInputText(event.target.value)}
|
||||||
|
className="min-h-20 resize-y"
|
||||||
|
placeholder={t('agents.debugInputPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="rounded-md border bg-muted/20 px-3 py-2">
|
||||||
|
<summary className="cursor-pointer text-xs font-medium">
|
||||||
|
{t('agents.debugEventPayload')}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('agents.debugSupportedEvents')}: {supportedLabel}
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
id="agent-debug-payload"
|
||||||
|
value={eventDataText}
|
||||||
|
onChange={(event) => setEventDataText(event.target.value)}
|
||||||
|
className="min-h-28 resize-y font-mono text-xs"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full"
|
||||||
|
disabled={running}
|
||||||
|
onClick={runDebugEvent}
|
||||||
|
>
|
||||||
|
{running ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="size-4" />
|
||||||
|
)}
|
||||||
|
{running
|
||||||
|
? t('agents.debugRunning')
|
||||||
|
: hasUnsavedChanges
|
||||||
|
? t('agents.debugSaveAndRun')
|
||||||
|
: t('agents.debugRun')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="agent-debug-input">
|
|
||||||
{isMessageEvent
|
|
||||||
? t('agents.debugMessageInput')
|
|
||||||
: t('agents.debugEventSummary')}
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="agent-debug-input"
|
|
||||||
value={inputText}
|
|
||||||
onChange={(event) => setInputText(event.target.value)}
|
|
||||||
className="min-h-20 resize-y"
|
|
||||||
placeholder={t('agents.debugInputPlaceholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details className="rounded-md border bg-muted/20 px-3 py-2">
|
|
||||||
<summary className="cursor-pointer text-xs font-medium">
|
|
||||||
{t('agents.debugEventPayload')}
|
|
||||||
</summary>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('agents.debugSupportedEvents')}: {supportedLabel}
|
|
||||||
</p>
|
|
||||||
<Textarea
|
|
||||||
id="agent-debug-payload"
|
|
||||||
value={eventDataText}
|
|
||||||
onChange={(event) => setEventDataText(event.target.value)}
|
|
||||||
className="min-h-28 resize-y font-mono text-xs"
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full"
|
|
||||||
disabled={running}
|
|
||||||
onClick={runDebugEvent}
|
|
||||||
>
|
|
||||||
{running ? (
|
|
||||||
<LoaderCircle className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Play className="size-4" />
|
|
||||||
)}
|
|
||||||
{running
|
|
||||||
? t('agents.debugRunning')
|
|
||||||
: hasUnsavedChanges
|
|
||||||
? t('agents.debugSaveAndRun')
|
|
||||||
: t('agents.debugRun')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
import { Check, ChevronDown, Plus, X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import type { AgentPlatformTool } from '@/app/infra/entities/api';
|
||||||
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -16,7 +23,6 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
import {
|
||||||
eventGroupLabel,
|
eventGroupLabel,
|
||||||
eventNamespaces,
|
eventNamespaces,
|
||||||
@@ -31,18 +37,53 @@ interface AgentEventPatternPickerProps {
|
|||||||
events: string[];
|
events: string[];
|
||||||
value: string[];
|
value: string[];
|
||||||
onChange: (patterns: string[]) => void;
|
onChange: (patterns: string[]) => void;
|
||||||
|
tools: AgentPlatformTool[];
|
||||||
|
catalogAvailable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventPatternsIntersect(left: string, right: string) {
|
||||||
|
if (left === '*' || right === '*' || left === right) return true;
|
||||||
|
if (!left.includes('*')) {
|
||||||
|
return right.endsWith('.*') && left.startsWith(right.slice(0, -1));
|
||||||
|
}
|
||||||
|
if (!right.includes('*')) {
|
||||||
|
return left.endsWith('.*') && right.startsWith(left.slice(0, -1));
|
||||||
|
}
|
||||||
|
const leftPrefix = left.slice(0, left.indexOf('*'));
|
||||||
|
const rightPrefix = right.slice(0, right.indexOf('*'));
|
||||||
|
return (
|
||||||
|
leftPrefix.startsWith(rightPrefix) || rightPrefix.startsWith(leftPrefix)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEventToolCompatibleWithPattern(
|
||||||
|
tool: AgentPlatformTool,
|
||||||
|
pattern: string,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
tool.scope === 'event' &&
|
||||||
|
tool.event_patterns.some((toolPattern) =>
|
||||||
|
eventPatternsIntersect(toolPattern, pattern),
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AgentEventPatternPicker({
|
export default function AgentEventPatternPicker({
|
||||||
events,
|
events,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
tools,
|
||||||
|
catalogAvailable = true,
|
||||||
}: AgentEventPatternPickerProps) {
|
}: AgentEventPatternPickerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const selectedPatterns = useMemo(
|
const [expandedPatterns, setExpandedPatterns] = useState<Set<string>>(
|
||||||
() => (value.length > 0 ? value : ['*']),
|
new Set(),
|
||||||
[value],
|
);
|
||||||
|
const selectedPatterns = value;
|
||||||
|
const eventTools = useMemo(
|
||||||
|
() => tools.filter((tool) => tool.scope === 'event'),
|
||||||
|
[tools],
|
||||||
);
|
);
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
const concreteEvents = Array.from(
|
const concreteEvents = Array.from(
|
||||||
@@ -59,115 +100,216 @@ export default function AgentEventPatternPicker({
|
|||||||
...selectedPatterns.filter((pattern) => pattern.endsWith('.*')),
|
...selectedPatterns.filter((pattern) => pattern.endsWith('.*')),
|
||||||
]),
|
]),
|
||||||
).sort();
|
).sort();
|
||||||
return ['*', ...namespaces, ...concreteEvents];
|
return ['*', ...namespaces, ...concreteEvents].filter(
|
||||||
|
(pattern) => !selectedPatterns.includes(pattern),
|
||||||
|
);
|
||||||
}, [events, selectedPatterns]);
|
}, [events, selectedPatterns]);
|
||||||
const optionGroups = useMemo(() => groupEventPatterns(options), [options]);
|
const optionGroups = useMemo(() => groupEventPatterns(options), [options]);
|
||||||
|
|
||||||
function togglePattern(pattern: string) {
|
function addPattern(pattern: string) {
|
||||||
|
let nextPatterns: string[];
|
||||||
if (pattern === '*') {
|
if (pattern === '*') {
|
||||||
onChange(['*']);
|
nextPatterns = ['*'];
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedPatterns.includes(pattern)) {
|
|
||||||
const next = selectedPatterns.filter((item) => item !== pattern);
|
|
||||||
onChange(next.length > 0 ? next : ['*']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let next = selectedPatterns.filter((item) => item !== '*');
|
|
||||||
const namespace = pattern.split('.')[0];
|
|
||||||
if (pattern.endsWith('.*')) {
|
|
||||||
next = next.filter(
|
|
||||||
(item) => item.split('.')[0] !== namespace || item.endsWith('.*'),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
next = next.filter((item) => item !== `${namespace}.*`);
|
const namespace = pattern.split('.')[0];
|
||||||
|
nextPatterns = selectedPatterns.filter((item) => {
|
||||||
|
if (item === '*') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const overlaps = pattern.endsWith('.*')
|
||||||
|
? item.split('.')[0] === namespace
|
||||||
|
: item === `${namespace}.*`;
|
||||||
|
return !overlaps;
|
||||||
|
});
|
||||||
|
nextPatterns.push(pattern);
|
||||||
}
|
}
|
||||||
onChange(Array.from(new Set([...next, pattern])));
|
|
||||||
|
onChange(Array.from(new Set(nextPatterns)));
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePattern(pattern: string) {
|
||||||
|
onChange(selectedPatterns.filter((item) => item !== pattern));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<div className="overflow-hidden rounded-xl border bg-background">
|
||||||
<PopoverTrigger asChild>
|
<div className="flex items-center justify-between gap-3 border-b bg-muted/30 px-4 py-3">
|
||||||
<Button
|
<div className="min-w-0">
|
||||||
type="button"
|
<p className="text-sm font-medium">{t('agents.configuredEvents')}</p>
|
||||||
variant="outline"
|
<p className="text-xs text-muted-foreground">
|
||||||
role="combobox"
|
{t('agents.configuredEventsCount', {
|
||||||
aria-expanded={open}
|
count: selectedPatterns.length,
|
||||||
aria-label={t('agents.supportedEvents')}
|
})}
|
||||||
className="h-auto min-h-10 w-full min-w-0 justify-between gap-2 px-3 py-2 font-normal"
|
</p>
|
||||||
>
|
</div>
|
||||||
<span className="flex min-w-0 flex-1 flex-wrap gap-1.5">
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
{selectedPatterns.slice(0, 3).map((pattern) => (
|
<PopoverTrigger asChild>
|
||||||
<Badge
|
<Button
|
||||||
key={pattern}
|
type="button"
|
||||||
variant="secondary"
|
variant="outline"
|
||||||
className="max-w-full rounded-md font-normal"
|
size="sm"
|
||||||
>
|
className="gap-1.5"
|
||||||
<span className="truncate">
|
>
|
||||||
{eventPatternLabel(pattern, t)}
|
<Plus className="size-4" />
|
||||||
</span>
|
{t('agents.addEvent')}
|
||||||
</Badge>
|
</Button>
|
||||||
))}
|
</PopoverTrigger>
|
||||||
{selectedPatterns.length > 3 && (
|
<PopoverContent align="end" className="w-96 max-w-[90vw] p-0">
|
||||||
<Badge variant="outline" className="rounded-md font-normal">
|
<Command>
|
||||||
+{selectedPatterns.length - 3}
|
<CommandInput placeholder={t('agents.searchEvents')} />
|
||||||
</Badge>
|
<CommandList>
|
||||||
)}
|
<CommandEmpty>{t('agents.noEventsFound')}</CommandEmpty>
|
||||||
</span>
|
{optionGroups.map((group) => (
|
||||||
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
|
<CommandGroup
|
||||||
</Button>
|
key={group.namespace}
|
||||||
</PopoverTrigger>
|
heading={eventGroupLabel(group.namespace, t)}
|
||||||
<PopoverContent
|
>
|
||||||
align="start"
|
{group.patterns.map((pattern) => (
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
<CommandItem
|
||||||
>
|
key={pattern}
|
||||||
<Command>
|
value={`${eventPatternLabel(pattern, t)} ${pattern}`}
|
||||||
<CommandInput placeholder={t('agents.searchEvents')} />
|
onSelect={() => addPattern(pattern)}
|
||||||
<CommandList>
|
className="items-start gap-2 py-2"
|
||||||
<CommandEmpty>{t('agents.noEventsFound')}</CommandEmpty>
|
>
|
||||||
{optionGroups.map((group) => (
|
<Plus className="mt-0.5 size-4 shrink-0" />
|
||||||
<CommandGroup
|
<span className="min-w-0 flex-1">
|
||||||
key={group.namespace}
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
heading={eventGroupLabel(group.namespace, t)}
|
<span className="truncate font-medium">
|
||||||
>
|
{eventPatternLabel(pattern, t)}
|
||||||
{group.patterns.map((pattern) => {
|
</span>
|
||||||
const selected = selectedPatterns.includes(pattern);
|
<code className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
return (
|
{pattern}
|
||||||
<CommandItem
|
</code>
|
||||||
key={pattern}
|
</span>
|
||||||
value={`${eventPatternLabel(pattern, t)} ${pattern}`}
|
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||||
onSelect={() => togglePattern(pattern)}
|
{eventPatternDescription(pattern, t)}
|
||||||
className="items-start gap-2 py-2"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
'mt-0.5 size-4 shrink-0',
|
|
||||||
selected ? 'opacity-100' : 'opacity-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="truncate font-medium">
|
|
||||||
{eventPatternLabel(pattern, t)}
|
|
||||||
</span>
|
</span>
|
||||||
<code className="shrink-0 text-[10px] text-muted-foreground">
|
|
||||||
{pattern}
|
|
||||||
</code>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
</CommandItem>
|
||||||
{eventPatternDescription(pattern, t)}
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y">
|
||||||
|
{selectedPatterns.length === 0 && (
|
||||||
|
<div className="px-4 py-8 text-center">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t('agents.noEventsConfigured')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t('agents.noEventsConfiguredDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedPatterns.map((pattern) => {
|
||||||
|
const compatibleTools = eventTools.filter((tool) =>
|
||||||
|
isEventToolCompatibleWithPattern(tool, pattern),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
key={pattern}
|
||||||
|
open={expandedPatterns.has(pattern)}
|
||||||
|
onOpenChange={(expanded) => {
|
||||||
|
setExpandedPatterns((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (expanded) next.add(pattern);
|
||||||
|
else next.delete(pattern);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 bg-muted/20 px-3 py-2">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex min-w-0 items-baseline gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">
|
||||||
|
{eventPatternLabel(pattern, t)}
|
||||||
</span>
|
</span>
|
||||||
|
<code className="shrink-0 text-[11px] text-muted-foreground">
|
||||||
|
{pattern}
|
||||||
|
</code>
|
||||||
</span>
|
</span>
|
||||||
</CommandItem>
|
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
|
||||||
);
|
{eventPatternDescription(pattern, t)}
|
||||||
})}
|
</span>
|
||||||
</CommandGroup>
|
</span>
|
||||||
))}
|
<span className="shrink-0 text-xs text-emerald-700 dark:text-emerald-300">
|
||||||
</CommandList>
|
{t('agents.eventToolsEnabledCount', {
|
||||||
</Command>
|
count: compatibleTools.length,
|
||||||
</PopoverContent>
|
})}
|
||||||
</Popover>
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'size-4 shrink-0 text-muted-foreground transition-transform',
|
||||||
|
expandedPatterns.has(pattern) && 'rotate-180',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removePattern(pattern)}
|
||||||
|
aria-label={t('agents.removeEvent')}
|
||||||
|
className="size-8 shrink-0 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleContent className="border-t px-3 py-2.5">
|
||||||
|
{!catalogAvailable ? (
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
|
{t('agents.apiToolsCatalogUnavailable')}
|
||||||
|
</div>
|
||||||
|
) : compatibleTools.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed px-3 py-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t('agents.noEventActions')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-lg border bg-background">
|
||||||
|
<div className="divide-y">
|
||||||
|
{compatibleTools.map((tool) => {
|
||||||
|
const label = extractI18nObject(tool.label);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tool.name}
|
||||||
|
className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 bg-background px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="flex min-w-0 items-center gap-2 text-sm font-medium leading-5">
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
<span className="shrink-0 text-xs font-normal text-muted-foreground">
|
||||||
|
{t(`agents.apiToolRisk.${tool.risk}`)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-1 pt-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
{t('agents.eventToolEnabled')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,14 @@ 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, Loader2, SlidersHorizontal, Zap } from 'lucide-react';
|
import { Bot, Loader2, SlidersHorizontal, Wrench } from 'lucide-react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
import {
|
||||||
|
Agent,
|
||||||
|
AgentPlatformTool,
|
||||||
|
ApiRespPluginSystemStatus,
|
||||||
|
PluginTool,
|
||||||
|
} from '@/app/infra/entities/api';
|
||||||
import {
|
import {
|
||||||
PipelineConfigStage,
|
PipelineConfigStage,
|
||||||
PipelineConfigTab,
|
PipelineConfigTab,
|
||||||
@@ -36,15 +41,18 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import {
|
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
Form,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/components/ui/form';
|
|
||||||
import AgentEventPatternPicker from './AgentEventPatternPicker';
|
import AgentEventPatternPicker from './AgentEventPatternPicker';
|
||||||
import AgentRunnerSelect from './AgentRunnerSelect';
|
import AgentRunnerSelect from './AgentRunnerSelect';
|
||||||
|
import AgentApiToolPicker from './AgentApiToolPicker';
|
||||||
|
|
||||||
|
const OTHER_TOOL_SCOPES = [
|
||||||
|
'platform',
|
||||||
|
'builtin',
|
||||||
|
'mcp',
|
||||||
|
'plugin',
|
||||||
|
'skill',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export interface AgentRunnerStatus {
|
export interface AgentRunnerStatus {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -62,7 +70,10 @@ interface AgentFormComponentProps {
|
|||||||
onSupportedEventPatternsChange?: (patterns: string[]) => void;
|
onSupportedEventPatternsChange?: (patterns: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentConfigSection = 'events' | 'runner' | 'runner_config';
|
export type AgentConfigSection =
|
||||||
|
| 'runner'
|
||||||
|
| 'runner_config'
|
||||||
|
| 'events_and_tools';
|
||||||
|
|
||||||
export interface AgentFormHandle {
|
export interface AgentFormHandle {
|
||||||
openSection: (section: AgentConfigSection) => void;
|
openSection: (section: AgentConfigSection) => void;
|
||||||
@@ -119,6 +130,12 @@ function AgentFormComponent(
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [runnerConfigSchema, setRunnerConfigSchema] =
|
const [runnerConfigSchema, setRunnerConfigSchema] =
|
||||||
useState<PipelineConfigTab | null>(null);
|
useState<PipelineConfigTab | null>(null);
|
||||||
|
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
|
||||||
|
const [platformToolCatalogAvailable, setPlatformToolCatalogAvailable] =
|
||||||
|
useState(true);
|
||||||
|
const [hostTools, setHostTools] = useState<PluginTool[]>([]);
|
||||||
|
const [hostToolCatalogAvailable, setHostToolCatalogAvailable] =
|
||||||
|
useState(true);
|
||||||
const [pluginSystemStatus, setPluginSystemStatus] =
|
const [pluginSystemStatus, setPluginSystemStatus] =
|
||||||
useState<ApiRespPluginSystemStatus | null>(null);
|
useState<ApiRespPluginSystemStatus | null>(null);
|
||||||
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
|
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
|
||||||
@@ -129,6 +146,7 @@ function AgentFormComponent(
|
|||||||
useState<AgentConfigSection>('runner');
|
useState<AgentConfigSection>('runner');
|
||||||
const isSavingRef = useRef(false);
|
const isSavingRef = useRef(false);
|
||||||
const hasUnsavedChangesRef = useRef(false);
|
const hasUnsavedChangesRef = useRef(false);
|
||||||
|
const loadedHostToolPolicyRef = useRef<string[] | undefined>(undefined);
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
basic: z.object({
|
basic: z.object({
|
||||||
@@ -138,7 +156,9 @@ function AgentFormComponent(
|
|||||||
}),
|
}),
|
||||||
runner: z.record(z.string(), z.any()),
|
runner: z.record(z.string(), z.any()),
|
||||||
runner_config: z.record(z.string(), z.any()),
|
runner_config: z.record(z.string(), z.any()),
|
||||||
supported_event_patterns: z.array(z.string()).min(1),
|
supported_event_patterns: z.array(z.string()),
|
||||||
|
allowed_platform_tools: z.array(z.string()),
|
||||||
|
allowed_tools: z.array(z.string()),
|
||||||
});
|
});
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
@@ -153,6 +173,8 @@ function AgentFormComponent(
|
|||||||
runner: {},
|
runner: {},
|
||||||
runner_config: {},
|
runner_config: {},
|
||||||
supported_event_patterns: ['*'],
|
supported_event_patterns: ['*'],
|
||||||
|
allowed_platform_tools: [],
|
||||||
|
allowed_tools: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const runnerInstallScope = `agent:${agentId}`;
|
const runnerInstallScope = `agent:${agentId}`;
|
||||||
@@ -188,8 +210,43 @@ function AgentFormComponent(
|
|||||||
.then(([metadata, resp]) => {
|
.then(([metadata, resp]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setRunnerConfigSchema(metadata.runner_config ?? null);
|
setRunnerConfigSchema(metadata.runner_config ?? null);
|
||||||
|
const hasPlatformToolCatalog = Array.isArray(metadata.platform_tools);
|
||||||
|
setPlatformToolCatalogAvailable(hasPlatformToolCatalog);
|
||||||
|
const availablePlatformTools = hasPlatformToolCatalog
|
||||||
|
? metadata.platform_tools
|
||||||
|
: [];
|
||||||
|
setPlatformTools(availablePlatformTools);
|
||||||
|
const hasHostToolCatalog = Array.isArray(metadata.host_tools);
|
||||||
|
const availableHostTools: PluginTool[] = Array.isArray(
|
||||||
|
metadata.host_tools,
|
||||||
|
)
|
||||||
|
? metadata.host_tools
|
||||||
|
: [];
|
||||||
|
setHostToolCatalogAvailable(hasHostToolCatalog);
|
||||||
|
setHostTools(availableHostTools);
|
||||||
const agent = resp.agent;
|
const agent = resp.agent;
|
||||||
const config = (agent.config ?? {}) as Record<string, any>;
|
const config = (agent.config ?? {}) as Record<string, any>;
|
||||||
|
const configuredHostTools = Array.isArray(config.allowed_tools)
|
||||||
|
? config.allowed_tools.filter(
|
||||||
|
(name): name is string => typeof name === 'string',
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const configuredPlatformTools = Array.isArray(
|
||||||
|
config.allowed_platform_tools,
|
||||||
|
)
|
||||||
|
? config.allowed_platform_tools.filter(
|
||||||
|
(name): name is string => typeof name === 'string',
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const configuredEventPatterns =
|
||||||
|
agent.supported_event_patterns ??
|
||||||
|
agent.capability?.supported_event_patterns;
|
||||||
|
const normalizedEventPatterns = Array.isArray(configuredEventPatterns)
|
||||||
|
? configuredEventPatterns.filter(
|
||||||
|
(pattern): pattern is string => typeof pattern === 'string',
|
||||||
|
)
|
||||||
|
: ['*'];
|
||||||
|
loadedHostToolPolicyRef.current = configuredHostTools;
|
||||||
const loadedValues: FormValues = {
|
const loadedValues: FormValues = {
|
||||||
basic: {
|
basic: {
|
||||||
name: agent.name ?? '',
|
name: agent.name ?? '',
|
||||||
@@ -199,8 +256,15 @@ function AgentFormComponent(
|
|||||||
runner: (config.runner as Record<string, unknown>) ?? {},
|
runner: (config.runner as Record<string, unknown>) ?? {},
|
||||||
runner_config:
|
runner_config:
|
||||||
(config.runner_config as Record<string, unknown>) ?? {},
|
(config.runner_config as Record<string, unknown>) ?? {},
|
||||||
supported_event_patterns: agent.supported_event_patterns ??
|
supported_event_patterns: normalizedEventPatterns,
|
||||||
agent.capability?.supported_event_patterns ?? ['*'],
|
allowed_platform_tools: configuredPlatformTools.filter((name) => {
|
||||||
|
const tool = availablePlatformTools.find(
|
||||||
|
(candidate) => candidate.name === name,
|
||||||
|
);
|
||||||
|
return !tool || tool.scope === 'platform';
|
||||||
|
}),
|
||||||
|
allowed_tools:
|
||||||
|
configuredHostTools ?? availableHostTools.map((tool) => tool.name),
|
||||||
};
|
};
|
||||||
form.reset(loadedValues);
|
form.reset(loadedValues);
|
||||||
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
||||||
@@ -320,9 +384,9 @@ function AgentFormComponent(
|
|||||||
icon: SlidersHorizontal,
|
icon: SlidersHorizontal,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'events',
|
name: 'events_and_tools',
|
||||||
label: t('agents.bindableEvents'),
|
label: t('agents.eventsAndTools'),
|
||||||
icon: Zap,
|
icon: Wrench,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -500,22 +564,32 @@ function AgentFormComponent(
|
|||||||
if (isSavingRef.current) return false;
|
if (isSavingRef.current) return false;
|
||||||
const submittedSnapshot = JSON.stringify(values);
|
const submittedSnapshot = JSON.stringify(values);
|
||||||
const runner = values.runner || {};
|
const runner = values.runner || {};
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
runner,
|
||||||
|
runner_config: values.runner_config ?? {},
|
||||||
|
allowed_platform_tools: values.allowed_platform_tools,
|
||||||
|
};
|
||||||
|
if (hostToolCatalogAvailable) {
|
||||||
|
config.allowed_tools = values.allowed_tools;
|
||||||
|
} else if (loadedHostToolPolicyRef.current !== undefined) {
|
||||||
|
config.allowed_tools = loadedHostToolPolicyRef.current;
|
||||||
|
}
|
||||||
const agent: Partial<Agent> = {
|
const agent: Partial<Agent> = {
|
||||||
name: values.basic.name,
|
name: values.basic.name,
|
||||||
description: values.basic.description ?? '',
|
description: values.basic.description ?? '',
|
||||||
emoji: values.basic.emoji,
|
emoji: values.basic.emoji,
|
||||||
component_ref: (runner.id as string) || null,
|
component_ref: (runner.id as string) || null,
|
||||||
supported_event_patterns: values.supported_event_patterns,
|
supported_event_patterns: values.supported_event_patterns,
|
||||||
config: {
|
config,
|
||||||
runner,
|
|
||||||
runner_config: values.runner_config ?? {},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
isSavingRef.current = true;
|
isSavingRef.current = true;
|
||||||
onSavingChange?.(true);
|
onSavingChange?.(true);
|
||||||
try {
|
try {
|
||||||
await httpClient.updateAgent(agentId, agent);
|
await httpClient.updateAgent(agentId, agent);
|
||||||
|
if (hostToolCatalogAvailable) {
|
||||||
|
loadedHostToolPolicyRef.current = [...values.allowed_tools];
|
||||||
|
}
|
||||||
savedSnapshotRef.current = submittedSnapshot;
|
savedSnapshotRef.current = submittedSnapshot;
|
||||||
onFinish(agent);
|
onFinish(agent);
|
||||||
toast.success(t('agents.saveSuccess'));
|
toast.success(t('agents.saveSuccess'));
|
||||||
@@ -532,7 +606,7 @@ function AgentFormComponent(
|
|||||||
onSavingChange?.(false);
|
onSavingChange?.(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[agentId, onFinish, onSavingChange, t],
|
[agentId, hostToolCatalogAvailable, onFinish, onSavingChange, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleSubmit(values: FormValues) {
|
function handleSubmit(values: FormValues) {
|
||||||
@@ -653,32 +727,79 @@ function AgentFormComponent(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSection === 'events' && (
|
{activeSection === 'events_and_tools' && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="pb-4">
|
||||||
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
|
<CardTitle>{t('agents.eventsAndTools')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t('agents.bindableEventsDescription')}
|
{t('agents.eventsAndToolsDescription')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-6">
|
||||||
<FormField
|
<section className="space-y-3">
|
||||||
control={form.control}
|
<div className="space-y-1">
|
||||||
name="supported_event_patterns"
|
<h3 className="text-sm font-medium">
|
||||||
render={({ field }) => (
|
{t('agents.bindableEvents')}
|
||||||
<FormItem>
|
</h3>
|
||||||
<AgentEventPatternPicker
|
<p className="text-xs text-muted-foreground">
|
||||||
events={availableEventTypes}
|
{t('agents.bindableEventsDescription')}
|
||||||
value={field.value}
|
</p>
|
||||||
onChange={field.onChange}
|
</div>
|
||||||
/>
|
<FormField
|
||||||
<FormDescription>
|
control={form.control}
|
||||||
{t('agents.supportedEventsDescription')}
|
name="supported_event_patterns"
|
||||||
</FormDescription>
|
render={({ field }) => (
|
||||||
<FormMessage />
|
<FormItem>
|
||||||
</FormItem>
|
<AgentEventPatternPicker
|
||||||
)}
|
events={availableEventTypes}
|
||||||
/>
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
tools={platformTools}
|
||||||
|
catalogAvailable={platformToolCatalogAvailable}
|
||||||
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3 border-t pt-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
{t('agents.otherTools')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('agents.otherToolsDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="allowed_platform_tools"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<AgentApiToolPicker
|
||||||
|
platformTools={platformTools}
|
||||||
|
platformValue={field.value}
|
||||||
|
onPlatformChange={field.onChange}
|
||||||
|
hostTools={hostTools}
|
||||||
|
hostValue={form.watch('allowed_tools')}
|
||||||
|
onHostChange={(value) =>
|
||||||
|
form.setValue('allowed_tools', value, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
platformCatalogAvailable={
|
||||||
|
platformToolCatalogAvailable
|
||||||
|
}
|
||||||
|
hostCatalogAvailable={hostToolCatalogAvailable}
|
||||||
|
scopes={OTHER_TOOL_SCOPES}
|
||||||
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -71,7 +71,14 @@ export function eventPatternLabel(pattern: string, t: TFunction) {
|
|||||||
export function eventPatternDescription(pattern: string, t: TFunction) {
|
export function eventPatternDescription(pattern: string, t: TFunction) {
|
||||||
if (pattern === '*') return t('bots.eventDescriptions.all');
|
if (pattern === '*') return t('bots.eventDescriptions.all');
|
||||||
if (pattern.endsWith('.*')) {
|
if (pattern.endsWith('.*')) {
|
||||||
return t('bots.eventDescriptions.namespace');
|
const namespace = pattern.slice(0, -2);
|
||||||
|
const key = `bots.eventDescriptions.namespace_${namespace}`;
|
||||||
|
const description = t(key);
|
||||||
|
return description === key
|
||||||
|
? t('bots.eventDescriptions.namespace', {
|
||||||
|
group: eventGroupLabel(namespace, t),
|
||||||
|
})
|
||||||
|
: description;
|
||||||
}
|
}
|
||||||
const key = `bots.eventDescriptions.${pattern.replace(/\./g, '_')}`;
|
const key = `bots.eventDescriptions.${pattern.replace(/\./g, '_')}`;
|
||||||
const description = t(key);
|
const description = t(key);
|
||||||
|
|||||||
@@ -1301,7 +1301,7 @@ function NavItems({
|
|||||||
disabled={quota.disabled}
|
disabled={quota.disabled}
|
||||||
aria-disabled={quota.disabled}
|
aria-disabled={quota.disabled}
|
||||||
aria-label={`${t('common.create')} ${config.name}`}
|
aria-label={`${t('common.create')} ${config.name}`}
|
||||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
@@ -1347,7 +1347,7 @@ function NavItems({
|
|||||||
disabled={quota.disabled}
|
disabled={quota.disabled}
|
||||||
aria-disabled={quota.disabled}
|
aria-disabled={quota.disabled}
|
||||||
aria-label={`${t('common.create')} ${config.name}`}
|
aria-label={`${t('common.create')} ${config.name}`}
|
||||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
@@ -1389,7 +1389,7 @@ function NavItems({
|
|||||||
disabled={quota.disabled}
|
disabled={quota.disabled}
|
||||||
aria-disabled={quota.disabled}
|
aria-disabled={quota.disabled}
|
||||||
aria-label={`${t('common.create')} ${config.name}`}
|
aria-label={`${t('common.create')} ${config.name}`}
|
||||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate(`${routePrefix}?id=new`);
|
navigate(`${routePrefix}?id=new`);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface ProcessorDetailStatus {
|
|||||||
|
|
||||||
interface ProcessorDetailWorkbenchProps {
|
interface ProcessorDetailWorkbenchProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
titleBadge?: ReactNode;
|
||||||
titleAction?: ReactNode;
|
titleAction?: ReactNode;
|
||||||
headerActions?: ReactNode;
|
headerActions?: ReactNode;
|
||||||
status?: ProcessorDetailStatus | null;
|
status?: ProcessorDetailStatus | null;
|
||||||
@@ -45,6 +46,7 @@ interface ProcessorDetailWorkbenchProps {
|
|||||||
|
|
||||||
export default function ProcessorDetailWorkbench({
|
export default function ProcessorDetailWorkbench({
|
||||||
title,
|
title,
|
||||||
|
titleBadge,
|
||||||
titleAction,
|
titleAction,
|
||||||
headerActions,
|
headerActions,
|
||||||
status,
|
status,
|
||||||
@@ -79,6 +81,7 @@ export default function ProcessorDetailWorkbench({
|
|||||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<h1 className="truncate text-xl font-semibold">{title}</h1>
|
<h1 className="truncate text-xl font-semibold">{title}</h1>
|
||||||
|
{titleBadge}
|
||||||
{titleAction}
|
{titleAction}
|
||||||
{monitoring && (
|
{monitoring && (
|
||||||
<TabsList
|
<TabsList
|
||||||
|
|||||||
@@ -193,6 +193,8 @@ export interface ApiRespAgent {
|
|||||||
|
|
||||||
export interface GetAgentMetadataResponseData {
|
export interface GetAgentMetadataResponseData {
|
||||||
runner_config?: PipelineConfigTab;
|
runner_config?: PipelineConfigTab;
|
||||||
|
platform_tools: AgentPlatformTool[];
|
||||||
|
host_tools?: PluginTool[] | null;
|
||||||
kinds: Array<{
|
kinds: Array<{
|
||||||
name: AgentKind;
|
name: AgentKind;
|
||||||
supported_event_patterns: string[];
|
supported_event_patterns: string[];
|
||||||
@@ -200,6 +202,18 @@ export interface GetAgentMetadataResponseData {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentPlatformTool {
|
||||||
|
name: string;
|
||||||
|
api: string;
|
||||||
|
scope: 'event' | 'platform';
|
||||||
|
category: string;
|
||||||
|
risk: 'read' | 'write' | 'dangerous';
|
||||||
|
label: I18nObject;
|
||||||
|
description: I18nObject;
|
||||||
|
event_patterns: string[];
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Pipeline {
|
export interface Pipeline {
|
||||||
uuid?: string;
|
uuid?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -553,7 +553,17 @@ const enUS = {
|
|||||||
},
|
},
|
||||||
eventDescriptions: {
|
eventDescriptions: {
|
||||||
all: 'Matches every event received by this adapter.',
|
all: 'Matches every event received by this adapter.',
|
||||||
namespace: 'Matches several concrete events in the same event group.',
|
namespace: 'Matches all {{group}} events.',
|
||||||
|
namespace_bot:
|
||||||
|
'Matches bot invitations, removals, mutes, and other status events.',
|
||||||
|
namespace_feedback: 'Matches feedback events from a platform or user.',
|
||||||
|
namespace_friend: 'Matches friend requests and friendship changes.',
|
||||||
|
namespace_group:
|
||||||
|
'Matches member joins, leaves, removals, and other group events.',
|
||||||
|
namespace_message:
|
||||||
|
'Matches received, edited, deleted, and reaction message events.',
|
||||||
|
namespace_platform:
|
||||||
|
'Matches platform-specific events provided by an adapter.',
|
||||||
custom: 'A custom event, or one without a description yet.',
|
custom: 'A custom event, or one without a description yet.',
|
||||||
message_received: 'A user or group sends a new message to the bot.',
|
message_received: 'A user or group sends a new message to the bot.',
|
||||||
message_edited: 'The platform reports that an existing message changed.',
|
message_edited: 'The platform reports that an existing message changed.',
|
||||||
@@ -740,9 +750,57 @@ const enUS = {
|
|||||||
basicInfoDescription: 'Set the name, icon and description',
|
basicInfoDescription: 'Set the name, icon and description',
|
||||||
runnerSettings: 'Runner',
|
runnerSettings: 'Runner',
|
||||||
advanced: 'Advanced',
|
advanced: 'Advanced',
|
||||||
bindableEvents: 'Bindable Event Range',
|
eventsAndTools: 'Events & tools',
|
||||||
|
eventsAndToolsDescription: 'Set trigger events and available tools.',
|
||||||
|
bindableEvents: 'Event scope',
|
||||||
bindableEventsDescription:
|
bindableEventsDescription:
|
||||||
'Limit which bot event routes can select this Agent. The default is suitable for most cases.',
|
'Add an event to automatically provide its tools to the Agent.',
|
||||||
|
configuredEvents: 'Added events',
|
||||||
|
configuredEventsCount: '{{count}} total',
|
||||||
|
addEvent: 'Add event',
|
||||||
|
removeEvent: 'Remove event',
|
||||||
|
eventActions: 'Automatically enabled tools',
|
||||||
|
eventToolEnabled: 'Enabled',
|
||||||
|
eventToolsEnabledCount: '{{count}} tools enabled',
|
||||||
|
noEventActions: 'No actions are available for this event.',
|
||||||
|
noEventsConfigured: 'No events added',
|
||||||
|
noEventsConfiguredDescription: 'No event can trigger this Agent.',
|
||||||
|
noEventsConfiguredBadge: 'No events',
|
||||||
|
apiTools: 'Tool access',
|
||||||
|
apiToolsDescription: 'Choose which tools this Agent can call.',
|
||||||
|
otherTools: 'Other tools',
|
||||||
|
otherToolsDescription:
|
||||||
|
'Choose platform, sandbox, MCP, plugin, and skill tools.',
|
||||||
|
apiToolsSelected: '{{count}} selected',
|
||||||
|
apiToolsSecurityHint: 'Only enable the tools this Agent needs.',
|
||||||
|
apiToolsSearch: 'Search tools…',
|
||||||
|
eventApiTools: 'Event tools',
|
||||||
|
eventToolUnavailable: 'Unavailable',
|
||||||
|
eventApiToolsDescription:
|
||||||
|
'Targets are frozen from the current event. The Agent only supplies action parameters.',
|
||||||
|
platformApiTools: 'Platform tools',
|
||||||
|
platformApiToolsDescription:
|
||||||
|
'The Agent may choose user, group, or message IDs. Grant only what the workflow needs.',
|
||||||
|
apiToolEvents: 'Events',
|
||||||
|
apiToolParameters: 'Agent parameters',
|
||||||
|
apiToolSource: 'Source',
|
||||||
|
apiToolNoParameters: 'none',
|
||||||
|
sandboxTools: 'Sandbox',
|
||||||
|
mcpTools: 'MCP',
|
||||||
|
pluginTools: 'Plugins',
|
||||||
|
skillTools: 'Skills',
|
||||||
|
langbotBuiltIn: 'LangBot',
|
||||||
|
apiToolDetails: 'Details',
|
||||||
|
apiToolHideDetails: 'Hide',
|
||||||
|
apiToolsNoResults: 'No matching API or tool',
|
||||||
|
apiToolsCatalogUnavailable:
|
||||||
|
'The LangBot backend did not return the API tool catalog. Make sure the backend is updated and restarted; this does not mean the current platform has no tools.',
|
||||||
|
hostToolsCatalogUnavailable: 'The tool catalog is temporarily unavailable.',
|
||||||
|
apiToolRisk: {
|
||||||
|
read: 'Read only',
|
||||||
|
write: 'Action',
|
||||||
|
dangerous: 'Sensitive',
|
||||||
|
},
|
||||||
supportedEvents: 'Event Range',
|
supportedEvents: 'Event Range',
|
||||||
supportedEventsDescription:
|
supportedEventsDescription:
|
||||||
'Choose all events, an event group, or individual events. Bot routes will only list this Agent for matching events.',
|
'Choose all events, an event group, or individual events. Bot routes will only list this Agent for matching events.',
|
||||||
@@ -793,6 +851,8 @@ const enUS = {
|
|||||||
'Run the current Agent with a message or platform event and inspect the real output.',
|
'Run the current Agent with a message or platform event and inspect the real output.',
|
||||||
debugResetSession: 'Reset session',
|
debugResetSession: 'Reset session',
|
||||||
debugEventType: 'Event type',
|
debugEventType: 'Event type',
|
||||||
|
debugNoEventsTitle: 'No events to debug',
|
||||||
|
debugNoEventsDescription: 'Add an event under Events & tools first.',
|
||||||
debugMessageReceived: 'Message received',
|
debugMessageReceived: 'Message received',
|
||||||
debugGroupMemberJoined: 'Group member joined',
|
debugGroupMemberJoined: 'Group member joined',
|
||||||
debugGroupMemberLeft: 'Group member left',
|
debugGroupMemberLeft: 'Group member left',
|
||||||
|
|||||||
@@ -560,7 +560,18 @@ const jaJP = {
|
|||||||
},
|
},
|
||||||
eventDescriptions: {
|
eventDescriptions: {
|
||||||
all: 'このアダプターが受信するすべてのイベントに一致します。',
|
all: 'このアダプターが受信するすべてのイベントに一致します。',
|
||||||
namespace: '同じイベントグループ内の複数の具体イベントに一致します。',
|
namespace: 'すべての{{group}}イベントに一致します。',
|
||||||
|
namespace_bot:
|
||||||
|
'ボットのグループ参加、退出、ミュートなどの状態イベントに一致します。',
|
||||||
|
namespace_feedback:
|
||||||
|
'プラットフォームまたはユーザーからのフィードバックイベントに一致します。',
|
||||||
|
namespace_friend: '友達リクエストや友達関係の変更イベントに一致します。',
|
||||||
|
namespace_group:
|
||||||
|
'メンバーの参加、退出、削除などのグループイベントに一致します。',
|
||||||
|
namespace_message:
|
||||||
|
'メッセージの受信、編集、削除、リアクションイベントに一致します。',
|
||||||
|
namespace_platform:
|
||||||
|
'アダプターが提供するプラットフォーム固有イベントに一致します。',
|
||||||
custom: 'カスタムイベント、または説明がまだないイベントです。',
|
custom: 'カスタムイベント、または説明がまだないイベントです。',
|
||||||
message_received:
|
message_received:
|
||||||
'ユーザーまたはグループがボットへ新しいメッセージを送信します。',
|
'ユーザーまたはグループがボットへ新しいメッセージを送信します。',
|
||||||
|
|||||||
@@ -528,7 +528,13 @@ const zhHans = {
|
|||||||
},
|
},
|
||||||
eventDescriptions: {
|
eventDescriptions: {
|
||||||
all: '匹配此适配器收到的全部事件。',
|
all: '匹配此适配器收到的全部事件。',
|
||||||
namespace: '匹配同一事件分组下的多个具体事件。',
|
namespace: '匹配所有{{group}}事件。',
|
||||||
|
namespace_bot: '匹配机器人入群、退群、禁言和解除禁言等状态事件。',
|
||||||
|
namespace_feedback: '匹配平台或用户反馈事件。',
|
||||||
|
namespace_friend: '匹配好友请求、好友添加成功等好友关系事件。',
|
||||||
|
namespace_group: '匹配成员加入、离开或被移出群组等群组成员事件。',
|
||||||
|
namespace_message: '匹配消息接收、编辑、删除和表态事件。',
|
||||||
|
namespace_platform: '匹配适配器提供的平台专属事件。',
|
||||||
custom: '自定义或暂未提供说明的事件。',
|
custom: '自定义或暂未提供说明的事件。',
|
||||||
message_received: '用户或群组向机器人发送新消息。',
|
message_received: '用户或群组向机器人发送新消息。',
|
||||||
message_edited: '平台通知已有消息内容发生变更。',
|
message_edited: '平台通知已有消息内容发生变更。',
|
||||||
@@ -709,9 +715,55 @@ const zhHans = {
|
|||||||
basicInfoDescription: '设置名称、图标和描述',
|
basicInfoDescription: '设置名称、图标和描述',
|
||||||
runnerSettings: '运行器',
|
runnerSettings: '运行器',
|
||||||
advanced: '高级',
|
advanced: '高级',
|
||||||
bindableEvents: '可绑定事件范围',
|
eventsAndTools: '事件与工具',
|
||||||
bindableEventsDescription:
|
eventsAndToolsDescription: '设置触发范围和可用工具。',
|
||||||
'限制此 Agent 可被机器人事件路由选择的事件范围。通常保持默认即可。',
|
bindableEvents: '事件范围',
|
||||||
|
bindableEventsDescription: '添加事件后,对应工具会自动提供给 Agent。',
|
||||||
|
configuredEvents: '已添加事件',
|
||||||
|
configuredEventsCount: '共 {{count}} 项',
|
||||||
|
addEvent: '添加事件',
|
||||||
|
removeEvent: '移除事件',
|
||||||
|
eventActions: '已自动启用的工具',
|
||||||
|
eventToolEnabled: '已启用',
|
||||||
|
eventToolsEnabledCount: '已启用 {{count}} 个工具',
|
||||||
|
noEventActions: '该事件暂无可用动作。',
|
||||||
|
noEventsConfigured: '暂未添加事件',
|
||||||
|
noEventsConfiguredDescription: '此 Agent 不会被任何事件触发。',
|
||||||
|
noEventsConfiguredBadge: '未配置事件',
|
||||||
|
apiTools: '工具权限',
|
||||||
|
apiToolsDescription: '选择 Agent 可以调用的工具。',
|
||||||
|
otherTools: '其他工具',
|
||||||
|
otherToolsDescription: '选择平台、沙盒、MCP、插件和技能工具。',
|
||||||
|
apiToolsSelected: '已选 {{count}}',
|
||||||
|
apiToolsSecurityHint: '只开放实际需要的工具。',
|
||||||
|
apiToolsSearch: '搜索工具…',
|
||||||
|
eventApiTools: '事件工具',
|
||||||
|
eventToolUnavailable: '不适用',
|
||||||
|
eventApiToolsDescription:
|
||||||
|
'目标从当前事件冻结,Agent 只能提供动作参数,适合回复、审核请求和处理相关成员。',
|
||||||
|
platformApiTools: '平台工具',
|
||||||
|
platformApiToolsDescription:
|
||||||
|
'Agent 可以指定用户、群组或消息标识。请只按实际业务需要授权。',
|
||||||
|
apiToolEvents: '适用事件',
|
||||||
|
apiToolParameters: 'Agent 可填写参数',
|
||||||
|
apiToolSource: '来源',
|
||||||
|
apiToolNoParameters: '无',
|
||||||
|
sandboxTools: '沙盒',
|
||||||
|
mcpTools: 'MCP',
|
||||||
|
pluginTools: '插件',
|
||||||
|
skillTools: '技能',
|
||||||
|
langbotBuiltIn: 'LangBot',
|
||||||
|
apiToolDetails: '详情',
|
||||||
|
apiToolHideDetails: '收起',
|
||||||
|
apiToolsNoResults: '没有匹配的 API 或工具',
|
||||||
|
apiToolsCatalogUnavailable:
|
||||||
|
'LangBot 主程序没有返回 API 工具目录。请确认后端已更新并重启;该状态不代表当前平台没有可用工具。',
|
||||||
|
hostToolsCatalogUnavailable: '暂时无法加载工具目录。',
|
||||||
|
apiToolRisk: {
|
||||||
|
read: '只读',
|
||||||
|
write: '操作',
|
||||||
|
dangerous: '敏感',
|
||||||
|
},
|
||||||
supportedEvents: '事件范围',
|
supportedEvents: '事件范围',
|
||||||
supportedEventsDescription:
|
supportedEventsDescription:
|
||||||
'选择全部事件、事件组或具体事件。机器人路由只会在匹配的事件中显示此 Agent。',
|
'选择全部事件、事件组或具体事件。机器人路由只会在匹配的事件中显示此 Agent。',
|
||||||
@@ -758,6 +810,8 @@ const zhHans = {
|
|||||||
debugDescription: '用消息或平台事件直接运行当前 Agent,并查看真实输出。',
|
debugDescription: '用消息或平台事件直接运行当前 Agent,并查看真实输出。',
|
||||||
debugResetSession: '重置会话',
|
debugResetSession: '重置会话',
|
||||||
debugEventType: '事件类型',
|
debugEventType: '事件类型',
|
||||||
|
debugNoEventsTitle: '暂无可调试事件',
|
||||||
|
debugNoEventsDescription: '请先在事件与工具中添加一个事件。',
|
||||||
debugMessageReceived: '收到消息',
|
debugMessageReceived: '收到消息',
|
||||||
debugGroupMemberJoined: '成员加入群组',
|
debugGroupMemberJoined: '成员加入群组',
|
||||||
debugGroupMemberLeft: '成员离开群组',
|
debugGroupMemberLeft: '成员离开群组',
|
||||||
|
|||||||
Reference in New Issue
Block a user