feat(agent): add event-aware tool permissions

This commit is contained in:
Hyu
2026-09-04 15:37:41 +08:00
parent 7c64756203
commit a4d36aa2db
31 changed files with 2519 additions and 485 deletions
@@ -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),
}
+32 -6
View File
@@ -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:
+1 -1
View File
@@ -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
+9 -2
View File
@@ -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,
+19
View File
@@ -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
+145 -147
View File
@@ -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,
+5
View File
@@ -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."""
+37 -1
View File
@@ -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
+14 -1
View File
@@ -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
+14
View File
@@ -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;
+63 -3
View File
@@ -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',
+12 -1
View File
@@ -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:
'ユーザーまたはグループがボットへ新しいメッセージを送信します。',
+58 -4
View File
@@ -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: '成员离开群组',