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