mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-06 17:47: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
|
||||
"""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
|
||||
"""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 .registry import AgentRunnerRegistry
|
||||
from .resource_builder import AgentResourceBuilder
|
||||
from .platform_tools import freeze_platform_context
|
||||
from .result_normalizer import AgentResultNormalizer
|
||||
from .run_journal import AgentRunJournal
|
||||
from .session_registry import AgentRunSessionRegistry, get_session_registry
|
||||
@@ -201,6 +202,7 @@ class AgentRunOrchestrator:
|
||||
},
|
||||
state_context=state_context,
|
||||
execution_query=execution_query,
|
||||
platform_context=freeze_platform_context(event),
|
||||
)
|
||||
|
||||
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 ...provider.tools.loaders.mcp import MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE
|
||||
from ...provider.tools.toolmgr import ToolSourceRef
|
||||
from .platform_tools import build_platform_tool_resources
|
||||
|
||||
|
||||
class AgentResourceBuilder:
|
||||
@@ -86,6 +87,18 @@ class AgentResourceBuilder:
|
||||
descriptor,
|
||||
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(
|
||||
execution_context,
|
||||
manifest_perms,
|
||||
@@ -106,7 +119,7 @@ class AgentResourceBuilder:
|
||||
'knowledge_bases': knowledge_bases,
|
||||
'skills': skills,
|
||||
'storage': storage,
|
||||
'platform_capabilities': {}, # Reserved for EBA
|
||||
'platform_capabilities': platform_capabilities,
|
||||
}
|
||||
|
||||
async def _build_models_from_binding(
|
||||
|
||||
@@ -21,6 +21,9 @@ class ResourcePolicyProjector:
|
||||
resolved_tool_sources: typing.Mapping[str, typing.Any] | None = None,
|
||||
resolved_kb_uuids: 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:
|
||||
"""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'))
|
||||
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)
|
||||
if enable_all_tools:
|
||||
allowed_tool_names = available_tool_names
|
||||
@@ -64,6 +70,7 @@ class ResourcePolicyProjector:
|
||||
allowed_model_uuids=cls.normalize_optional_names(resolved_model_uuids),
|
||||
allowed_tool_names=allowed_tool_names,
|
||||
allowed_tool_sources=allowed_tool_sources,
|
||||
allowed_platform_tool_names=cls.normalize_names(allowed_platform_tool_names),
|
||||
allow_all_tools=allow_all_tools,
|
||||
allowed_kb_uuids=allowed_kb_uuids,
|
||||
allowed_skill_names=cls.normalize_optional_names(resolved_skill_names),
|
||||
|
||||
@@ -47,6 +47,7 @@ class RunAuthorizationSnapshot(typing.TypedDict):
|
||||
thread_id: str | None
|
||||
state_policy: dict[str, typing.Any]
|
||||
state_context: dict[str, typing.Any]
|
||||
platform_context: dict[str, typing.Any]
|
||||
authorized_ids: 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_context: dict[str, typing.Any] | None = None,
|
||||
execution_query: pipeline_query.Query | None = None,
|
||||
platform_context: dict[str, typing.Any] | None = None,
|
||||
) -> None:
|
||||
"""Register a new agent run session.
|
||||
|
||||
@@ -155,6 +157,7 @@ class AgentRunSessionRegistry:
|
||||
'thread_id': thread_id,
|
||||
'state_policy': copy.deepcopy(state_policy),
|
||||
'state_context': copy.deepcopy(state_context),
|
||||
'platform_context': copy.deepcopy(platform_context or {}),
|
||||
'authorized_ids': self._build_authorized_ids(resources_snapshot),
|
||||
'authorized_operations': self._build_authorized_operations(resources_snapshot),
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ from ....agent.runner.host_models import (
|
||||
StatePolicy,
|
||||
)
|
||||
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 ....workspace.errors import WorkspaceNotFoundError
|
||||
from ..context import ExecutionContext, RequestContext
|
||||
@@ -49,8 +53,21 @@ class AgentService:
|
||||
"""Return metadata needed by Agent forms."""
|
||||
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)
|
||||
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 {
|
||||
'runner_config': ai_metadata,
|
||||
'platform_tools': platform_tool_catalog(),
|
||||
'host_tools': host_tools,
|
||||
'kinds': [
|
||||
{
|
||||
'name': AGENT_KIND_AGENT,
|
||||
@@ -192,7 +209,12 @@ class AgentService:
|
||||
event_types=[event_type],
|
||||
runner_id=runner_id,
|
||||
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'],
|
||||
),
|
||||
@@ -292,7 +314,11 @@ class AgentService:
|
||||
'kind': AGENT_KIND_AGENT,
|
||||
'component_ref': runner_id,
|
||||
'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))
|
||||
return {'uuid': new_uuid, 'kind': AGENT_KIND_AGENT}
|
||||
@@ -317,9 +343,6 @@ class AgentService:
|
||||
else:
|
||||
_, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(existing_agent.config)
|
||||
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(
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_agent.Agent)
|
||||
@@ -404,8 +427,11 @@ class AgentService:
|
||||
) -> dict[str, typing.Any]:
|
||||
item = self.ap.persistence_mgr.serialize_model(persistence_agent.Agent, agent)
|
||||
item['kind'] = AGENT_KIND_AGENT
|
||||
supported_event_patterns = item.get('supported_event_patterns')
|
||||
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,
|
||||
}
|
||||
if not include_config:
|
||||
|
||||
@@ -76,7 +76,7 @@ class BotService:
|
||||
|
||||
@classmethod
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -27,6 +27,7 @@ from ..agent.runner.host_models import (
|
||||
StatePolicy,
|
||||
)
|
||||
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 ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
|
||||
@@ -165,7 +166,8 @@ class RuntimeBot:
|
||||
supported_patterns: list[str] | None,
|
||||
event_type: str,
|
||||
) -> 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
|
||||
def _get_nested_value(data: dict[str, typing.Any], path: str) -> typing.Any:
|
||||
@@ -808,7 +810,12 @@ class RuntimeBot:
|
||||
event_types=[event_type],
|
||||
runner_id=runner_id,
|
||||
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']),
|
||||
delivery_policy=DeliveryPolicy(
|
||||
enable_streaming=False,
|
||||
|
||||
@@ -52,6 +52,7 @@ from ..utils import constants
|
||||
from ..agent.runner.session_registry import get_session_registry
|
||||
from ..agent.runner.config_resolver import RunnerConfigResolver
|
||||
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
|
||||
|
||||
|
||||
@@ -302,6 +303,8 @@ def _validate_frozen_tool_source_identity(
|
||||
MCP_TOOL_READ_RESOURCE,
|
||||
}:
|
||||
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:
|
||||
return source_ref, None
|
||||
@@ -1547,6 +1550,15 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
# In real implementation, you would reconstruct the full session
|
||||
# For now, we'll call the tool manager's execute method
|
||||
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(
|
||||
data,
|
||||
session,
|
||||
@@ -1602,6 +1614,13 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
return error
|
||||
|
||||
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] = {}
|
||||
if source_ref is not None:
|
||||
detail_kwargs['source_ref'] = source_ref
|
||||
|
||||
@@ -10,6 +10,7 @@ import shutil
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
@@ -25,7 +26,6 @@ pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
QA_RUNNER_ID = 'plugin:e2e/agent-runner-qa/default'
|
||||
QA_PLUGIN_DIRNAME = 'e2e__agent-runner-qa'
|
||||
|
||||
|
||||
@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:
|
||||
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
|
||||
|
||||
|
||||
@@ -212,7 +218,7 @@ def agent_runner_runtime_process(agent_runner_e2e_tmpdir, agent_runner_runtime_p
|
||||
stderr_file = open(stderr_path, 'wb')
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
str(find_project_root() / '.venv' / 'bin' / 'python'),
|
||||
sys.executable,
|
||||
'-m',
|
||||
'langbot_plugin.cli.__init__',
|
||||
'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:
|
||||
"""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.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
|
||||
payload = auth_resp.json()
|
||||
assert payload['code'] == 0
|
||||
return payload['data']['token']
|
||||
|
||||
|
||||
def test_plugin_runtime_discovers_agent_runner(agent_runner_client, agent_runner_langbot_process):
|
||||
"""Pipeline metadata should include the real runtime-discovered QA runner."""
|
||||
token = _init_and_auth(agent_runner_client)
|
||||
start = time.time()
|
||||
while time.time() - start < 60:
|
||||
response = agent_runner_client.get(
|
||||
def _install_qa_plugin(client: httpx.Client, token: str, package_path: Path) -> None:
|
||||
"""Install the QA Runner through the same asynchronous local-upload API as the UI."""
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
with package_path.open('rb') as package_file:
|
||||
response = client.post(
|
||||
'/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',
|
||||
headers={'Authorization': f'Bearer {token}'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data['code'] == 0
|
||||
assert data['code'] == 0, data
|
||||
metadata_groups = data['data']['configs']
|
||||
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_select = next(item for item in runner_stage['config'] if item['name'] == 'id')
|
||||
option_names = {option['name'] for option in runner_select['options']}
|
||||
if QA_RUNNER_ID in option_names:
|
||||
return
|
||||
time.sleep(2)
|
||||
break
|
||||
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(
|
||||
agent_runner_e2e_config_path,
|
||||
agent_runner_client,
|
||||
agent_runner_langbot_process,
|
||||
agent_runner_e2e_tmpdir,
|
||||
agent_runner_runtime_process,
|
||||
):
|
||||
"""The Host orchestrator should run the pluginized runner and persist run side effects."""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from langbot.pkg.agent.runner.host_models import (
|
||||
AgentBinding,
|
||||
AgentEventEnvelope,
|
||||
BindingScope,
|
||||
DeliveryPolicy,
|
||||
StatePolicy,
|
||||
"""Create/configure/debug an Agent through HTTP and persist Runner side effects."""
|
||||
del agent_runner_langbot_process
|
||||
token = _init_and_auth(agent_runner_client)
|
||||
_ensure_qa_plugin(
|
||||
agent_runner_client,
|
||||
token,
|
||||
agent_runner_e2e_tmpdir / 'agent-runner-qa.zip',
|
||||
)
|
||||
from langbot.pkg.core import boot
|
||||
from langbot.pkg.utils import platform as platform_utils
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext, SubjectContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
create_response = agent_runner_client.post(
|
||||
'/api/v1/agents',
|
||||
headers=headers,
|
||||
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():
|
||||
previous_cwd = Path.cwd()
|
||||
previous_standalone_runtime = platform_utils.standalone_runtime
|
||||
os.chdir(agent_runner_e2e_tmpdir)
|
||||
platform_utils.standalone_runtime = True
|
||||
ap = None
|
||||
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')
|
||||
get_response = agent_runner_client.get(f'/api/v1/agents/{agent_uuid}', headers=headers)
|
||||
assert get_response.status_code == 200, get_response.text
|
||||
stored_agent = get_response.json()['data']['agent']
|
||||
assert stored_agent['config']['allowed_platform_tools'] == [
|
||||
'event_reply',
|
||||
'platform_get_user_info',
|
||||
]
|
||||
|
||||
for _ in range(60):
|
||||
runners = await ap.agent_runner_registry.list_runners(use_cache=False)
|
||||
if any(runner.id == QA_RUNNER_ID for runner in runners):
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
raise AssertionError(f'{QA_RUNNER_ID} was not discovered')
|
||||
|
||||
event = AgentEventEnvelope(
|
||||
event_id='e2e-orchestrator-event-001',
|
||||
event_type='message.received',
|
||||
source='api',
|
||||
conversation_id='e2e-conversation',
|
||||
thread_id='e2e-thread',
|
||||
actor=ActorContext(actor_type='user', actor_id='user-001', actor_name='E2E User'),
|
||||
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'
|
||||
debug_response = agent_runner_client.post(
|
||||
f'/api/v1/agents/{agent_uuid}/debug',
|
||||
headers=headers,
|
||||
json={
|
||||
'event_type': 'message.received',
|
||||
'text': 'hello from orchestrator e2e',
|
||||
'conversation_id': 'e2e-conversation',
|
||||
},
|
||||
)
|
||||
assert debug_response.status_code == 200, debug_response.text
|
||||
debug_payload = debug_response.json()
|
||||
assert debug_payload['code'] == 0, debug_payload
|
||||
result = debug_payload['data']
|
||||
assert result['final_text'] == 'e2e echo: hello from orchestrator e2e'
|
||||
assert result['outputs'][0]['role'] == 'assistant'
|
||||
|
||||
db_path = agent_runner_e2e_tmpdir / 'data' / 'langbot.db'
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
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()
|
||||
assert run_row == ('completed', QA_RUNNER_ID)
|
||||
|
||||
event_types = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT type FROM agent_run_event WHERE run_id = (SELECT run_id FROM agent_run WHERE event_id = '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()
|
||||
}
|
||||
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]
|
||||
finally:
|
||||
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 sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -30,7 +31,6 @@ pytestmark = pytest.mark.e2e
|
||||
LOCAL_AGENT_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
|
||||
FAKE_PROVIDER_UUID = 'e2e-fake-provider'
|
||||
FAKE_MODEL_UUID = 'e2e-fake-local-agent-model'
|
||||
LOCAL_AGENT_PLUGIN_DIRNAME = 'langbot__local-agent'
|
||||
E2E_TOOL_NAME = 'e2e_lookup'
|
||||
E2E_KB_UUID = 'e2e-kb-local-agent'
|
||||
|
||||
@@ -48,13 +48,13 @@ def _local_agent_repo() -> Path:
|
||||
return project_root.parent / 'langbot-local-agent'
|
||||
|
||||
|
||||
def _copy_local_agent_plugin(tmpdir: Path) -> None:
|
||||
"""Copy the sibling Local Agent plugin into the temporary LangBot data dir."""
|
||||
def _package_local_agent_plugin(tmpdir: Path) -> Path:
|
||||
"""Package the sibling Local Agent plugin for the real local-install flow."""
|
||||
local_agent_src = _local_agent_repo()
|
||||
if not (local_agent_src / 'manifest.yaml').exists():
|
||||
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(
|
||||
'.git',
|
||||
'.venv',
|
||||
@@ -64,7 +64,16 @@ def _copy_local_agent_plugin(tmpdir: Path) -> None:
|
||||
'build',
|
||||
'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:
|
||||
@@ -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:
|
||||
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
|
||||
|
||||
|
||||
@@ -304,7 +313,7 @@ def local_agent_runtime_process(local_agent_e2e_tmpdir, local_agent_runtime_port
|
||||
stderr_file = open(stderr_path, 'wb')
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
str(find_project_root() / '.venv' / 'bin' / 'python'),
|
||||
sys.executable,
|
||||
'-m',
|
||||
'langbot_plugin.cli.__init__',
|
||||
'rt',
|
||||
@@ -329,14 +338,18 @@ def local_agent_runtime_process(local_agent_e2e_tmpdir, local_agent_runtime_port
|
||||
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."""
|
||||
import sqlalchemy
|
||||
|
||||
from langbot.pkg.entity.persistence import model as persistence_model
|
||||
from langbot.pkg.provider.modelmgr import requester, token
|
||||
from tests.unit_tests.provider.conftest import FakeProviderAPIRequester
|
||||
|
||||
execution_context = await ap.plugin_connector._current_execution_context()
|
||||
provider_entity = persistence_model.ModelProvider(
|
||||
uuid=FAKE_PROVIDER_UUID,
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
name='E2E Fake Provider',
|
||||
requester='fake-requester',
|
||||
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})
|
||||
runtime_provider = requester.RuntimeProvider(
|
||||
execution_context=execution_context,
|
||||
provider_entity=provider_entity,
|
||||
token_mgr=token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys),
|
||||
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(
|
||||
model_entity=persistence_model.LLMModel(
|
||||
uuid=FAKE_MODEL_UUID,
|
||||
name=FAKE_MODEL_UUID,
|
||||
provider_uuid=provider_entity.uuid,
|
||||
abilities=['func_call'],
|
||||
context_length=8192,
|
||||
extra_args={},
|
||||
),
|
||||
model_entity=model_entity,
|
||||
execution_context=execution_context,
|
||||
provider=runtime_provider,
|
||||
)
|
||||
ap.model_mgr.provider_dict[provider_entity.uuid] = runtime_provider
|
||||
ap.model_mgr.llm_models.append(runtime_model)
|
||||
await ap.persistence_mgr.execute_async(
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
tool_name: str = E2E_TOOL_NAME,
|
||||
*,
|
||||
@@ -392,8 +443,10 @@ def _scripted_tool_call(
|
||||
async def _boot_local_agent_app(tmpdir: Path):
|
||||
"""Boot LangBot and wait until the Local Agent runner is discoverable."""
|
||||
from langbot.pkg.core import boot
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
|
||||
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):
|
||||
handler = getattr(ap.plugin_connector, 'handler', None)
|
||||
if handler is not None:
|
||||
@@ -401,17 +454,31 @@ async def _boot_local_agent_app(tmpdir: Path):
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
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):
|
||||
runners = await ap.agent_runner_registry.list_runners(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')
|
||||
execution_context = await ap.plugin_connector._current_execution_context()
|
||||
runners = await ap.agent_runner_registry.list_runners(execution_context, use_cache=False)
|
||||
if not any(runner.id == LOCAL_AGENT_RUNNER_ID for runner in runners):
|
||||
await ap.plugin_connector.install_plugin(
|
||||
PluginInstallSource.LOCAL,
|
||||
{'plugin_file': (tmpdir / 'langbot-local-agent.zip').read_bytes()},
|
||||
)
|
||||
|
||||
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):
|
||||
@@ -424,12 +491,30 @@ def _run_local_agent_probe(tmpdir: Path, probe):
|
||||
os.chdir(tmpdir)
|
||||
platform_utils.standalone_runtime = True
|
||||
ap = None
|
||||
run_task = None
|
||||
try:
|
||||
ap = await _boot_local_agent_app(tmpdir)
|
||||
ap, run_task = await _boot_local_agent_app(tmpdir)
|
||||
return await probe(ap)
|
||||
finally:
|
||||
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
|
||||
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
|
||||
|
||||
async def _run_probe(ap):
|
||||
fake_requester = _inject_fake_llm_model(ap)
|
||||
fake_requester = await _inject_fake_llm_model(ap)
|
||||
event = _event(
|
||||
event_id='e2e-local-agent-event-001',
|
||||
conversation_id='e2e-local-agent-conversation',
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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(
|
||||
_scripted_tool_call(),
|
||||
'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',
|
||||
},
|
||||
)
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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_kb = _FakeKnowledgeBase()
|
||||
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,
|
||||
},
|
||||
)
|
||||
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)
|
||||
|
||||
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):
|
||||
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(
|
||||
'SUMMARY_SENTINEL compacted older history including HIST_SENTINEL',
|
||||
'Compaction final answer',
|
||||
@@ -682,7 +767,7 @@ def test_local_agent_runner_compacts_history_and_persists_checkpoint(
|
||||
'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
|
||||
|
||||
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):
|
||||
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):
|
||||
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,
|
||||
},
|
||||
)
|
||||
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,
|
||||
|
||||
@@ -64,6 +64,11 @@ class LangBotProcess:
|
||||
env.pop(proxy_key, None)
|
||||
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
|
||||
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
|
||||
|
||||
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.binding_resolver import AgentBindingResolver
|
||||
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
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
|
||||
|
||||
|
||||
@@ -201,6 +202,41 @@ async def test_build_models_from_config_without_manifest_acl(app):
|
||||
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
|
||||
async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
|
||||
"""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():
|
||||
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.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', [], {}])
|
||||
@@ -78,6 +92,28 @@ def test_independent_agent_projection_preserves_selected_tools():
|
||||
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():
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{'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_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),
|
||||
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={}),
|
||||
)
|
||||
app.agent_runner_registry = None
|
||||
app.tool_mgr = None
|
||||
app.logger = Mock()
|
||||
return app
|
||||
|
||||
@@ -107,11 +108,32 @@ class TestAgentServiceMetadata:
|
||||
app.pipeline_service.get_pipeline_metadata = AsyncMock(
|
||||
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)
|
||||
app.pipeline_service.get_pipeline_metadata.assert_awaited_once_with(WORKSPACE_UUID)
|
||||
|
||||
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'] == [
|
||||
{
|
||||
'name': AGENT_KIND_AGENT,
|
||||
@@ -129,6 +151,13 @@ class TestAgentServiceMetadata:
|
||||
class TestAgentServiceDebug:
|
||||
async def test_debug_agent_runs_configured_runner_with_synthetic_event(self):
|
||||
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):
|
||||
yield SimpleNamespace(
|
||||
@@ -144,7 +173,7 @@ class TestAgentServiceDebug:
|
||||
'uuid': 'agent-1',
|
||||
'kind': AGENT_KIND_AGENT,
|
||||
'supported_event_patterns': ['*'],
|
||||
'config': _agent_row().config,
|
||||
'config': agent_config,
|
||||
}
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
@@ -181,6 +210,15 @@ class TestAgentServiceDebug:
|
||||
assert event.data == {'member_id': 'user-1'}
|
||||
assert binding.agent_id == 'agent-1'
|
||||
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 (
|
||||
app.agent_run_orchestrator.run.call_args.kwargs['adapter_context']['_execution_context'].workspace_uuid
|
||||
== WORKSPACE_UUID
|
||||
@@ -435,6 +473,25 @@ class TestAgentServiceCreateUpdateDelete:
|
||||
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||
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):
|
||||
app = _make_app()
|
||||
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 == {
|
||||
'name': 'Updated Agent',
|
||||
'config': new_config,
|
||||
'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS,
|
||||
'supported_event_patterns': [],
|
||||
'component_ref': 'plugin:test/new-runner/default',
|
||||
}
|
||||
|
||||
|
||||
@@ -340,6 +340,12 @@ class TestEBAEventBindings:
|
||||
bot.bot_entity = SimpleNamespace(event_bindings=bindings)
|
||||
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):
|
||||
"""The selected binding is the first matching highest-priority binding."""
|
||||
bot = self._make_bot(
|
||||
@@ -408,6 +414,12 @@ class TestEBAEventBindings:
|
||||
'component_ref': 'plugin:test/fallback/default',
|
||||
'config': {
|
||||
'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': {
|
||||
'plugin:test/runner/default': {
|
||||
'temperature': 0.2,
|
||||
@@ -428,8 +440,12 @@ class TestEBAEventBindings:
|
||||
assert binding.event_types == ['platform.member.joined']
|
||||
assert binding.runner_id == 'plugin:test/runner/default'
|
||||
assert binding.runner_config == {'temperature': 0.2, 'max_tokens': 1000}
|
||||
assert binding.resource_policy.allow_all_tools is True
|
||||
assert binding.resource_policy.allowed_tool_names is None
|
||||
assert binding.resource_policy.allow_all_tools is False
|
||||
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_reply 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 { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { Agent } from '@/app/infra/entities/api';
|
||||
@@ -13,6 +13,7 @@ import EntityBasicInfoDialog, {
|
||||
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
|
||||
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -168,6 +169,18 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
title={`${agent.emoji || '🤖'} ${agent.name}`}
|
||||
titleBadge={
|
||||
supportedEventPatterns.length === 0 ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
role="status"
|
||||
className="shrink-0 gap-1 rounded-full border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<AlertTriangle className="size-3" />
|
||||
{t('agents.noEventsConfiguredBadge')}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
titleAction={
|
||||
canManage ? (
|
||||
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
|
||||
|
||||
@@ -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 {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
LoaderCircle,
|
||||
Play,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -167,11 +167,6 @@ export default function AgentDebugPanel({
|
||||
setEventDataText(JSON.stringify(nextPreset.data, null, 2));
|
||||
}
|
||||
|
||||
function resetSession() {
|
||||
sessionIdRef.current = createDebugSessionId(agentId);
|
||||
setEntries([]);
|
||||
}
|
||||
|
||||
async function runDebugEvent() {
|
||||
if (!eventType) {
|
||||
toast.error(t('agents.debugEventTypeRequired'));
|
||||
@@ -368,130 +363,131 @@ export default function AgentDebugPanel({
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 space-y-3 border-t p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<Label>{t('agents.debugEventType')}</Label>
|
||||
<Select value={preset} onValueChange={selectPreset}>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-label={t('agents.debugEventType')}
|
||||
>
|
||||
<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) => (
|
||||
{supportedEventPatterns.length === 0 ? (
|
||||
<Alert className="bg-amber-500/5 text-amber-800 dark:text-amber-200">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertTitle>{t('agents.debugNoEventsTitle')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.debugNoEventsDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('agents.debugEventType')}</Label>
|
||||
<Select value={preset} onValueChange={selectPreset}>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-label={t('agents.debugEventType')}
|
||||
>
|
||||
<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
|
||||
key={event}
|
||||
value={event}
|
||||
description={eventPatternDescription(event, t)}
|
||||
value="custom"
|
||||
description={t('bots.eventDescriptions.custom')}
|
||||
className="py-2"
|
||||
>
|
||||
<EventSelectOptionContent
|
||||
event={event}
|
||||
label={eventPatternLabel(event, t)}
|
||||
event="custom.event"
|
||||
label={t('agents.debugCustomEvent')}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
{supportsCustomEvent && (
|
||||
<SelectGroup>
|
||||
<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>
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{preset === 'custom' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="agent-debug-custom-event">
|
||||
{t('agents.debugCustomEventType')}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-debug-custom-event"
|
||||
value={customEventType}
|
||||
onChange={(event) => setCustomEventType(event.target.value)}
|
||||
placeholder="custom.event"
|
||||
/>
|
||||
</div>
|
||||
{preset === 'custom' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="agent-debug-custom-event">
|
||||
{t('agents.debugCustomEventType')}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-debug-custom-event"
|
||||
value={customEventType}
|
||||
onChange={(event) => setCustomEventType(event.target.value)}
|
||||
placeholder="custom.event"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
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 { 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 {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -16,7 +23,6 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
eventGroupLabel,
|
||||
eventNamespaces,
|
||||
@@ -31,18 +37,53 @@ interface AgentEventPatternPickerProps {
|
||||
events: string[];
|
||||
value: string[];
|
||||
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({
|
||||
events,
|
||||
value,
|
||||
onChange,
|
||||
tools,
|
||||
catalogAvailable = true,
|
||||
}: AgentEventPatternPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const selectedPatterns = useMemo(
|
||||
() => (value.length > 0 ? value : ['*']),
|
||||
[value],
|
||||
const [expandedPatterns, setExpandedPatterns] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const selectedPatterns = value;
|
||||
const eventTools = useMemo(
|
||||
() => tools.filter((tool) => tool.scope === 'event'),
|
||||
[tools],
|
||||
);
|
||||
const options = useMemo(() => {
|
||||
const concreteEvents = Array.from(
|
||||
@@ -59,115 +100,216 @@ export default function AgentEventPatternPicker({
|
||||
...selectedPatterns.filter((pattern) => pattern.endsWith('.*')),
|
||||
]),
|
||||
).sort();
|
||||
return ['*', ...namespaces, ...concreteEvents];
|
||||
return ['*', ...namespaces, ...concreteEvents].filter(
|
||||
(pattern) => !selectedPatterns.includes(pattern),
|
||||
);
|
||||
}, [events, selectedPatterns]);
|
||||
const optionGroups = useMemo(() => groupEventPatterns(options), [options]);
|
||||
|
||||
function togglePattern(pattern: string) {
|
||||
function addPattern(pattern: string) {
|
||||
let nextPatterns: string[];
|
||||
if (pattern === '*') {
|
||||
onChange(['*']);
|
||||
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('.*'),
|
||||
);
|
||||
nextPatterns = ['*'];
|
||||
} 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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
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"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap gap-1.5">
|
||||
{selectedPatterns.slice(0, 3).map((pattern) => (
|
||||
<Badge
|
||||
key={pattern}
|
||||
variant="secondary"
|
||||
className="max-w-full rounded-md font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{eventPatternLabel(pattern, t)}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{selectedPatterns.length > 3 && (
|
||||
<Badge variant="outline" className="rounded-md font-normal">
|
||||
+{selectedPatterns.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t('agents.searchEvents')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('agents.noEventsFound')}</CommandEmpty>
|
||||
{optionGroups.map((group) => (
|
||||
<CommandGroup
|
||||
key={group.namespace}
|
||||
heading={eventGroupLabel(group.namespace, t)}
|
||||
>
|
||||
{group.patterns.map((pattern) => {
|
||||
const selected = selectedPatterns.includes(pattern);
|
||||
return (
|
||||
<CommandItem
|
||||
key={pattern}
|
||||
value={`${eventPatternLabel(pattern, t)} ${pattern}`}
|
||||
onSelect={() => togglePattern(pattern)}
|
||||
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)}
|
||||
<div className="overflow-hidden rounded-xl border bg-background">
|
||||
<div className="flex items-center justify-between gap-3 border-b bg-muted/30 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{t('agents.configuredEvents')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('agents.configuredEventsCount', {
|
||||
count: selectedPatterns.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t('agents.addEvent')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-96 max-w-[90vw] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={t('agents.searchEvents')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('agents.noEventsFound')}</CommandEmpty>
|
||||
{optionGroups.map((group) => (
|
||||
<CommandGroup
|
||||
key={group.namespace}
|
||||
heading={eventGroupLabel(group.namespace, t)}
|
||||
>
|
||||
{group.patterns.map((pattern) => (
|
||||
<CommandItem
|
||||
key={pattern}
|
||||
value={`${eventPatternLabel(pattern, t)} ${pattern}`}
|
||||
onSelect={() => addPattern(pattern)}
|
||||
className="items-start gap-2 py-2"
|
||||
>
|
||||
<Plus className="mt-0.5 size-4 shrink-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>
|
||||
<code className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{pattern}
|
||||
</code>
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
{eventPatternDescription(pattern, t)}
|
||||
</span>
|
||||
<code className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{pattern}
|
||||
</code>
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
{eventPatternDescription(pattern, t)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</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>
|
||||
<code className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{pattern}
|
||||
</code>
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
|
||||
{eventPatternDescription(pattern, t)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
{t('agents.eventToolsEnabledCount', {
|
||||
count: compatibleTools.length,
|
||||
})}
|
||||
</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 { useTranslation } from 'react-i18next';
|
||||
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 { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
||||
import {
|
||||
Agent,
|
||||
AgentPlatformTool,
|
||||
ApiRespPluginSystemStatus,
|
||||
PluginTool,
|
||||
} from '@/app/infra/entities/api';
|
||||
import {
|
||||
PipelineConfigStage,
|
||||
PipelineConfigTab,
|
||||
@@ -36,15 +41,18 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import AgentEventPatternPicker from './AgentEventPatternPicker';
|
||||
import AgentRunnerSelect from './AgentRunnerSelect';
|
||||
import AgentApiToolPicker from './AgentApiToolPicker';
|
||||
|
||||
const OTHER_TOOL_SCOPES = [
|
||||
'platform',
|
||||
'builtin',
|
||||
'mcp',
|
||||
'plugin',
|
||||
'skill',
|
||||
] as const;
|
||||
|
||||
export interface AgentRunnerStatus {
|
||||
label: string;
|
||||
@@ -62,7 +70,10 @@ interface AgentFormComponentProps {
|
||||
onSupportedEventPatternsChange?: (patterns: string[]) => void;
|
||||
}
|
||||
|
||||
export type AgentConfigSection = 'events' | 'runner' | 'runner_config';
|
||||
export type AgentConfigSection =
|
||||
| 'runner'
|
||||
| 'runner_config'
|
||||
| 'events_and_tools';
|
||||
|
||||
export interface AgentFormHandle {
|
||||
openSection: (section: AgentConfigSection) => void;
|
||||
@@ -119,6 +130,12 @@ function AgentFormComponent(
|
||||
const { t } = useTranslation();
|
||||
const [runnerConfigSchema, setRunnerConfigSchema] =
|
||||
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] =
|
||||
useState<ApiRespPluginSystemStatus | null>(null);
|
||||
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
|
||||
@@ -129,6 +146,7 @@ function AgentFormComponent(
|
||||
useState<AgentConfigSection>('runner');
|
||||
const isSavingRef = useRef(false);
|
||||
const hasUnsavedChangesRef = useRef(false);
|
||||
const loadedHostToolPolicyRef = useRef<string[] | undefined>(undefined);
|
||||
|
||||
const formSchema = z.object({
|
||||
basic: z.object({
|
||||
@@ -138,7 +156,9 @@ function AgentFormComponent(
|
||||
}),
|
||||
runner: 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>;
|
||||
|
||||
@@ -153,6 +173,8 @@ function AgentFormComponent(
|
||||
runner: {},
|
||||
runner_config: {},
|
||||
supported_event_patterns: ['*'],
|
||||
allowed_platform_tools: [],
|
||||
allowed_tools: [],
|
||||
},
|
||||
});
|
||||
const runnerInstallScope = `agent:${agentId}`;
|
||||
@@ -188,8 +210,43 @@ function AgentFormComponent(
|
||||
.then(([metadata, resp]) => {
|
||||
if (cancelled) return;
|
||||
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 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 = {
|
||||
basic: {
|
||||
name: agent.name ?? '',
|
||||
@@ -199,8 +256,15 @@ function AgentFormComponent(
|
||||
runner: (config.runner as Record<string, unknown>) ?? {},
|
||||
runner_config:
|
||||
(config.runner_config as Record<string, unknown>) ?? {},
|
||||
supported_event_patterns: agent.supported_event_patterns ??
|
||||
agent.capability?.supported_event_patterns ?? ['*'],
|
||||
supported_event_patterns: normalizedEventPatterns,
|
||||
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);
|
||||
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
||||
@@ -320,9 +384,9 @@ function AgentFormComponent(
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
{
|
||||
name: 'events',
|
||||
label: t('agents.bindableEvents'),
|
||||
icon: Zap,
|
||||
name: 'events_and_tools',
|
||||
label: t('agents.eventsAndTools'),
|
||||
icon: Wrench,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -500,22 +564,32 @@ function AgentFormComponent(
|
||||
if (isSavingRef.current) return false;
|
||||
const submittedSnapshot = JSON.stringify(values);
|
||||
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> = {
|
||||
name: values.basic.name,
|
||||
description: values.basic.description ?? '',
|
||||
emoji: values.basic.emoji,
|
||||
component_ref: (runner.id as string) || null,
|
||||
supported_event_patterns: values.supported_event_patterns,
|
||||
config: {
|
||||
runner,
|
||||
runner_config: values.runner_config ?? {},
|
||||
},
|
||||
config,
|
||||
};
|
||||
|
||||
isSavingRef.current = true;
|
||||
onSavingChange?.(true);
|
||||
try {
|
||||
await httpClient.updateAgent(agentId, agent);
|
||||
if (hostToolCatalogAvailable) {
|
||||
loadedHostToolPolicyRef.current = [...values.allowed_tools];
|
||||
}
|
||||
savedSnapshotRef.current = submittedSnapshot;
|
||||
onFinish(agent);
|
||||
toast.success(t('agents.saveSuccess'));
|
||||
@@ -532,7 +606,7 @@ function AgentFormComponent(
|
||||
onSavingChange?.(false);
|
||||
}
|
||||
},
|
||||
[agentId, onFinish, onSavingChange, t],
|
||||
[agentId, hostToolCatalogAvailable, onFinish, onSavingChange, t],
|
||||
);
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
@@ -653,32 +727,79 @@ function AgentFormComponent(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'events' && (
|
||||
{activeSection === 'events_and_tools' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle>{t('agents.eventsAndTools')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.bindableEventsDescription')}
|
||||
{t('agents.eventsAndToolsDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="supported_event_patterns"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<AgentEventPatternPicker
|
||||
events={availableEventTypes}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t('agents.supportedEventsDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<CardContent className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t('agents.bindableEvents')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('agents.bindableEventsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="supported_event_patterns"
|
||||
render={({ field }) => (
|
||||
<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>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -71,7 +71,14 @@ export function eventPatternLabel(pattern: string, t: TFunction) {
|
||||
export function eventPatternDescription(pattern: string, t: TFunction) {
|
||||
if (pattern === '*') return t('bots.eventDescriptions.all');
|
||||
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 description = t(key);
|
||||
|
||||
@@ -1301,7 +1301,7 @@ function NavItems({
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
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()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
@@ -1347,7 +1347,7 @@ function NavItems({
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
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()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
@@ -1389,7 +1389,7 @@ function NavItems({
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
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();
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ProcessorDetailStatus {
|
||||
|
||||
interface ProcessorDetailWorkbenchProps {
|
||||
title: string;
|
||||
titleBadge?: ReactNode;
|
||||
titleAction?: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
status?: ProcessorDetailStatus | null;
|
||||
@@ -45,6 +46,7 @@ interface ProcessorDetailWorkbenchProps {
|
||||
|
||||
export default function ProcessorDetailWorkbench({
|
||||
title,
|
||||
titleBadge,
|
||||
titleAction,
|
||||
headerActions,
|
||||
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 min-w-0 items-center gap-2">
|
||||
<h1 className="truncate text-xl font-semibold">{title}</h1>
|
||||
{titleBadge}
|
||||
{titleAction}
|
||||
{monitoring && (
|
||||
<TabsList
|
||||
|
||||
@@ -193,6 +193,8 @@ export interface ApiRespAgent {
|
||||
|
||||
export interface GetAgentMetadataResponseData {
|
||||
runner_config?: PipelineConfigTab;
|
||||
platform_tools: AgentPlatformTool[];
|
||||
host_tools?: PluginTool[] | null;
|
||||
kinds: Array<{
|
||||
name: AgentKind;
|
||||
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 {
|
||||
uuid?: string;
|
||||
name: string;
|
||||
|
||||
@@ -553,7 +553,17 @@ const enUS = {
|
||||
},
|
||||
eventDescriptions: {
|
||||
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.',
|
||||
message_received: 'A user or group sends a new message to the bot.',
|
||||
message_edited: 'The platform reports that an existing message changed.',
|
||||
@@ -740,9 +750,57 @@ const enUS = {
|
||||
basicInfoDescription: 'Set the name, icon and description',
|
||||
runnerSettings: 'Runner',
|
||||
advanced: 'Advanced',
|
||||
bindableEvents: 'Bindable Event Range',
|
||||
eventsAndTools: 'Events & tools',
|
||||
eventsAndToolsDescription: 'Set trigger events and available tools.',
|
||||
bindableEvents: 'Event scope',
|
||||
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',
|
||||
supportedEventsDescription:
|
||||
'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.',
|
||||
debugResetSession: 'Reset session',
|
||||
debugEventType: 'Event type',
|
||||
debugNoEventsTitle: 'No events to debug',
|
||||
debugNoEventsDescription: 'Add an event under Events & tools first.',
|
||||
debugMessageReceived: 'Message received',
|
||||
debugGroupMemberJoined: 'Group member joined',
|
||||
debugGroupMemberLeft: 'Group member left',
|
||||
|
||||
@@ -560,7 +560,18 @@ const jaJP = {
|
||||
},
|
||||
eventDescriptions: {
|
||||
all: 'このアダプターが受信するすべてのイベントに一致します。',
|
||||
namespace: '同じイベントグループ内の複数の具体イベントに一致します。',
|
||||
namespace: 'すべての{{group}}イベントに一致します。',
|
||||
namespace_bot:
|
||||
'ボットのグループ参加、退出、ミュートなどの状態イベントに一致します。',
|
||||
namespace_feedback:
|
||||
'プラットフォームまたはユーザーからのフィードバックイベントに一致します。',
|
||||
namespace_friend: '友達リクエストや友達関係の変更イベントに一致します。',
|
||||
namespace_group:
|
||||
'メンバーの参加、退出、削除などのグループイベントに一致します。',
|
||||
namespace_message:
|
||||
'メッセージの受信、編集、削除、リアクションイベントに一致します。',
|
||||
namespace_platform:
|
||||
'アダプターが提供するプラットフォーム固有イベントに一致します。',
|
||||
custom: 'カスタムイベント、または説明がまだないイベントです。',
|
||||
message_received:
|
||||
'ユーザーまたはグループがボットへ新しいメッセージを送信します。',
|
||||
|
||||
@@ -528,7 +528,13 @@ const zhHans = {
|
||||
},
|
||||
eventDescriptions: {
|
||||
all: '匹配此适配器收到的全部事件。',
|
||||
namespace: '匹配同一事件分组下的多个具体事件。',
|
||||
namespace: '匹配所有{{group}}事件。',
|
||||
namespace_bot: '匹配机器人入群、退群、禁言和解除禁言等状态事件。',
|
||||
namespace_feedback: '匹配平台或用户反馈事件。',
|
||||
namespace_friend: '匹配好友请求、好友添加成功等好友关系事件。',
|
||||
namespace_group: '匹配成员加入、离开或被移出群组等群组成员事件。',
|
||||
namespace_message: '匹配消息接收、编辑、删除和表态事件。',
|
||||
namespace_platform: '匹配适配器提供的平台专属事件。',
|
||||
custom: '自定义或暂未提供说明的事件。',
|
||||
message_received: '用户或群组向机器人发送新消息。',
|
||||
message_edited: '平台通知已有消息内容发生变更。',
|
||||
@@ -709,9 +715,55 @@ const zhHans = {
|
||||
basicInfoDescription: '设置名称、图标和描述',
|
||||
runnerSettings: '运行器',
|
||||
advanced: '高级',
|
||||
bindableEvents: '可绑定事件范围',
|
||||
bindableEventsDescription:
|
||||
'限制此 Agent 可被机器人事件路由选择的事件范围。通常保持默认即可。',
|
||||
eventsAndTools: '事件与工具',
|
||||
eventsAndToolsDescription: '设置触发范围和可用工具。',
|
||||
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: '事件范围',
|
||||
supportedEventsDescription:
|
||||
'选择全部事件、事件组或具体事件。机器人路由只会在匹配的事件中显示此 Agent。',
|
||||
@@ -758,6 +810,8 @@ const zhHans = {
|
||||
debugDescription: '用消息或平台事件直接运行当前 Agent,并查看真实输出。',
|
||||
debugResetSession: '重置会话',
|
||||
debugEventType: '事件类型',
|
||||
debugNoEventsTitle: '暂无可调试事件',
|
||||
debugNoEventsDescription: '请先在事件与工具中添加一个事件。',
|
||||
debugMessageReceived: '收到消息',
|
||||
debugGroupMemberJoined: '成员加入群组',
|
||||
debugGroupMemberLeft: '成员离开群组',
|
||||
|
||||
Reference in New Issue
Block a user