diff --git a/docs/agent-runner-pluginization/PLATFORM_ACTION_TOOLS.md b/docs/agent-runner-pluginization/PLATFORM_ACTION_TOOLS.md new file mode 100644 index 000000000..d5e376a1f --- /dev/null +++ b/docs/agent-runner-pluginization/PLATFORM_ACTION_TOOLS.md @@ -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 拒绝执行。 + +这些规则保证配置白名单不是唯一防线;真正的执行授权始终由单次运行快照和执行时检查 +共同决定。 diff --git a/src/langbot/pkg/agent/runner/host_models.py b/src/langbot/pkg/agent/runner/host_models.py index ba9f10079..71d2e9259 100644 --- a/src/langbot/pkg/agent/runner/host_models.py +++ b/src/langbot/pkg/agent/runner/host_models.py @@ -96,6 +96,9 @@ class ResourcePolicy(pydantic.BaseModel): allowed_tool_sources: dict[str, dict[str, str | None]] | None = None """Host-resolved implementation identity for each allowed tool name.""" + allowed_platform_tool_names: list[str] = pydantic.Field(default_factory=list) + """Platform and event action tools explicitly granted by the Agent owner.""" + allow_all_tools: bool = False """Whether all tools visible to the current Host scope are granted.""" diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py index 47c8051c7..c94749199 100644 --- a/src/langbot/pkg/agent/runner/orchestrator.py +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -28,6 +28,7 @@ from .interaction_manager import InteractionManager from .query_bridge import QueryRunBridge from .registry import AgentRunnerRegistry from .resource_builder import AgentResourceBuilder +from .platform_tools import freeze_platform_context from .result_normalizer import AgentResultNormalizer from .run_journal import AgentRunJournal from .session_registry import AgentRunSessionRegistry, get_session_registry @@ -201,6 +202,7 @@ class AgentRunOrchestrator: }, state_context=state_context, execution_query=execution_query, + platform_context=freeze_platform_context(event), ) event_log_id = await self.journal.write_event_log( diff --git a/src/langbot/pkg/agent/runner/platform_tools.py b/src/langbot/pkg/agent/runner/platform_tools.py new file mode 100644 index 000000000..6d4b4458e --- /dev/null +++ b/src/langbot/pkg/agent/runner/platform_tools.py @@ -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', +] diff --git a/src/langbot/pkg/agent/runner/resource_builder.py b/src/langbot/pkg/agent/runner/resource_builder.py index 37a289746..e18cdf1fe 100644 --- a/src/langbot/pkg/agent/runner/resource_builder.py +++ b/src/langbot/pkg/agent/runner/resource_builder.py @@ -20,6 +20,7 @@ from .host_models import AgentEventEnvelope, AgentBinding from .resource_policy import ResourcePolicyProjector from ...provider.tools.loaders.mcp import MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE from ...provider.tools.toolmgr import ToolSourceRef +from .platform_tools import build_platform_tool_resources class AgentResourceBuilder: @@ -86,6 +87,18 @@ class AgentResourceBuilder: descriptor, runner_config, ) + runner_uses_host_tools = config_schema.uses_host_tools(descriptor) + platform_tools, platform_capabilities = build_platform_tool_resources( + event, + resource_policy.allowed_platform_tool_names, + ( + [operation for operation in ('detail', 'call') if operation in set(manifest_perms.tools)] + if runner_uses_host_tools + else [] + ), + ) + if runner_uses_host_tools: + tools.extend(platform_tools) knowledge_bases = await self._build_knowledge_bases_from_binding( execution_context, manifest_perms, @@ -106,7 +119,7 @@ class AgentResourceBuilder: 'knowledge_bases': knowledge_bases, 'skills': skills, 'storage': storage, - 'platform_capabilities': {}, # Reserved for EBA + 'platform_capabilities': platform_capabilities, } async def _build_models_from_binding( diff --git a/src/langbot/pkg/agent/runner/resource_policy.py b/src/langbot/pkg/agent/runner/resource_policy.py index 92d934566..b157d1096 100644 --- a/src/langbot/pkg/agent/runner/resource_policy.py +++ b/src/langbot/pkg/agent/runner/resource_policy.py @@ -21,6 +21,9 @@ class ResourcePolicyProjector: resolved_tool_sources: typing.Mapping[str, typing.Any] | None = None, resolved_kb_uuids: collections.abc.Iterable[typing.Any] | None = None, resolved_skill_names: collections.abc.Iterable[typing.Any] | None = None, + allowed_platform_tool_names: collections.abc.Iterable[typing.Any] | None = None, + allowed_host_tool_names: collections.abc.Iterable[typing.Any] | None = None, + override_runner_tools: bool = False, ) -> ResourcePolicy: """Project standard resource fields without depending on a runner ID. @@ -33,7 +36,10 @@ class ResourcePolicyProjector: selected_tool_names = cls.normalize_names(config.get('tools')) enable_all_tools = config.get('enable-all-tools', True) is True - if resolved_tool_names is not None: + if override_runner_tools: + allowed_tool_names = cls.normalize_names(allowed_host_tool_names) + allow_all_tools = False + elif resolved_tool_names is not None: available_tool_names = cls.normalize_names(resolved_tool_names) if enable_all_tools: allowed_tool_names = available_tool_names @@ -64,6 +70,7 @@ class ResourcePolicyProjector: allowed_model_uuids=cls.normalize_optional_names(resolved_model_uuids), allowed_tool_names=allowed_tool_names, allowed_tool_sources=allowed_tool_sources, + allowed_platform_tool_names=cls.normalize_names(allowed_platform_tool_names), allow_all_tools=allow_all_tools, allowed_kb_uuids=allowed_kb_uuids, allowed_skill_names=cls.normalize_optional_names(resolved_skill_names), diff --git a/src/langbot/pkg/agent/runner/session_registry.py b/src/langbot/pkg/agent/runner/session_registry.py index 7d5df93f6..eb59ed824 100644 --- a/src/langbot/pkg/agent/runner/session_registry.py +++ b/src/langbot/pkg/agent/runner/session_registry.py @@ -47,6 +47,7 @@ class RunAuthorizationSnapshot(typing.TypedDict): thread_id: str | None state_policy: dict[str, typing.Any] state_context: dict[str, typing.Any] + platform_context: dict[str, typing.Any] authorized_ids: dict[str, set[str]] authorized_operations: dict[str, dict[str, set[str]]] @@ -113,6 +114,7 @@ class AgentRunSessionRegistry: state_policy: dict[str, typing.Any] | None = None, state_context: dict[str, typing.Any] | None = None, execution_query: pipeline_query.Query | None = None, + platform_context: dict[str, typing.Any] | None = None, ) -> None: """Register a new agent run session. @@ -155,6 +157,7 @@ class AgentRunSessionRegistry: 'thread_id': thread_id, 'state_policy': copy.deepcopy(state_policy), 'state_context': copy.deepcopy(state_context), + 'platform_context': copy.deepcopy(platform_context or {}), 'authorized_ids': self._build_authorized_ids(resources_snapshot), 'authorized_operations': self._build_authorized_operations(resources_snapshot), } diff --git a/src/langbot/pkg/api/http/service/agent.py b/src/langbot/pkg/api/http/service/agent.py index 13a53f363..2902133a8 100644 --- a/src/langbot/pkg/api/http/service/agent.py +++ b/src/langbot/pkg/api/http/service/agent.py @@ -25,6 +25,10 @@ from ....agent.runner.host_models import ( StatePolicy, ) from ....agent.runner.resource_policy import ResourcePolicyProjector +from ....agent.runner.platform_tools import ( + platform_tool_catalog, + resolve_agent_platform_tool_names, +) from ....entity.persistence import agent as persistence_agent from ....workspace.errors import WorkspaceNotFoundError from ..context import ExecutionContext, RequestContext @@ -49,8 +53,21 @@ class AgentService: """Return metadata needed by Agent forms.""" pipeline_metadata = await self.ap.pipeline_service.get_pipeline_metadata(context) ai_metadata = next((item for item in pipeline_metadata if item.get('name') == 'ai'), None) + host_tools: list[dict[str, typing.Any]] | None = None + get_tool_catalog = getattr(getattr(self.ap, 'tool_mgr', None), 'get_resolved_tool_catalog', None) + if get_tool_catalog is not None: + try: + host_tools = await get_tool_catalog( + context, + include_skill_authoring=True, + include_mcp_resource_tools=True, + ) + except Exception as exc: + self.ap.logger.warning(f'Failed to load Agent Host tool catalog: {exc}') return { 'runner_config': ai_metadata, + 'platform_tools': platform_tool_catalog(), + 'host_tools': host_tools, 'kinds': [ { 'name': AGENT_KIND_AGENT, @@ -192,7 +209,12 @@ class AgentService: event_types=[event_type], runner_id=runner_id, runner_config=runner_config, - resource_policy=ResourcePolicyProjector.from_runner_config(runner_config), + resource_policy=ResourcePolicyProjector.from_runner_config( + runner_config, + allowed_platform_tool_names=resolve_agent_platform_tool_names(config, event_type), + allowed_host_tool_names=config.get('allowed_tools'), + override_runner_tools='allowed_tools' in config, + ), state_policy=StatePolicy( state_scopes=['conversation', 'actor', 'subject', 'runner'], ), @@ -292,7 +314,11 @@ class AgentService: 'kind': AGENT_KIND_AGENT, 'component_ref': runner_id, 'config': config, - 'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS, + 'supported_event_patterns': ( + agent_data['supported_event_patterns'] + if 'supported_event_patterns' in agent_data + else AGENT_DEFAULT_EVENT_PATTERNS + ), } await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values)) return {'uuid': new_uuid, 'kind': AGENT_KIND_AGENT} @@ -317,9 +343,6 @@ class AgentService: else: _, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(existing_agent.config) update_data['component_ref'] = runner_id - if 'supported_event_patterns' in update_data and not update_data['supported_event_patterns']: - update_data['supported_event_patterns'] = AGENT_DEFAULT_EVENT_PATTERNS - result = await self.ap.persistence_mgr.execute_async( scope_statement( sqlalchemy.update(persistence_agent.Agent) @@ -404,8 +427,11 @@ class AgentService: ) -> dict[str, typing.Any]: item = self.ap.persistence_mgr.serialize_model(persistence_agent.Agent, agent) item['kind'] = AGENT_KIND_AGENT + supported_event_patterns = item.get('supported_event_patterns') item['capability'] = { - 'supported_event_patterns': item.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS, + 'supported_event_patterns': ( + supported_event_patterns if isinstance(supported_event_patterns, list) else AGENT_DEFAULT_EVENT_PATTERNS + ), 'message_only': False, } if not include_config: diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index e9e9b2113..768aa648f 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -76,7 +76,7 @@ class BotService: @classmethod def _agent_supports_event_pattern(cls, supported_patterns: list[str] | None, event_pattern: str) -> bool: - patterns = supported_patterns or ['*'] + patterns = supported_patterns if isinstance(supported_patterns, list) else ['*'] return any(cls._event_pattern_covers(pattern, event_pattern) for pattern in patterns) @staticmethod diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 434cd37b4..3cdbb1ef9 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -27,6 +27,7 @@ from ..agent.runner.host_models import ( StatePolicy, ) from ..agent.runner.resource_policy import ResourcePolicyProjector +from ..agent.runner.platform_tools import resolve_agent_platform_tool_names from ..entity.persistence import workspace as persistence_workspace from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext @@ -165,7 +166,8 @@ class RuntimeBot: supported_patterns: list[str] | None, event_type: str, ) -> bool: - return any(cls._match_event_pattern(event_type, pattern) for pattern in (supported_patterns or ['*'])) + patterns = supported_patterns if isinstance(supported_patterns, list) else ['*'] + return any(cls._match_event_pattern(event_type, pattern) for pattern in patterns) @staticmethod def _get_nested_value(data: dict[str, typing.Any], path: str) -> typing.Any: @@ -808,7 +810,12 @@ class RuntimeBot: event_types=[event_type], runner_id=runner_id, runner_config=runner_config, - resource_policy=ResourcePolicyProjector.from_runner_config(runner_config), + resource_policy=ResourcePolicyProjector.from_runner_config( + runner_config, + allowed_platform_tool_names=resolve_agent_platform_tool_names(config, event_type), + allowed_host_tool_names=config.get('allowed_tools'), + override_runner_tools='allowed_tools' in config, + ), state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']), delivery_policy=DeliveryPolicy( enable_streaming=False, diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index f23d0eaf0..afdbc540f 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -52,6 +52,7 @@ from ..utils import constants from ..agent.runner.session_registry import get_session_registry from ..agent.runner.config_resolver import RunnerConfigResolver from ..agent.runner import config_schema +from ..agent.runner.platform_tools import execute_platform_tool, get_platform_tool_detail from ..pipeline.pool import get_query_execution_context @@ -302,6 +303,8 @@ def _validate_frozen_tool_source_identity( MCP_TOOL_READ_RESOURCE, }: source_ref = {'source': source, 'source_id': source_id} + elif source == 'platform' and source_id == tool_name: + source_ref = {'source': source, 'source_id': source_id} if source_ref is not None: return source_ref, None @@ -1547,6 +1550,15 @@ class RuntimeConnectionHandler(handler.Handler): # In real implementation, you would reconstruct the full session # For now, we'll call the tool manager's execute method try: + if source_ref is not None and source_ref['source'] == 'platform': + result = await execute_platform_tool( + self.ap, + self._execution_context(action_context), + session, + tool_name, + parameters, + ) + return handler.ActionResponse.success(data={'result': _serialize_plugin_api_result(result)}) query = _resolve_action_query( data, session, @@ -1602,6 +1614,13 @@ class RuntimeConnectionHandler(handler.Handler): return error try: + if source_ref is not None and source_ref['source'] == 'platform': + tool_detail = get_platform_tool_detail(session, tool_name) + if tool_detail is None: + return handler.ActionResponse.error( + message=f'Tool {tool_name} not found', + ) + return handler.ActionResponse.success(data={'tool': tool_detail}) detail_kwargs: dict[str, Any] = {} if source_ref is not None: detail_kwargs['source_ref'] = source_ref diff --git a/tests/e2e/test_agent_runner_plugin_runtime.py b/tests/e2e/test_agent_runner_plugin_runtime.py index abeb0da8d..29c61f4d9 100644 --- a/tests/e2e/test_agent_runner_plugin_runtime.py +++ b/tests/e2e/test_agent_runner_plugin_runtime.py @@ -10,6 +10,7 @@ import shutil import socket import sqlite3 import subprocess +import sys import tempfile import textwrap import time @@ -25,7 +26,6 @@ pytestmark = pytest.mark.e2e QA_RUNNER_ID = 'plugin:e2e/agent-runner-qa/default' -QA_PLUGIN_DIRNAME = 'e2e__agent-runner-qa' @pytest.fixture(scope='session') @@ -198,7 +198,13 @@ def agent_runner_e2e_config_path(agent_runner_e2e_tmpdir, agent_runner_e2e_port, with open(config_path, 'w', encoding='utf-8') as f: yaml.safe_dump(config, f, default_flow_style=False) - _write_qa_agent_runner_plugin(agent_runner_e2e_tmpdir / 'data' / 'plugins' / QA_PLUGIN_DIRNAME) + plugin_source = agent_runner_e2e_tmpdir / 'agent-runner-qa-package' + _write_qa_agent_runner_plugin(plugin_source) + shutil.make_archive( + str(agent_runner_e2e_tmpdir / 'agent-runner-qa'), + 'zip', + root_dir=plugin_source, + ) return config_path @@ -212,7 +218,7 @@ def agent_runner_runtime_process(agent_runner_e2e_tmpdir, agent_runner_runtime_p stderr_file = open(stderr_path, 'wb') proc = subprocess.Popen( [ - str(find_project_root() / '.venv' / 'bin' / 'python'), + sys.executable, '-m', 'langbot_plugin.cli.__init__', 'rt', @@ -278,132 +284,180 @@ def agent_runner_client(agent_runner_e2e_port, agent_runner_langbot_process): def _init_and_auth(client: httpx.Client) -> str: """Initialize the test admin user and return a bearer token.""" - init_resp = client.post('/api/v1/user/init', json={'user': 'admin', 'password': 'admin'}) + credentials = {'user': 'admin@langbot.test', 'password': 'admin'} + init_resp = client.post('/api/v1/user/init', json=credentials) assert init_resp.status_code == 200 assert init_resp.json()['code'] in [0, 1] - auth_resp = client.post('/api/v1/user/auth', json={'user': 'admin', 'password': 'admin'}) + auth_resp = client.post('/api/v1/user/auth', json=credentials) assert auth_resp.status_code == 200 payload = auth_resp.json() assert payload['code'] == 0 return payload['data']['token'] -def test_plugin_runtime_discovers_agent_runner(agent_runner_client, agent_runner_langbot_process): - """Pipeline metadata should include the real runtime-discovered QA runner.""" - token = _init_and_auth(agent_runner_client) - start = time.time() - while time.time() - start < 60: - response = agent_runner_client.get( +def _install_qa_plugin(client: httpx.Client, token: str, package_path: Path) -> None: + """Install the QA Runner through the same asynchronous local-upload API as the UI.""" + headers = {'Authorization': f'Bearer {token}'} + with package_path.open('rb') as package_file: + response = client.post( + '/api/v1/plugins/install/local', + headers=headers, + files={'file': ('agent-runner-qa.zip', package_file, 'application/zip')}, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert payload['code'] == 0, payload + task_id = payload['data']['task_id'] + + deadline = time.time() + 90 + while time.time() < deadline: + task_response = client.get(f'/api/v1/system/tasks/{task_id}', headers=headers) + assert task_response.status_code == 200, task_response.text + task_payload = task_response.json() + assert task_payload['code'] == 0, task_payload + task = task_payload['data'] + if task['runtime']['done']: + assert task['runtime']['exception'] is None, task + assert task['task_context']['metadata']['progress_percent'] == 100 + return + time.sleep(1) + raise AssertionError(f'Plugin installation task {task_id} did not complete') + + +def _wait_for_qa_runner(client: httpx.Client, token: str, timeout: float = 60) -> set[str]: + """Return the latest Runner option set, waiting for the QA Runner when needed.""" + deadline = time.time() + timeout + option_names: set[str] = set() + while time.time() < deadline: + response = client.get( '/api/v1/pipelines/_/metadata', headers={'Authorization': f'Bearer {token}'}, ) - - assert response.status_code == 200 + assert response.status_code == 200, response.text data = response.json() - assert data['code'] == 0 + assert data['code'] == 0, data metadata_groups = data['data']['configs'] ai_metadata = next(group for group in metadata_groups if group.get('name') == 'ai') - runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner') runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id') option_names = {option['name'] for option in runner_select['options']} if QA_RUNNER_ID in option_names: - return - time.sleep(2) + break + time.sleep(1) + return option_names - assert QA_RUNNER_ID in option_names + +def _ensure_qa_plugin(client: httpx.Client, token: str, package_path: Path) -> None: + if QA_RUNNER_ID in _wait_for_qa_runner(client, token, timeout=2): + return + _install_qa_plugin(client, token, package_path) + + +def test_plugin_runtime_discovers_agent_runner( + agent_runner_client, + agent_runner_langbot_process, + agent_runner_e2e_tmpdir, +): + """Pipeline metadata should include the real runtime-discovered QA runner.""" + token = _init_and_auth(agent_runner_client) + _ensure_qa_plugin( + agent_runner_client, + token, + agent_runner_e2e_tmpdir / 'agent-runner-qa.zip', + ) + option_names = _wait_for_qa_runner(agent_runner_client, token) + if QA_RUNNER_ID in option_names: + return + + host_stdout, host_stderr = agent_runner_langbot_process.get_logs() + runtime_stdout = (agent_runner_e2e_tmpdir / 'plugin-runtime.stdout.log').read_text( + encoding='utf-8', errors='replace' + ) + runtime_stderr = (agent_runner_e2e_tmpdir / 'plugin-runtime.stderr.log').read_text( + encoding='utf-8', errors='replace' + ) + assert QA_RUNNER_ID in option_names, ( + f'{QA_RUNNER_ID} was not discovered\n' + f'Host stdout (tail):\n{host_stdout[-20_000:]}\nHost stderr (tail):\n{host_stderr[-20_000:]}\n' + f'Runtime stdout (tail):\n{runtime_stdout[-20_000:]}\n' + f'Runtime stderr (tail):\n{runtime_stderr[-20_000:]}' + ) def test_host_orchestrator_runs_agent_runner_and_records_ledger( - agent_runner_e2e_config_path, + agent_runner_client, + agent_runner_langbot_process, agent_runner_e2e_tmpdir, - agent_runner_runtime_process, ): - """The Host orchestrator should run the pluginized runner and persist run side effects.""" - import asyncio - import os - - from langbot.pkg.agent.runner.host_models import ( - AgentBinding, - AgentEventEnvelope, - BindingScope, - DeliveryPolicy, - StatePolicy, + """Create/configure/debug an Agent through HTTP and persist Runner side effects.""" + del agent_runner_langbot_process + token = _init_and_auth(agent_runner_client) + _ensure_qa_plugin( + agent_runner_client, + token, + agent_runner_e2e_tmpdir / 'agent-runner-qa.zip', ) - from langbot.pkg.core import boot - from langbot.pkg.utils import platform as platform_utils - from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext - from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext, SubjectContext - from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput + headers = {'Authorization': f'Bearer {token}'} + create_response = agent_runner_client.post( + '/api/v1/agents', + headers=headers, + json={ + 'kind': 'agent', + 'name': 'AgentRunner E2E Agent', + 'description': 'Exercises the installed QA Runner.', + 'emoji': 'QA', + 'supported_event_patterns': ['message.*'], + 'config': { + 'runner': {'id': QA_RUNNER_ID}, + 'runner_config': {QA_RUNNER_ID: {}}, + 'allowed_platform_tools': ['event_reply', 'platform_get_user_info'], + }, + }, + ) + assert create_response.status_code == 200, create_response.text + create_payload = create_response.json() + assert create_payload['code'] == 0, create_payload + agent_uuid = create_payload['data']['uuid'] - async def _run_probe(): - previous_cwd = Path.cwd() - previous_standalone_runtime = platform_utils.standalone_runtime - os.chdir(agent_runner_e2e_tmpdir) - platform_utils.standalone_runtime = True - ap = None - try: - ap = await boot.make_app(asyncio.get_running_loop()) - for _ in range(60): - handler = getattr(ap.plugin_connector, 'handler', None) - if handler is not None: - await handler.ping() - break - await asyncio.sleep(1) - else: - raise AssertionError('Plugin runtime did not connect') + get_response = agent_runner_client.get(f'/api/v1/agents/{agent_uuid}', headers=headers) + assert get_response.status_code == 200, get_response.text + stored_agent = get_response.json()['data']['agent'] + assert stored_agent['config']['allowed_platform_tools'] == [ + 'event_reply', + 'platform_get_user_info', + ] - for _ in range(60): - runners = await ap.agent_runner_registry.list_runners(use_cache=False) - if any(runner.id == QA_RUNNER_ID for runner in runners): - break - await asyncio.sleep(1) - else: - raise AssertionError(f'{QA_RUNNER_ID} was not discovered') - - event = AgentEventEnvelope( - event_id='e2e-orchestrator-event-001', - event_type='message.received', - source='api', - conversation_id='e2e-conversation', - thread_id='e2e-thread', - actor=ActorContext(actor_type='user', actor_id='user-001', actor_name='E2E User'), - subject=SubjectContext(subject_type='chat', subject_id='chat-001'), - input=AgentInput(text='hello from orchestrator e2e'), - delivery=DeliveryContext(surface='e2e'), - ) - binding = AgentBinding( - binding_id='e2e-binding', - scope=BindingScope(scope_type='global'), - runner_id=QA_RUNNER_ID, - state_policy=StatePolicy(enable_state=True, state_scopes=['conversation']), - delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True), - ) - return [message async for message in ap.agent_run_orchestrator.run(event, binding)] - finally: - if ap is not None: - ap.dispose() - platform_utils.standalone_runtime = previous_standalone_runtime - os.chdir(previous_cwd) - - messages = asyncio.run(_run_probe()) - - assert len(messages) == 1 - assert messages[0].role == 'assistant' - assert messages[0].content == 'e2e echo: hello from orchestrator e2e' + debug_response = agent_runner_client.post( + f'/api/v1/agents/{agent_uuid}/debug', + headers=headers, + json={ + 'event_type': 'message.received', + 'text': 'hello from orchestrator e2e', + 'conversation_id': 'e2e-conversation', + }, + ) + assert debug_response.status_code == 200, debug_response.text + debug_payload = debug_response.json() + assert debug_payload['code'] == 0, debug_payload + result = debug_payload['data'] + assert result['final_text'] == 'e2e echo: hello from orchestrator e2e' + assert result['outputs'][0]['role'] == 'assistant' db_path = agent_runner_e2e_tmpdir / 'data' / 'langbot.db' conn = sqlite3.connect(str(db_path)) try: run_row = conn.execute( - "SELECT status, runner_id FROM agent_run WHERE event_id = 'e2e-orchestrator-event-001'" + 'SELECT status, runner_id FROM agent_run WHERE event_id = ?', + (result['event_id'],), ).fetchone() assert run_row == ('completed', QA_RUNNER_ID) event_types = { row[0] for row in conn.execute( - "SELECT type FROM agent_run_event WHERE run_id = (SELECT run_id FROM agent_run WHERE event_id = 'e2e-orchestrator-event-001')" + 'SELECT type FROM agent_run_event WHERE run_id = ' + '(SELECT run_id FROM agent_run WHERE event_id = ?)', + (result['event_id'],), ).fetchall() } assert {'state.updated', 'message.completed', 'run.completed'}.issubset(event_types) @@ -415,59 +469,3 @@ def test_host_orchestrator_runs_agent_runner_and_records_ledger( assert '"count": 1' in state_row[0] finally: conn.close() - - -def test_pluginized_agent_runner_executes_through_runtime(agent_runner_client, agent_runner_langbot_process): - """The Host debug surface should invoke the QA runner through the real Plugin Runtime.""" - token = _init_and_auth(agent_runner_client) - start = time.time() - while time.time() - start < 60: - metadata_response = agent_runner_client.get( - '/api/v1/pipelines/_/metadata', - headers={'Authorization': f'Bearer {token}'}, - ) - assert metadata_response.status_code == 200 - metadata = metadata_response.json()['data']['configs'] - ai_metadata = next(group for group in metadata if group.get('name') == 'ai') - runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner') - runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id') - if QA_RUNNER_ID in {option['name'] for option in runner_select['options']}: - break - time.sleep(2) - else: - pytest.fail(f'{QA_RUNNER_ID} was not discovered before run_agent') - - response = agent_runner_client.post( - '/api/v1/system/debug/plugin/action', - headers={'Authorization': f'Bearer {token}'}, - json={ - 'action': 'run_agent', - 'timeout': 60, - 'data': { - 'plugin_author': 'e2e', - 'plugin_name': 'agent-runner-qa', - 'runner_name': 'default', - 'context': { - 'run_id': 'e2e-run-001', - 'trigger': {'type': 'message.received'}, - 'event': { - 'event_id': 'e2e-event-001', - 'event_type': 'message.received', - 'source': 'api', - }, - 'input': {'text': 'hello from real e2e'}, - 'delivery': {'surface': 'e2e'}, - 'resources': {}, - 'runtime': {}, - }, - }, - }, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload['code'] == 0 - result = payload['data'] - assert result['type'] == 'message.completed', result - assert result['data']['message']['role'] == 'assistant' - assert result['data']['message']['content'] == 'e2e echo: hello from real e2e' diff --git a/tests/e2e/test_local_agent_runner_fake_provider.py b/tests/e2e/test_local_agent_runner_fake_provider.py index 0a4e9bc09..9d6a7fc02 100644 --- a/tests/e2e/test_local_agent_runner_fake_provider.py +++ b/tests/e2e/test_local_agent_runner_fake_provider.py @@ -14,6 +14,7 @@ import shutil import socket import sqlite3 import subprocess +import sys import tempfile from pathlib import Path from types import SimpleNamespace @@ -30,7 +31,6 @@ pytestmark = pytest.mark.e2e LOCAL_AGENT_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default' FAKE_PROVIDER_UUID = 'e2e-fake-provider' FAKE_MODEL_UUID = 'e2e-fake-local-agent-model' -LOCAL_AGENT_PLUGIN_DIRNAME = 'langbot__local-agent' E2E_TOOL_NAME = 'e2e_lookup' E2E_KB_UUID = 'e2e-kb-local-agent' @@ -48,13 +48,13 @@ def _local_agent_repo() -> Path: return project_root.parent / 'langbot-local-agent' -def _copy_local_agent_plugin(tmpdir: Path) -> None: - """Copy the sibling Local Agent plugin into the temporary LangBot data dir.""" +def _package_local_agent_plugin(tmpdir: Path) -> Path: + """Package the sibling Local Agent plugin for the real local-install flow.""" local_agent_src = _local_agent_repo() if not (local_agent_src / 'manifest.yaml').exists(): pytest.skip(f'local-agent repository not found at {local_agent_src}') - plugin_dst = tmpdir / 'data' / 'plugins' / LOCAL_AGENT_PLUGIN_DIRNAME + package_source = tmpdir / 'local-agent-package' ignore = shutil.ignore_patterns( '.git', '.venv', @@ -64,7 +64,16 @@ def _copy_local_agent_plugin(tmpdir: Path) -> None: 'build', 'dist', ) - shutil.copytree(local_agent_src, plugin_dst, ignore=ignore) + shutil.copytree(local_agent_src, package_source, ignore=ignore) + archive_path = Path( + shutil.make_archive( + str(tmpdir / 'langbot-local-agent'), + 'zip', + root_dir=package_source, + ) + ) + shutil.rmtree(package_source) + return archive_path def _content_text(content: Any) -> str: @@ -289,7 +298,7 @@ def local_agent_e2e_config_path(local_agent_e2e_tmpdir, local_agent_e2e_port, lo with open(config_path, 'w', encoding='utf-8') as f: yaml.safe_dump(config, f, default_flow_style=False) - _copy_local_agent_plugin(local_agent_e2e_tmpdir) + _package_local_agent_plugin(local_agent_e2e_tmpdir) return config_path @@ -304,7 +313,7 @@ def local_agent_runtime_process(local_agent_e2e_tmpdir, local_agent_runtime_port stderr_file = open(stderr_path, 'wb') proc = subprocess.Popen( [ - str(find_project_root() / '.venv' / 'bin' / 'python'), + sys.executable, '-m', 'langbot_plugin.cli.__init__', 'rt', @@ -329,14 +338,18 @@ def local_agent_runtime_process(local_agent_e2e_tmpdir, local_agent_runtime_port stderr_file.close() -def _inject_fake_llm_model(ap) -> Any: +async def _inject_fake_llm_model(ap) -> Any: """Register a runtime-only fake model that supports count_tokens/invoke.""" + import sqlalchemy + from langbot.pkg.entity.persistence import model as persistence_model from langbot.pkg.provider.modelmgr import requester, token from tests.unit_tests.provider.conftest import FakeProviderAPIRequester + execution_context = await ap.plugin_connector._current_execution_context() provider_entity = persistence_model.ModelProvider( uuid=FAKE_PROVIDER_UUID, + workspace_uuid=execution_context.workspace_uuid, name='E2E Fake Provider', requester='fake-requester', base_url='https://fake.invalid', @@ -344,26 +357,64 @@ def _inject_fake_llm_model(ap) -> Any: ) fake_requester = FakeProviderAPIRequester(ap, {'base_url': provider_entity.base_url}) runtime_provider = requester.RuntimeProvider( + execution_context=execution_context, provider_entity=provider_entity, token_mgr=token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys), requester=fake_requester, ) + model_entity = persistence_model.LLMModel( + uuid=FAKE_MODEL_UUID, + workspace_uuid=execution_context.workspace_uuid, + name=FAKE_MODEL_UUID, + provider_uuid=provider_entity.uuid, + abilities=['func_call'], + context_length=8192, + extra_args={}, + ) runtime_model = requester.RuntimeLLMModel( - model_entity=persistence_model.LLMModel( - uuid=FAKE_MODEL_UUID, - name=FAKE_MODEL_UUID, - provider_uuid=provider_entity.uuid, - abilities=['func_call'], - context_length=8192, - extra_args={}, - ), + model_entity=model_entity, + execution_context=execution_context, provider=runtime_provider, ) - ap.model_mgr.provider_dict[provider_entity.uuid] = runtime_provider - ap.model_mgr.llm_models.append(runtime_model) + await ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_model.ModelProvider).values( + uuid=provider_entity.uuid, + workspace_uuid=provider_entity.workspace_uuid, + name=provider_entity.name, + requester=provider_entity.requester, + base_url=provider_entity.base_url, + api_keys=provider_entity.api_keys, + ) + ) + await ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_model.LLMModel).values( + uuid=model_entity.uuid, + workspace_uuid=model_entity.workspace_uuid, + name=model_entity.name, + provider_uuid=model_entity.provider_uuid, + abilities=model_entity.abilities, + context_length=model_entity.context_length, + extra_args=model_entity.extra_args, + ) + ) + await ap.model_mgr.cache_provider(execution_context, runtime_provider) + await ap.model_mgr.cache_llm_model(execution_context, runtime_model) return fake_requester +async def _run_agent(ap, event, binding) -> list[Any]: + """Execute through the trusted Workspace context used by the real Host.""" + execution_context = await ap.plugin_connector._current_execution_context() + return [ + message + async for message in ap.agent_run_orchestrator.run( + event, + binding, + adapter_context={'_execution_context': execution_context}, + ) + ] + + def _scripted_tool_call( tool_name: str = E2E_TOOL_NAME, *, @@ -392,8 +443,10 @@ def _scripted_tool_call( async def _boot_local_agent_app(tmpdir: Path): """Boot LangBot and wait until the Local Agent runner is discoverable.""" from langbot.pkg.core import boot + from langbot_plugin.runtime.plugin.mgr import PluginInstallSource ap = await boot.make_app(asyncio.get_running_loop()) + run_task = asyncio.create_task(ap.run(), name='local-agent-e2e-app') for _ in range(60): handler = getattr(ap.plugin_connector, 'handler', None) if handler is not None: @@ -401,17 +454,31 @@ async def _boot_local_agent_app(tmpdir: Path): break await asyncio.sleep(1) else: - raise AssertionError(f'Plugin runtime did not connect; tmpdir={tmpdir}') + runtime_stdout = (tmpdir / 'plugin-runtime.stdout.log').read_text(encoding='utf-8', errors='replace') + runtime_stderr = (tmpdir / 'plugin-runtime.stderr.log').read_text(encoding='utf-8', errors='replace') + raise AssertionError( + f'Plugin runtime did not connect; tmpdir={tmpdir}\n' + f'Runtime stdout:\n{runtime_stdout[-20_000:]}\n' + f'Runtime stderr:\n{runtime_stderr[-20_000:]}' + ) - for _ in range(60): - runners = await ap.agent_runner_registry.list_runners(use_cache=False) - if any(runner.id == LOCAL_AGENT_RUNNER_ID for runner in runners): - break - await asyncio.sleep(1) - else: - raise AssertionError(f'{LOCAL_AGENT_RUNNER_ID} was not discovered') + execution_context = await ap.plugin_connector._current_execution_context() + runners = await ap.agent_runner_registry.list_runners(execution_context, use_cache=False) + if not any(runner.id == LOCAL_AGENT_RUNNER_ID for runner in runners): + await ap.plugin_connector.install_plugin( + PluginInstallSource.LOCAL, + {'plugin_file': (tmpdir / 'langbot-local-agent.zip').read_bytes()}, + ) - return ap + for _ in range(60): + runners = await ap.agent_runner_registry.list_runners(execution_context, use_cache=False) + if any(runner.id == LOCAL_AGENT_RUNNER_ID for runner in runners): + break + await asyncio.sleep(1) + else: + raise AssertionError(f'{LOCAL_AGENT_RUNNER_ID} was not discovered after installation') + + return ap, run_task def _run_local_agent_probe(tmpdir: Path, probe): @@ -424,12 +491,30 @@ def _run_local_agent_probe(tmpdir: Path, probe): os.chdir(tmpdir) platform_utils.standalone_runtime = True ap = None + run_task = None try: - ap = await _boot_local_agent_app(tmpdir) + ap, run_task = await _boot_local_agent_app(tmpdir) return await probe(ap) finally: if ap is not None: - ap.dispose() + import sqlalchemy + + from langbot.pkg.entity.persistence import model as persistence_model + + await ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_model.LLMModel).where( + persistence_model.LLMModel.uuid == FAKE_MODEL_UUID + ) + ) + await ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_model.ModelProvider).where( + persistence_model.ModelProvider.uuid == FAKE_PROVIDER_UUID + ) + ) + await ap.shutdown() + if run_task is not None: + run_task.cancel() + await asyncio.gather(run_task, return_exceptions=True) platform_utils.standalone_runtime = previous_standalone_runtime os.chdir(previous_cwd) @@ -445,13 +530,13 @@ def test_local_agent_runner_uses_host_fake_provider_and_persists_ledger( del local_agent_e2e_config_path, local_agent_runtime_process async def _run_probe(ap): - fake_requester = _inject_fake_llm_model(ap) + fake_requester = await _inject_fake_llm_model(ap) event = _event( event_id='e2e-local-agent-event-001', conversation_id='e2e-local-agent-conversation', text='Say pong through the fake provider.', ) - messages = [message async for message in ap.agent_run_orchestrator.run(event, _binding())] + messages = await _run_agent(ap, event, _binding()) return messages, list(fake_requester._count_tokens_payloads) messages, token_payloads = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe) @@ -507,7 +592,7 @@ def test_local_agent_runner_executes_authorized_tool_loop_through_host_action( del local_agent_e2e_config_path, local_agent_runtime_process async def _run_probe(ap): - fake_requester = _inject_fake_llm_model(ap) + fake_requester = await _inject_fake_llm_model(ap) fake_requester.queue_llm_responses( _scripted_tool_call(), 'Tool loop final answer after tool-result:alpha', @@ -528,7 +613,7 @@ def test_local_agent_runner_executes_authorized_tool_loop_through_host_action( 'tool-execution-mode': 'serial', }, ) - messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)] + messages = await _run_agent(ap, event, binding) return messages, tool_mgr.calls, _invoke_payload_texts(fake_requester) messages, tool_calls, invoke_payload_texts = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe) @@ -576,7 +661,7 @@ def test_local_agent_runner_retrieves_authorized_rag_context_through_host_action del local_agent_e2e_config_path, local_agent_runtime_process async def _run_probe(ap): - fake_requester = _inject_fake_llm_model(ap) + fake_requester = await _inject_fake_llm_model(ap) fake_requester.queue_llm_responses('RAG final answer with RAG_SENTINEL') fake_kb = _FakeKnowledgeBase() ap.rag_mgr = _FakeRagManager(fake_kb) @@ -594,7 +679,7 @@ def test_local_agent_runner_retrieves_authorized_rag_context_through_host_action 'retrieval-top-k': 1, }, ) - messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)] + messages = await _run_agent(ap, event, binding) return messages, fake_kb.retrieve_calls, _invoke_payload_texts(fake_requester) messages, retrieve_calls, invoke_payload_texts = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe) @@ -646,7 +731,7 @@ def test_local_agent_runner_compacts_history_and_persists_checkpoint( async def _run_probe(ap): from langbot.pkg.agent.runner.transcript_store import TranscriptStore - fake_requester = _inject_fake_llm_model(ap) + fake_requester = await _inject_fake_llm_model(ap) fake_requester.queue_llm_responses( 'SUMMARY_SENTINEL compacted older history including HIST_SENTINEL', 'Compaction final answer', @@ -682,7 +767,7 @@ def test_local_agent_runner_compacts_history_and_persists_checkpoint( 'context-history-fetch-limit': 20, }, ) - messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)] + messages = await _run_agent(ap, event, binding) return messages, _invoke_payload_texts(fake_requester), fake_requester._invoke_count messages, invoke_payload_texts, invoke_count = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe) @@ -738,7 +823,7 @@ def test_local_agent_runner_combines_rag_compaction_and_multi_turn_tool_loop( async def _run_probe(ap): from langbot.pkg.agent.runner.transcript_store import TranscriptStore - fake_requester = _inject_fake_llm_model(ap) + fake_requester = await _inject_fake_llm_model(ap) async def scripted_response(**kwargs): messages = kwargs['messages'] @@ -798,7 +883,7 @@ def test_local_agent_runner_combines_rag_compaction_and_multi_turn_tool_loop( 'context-history-fetch-limit': 25, }, ) - messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)] + messages = await _run_agent(ap, event, binding) return ( messages, tool_mgr.calls, diff --git a/tests/e2e/utils/process_manager.py b/tests/e2e/utils/process_manager.py index b8b5816ab..e2f9f48d0 100644 --- a/tests/e2e/utils/process_manager.py +++ b/tests/e2e/utils/process_manager.py @@ -64,6 +64,11 @@ class LangBotProcess: env.pop(proxy_key, None) env['NO_PROXY'] = '127.0.0.1,localhost' env['no_proxy'] = '127.0.0.1,localhost' + # The startup banner contains Unicode symbols. Force deterministic + # UTF-8 subprocess streams so Windows locales such as GBK do not crash + # before the application can bind its HTTP port. + env['PYTHONUTF8'] = '1' + env['PYTHONIOENCODING'] = 'utf-8' # Set API port via environment variable env['API__PORT'] = str(self.port) diff --git a/tests/unit_tests/agent/test_platform_tools.py b/tests/unit_tests/agent/test_platform_tools.py new file mode 100644 index 000000000..cc180b055 --- /dev/null +++ b/tests/unit_tests/agent/test_platform_tools.py @@ -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() diff --git a/tests/unit_tests/agent/test_resource_builder.py b/tests/unit_tests/agent/test_resource_builder.py index b45e91413..97219d5fc 100644 --- a/tests/unit_tests/agent/test_resource_builder.py +++ b/tests/unit_tests/agent/test_resource_builder.py @@ -6,12 +6,13 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import pytest +from langbot_plugin.api.entities.builtin.agent_runner import AgentInput, DeliveryContext from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder -from langbot.pkg.agent.runner.host_models import AgentBinding, BindingScope, ResourcePolicy +from langbot.pkg.agent.runner.host_models import AgentBinding, AgentEventEnvelope, BindingScope, ResourcePolicy from langbot.pkg.api.http.context import ExecutionContext @@ -201,6 +202,41 @@ async def test_build_models_from_config_without_manifest_acl(app): assert resources['models'] == [] +@pytest.mark.asyncio +async def test_platform_tools_are_not_claimed_when_runner_disables_tool_calling(app): + event = AgentEventEnvelope( + event_id='event-platform-disabled', + event_type='message.received', + source='platform', + bot_id='bot-1', + input=AgentInput(text='hello'), + delivery=DeliveryContext( + surface='platform', + reply_target={'target_type': 'person', 'target_id': 'user-1'}, + platform_capabilities={'supported_apis': ['send_message']}, + ), + ) + binding = AgentBinding( + binding_id='binding-platform-disabled', + scope=BindingScope(scope_type='global'), + runner_id=RUNNER_ID, + resource_policy=ResourcePolicy(allowed_platform_tool_names=['event_reply']), + ) + + resources = await AgentResourceBuilder(app).build_resources_from_binding( + execution_context=TEST_CONTEXT, + event=event, + binding=binding, + descriptor=make_descriptor(capabilities={'tool_calling': False}), + ) + + assert resources['tools'] == [] + assert resources['platform_capabilities']['authorized_tools'] == [] + assert resources['platform_capabilities']['unavailable_tools'] == [ + {'name': 'event_reply', 'reason': 'runner_call_permission_missing'} + ] + + @pytest.mark.asyncio async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app): """Config-selected model references are projected regardless of method granularity.""" diff --git a/tests/unit_tests/agent/test_resource_policy.py b/tests/unit_tests/agent/test_resource_policy.py index 839d62fb2..7f867d7ef 100644 --- a/tests/unit_tests/agent/test_resource_policy.py +++ b/tests/unit_tests/agent/test_resource_policy.py @@ -53,10 +53,24 @@ def test_pipeline_projection_keeps_sources_only_for_authorized_tools(): def test_independent_agent_projection_preserves_all_tools_intent(): - policy = ResourcePolicyProjector.from_runner_config({}) + policy = ResourcePolicyProjector.from_runner_config( + {}, allowed_platform_tool_names=['event_reply', '', 'event_reply'] + ) assert policy.allow_all_tools is True assert policy.allowed_tool_names is None + assert policy.allowed_platform_tool_names == ['event_reply'] + + +def test_runner_config_cannot_self_grant_platform_tools(): + policy = ResourcePolicyProjector.from_runner_config( + { + 'platform-tools': ['platform_send_message'], + 'allowed_platform_tools': ['platform_delete_message'], + } + ) + + assert policy.allowed_platform_tool_names == [] @pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}]) @@ -78,6 +92,28 @@ def test_independent_agent_projection_preserves_selected_tools(): assert policy.allowed_tool_names == ['exec'] +def test_agent_level_host_tool_policy_overrides_runner_tool_defaults(): + policy = ResourcePolicyProjector.from_runner_config( + {'enable-all-tools': True, 'tools': ['runner-tool']}, + allowed_host_tool_names=['exec', 'mcp_tool', 'exec'], + override_runner_tools=True, + ) + + assert policy.allow_all_tools is False + assert policy.allowed_tool_names == ['exec', 'mcp_tool'] + + +def test_agent_level_empty_host_tool_policy_fails_closed(): + policy = ResourcePolicyProjector.from_runner_config( + {'enable-all-tools': True}, + allowed_host_tool_names=[], + override_runner_tools=True, + ) + + assert policy.allow_all_tools is False + assert policy.allowed_tool_names == [] + + def test_filter_tools_supports_sdk_objects_and_dictionary_tools(): policy = ResourcePolicyProjector.from_runner_config( {'enable-all-tools': False, 'tools': ['dict-tool', 'object-tool']}, diff --git a/tests/unit_tests/api/service/test_agent_service.py b/tests/unit_tests/api/service/test_agent_service.py index ad06a96b1..5008dbaf2 100644 --- a/tests/unit_tests/api/service/test_agent_service.py +++ b/tests/unit_tests/api/service/test_agent_service.py @@ -46,7 +46,7 @@ def _agent_row( 'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0}, 'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}}, }, - supported_event_patterns=supported_event_patterns or ['*'], + supported_event_patterns=(supported_event_patterns if supported_event_patterns is not None else ['*']), created_at=dt.datetime(2026, 1, 1, 9, 0, 0), updated_at=updated_at or dt.datetime(2026, 1, 1, 10, 0, 0), ) @@ -96,6 +96,7 @@ def _make_app(): _get_default_values_from_schema=Mock(return_value={}), ) app.agent_runner_registry = None + app.tool_mgr = None app.logger = Mock() return app @@ -107,11 +108,32 @@ class TestAgentServiceMetadata: app.pipeline_service.get_pipeline_metadata = AsyncMock( return_value=[{'name': 'trigger'}, ai_metadata, {'name': 'output'}] ) + host_tools = [ + { + 'name': 'exec', + 'source': 'builtin', + 'source_name': 'LangBot', + }, + { + 'name': 'weather', + 'source': 'mcp', + 'source_name': 'weather-server', + }, + ] + app.tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=host_tools)) metadata = await AgentService(app).get_agent_metadata(WORKSPACE_UUID) app.pipeline_service.get_pipeline_metadata.assert_awaited_once_with(WORKSPACE_UUID) assert metadata['runner_config'] == ai_metadata + assert any(tool['name'] == 'event_reply' for tool in metadata['platform_tools']) + assert all(tool['name'] != 'call_platform_api' for tool in metadata['platform_tools']) + assert metadata['host_tools'] == host_tools + app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + WORKSPACE_UUID, + include_skill_authoring=True, + include_mcp_resource_tools=True, + ) assert metadata['kinds'] == [ { 'name': AGENT_KIND_AGENT, @@ -129,6 +151,13 @@ class TestAgentServiceMetadata: class TestAgentServiceDebug: async def test_debug_agent_runs_configured_runner_with_synthetic_event(self): app = _make_app() + agent_config = _agent_row().config + agent_config['allowed_platform_tools'] = ['platform_get_user_info'] + agent_config['event_tool_permissions'] = { + 'message.*': ['event_reply'], + 'group.member.joined': ['event_get_actor'], + } + agent_config['allowed_tools'] = ['exec', 'weather'] async def run_agent(event, binding, adapter_context): yield SimpleNamespace( @@ -144,7 +173,7 @@ class TestAgentServiceDebug: 'uuid': 'agent-1', 'kind': AGENT_KIND_AGENT, 'supported_event_patterns': ['*'], - 'config': _agent_row().config, + 'config': agent_config, } ) context = SimpleNamespace( @@ -181,6 +210,15 @@ class TestAgentServiceDebug: assert event.data == {'member_id': 'user-1'} assert binding.agent_id == 'agent-1' assert binding.runner_id == 'plugin:test/runner/default' + assert binding.resource_policy.allowed_platform_tool_names == [ + 'platform_get_user_info', + 'event_reply', + 'event_get_actor', + 'event_get_group', + 'event_get_group_member', + ] + assert binding.resource_policy.allow_all_tools is False + assert binding.resource_policy.allowed_tool_names == ['exec', 'weather'] assert ( app.agent_run_orchestrator.run.call_args.kwargs['adapter_context']['_execution_context'].workspace_uuid == WORKSPACE_UUID @@ -435,6 +473,25 @@ class TestAgentServiceCreateUpdateDelete: insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0]) assert insert_values['component_ref'] is None + async def test_create_agent_preserves_explicit_empty_event_scope(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock(return_value=Mock()) + + await AgentService(app).create_agent( + WORKSPACE_UUID, + { + 'name': 'Dormant Agent', + 'supported_event_patterns': [], + 'config': { + 'runner': {'id': ''}, + 'runner_config': {}, + }, + }, + ) + + insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0]) + assert insert_values['supported_event_patterns'] == [] + async def test_update_agent_rejects_malformed_4x_runner_config_before_write(self): app = _make_app() app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=_agent_row(agent_uuid='agent-1'))) @@ -486,7 +543,7 @@ class TestAgentServiceCreateUpdateDelete: assert update_values == { 'name': 'Updated Agent', 'config': new_config, - 'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS, + 'supported_event_patterns': [], 'component_ref': 'plugin:test/new-runner/default', } diff --git a/tests/unit_tests/platform/test_routing_rules.py b/tests/unit_tests/platform/test_routing_rules.py index 82f9bc9fc..0378a10be 100644 --- a/tests/unit_tests/platform/test_routing_rules.py +++ b/tests/unit_tests/platform/test_routing_rules.py @@ -340,6 +340,12 @@ class TestEBAEventBindings: bot.bot_entity = SimpleNamespace(event_bindings=bindings) return bot + def test_empty_agent_event_scope_matches_nothing(self): + from langbot.pkg.platform.botmgr import RuntimeBot + + assert RuntimeBot._agent_supports_event_type([], 'message.received') is False + assert RuntimeBot._agent_supports_event_type(None, 'message.received') is True + def test_resolve_eba_event_binding_uses_enabled_pattern_filters_priority_and_order(self): """The selected binding is the first matching highest-priority binding.""" bot = self._make_bot( @@ -408,6 +414,12 @@ class TestEBAEventBindings: 'component_ref': 'plugin:test/fallback/default', 'config': { 'runner': {'id': 'plugin:test/runner/default'}, + 'allowed_platform_tools': ['platform_get_user_info'], + 'event_tool_permissions': { + 'message.*': ['event_reply'], + 'platform.member.joined': ['event_get_actor'], + }, + 'allowed_tools': ['exec', 'mcp_tool'], 'runner_config': { 'plugin:test/runner/default': { 'temperature': 0.2, @@ -428,8 +440,12 @@ class TestEBAEventBindings: assert binding.event_types == ['platform.member.joined'] assert binding.runner_id == 'plugin:test/runner/default' assert binding.runner_config == {'temperature': 0.2, 'max_tokens': 1000} - assert binding.resource_policy.allow_all_tools is True - assert binding.resource_policy.allowed_tool_names is None + assert binding.resource_policy.allow_all_tools is False + assert binding.resource_policy.allowed_tool_names == ['exec', 'mcp_tool'] + assert binding.resource_policy.allowed_platform_tool_names == [ + 'platform_get_user_info', + 'event_get_actor', + ] assert binding.delivery_policy.enable_streaming is False assert binding.delivery_policy.enable_reply is True assert binding.delivery_policy.enable_interactions is True diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx index 6f3a3233b..cc64b7ba4 100644 --- a/web/src/app/home/agents/AgentDetailContent.tsx +++ b/web/src/app/home/agents/AgentDetailContent.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; -import { Trash2 } from 'lucide-react'; +import { AlertTriangle, Trash2 } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { useCurrentWorkspace } from '@/app/infra/http'; import { Agent } from '@/app/infra/entities/api'; @@ -13,6 +13,7 @@ import EntityBasicInfoDialog, { } from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog'; import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, @@ -168,6 +169,18 @@ export default function AgentDetailContent({ id }: { id: string }) { + + {t('agents.noEventsConfiguredBadge')} + + ) : undefined + } titleAction={ canManage ? ( setBasicInfoOpen(true)} /> diff --git a/web/src/app/home/agents/components/AgentApiToolPicker.tsx b/web/src/app/home/agents/components/AgentApiToolPicker.tsx new file mode 100644 index 000000000..6b7032c53 --- /dev/null +++ b/web/src/app/home/agents/components/AgentApiToolPicker.tsx @@ -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; + api?: string; + eventPatterns?: string[]; + risk?: AgentPlatformTool['risk']; +}; + +const PLATFORM_CATEGORY_LABELS: Record = { + 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( + scopes[0] ?? 'event', + ); + const [expandedTool, setExpandedTool] = useState(null); + const isChinese = i18n.language.startsWith('zh'); + const selectedPlatform = useMemo( + () => new Set(platformValue), + [platformValue], + ); + const selectedHost = useMemo(() => new Set(hostValue), [hostValue]); + + const entries = useMemo( + () => [ + ...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, + })), + ], + [hostTools, platformTools, t], + ); + + const scopeCounts = useMemo( + () => + Object.fromEntries( + SCOPE_ORDER.map((scope) => [ + scope, + entries.filter((tool) => tool.scope === scope).length, + ]), + ) as Record, + [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(); + 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 = { + 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 ( +
+
+ {visibleScopes.length === 1 ? ( + + {scopeLabel(visibleScopes[0])} + + {scopeCounts[visibleScopes[0]]} + + + ) : ( +
+ {visibleScopes.map((scope) => ( + + ))} +
+ )} + + {t('agents.apiToolsSelected', { + count: selectedCount, + })} + +
+ +
+ + setQuery(event.target.value)} + placeholder={t('agents.apiToolsSearch')} + className="pl-9" + /> +
+ + {!catalogAvailable && ( +
+ {activeScope === 'event' || activeScope === 'platform' + ? t('agents.apiToolsCatalogUnavailable') + : t('agents.hostToolsCatalogUnavailable')} +
+ )} + {catalogAvailable && !filteredEntries.length && ( +
+ {t('agents.apiToolsNoResults')} +
+ )} + {catalogAvailable && filteredEntries.length > 0 && ( +
+ {groupedEntries.map(([group, groupTools], groupIndex) => ( +
0 && 'border-t')}> +
+ {groupLabel(group)} +
+
+ {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 + | undefined) ?? {}, + ); + return ( +
+ + + {expanded && ( +
+
+ {tool.name} +
+ {tool.api &&
API: {tool.api}
} + {tool.kind === 'host' && ( +
+ {t('agents.apiToolSource')}:{' '} + {groupLabel(tool.group)} +
+ )} + {tool.eventPatterns && ( +
+ {t('agents.apiToolEvents')}:{' '} + {tool.eventPatterns.join(', ')} +
+ )} +
+ {t('agents.apiToolParameters')}:{' '} + {parameterNames.length + ? parameterNames.join(', ') + : t('agents.apiToolNoParameters')} +
+
+ )} +
+ ); + })} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/web/src/app/home/agents/components/AgentDebugPanel.tsx b/web/src/app/home/agents/components/AgentDebugPanel.tsx index 2cdbe06c6..b4e19f038 100644 --- a/web/src/app/home/agents/components/AgentDebugPanel.tsx +++ b/web/src/app/home/agents/components/AgentDebugPanel.tsx @@ -3,11 +3,11 @@ import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { AlertCircle, + AlertTriangle, ChevronDown, CircleHelp, LoaderCircle, Play, - RotateCcw, } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { Badge } from '@/components/ui/badge'; @@ -167,11 +167,6 @@ export default function AgentDebugPanel({ setEventDataText(JSON.stringify(nextPreset.data, null, 2)); } - function resetSession() { - sessionIdRef.current = createDebugSessionId(agentId); - setEntries([]); - } - async function runDebugEvent() { if (!eventType) { toast.error(t('agents.debugEventTypeRequired')); @@ -368,130 +363,131 @@ export default function AgentDebugPanel({
-
-
- - + + + + + {eventGroups.map((group) => ( + + + {eventGroupLabel(group.namespace, t)} + + {group.patterns.map((event) => ( + + + + ))} + + ))} + {supportsCustomEvent && ( + + {t('agents.debugCustomEvent')} - ))} - - ))} - {supportsCustomEvent && ( - - {t('agents.debugCustomEvent')} - - - - - )} - - -
- -
+ + )} + + +
- {preset === 'custom' && ( -
- - setCustomEventType(event.target.value)} - placeholder="custom.event" - /> -
+ {preset === 'custom' && ( +
+ + setCustomEventType(event.target.value)} + placeholder="custom.event" + /> +
+ )} + +
+ +