Files
LangBot/src/langbot/pkg/agent/runner/platform_tools.py
T

788 lines
33 KiB
Python

"""Run-scoped platform and event action tools exposed to Runners."""
from __future__ import annotations
from ...telemetry import diagnostics
import copy
import fnmatch
import typing
from dataclasses import dataclass
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
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 无法改写。'
'需要发送消息时请调用此工具,Agent 的输出文本不会自动发送。',
'en_US': 'Send a reply or task progress to the conversation that triggered this run. LangBot fixes the target. '
'Call this tool to send a message; Agent output text is not sent automatically.',
},
_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': '使用当前机器人向指定用户或群组发送回复或任务进度。Agent 的输出文本不会自动发送。',
'en_US': 'Send a reply or task progress to a specified person or group using the current bot. '
'Agent output text is not sent automatically.',
},
_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 validate_debug_mock_options(value: typing.Any) -> dict[str, typing.Any]:
if not isinstance(value, dict) or set(value) - {'errors', 'results', 'unsupported_apis'}:
raise ValueError('Mock options must contain only errors, results and unsupported_apis')
for field in ('errors', 'results'):
entries = value.get(field, {})
if not isinstance(entries, dict) or set(entries) - set(PLATFORM_TOOLS_BY_NAME):
raise ValueError(f'Mock {field} must map platform tool names to outcomes')
if any(not isinstance(error, str) or not error.strip() for error in value.get('errors', {}).values()):
raise ValueError('Mock errors must be non-empty strings')
if set(value.get('errors', {})) & set(value.get('results', {})):
raise ValueError('A mock tool cannot have both an error and a result')
unsupported = value.get('unsupported_apis', [])
if not isinstance(unsupported, list) or any(
not isinstance(api, str) or api not in {tool.api for tool in PLATFORM_TOOL_DEFINITIONS} for api in unsupported
):
raise ValueError('Mock unsupported_apis must be an array of platform API names')
return copy.deepcopy(value)
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())
# Some model responses annotate no-argument calls with a textual intent.
# Keep the original trace, but never pass this annotation to the adapter.
if not allowed and isinstance(parameters.get('_call'), str):
parameters = {key: value for key, value in parameters.items() if key != '_call'}
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)
def resolve_platform_api_call(session, bot_uuid, action, params, context_tool=None):
"""Resolve an adapter call against Host-frozen tool grants and event targets."""
authorization = session.get('authorization') or {}
context = authorization.get('platform_context') or {}
if context_tool is None and (not bot_uuid or bot_uuid != authorization.get('bot_id')):
raise ValueError('Platform API bot is not authorized for this run')
if context_tool is not None and context_tool != 'event_reply':
raise ValueError('Unknown context platform API')
params = dict(params)
message = None
quote_origin = False
if action == 'send_message':
message = platform_message.MessageChain.model_validate(params.pop('message'))
if not message.root:
raise ValueError('Message must not be empty')
quote_origin = params.pop('quote_origin', False)
if not isinstance(quote_origin, bool):
raise ValueError('quote_origin must be a boolean')
if quote_origin and context_tool != 'event_reply':
raise ValueError('quote_origin requires the current event context')
# Only text is used for tool-schema validation. The original chain is delivered intact.
params['text'] = ''.join(c.text for c in message.root if isinstance(c, platform_message.Plain)) or '[message]'
granted = {
tool['tool_name']
for tool in authorization.get('resources', {}).get('tools', [])
if tool.get('source') == 'platform' and (not tool.get('operations') or 'call' in tool['operations'])
}
for definition in PLATFORM_TOOL_DEFINITIONS:
if definition.name not in granted or definition.api != action:
continue
if context_tool is not None and definition.name != context_tool:
continue
properties = definition.parameters.get('properties') or {}
tool_params = {key: value for key, value in params.items() if key in properties}
try:
normalized = _normalize_platform_params(definition, tool_params)
bound = _event_params(definition, context, normalized) if definition.scope == 'event' else normalized
except ValueError:
continue
if context_tool is None and bound != params:
continue
if context_tool is not None and set(params) - set(properties):
raise ValueError('Unexpected context API parameters')
if message is not None and quote_origin:
target = (context.get('delivery') or {}).get('reply_target') or {}
if not target.get('message_id'):
raise ValueError('The current event has no message to quote')
message = platform_message.MessageChain(
[
platform_message.Quote(
id=target['message_id'],
group_id=target.get('group_id'),
sender_id=(context.get('actor') or {}).get('actor_id'),
target_id=target.get('target_id'),
origin=platform_message.MessageChain([]),
),
*message.root,
]
)
return definition.name, tool_params, message
raise ValueError(f'Platform API {action} or its target is not authorized for this run')
@diagnostics.observe('api', 'host.platform_tool', source='agent')
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],
message_chain: platform_message.MessageChain | None = None,
) -> 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', {})
context = authorization.get('platform_context') or {}
delivery = context.get('delivery') or {}
normalized = _normalize_platform_params(definition, parameters)
if definition.scope == 'event':
normalized = _event_params(definition, context, normalized)
# This flag is frozen by the Host from the synthetic debug envelope, not tool arguments.
if delivery.get('surface') == 'webui' and (delivery.get('platform_capabilities') or {}).get('debug_mock') is True:
diagnostics.annotate(source='webui_debug', attributes={'synthetic': True})
result = _execute_mock_platform_tool(definition, context, normalized)
if message_chain is not None:
result['parameters']['message'] = message_chain.model_dump(mode='json')
return result
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')
if definition.api == 'send_message':
normalized = {
'target_type': _require_string(normalized, 'target_type'),
'target_id': _require_string(normalized, 'target_id'),
'message': message_chain
if message_chain is not None
else platform_message.MessageChain([platform_message.Plain(text=_require_string(normalized, 'text'))]),
}
return await api_func(**normalized)
def _execute_mock_platform_tool(
definition: PlatformToolDefinition, context: dict[str, typing.Any], parameters: dict[str, typing.Any]
) -> dict[str, typing.Any]:
"""Simulate the adapter boundary while preserving real model calls and validated targets."""
data = context.get('data') or {}
actor = context.get('actor') or {}
options = ((context.get('delivery') or {}).get('platform_capabilities') or {}).get('mock_options') or {}
result: typing.Any = None
user_id = parameters.get('user_id') or actor.get('actor_id') or 'debug-user'
user = platform_entities.User(
id=user_id,
nickname=(actor.get('actor_name') or 'Debug User')
if user_id == actor.get('actor_id')
else f'Mock user {user_id}',
)
group_id = parameters.get('group_id') or data.get('group_id') or 'debug-group'
group = platform_entities.UserGroup(id=group_id, name=data.get('group_name') or 'Mock group')
member = platform_entities.UserGroupMember(user=user, group_id=group_id)
if definition.api == 'send_message':
for field in ('target_type', 'target_id', 'text'):
_require_string(parameters, field)
result = {'message_id': 'mock-message'}
elif definition.api == 'get_user_info':
result = user.model_dump(mode='json')
elif definition.api == 'get_group_member_info':
result = member.model_dump(mode='json')
elif definition.api == 'get_group_info':
result = group.model_dump(mode='json')
elif definition.api == 'get_group_list':
result = [group.model_dump(mode='json')]
elif definition.api == 'get_group_member_list':
result = [member.model_dump(mode='json')]
elif definition.api == 'get_friend_list':
result = [user.model_dump(mode='json')]
elif definition.api == 'get_message':
result = platform_events.MessageReceivedEvent(
message_id=parameters['message_id'],
chat_id=parameters['chat_id'],
chat_type='group' if parameters['chat_type'] == 'group' else 'private',
sender=user,
message_chain=platform_message.MessageChain(
[platform_message.Plain(text=str(data.get('text') or 'Mock message'))]
),
).model_dump(mode='json')
error = options.get('errors', {}).get(definition.name)
if definition.name in options.get('results', {}):
result = copy.deepcopy(options['results'][definition.name])
return {
'ok': error is None,
'mock': True,
'delivery': 'simulated',
'tool': definition.name,
'api': definition.api,
'parameters': parameters,
'result': None if error else result,
**({'error': error} if error else {}),
'notice': 'Simulated platform operation. No real platform API was called.',
}
__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',
]