mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 23:07:14 +00:00
feat(agent): add event-aware tool permissions
This commit is contained in:
@@ -96,6 +96,9 @@ class ResourcePolicy(pydantic.BaseModel):
|
||||
allowed_tool_sources: dict[str, dict[str, str | None]] | None = None
|
||||
"""Host-resolved implementation identity for each allowed tool name."""
|
||||
|
||||
allowed_platform_tool_names: list[str] = pydantic.Field(default_factory=list)
|
||||
"""Platform and event action tools explicitly granted by the Agent owner."""
|
||||
|
||||
allow_all_tools: bool = False
|
||||
"""Whether all tools visible to the current Host scope are granted."""
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from .interaction_manager import InteractionManager
|
||||
from .query_bridge import QueryRunBridge
|
||||
from .registry import AgentRunnerRegistry
|
||||
from .resource_builder import AgentResourceBuilder
|
||||
from .platform_tools import freeze_platform_context
|
||||
from .result_normalizer import AgentResultNormalizer
|
||||
from .run_journal import AgentRunJournal
|
||||
from .session_registry import AgentRunSessionRegistry, get_session_registry
|
||||
@@ -201,6 +202,7 @@ class AgentRunOrchestrator:
|
||||
},
|
||||
state_context=state_context,
|
||||
execution_query=execution_query,
|
||||
platform_context=freeze_platform_context(event),
|
||||
)
|
||||
|
||||
event_log_id = await self.journal.write_event_log(
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
"""Run-scoped platform and event action tools exposed to AgentRunners."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import fnmatch
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
from .host_models import AgentEventEnvelope
|
||||
|
||||
|
||||
JsonSchema = dict[str, typing.Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlatformToolDefinition:
|
||||
name: str
|
||||
api: str
|
||||
scope: typing.Literal['event', 'platform']
|
||||
category: str
|
||||
risk: typing.Literal['read', 'write', 'dangerous']
|
||||
label: dict[str, str]
|
||||
description: dict[str, str]
|
||||
parameters: JsonSchema
|
||||
event_patterns: tuple[str, ...] = ('*',)
|
||||
binding: str | None = None
|
||||
|
||||
|
||||
def _object_schema(properties: dict[str, JsonSchema], required: list[str] | None = None) -> JsonSchema:
|
||||
schema: JsonSchema = {'type': 'object', 'properties': properties, 'additionalProperties': False}
|
||||
if required:
|
||||
schema['required'] = required
|
||||
return schema
|
||||
|
||||
|
||||
_TEXT = {'type': 'string', 'minLength': 1}
|
||||
_ID = {'type': 'string', 'minLength': 1}
|
||||
_TARGET_TYPE = {'type': 'string', 'enum': ['person', 'group']}
|
||||
_CHAT_TYPE = {'type': 'string', 'enum': ['person', 'private', 'group']}
|
||||
_APPROVE = {'type': 'boolean', 'description': 'true to accept; false to reject'}
|
||||
|
||||
|
||||
PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = (
|
||||
PlatformToolDefinition(
|
||||
'event_reply',
|
||||
'send_message',
|
||||
'event',
|
||||
'message',
|
||||
'write',
|
||||
{'zh_Hans': '回复当前会话', 'en_US': 'Reply to current conversation'},
|
||||
{
|
||||
'zh_Hans': '向触发当前事件的会话发送文本消息。目标由 LangBot 固定,Agent 无法改写。',
|
||||
'en_US': 'Send text to the conversation that triggered this run. LangBot fixes the target.',
|
||||
},
|
||||
_object_schema({'text': {**_TEXT, 'description': 'Reply text'}}, ['text']),
|
||||
('message.*', 'friend.*', 'group.*', 'feedback.*'),
|
||||
'reply_target',
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'event_delete_message',
|
||||
'delete_message',
|
||||
'event',
|
||||
'message',
|
||||
'dangerous',
|
||||
{'zh_Hans': '删除当前消息', 'en_US': 'Delete current message'},
|
||||
{
|
||||
'zh_Hans': '删除触发当前事件的消息。消息与会话标识由 LangBot 固定。',
|
||||
'en_US': 'Delete the message that triggered the run. Message and chat IDs are fixed by LangBot.',
|
||||
},
|
||||
_object_schema({}),
|
||||
('message.received', 'message.edited'),
|
||||
'current_message',
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'event_get_actor',
|
||||
'get_user_info',
|
||||
'event',
|
||||
'identity',
|
||||
'read',
|
||||
{'zh_Hans': '查询事件发起者', 'en_US': 'Get event actor'},
|
||||
{
|
||||
'zh_Hans': '查询当前事件发起者的用户资料。用户标识由 LangBot 固定。',
|
||||
'en_US': 'Read the current event actor profile. LangBot fixes the user ID.',
|
||||
},
|
||||
_object_schema({}),
|
||||
('*',),
|
||||
'actor',
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'event_get_group',
|
||||
'get_group_info',
|
||||
'event',
|
||||
'group',
|
||||
'read',
|
||||
{'zh_Hans': '查询当前群组', 'en_US': 'Get current group'},
|
||||
{
|
||||
'zh_Hans': '查询当前事件所属群组的信息。群组标识由 LangBot 固定。',
|
||||
'en_US': 'Read the group associated with the current event. LangBot fixes the group ID.',
|
||||
},
|
||||
_object_schema({}),
|
||||
('message.*', 'group.*', 'bot.invited_to_group', 'bot.removed_from_group', 'bot.muted', 'bot.unmuted'),
|
||||
'group',
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'event_get_group_member',
|
||||
'get_group_member_info',
|
||||
'event',
|
||||
'group',
|
||||
'read',
|
||||
{'zh_Hans': '查询相关群成员', 'en_US': 'Get related group member'},
|
||||
{
|
||||
'zh_Hans': '查询当前事件发起者在当前群组中的成员信息。',
|
||||
'en_US': 'Read the current actor membership in the current group.',
|
||||
},
|
||||
_object_schema({}),
|
||||
('message.*', 'group.*'),
|
||||
'group_actor',
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'event_mute_member',
|
||||
'mute_member',
|
||||
'event',
|
||||
'moderation',
|
||||
'dangerous',
|
||||
{'zh_Hans': '禁言相关群成员', 'en_US': 'Mute related group member'},
|
||||
{
|
||||
'zh_Hans': '禁言当前事件关联的群成员,群组和成员标识由 LangBot 固定。',
|
||||
'en_US': 'Mute the member related to this event. LangBot fixes group and user IDs.',
|
||||
},
|
||||
_object_schema(
|
||||
{
|
||||
'duration': {
|
||||
'type': 'integer',
|
||||
'minimum': 0,
|
||||
'description': 'Mute duration in seconds; 0 uses the adapter default',
|
||||
}
|
||||
}
|
||||
),
|
||||
('message.*', 'group.member_*'),
|
||||
'group_actor',
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'event_unmute_member',
|
||||
'unmute_member',
|
||||
'event',
|
||||
'moderation',
|
||||
'dangerous',
|
||||
{'zh_Hans': '解除相关群成员禁言', 'en_US': 'Unmute related group member'},
|
||||
{'zh_Hans': '解除当前事件关联群成员的禁言。', 'en_US': 'Unmute the member related to this event.'},
|
||||
_object_schema({}),
|
||||
('message.*', 'group.member_*'),
|
||||
'group_actor',
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'event_kick_member',
|
||||
'kick_member',
|
||||
'event',
|
||||
'moderation',
|
||||
'dangerous',
|
||||
{'zh_Hans': '移出相关群成员', 'en_US': 'Kick related group member'},
|
||||
{
|
||||
'zh_Hans': '将当前事件关联的成员移出群组。',
|
||||
'en_US': 'Remove the member related to this event from the group.',
|
||||
},
|
||||
_object_schema({}),
|
||||
('message.*', 'group.member_*'),
|
||||
'group_actor',
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'event_respond_friend_request',
|
||||
'approve_friend_request',
|
||||
'event',
|
||||
'request',
|
||||
'dangerous',
|
||||
{'zh_Hans': '处理好友请求', 'en_US': 'Respond to friend request'},
|
||||
{
|
||||
'zh_Hans': '同意或拒绝触发当前事件的好友请求。请求标识由 LangBot 固定。',
|
||||
'en_US': 'Accept or reject the friend request that triggered this run. LangBot fixes the request ID.',
|
||||
},
|
||||
_object_schema({'approve': _APPROVE, 'remark': {'type': 'string'}}, ['approve']),
|
||||
('friend.request_received',),
|
||||
'request',
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'event_respond_group_invite',
|
||||
'approve_group_invite',
|
||||
'event',
|
||||
'request',
|
||||
'dangerous',
|
||||
{'zh_Hans': '处理入群邀请', 'en_US': 'Respond to group invite'},
|
||||
{
|
||||
'zh_Hans': '同意或拒绝触发当前事件的机器人入群邀请。',
|
||||
'en_US': 'Accept or reject the bot group invitation that triggered this run.',
|
||||
},
|
||||
_object_schema({'approve': _APPROVE}, ['approve']),
|
||||
('bot.invited_to_group',),
|
||||
'request',
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'platform_send_message',
|
||||
'send_message',
|
||||
'platform',
|
||||
'message',
|
||||
'write',
|
||||
{'zh_Hans': '发送消息', 'en_US': 'Send message'},
|
||||
{
|
||||
'zh_Hans': '使用当前机器人向指定用户或群组发送文本消息。',
|
||||
'en_US': 'Send text to a specified person or group using the current bot.',
|
||||
},
|
||||
_object_schema(
|
||||
{'target_type': _TARGET_TYPE, 'target_id': _ID, 'text': _TEXT}, ['target_type', 'target_id', 'text']
|
||||
),
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'platform_get_message',
|
||||
'get_message',
|
||||
'platform',
|
||||
'message',
|
||||
'read',
|
||||
{'zh_Hans': '查询消息', 'en_US': 'Get message'},
|
||||
{'zh_Hans': '按会话和消息标识查询消息。', 'en_US': 'Get a message by chat and message ID.'},
|
||||
_object_schema(
|
||||
{'chat_type': _CHAT_TYPE, 'chat_id': _ID, 'message_id': _ID}, ['chat_type', 'chat_id', 'message_id']
|
||||
),
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'platform_delete_message',
|
||||
'delete_message',
|
||||
'platform',
|
||||
'message',
|
||||
'dangerous',
|
||||
{'zh_Hans': '删除指定消息', 'en_US': 'Delete message'},
|
||||
{'zh_Hans': '按会话和消息标识删除消息。', 'en_US': 'Delete a message by chat and message ID.'},
|
||||
_object_schema(
|
||||
{'chat_type': _CHAT_TYPE, 'chat_id': _ID, 'message_id': _ID}, ['chat_type', 'chat_id', 'message_id']
|
||||
),
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'platform_get_group_info',
|
||||
'get_group_info',
|
||||
'platform',
|
||||
'group',
|
||||
'read',
|
||||
{'zh_Hans': '查询群组', 'en_US': 'Get group'},
|
||||
{'zh_Hans': '查询指定群组的信息。', 'en_US': 'Read information about a specified group.'},
|
||||
_object_schema({'group_id': _ID}, ['group_id']),
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'platform_get_group_list',
|
||||
'get_group_list',
|
||||
'platform',
|
||||
'group',
|
||||
'read',
|
||||
{'zh_Hans': '列出群组', 'en_US': 'List groups'},
|
||||
{'zh_Hans': '列出当前机器人加入的群组。', 'en_US': 'List groups joined by the current bot.'},
|
||||
_object_schema({}),
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'platform_get_group_member_list',
|
||||
'get_group_member_list',
|
||||
'platform',
|
||||
'group',
|
||||
'read',
|
||||
{'zh_Hans': '列出群成员', 'en_US': 'List group members'},
|
||||
{'zh_Hans': '列出指定群组的成员。', 'en_US': 'List members of a specified group.'},
|
||||
_object_schema({'group_id': _ID}, ['group_id']),
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'platform_get_group_member_info',
|
||||
'get_group_member_info',
|
||||
'platform',
|
||||
'group',
|
||||
'read',
|
||||
{'zh_Hans': '查询群成员', 'en_US': 'Get group member'},
|
||||
{'zh_Hans': '查询指定用户在指定群组中的成员信息。', 'en_US': 'Read a specified user membership in a group.'},
|
||||
_object_schema({'group_id': _ID, 'user_id': _ID}, ['group_id', 'user_id']),
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'platform_set_group_name',
|
||||
'set_group_name',
|
||||
'platform',
|
||||
'group',
|
||||
'dangerous',
|
||||
{'zh_Hans': '修改群名称', 'en_US': 'Rename group'},
|
||||
{'zh_Hans': '修改指定群组的名称。', 'en_US': 'Change the name of a specified group.'},
|
||||
_object_schema({'group_id': _ID, 'name': _TEXT}, ['group_id', 'name']),
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'platform_mute_member',
|
||||
'mute_member',
|
||||
'platform',
|
||||
'moderation',
|
||||
'dangerous',
|
||||
{'zh_Hans': '禁言群成员', 'en_US': 'Mute group member'},
|
||||
{'zh_Hans': '禁言指定群组中的指定成员。', 'en_US': 'Mute a specified member in a group.'},
|
||||
_object_schema(
|
||||
{'group_id': _ID, 'user_id': _ID, 'duration': {'type': 'integer', 'minimum': 0}}, ['group_id', 'user_id']
|
||||
),
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'platform_unmute_member',
|
||||
'unmute_member',
|
||||
'platform',
|
||||
'moderation',
|
||||
'dangerous',
|
||||
{'zh_Hans': '解除群成员禁言', 'en_US': 'Unmute group member'},
|
||||
{'zh_Hans': '解除指定群成员的禁言。', 'en_US': 'Unmute a specified group member.'},
|
||||
_object_schema({'group_id': _ID, 'user_id': _ID}, ['group_id', 'user_id']),
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'platform_kick_member',
|
||||
'kick_member',
|
||||
'platform',
|
||||
'moderation',
|
||||
'dangerous',
|
||||
{'zh_Hans': '移出群成员', 'en_US': 'Kick group member'},
|
||||
{'zh_Hans': '将指定成员移出指定群组。', 'en_US': 'Remove a specified member from a group.'},
|
||||
_object_schema({'group_id': _ID, 'user_id': _ID}, ['group_id', 'user_id']),
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'platform_leave_group',
|
||||
'leave_group',
|
||||
'platform',
|
||||
'moderation',
|
||||
'dangerous',
|
||||
{'zh_Hans': '退出群组', 'en_US': 'Leave group'},
|
||||
{'zh_Hans': '让当前机器人退出指定群组。', 'en_US': 'Make the current bot leave a specified group.'},
|
||||
_object_schema({'group_id': _ID}, ['group_id']),
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'platform_get_user_info',
|
||||
'get_user_info',
|
||||
'platform',
|
||||
'identity',
|
||||
'read',
|
||||
{'zh_Hans': '查询用户', 'en_US': 'Get user'},
|
||||
{'zh_Hans': '查询指定用户的资料。', 'en_US': 'Read a specified user profile.'},
|
||||
_object_schema({'user_id': _ID}, ['user_id']),
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
'platform_get_friend_list',
|
||||
'get_friend_list',
|
||||
'platform',
|
||||
'identity',
|
||||
'read',
|
||||
{'zh_Hans': '列出好友', 'en_US': 'List friends'},
|
||||
{'zh_Hans': '列出当前机器人的好友。', 'en_US': 'List friends of the current bot.'},
|
||||
_object_schema({}),
|
||||
),
|
||||
)
|
||||
|
||||
PLATFORM_TOOLS_BY_NAME = {definition.name: definition for definition in PLATFORM_TOOL_DEFINITIONS}
|
||||
|
||||
|
||||
def resolve_agent_platform_tool_names(config: typing.Mapping[str, typing.Any], event_type: str) -> list[str]:
|
||||
"""Resolve platform tools plus the event actions implied by this event."""
|
||||
configured = [name for name in config.get('allowed_platform_tools', []) if isinstance(name, str)]
|
||||
selected = [
|
||||
name
|
||||
for name in configured
|
||||
if (definition := PLATFORM_TOOLS_BY_NAME.get(name)) is not None and definition.scope == 'platform'
|
||||
]
|
||||
selected.extend(
|
||||
definition.name
|
||||
for definition in PLATFORM_TOOL_DEFINITIONS
|
||||
if definition.scope == 'event' and _event_matches(event_type, definition.event_patterns)
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
def platform_tool_catalog() -> list[dict[str, typing.Any]]:
|
||||
return [
|
||||
{
|
||||
'name': item.name,
|
||||
'api': item.api,
|
||||
'scope': item.scope,
|
||||
'category': item.category,
|
||||
'risk': item.risk,
|
||||
'label': item.label,
|
||||
'description': item.description,
|
||||
'event_patterns': list(item.event_patterns),
|
||||
'parameters': copy.deepcopy(item.parameters),
|
||||
}
|
||||
for item in PLATFORM_TOOL_DEFINITIONS
|
||||
]
|
||||
|
||||
|
||||
def _event_matches(event_type: str, patterns: tuple[str, ...]) -> bool:
|
||||
return any(fnmatch.fnmatchcase(event_type, pattern) for pattern in patterns)
|
||||
|
||||
|
||||
def _event_binding_available(event: AgentEventEnvelope, binding: str | None) -> bool:
|
||||
if binding is None:
|
||||
return True
|
||||
reply_target = event.delivery.reply_target or {}
|
||||
if binding == 'reply_target':
|
||||
return bool(reply_target.get('target_type') and reply_target.get('target_id'))
|
||||
if binding == 'current_message':
|
||||
return bool(
|
||||
reply_target.get('target_type') and reply_target.get('target_id') and reply_target.get('message_id')
|
||||
)
|
||||
if binding == 'actor':
|
||||
return bool(event.actor and event.actor.actor_id)
|
||||
group_id = reply_target.get('group_id') or (
|
||||
event.subject.subject_id if event.subject and event.subject.subject_type == 'group' else None
|
||||
)
|
||||
if binding == 'group':
|
||||
return bool(group_id)
|
||||
if binding == 'group_actor':
|
||||
return bool(group_id and event.actor and event.actor.actor_id)
|
||||
if binding == 'request':
|
||||
# A Host event reference is only a journal identity. It is not a
|
||||
# platform request token and must never be forwarded to an adapter as
|
||||
# one. Request actions are therefore available only when the adapter
|
||||
# event supplied its real request_id.
|
||||
return bool(event.data.get('request_id'))
|
||||
return False
|
||||
|
||||
|
||||
def build_platform_tool_resources(
|
||||
event: AgentEventEnvelope, selected_names: typing.Iterable[str] | None, operations: list[str]
|
||||
) -> tuple[list[dict[str, typing.Any]], dict[str, typing.Any]]:
|
||||
selected = list(dict.fromkeys(selected_names or []))
|
||||
supported_apis = set((event.delivery.platform_capabilities or {}).get('supported_apis') or [])
|
||||
resources: list[dict[str, typing.Any]] = []
|
||||
unavailable: list[dict[str, str]] = []
|
||||
if 'call' not in operations:
|
||||
capabilities = copy.deepcopy(event.delivery.platform_capabilities or {})
|
||||
capabilities.update(
|
||||
{
|
||||
'authorized_tools': [],
|
||||
'unavailable_tools': [{'name': name, 'reason': 'runner_call_permission_missing'} for name in selected],
|
||||
}
|
||||
)
|
||||
return resources, capabilities
|
||||
for name in selected:
|
||||
definition = PLATFORM_TOOLS_BY_NAME.get(name)
|
||||
reason = None
|
||||
if definition is None:
|
||||
reason = 'unknown_tool'
|
||||
elif definition.api not in supported_apis:
|
||||
reason = 'adapter_api_unsupported'
|
||||
elif definition.scope == 'event' and not _event_matches(event.event_type, definition.event_patterns):
|
||||
reason = 'event_incompatible'
|
||||
elif definition.scope == 'event' and not _event_binding_available(event, definition.binding):
|
||||
reason = 'event_target_unavailable'
|
||||
if reason:
|
||||
unavailable.append({'name': name, 'reason': reason})
|
||||
continue
|
||||
assert definition is not None
|
||||
resources.append(
|
||||
{
|
||||
'tool_name': definition.name,
|
||||
'tool_type': 'platform',
|
||||
'description': definition.description.get('en_US') or definition.description.get('zh_Hans'),
|
||||
'operations': list(operations),
|
||||
'parameters': copy.deepcopy(definition.parameters),
|
||||
'source': 'platform',
|
||||
'source_id': definition.name,
|
||||
}
|
||||
)
|
||||
capabilities = copy.deepcopy(event.delivery.platform_capabilities or {})
|
||||
capabilities.update(
|
||||
{'authorized_tools': [item['tool_name'] for item in resources], 'unavailable_tools': unavailable}
|
||||
)
|
||||
return resources, capabilities
|
||||
|
||||
|
||||
def freeze_platform_context(event: AgentEventEnvelope) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'event_type': event.event_type,
|
||||
'data': copy.deepcopy(event.data),
|
||||
'actor': event.actor.model_dump(mode='json') if event.actor else None,
|
||||
'subject': event.subject.model_dump(mode='json') if event.subject else None,
|
||||
'delivery': event.delivery.model_dump(mode='json'),
|
||||
'raw_ref': event.raw_ref.model_dump(mode='json') if event.raw_ref else None,
|
||||
}
|
||||
|
||||
|
||||
def get_platform_tool_detail(session: typing.Mapping[str, typing.Any], tool_name: str) -> dict[str, typing.Any] | None:
|
||||
for tool in session.get('authorization', {}).get('resources', {}).get('tools', []):
|
||||
if tool.get('tool_name') == tool_name and tool.get('source') == 'platform':
|
||||
return {
|
||||
'name': tool_name,
|
||||
'description': tool.get('description'),
|
||||
'parameters': copy.deepcopy(tool.get('parameters') or _object_schema({})),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _require_string(parameters: dict[str, typing.Any], name: str) -> str:
|
||||
value = parameters.get(name)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f'{name} must be a non-empty string')
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _event_params(
|
||||
definition: PlatformToolDefinition, context: dict[str, typing.Any], parameters: dict[str, typing.Any]
|
||||
) -> dict[str, typing.Any]:
|
||||
reply_target = (context.get('delivery') or {}).get('reply_target') or {}
|
||||
actor = context.get('actor') or {}
|
||||
subject = context.get('subject') or {}
|
||||
data = context.get('data') or {}
|
||||
group_id = reply_target.get('group_id') or (
|
||||
subject.get('subject_id') if subject.get('subject_type') == 'group' else None
|
||||
)
|
||||
actor_id = actor.get('actor_id')
|
||||
if definition.binding == 'reply_target':
|
||||
return {
|
||||
'target_type': reply_target.get('target_type'),
|
||||
'target_id': reply_target.get('target_id'),
|
||||
'text': _require_string(parameters, 'text'),
|
||||
}
|
||||
if definition.binding == 'current_message':
|
||||
return {
|
||||
'chat_type': reply_target.get('target_type'),
|
||||
'chat_id': reply_target.get('target_id'),
|
||||
'message_id': reply_target.get('message_id'),
|
||||
}
|
||||
if definition.binding == 'actor':
|
||||
return {'user_id': actor_id}
|
||||
if definition.binding == 'group':
|
||||
return {'group_id': group_id}
|
||||
if definition.binding == 'group_actor':
|
||||
result = {'group_id': group_id, 'user_id': actor_id}
|
||||
if definition.api == 'mute_member':
|
||||
duration = parameters.get('duration', 0)
|
||||
if not isinstance(duration, int) or duration < 0:
|
||||
raise ValueError('duration must be a non-negative integer')
|
||||
result['duration'] = duration
|
||||
return result
|
||||
if definition.binding == 'request':
|
||||
result = {
|
||||
'request_id': data.get('request_id'),
|
||||
'approve': parameters.get('approve'),
|
||||
}
|
||||
if not isinstance(result['approve'], bool):
|
||||
raise ValueError('approve must be a boolean')
|
||||
if definition.api == 'approve_friend_request' and isinstance(parameters.get('remark'), str):
|
||||
result['remark'] = parameters['remark']
|
||||
return result
|
||||
return dict(parameters)
|
||||
|
||||
|
||||
def _normalize_platform_params(
|
||||
definition: PlatformToolDefinition, parameters: dict[str, typing.Any]
|
||||
) -> dict[str, typing.Any]:
|
||||
if not isinstance(parameters, dict):
|
||||
raise ValueError('parameters must be an object')
|
||||
allowed = set((definition.parameters.get('properties') or {}).keys())
|
||||
extra = set(parameters) - allowed
|
||||
if extra:
|
||||
raise ValueError(f'Unexpected parameters: {", ".join(sorted(extra))}')
|
||||
for required in definition.parameters.get('required', []):
|
||||
if required not in parameters:
|
||||
raise ValueError(f'{required} is required')
|
||||
for name, value in parameters.items():
|
||||
field = definition.parameters['properties'][name]
|
||||
expected_type = field.get('type')
|
||||
if expected_type == 'string' and not isinstance(value, str):
|
||||
raise ValueError(f'{name} must be a string')
|
||||
if expected_type == 'boolean' and not isinstance(value, bool):
|
||||
raise ValueError(f'{name} must be a boolean')
|
||||
if expected_type == 'integer' and (isinstance(value, bool) or not isinstance(value, int)):
|
||||
raise ValueError(f'{name} must be an integer')
|
||||
if isinstance(value, str) and field.get('minLength', 0) > len(value):
|
||||
raise ValueError(f'{name} must be a non-empty string')
|
||||
if isinstance(value, int) and 'minimum' in field and value < field['minimum']:
|
||||
raise ValueError(f'{name} must be at least {field["minimum"]}')
|
||||
if 'enum' in field and value not in field['enum']:
|
||||
raise ValueError(f'{name} must be one of: {", ".join(field["enum"])}')
|
||||
return dict(parameters)
|
||||
|
||||
|
||||
async def execute_platform_tool(
|
||||
ap: typing.Any,
|
||||
execution_context: typing.Any,
|
||||
session: typing.Mapping[str, typing.Any],
|
||||
tool_name: str,
|
||||
parameters: dict[str, typing.Any],
|
||||
) -> typing.Any:
|
||||
definition = PLATFORM_TOOLS_BY_NAME.get(tool_name)
|
||||
if definition is None:
|
||||
raise ValueError(f'Unknown platform tool: {tool_name}')
|
||||
authorization = session.get('authorization', {})
|
||||
bot_id = authorization.get('bot_id')
|
||||
if not bot_id:
|
||||
raise ValueError('This run is not associated with a platform bot')
|
||||
bot = await ap.platform_mgr.get_bot_by_uuid(execution_context, bot_id)
|
||||
if bot is None:
|
||||
raise ValueError(f'Bot {bot_id} is not running')
|
||||
if definition.api not in set(bot.adapter.get_supported_apis() or []):
|
||||
raise ValueError(f'Platform API {definition.api} is no longer supported by bot {bot_id}')
|
||||
api_func = getattr(bot.adapter, definition.api, None)
|
||||
if not callable(api_func):
|
||||
raise ValueError(f'Platform API {definition.api} is declared but not implemented')
|
||||
normalized = _normalize_platform_params(definition, parameters)
|
||||
if definition.scope == 'event':
|
||||
normalized = _event_params(definition, authorization.get('platform_context') or {}, normalized)
|
||||
if definition.api == 'send_message':
|
||||
normalized = {
|
||||
'target_type': _require_string(normalized, 'target_type'),
|
||||
'target_id': _require_string(normalized, 'target_id'),
|
||||
'message': platform_message.MessageChain(
|
||||
[platform_message.Plain(text=_require_string(normalized, 'text'))]
|
||||
),
|
||||
}
|
||||
return await api_func(**normalized)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'PLATFORM_TOOL_DEFINITIONS',
|
||||
'build_platform_tool_resources',
|
||||
'execute_platform_tool',
|
||||
'freeze_platform_context',
|
||||
'get_platform_tool_detail',
|
||||
'platform_tool_catalog',
|
||||
'resolve_agent_platform_tool_names',
|
||||
]
|
||||
@@ -20,6 +20,7 @@ from .host_models import AgentEventEnvelope, AgentBinding
|
||||
from .resource_policy import ResourcePolicyProjector
|
||||
from ...provider.tools.loaders.mcp import MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE
|
||||
from ...provider.tools.toolmgr import ToolSourceRef
|
||||
from .platform_tools import build_platform_tool_resources
|
||||
|
||||
|
||||
class AgentResourceBuilder:
|
||||
@@ -86,6 +87,18 @@ class AgentResourceBuilder:
|
||||
descriptor,
|
||||
runner_config,
|
||||
)
|
||||
runner_uses_host_tools = config_schema.uses_host_tools(descriptor)
|
||||
platform_tools, platform_capabilities = build_platform_tool_resources(
|
||||
event,
|
||||
resource_policy.allowed_platform_tool_names,
|
||||
(
|
||||
[operation for operation in ('detail', 'call') if operation in set(manifest_perms.tools)]
|
||||
if runner_uses_host_tools
|
||||
else []
|
||||
),
|
||||
)
|
||||
if runner_uses_host_tools:
|
||||
tools.extend(platform_tools)
|
||||
knowledge_bases = await self._build_knowledge_bases_from_binding(
|
||||
execution_context,
|
||||
manifest_perms,
|
||||
@@ -106,7 +119,7 @@ class AgentResourceBuilder:
|
||||
'knowledge_bases': knowledge_bases,
|
||||
'skills': skills,
|
||||
'storage': storage,
|
||||
'platform_capabilities': {}, # Reserved for EBA
|
||||
'platform_capabilities': platform_capabilities,
|
||||
}
|
||||
|
||||
async def _build_models_from_binding(
|
||||
|
||||
@@ -21,6 +21,9 @@ class ResourcePolicyProjector:
|
||||
resolved_tool_sources: typing.Mapping[str, typing.Any] | None = None,
|
||||
resolved_kb_uuids: collections.abc.Iterable[typing.Any] | None = None,
|
||||
resolved_skill_names: collections.abc.Iterable[typing.Any] | None = None,
|
||||
allowed_platform_tool_names: collections.abc.Iterable[typing.Any] | None = None,
|
||||
allowed_host_tool_names: collections.abc.Iterable[typing.Any] | None = None,
|
||||
override_runner_tools: bool = False,
|
||||
) -> ResourcePolicy:
|
||||
"""Project standard resource fields without depending on a runner ID.
|
||||
|
||||
@@ -33,7 +36,10 @@ class ResourcePolicyProjector:
|
||||
selected_tool_names = cls.normalize_names(config.get('tools'))
|
||||
enable_all_tools = config.get('enable-all-tools', True) is True
|
||||
|
||||
if resolved_tool_names is not None:
|
||||
if override_runner_tools:
|
||||
allowed_tool_names = cls.normalize_names(allowed_host_tool_names)
|
||||
allow_all_tools = False
|
||||
elif resolved_tool_names is not None:
|
||||
available_tool_names = cls.normalize_names(resolved_tool_names)
|
||||
if enable_all_tools:
|
||||
allowed_tool_names = available_tool_names
|
||||
@@ -64,6 +70,7 @@ class ResourcePolicyProjector:
|
||||
allowed_model_uuids=cls.normalize_optional_names(resolved_model_uuids),
|
||||
allowed_tool_names=allowed_tool_names,
|
||||
allowed_tool_sources=allowed_tool_sources,
|
||||
allowed_platform_tool_names=cls.normalize_names(allowed_platform_tool_names),
|
||||
allow_all_tools=allow_all_tools,
|
||||
allowed_kb_uuids=allowed_kb_uuids,
|
||||
allowed_skill_names=cls.normalize_optional_names(resolved_skill_names),
|
||||
|
||||
@@ -47,6 +47,7 @@ class RunAuthorizationSnapshot(typing.TypedDict):
|
||||
thread_id: str | None
|
||||
state_policy: dict[str, typing.Any]
|
||||
state_context: dict[str, typing.Any]
|
||||
platform_context: dict[str, typing.Any]
|
||||
authorized_ids: dict[str, set[str]]
|
||||
authorized_operations: dict[str, dict[str, set[str]]]
|
||||
|
||||
@@ -113,6 +114,7 @@ class AgentRunSessionRegistry:
|
||||
state_policy: dict[str, typing.Any] | None = None,
|
||||
state_context: dict[str, typing.Any] | None = None,
|
||||
execution_query: pipeline_query.Query | None = None,
|
||||
platform_context: dict[str, typing.Any] | None = None,
|
||||
) -> None:
|
||||
"""Register a new agent run session.
|
||||
|
||||
@@ -155,6 +157,7 @@ class AgentRunSessionRegistry:
|
||||
'thread_id': thread_id,
|
||||
'state_policy': copy.deepcopy(state_policy),
|
||||
'state_context': copy.deepcopy(state_context),
|
||||
'platform_context': copy.deepcopy(platform_context or {}),
|
||||
'authorized_ids': self._build_authorized_ids(resources_snapshot),
|
||||
'authorized_operations': self._build_authorized_operations(resources_snapshot),
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ from ....agent.runner.host_models import (
|
||||
StatePolicy,
|
||||
)
|
||||
from ....agent.runner.resource_policy import ResourcePolicyProjector
|
||||
from ....agent.runner.platform_tools import (
|
||||
platform_tool_catalog,
|
||||
resolve_agent_platform_tool_names,
|
||||
)
|
||||
from ....entity.persistence import agent as persistence_agent
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ..context import ExecutionContext, RequestContext
|
||||
@@ -49,8 +53,21 @@ class AgentService:
|
||||
"""Return metadata needed by Agent forms."""
|
||||
pipeline_metadata = await self.ap.pipeline_service.get_pipeline_metadata(context)
|
||||
ai_metadata = next((item for item in pipeline_metadata if item.get('name') == 'ai'), None)
|
||||
host_tools: list[dict[str, typing.Any]] | None = None
|
||||
get_tool_catalog = getattr(getattr(self.ap, 'tool_mgr', None), 'get_resolved_tool_catalog', None)
|
||||
if get_tool_catalog is not None:
|
||||
try:
|
||||
host_tools = await get_tool_catalog(
|
||||
context,
|
||||
include_skill_authoring=True,
|
||||
include_mcp_resource_tools=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(f'Failed to load Agent Host tool catalog: {exc}')
|
||||
return {
|
||||
'runner_config': ai_metadata,
|
||||
'platform_tools': platform_tool_catalog(),
|
||||
'host_tools': host_tools,
|
||||
'kinds': [
|
||||
{
|
||||
'name': AGENT_KIND_AGENT,
|
||||
@@ -192,7 +209,12 @@ class AgentService:
|
||||
event_types=[event_type],
|
||||
runner_id=runner_id,
|
||||
runner_config=runner_config,
|
||||
resource_policy=ResourcePolicyProjector.from_runner_config(runner_config),
|
||||
resource_policy=ResourcePolicyProjector.from_runner_config(
|
||||
runner_config,
|
||||
allowed_platform_tool_names=resolve_agent_platform_tool_names(config, event_type),
|
||||
allowed_host_tool_names=config.get('allowed_tools'),
|
||||
override_runner_tools='allowed_tools' in config,
|
||||
),
|
||||
state_policy=StatePolicy(
|
||||
state_scopes=['conversation', 'actor', 'subject', 'runner'],
|
||||
),
|
||||
@@ -292,7 +314,11 @@ class AgentService:
|
||||
'kind': AGENT_KIND_AGENT,
|
||||
'component_ref': runner_id,
|
||||
'config': config,
|
||||
'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
|
||||
'supported_event_patterns': (
|
||||
agent_data['supported_event_patterns']
|
||||
if 'supported_event_patterns' in agent_data
|
||||
else AGENT_DEFAULT_EVENT_PATTERNS
|
||||
),
|
||||
}
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values))
|
||||
return {'uuid': new_uuid, 'kind': AGENT_KIND_AGENT}
|
||||
@@ -317,9 +343,6 @@ class AgentService:
|
||||
else:
|
||||
_, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(existing_agent.config)
|
||||
update_data['component_ref'] = runner_id
|
||||
if 'supported_event_patterns' in update_data and not update_data['supported_event_patterns']:
|
||||
update_data['supported_event_patterns'] = AGENT_DEFAULT_EVENT_PATTERNS
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_agent.Agent)
|
||||
@@ -404,8 +427,11 @@ class AgentService:
|
||||
) -> dict[str, typing.Any]:
|
||||
item = self.ap.persistence_mgr.serialize_model(persistence_agent.Agent, agent)
|
||||
item['kind'] = AGENT_KIND_AGENT
|
||||
supported_event_patterns = item.get('supported_event_patterns')
|
||||
item['capability'] = {
|
||||
'supported_event_patterns': item.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
|
||||
'supported_event_patterns': (
|
||||
supported_event_patterns if isinstance(supported_event_patterns, list) else AGENT_DEFAULT_EVENT_PATTERNS
|
||||
),
|
||||
'message_only': False,
|
||||
}
|
||||
if not include_config:
|
||||
|
||||
@@ -76,7 +76,7 @@ class BotService:
|
||||
|
||||
@classmethod
|
||||
def _agent_supports_event_pattern(cls, supported_patterns: list[str] | None, event_pattern: str) -> bool:
|
||||
patterns = supported_patterns or ['*']
|
||||
patterns = supported_patterns if isinstance(supported_patterns, list) else ['*']
|
||||
return any(cls._event_pattern_covers(pattern, event_pattern) for pattern in patterns)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -27,6 +27,7 @@ from ..agent.runner.host_models import (
|
||||
StatePolicy,
|
||||
)
|
||||
from ..agent.runner.resource_policy import ResourcePolicyProjector
|
||||
from ..agent.runner.platform_tools import resolve_agent_platform_tool_names
|
||||
from ..entity.persistence import workspace as persistence_workspace
|
||||
|
||||
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
|
||||
@@ -165,7 +166,8 @@ class RuntimeBot:
|
||||
supported_patterns: list[str] | None,
|
||||
event_type: str,
|
||||
) -> bool:
|
||||
return any(cls._match_event_pattern(event_type, pattern) for pattern in (supported_patterns or ['*']))
|
||||
patterns = supported_patterns if isinstance(supported_patterns, list) else ['*']
|
||||
return any(cls._match_event_pattern(event_type, pattern) for pattern in patterns)
|
||||
|
||||
@staticmethod
|
||||
def _get_nested_value(data: dict[str, typing.Any], path: str) -> typing.Any:
|
||||
@@ -808,7 +810,12 @@ class RuntimeBot:
|
||||
event_types=[event_type],
|
||||
runner_id=runner_id,
|
||||
runner_config=runner_config,
|
||||
resource_policy=ResourcePolicyProjector.from_runner_config(runner_config),
|
||||
resource_policy=ResourcePolicyProjector.from_runner_config(
|
||||
runner_config,
|
||||
allowed_platform_tool_names=resolve_agent_platform_tool_names(config, event_type),
|
||||
allowed_host_tool_names=config.get('allowed_tools'),
|
||||
override_runner_tools='allowed_tools' in config,
|
||||
),
|
||||
state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']),
|
||||
delivery_policy=DeliveryPolicy(
|
||||
enable_streaming=False,
|
||||
|
||||
@@ -52,6 +52,7 @@ from ..utils import constants
|
||||
from ..agent.runner.session_registry import get_session_registry
|
||||
from ..agent.runner.config_resolver import RunnerConfigResolver
|
||||
from ..agent.runner import config_schema
|
||||
from ..agent.runner.platform_tools import execute_platform_tool, get_platform_tool_detail
|
||||
from ..pipeline.pool import get_query_execution_context
|
||||
|
||||
|
||||
@@ -302,6 +303,8 @@ def _validate_frozen_tool_source_identity(
|
||||
MCP_TOOL_READ_RESOURCE,
|
||||
}:
|
||||
source_ref = {'source': source, 'source_id': source_id}
|
||||
elif source == 'platform' and source_id == tool_name:
|
||||
source_ref = {'source': source, 'source_id': source_id}
|
||||
|
||||
if source_ref is not None:
|
||||
return source_ref, None
|
||||
@@ -1547,6 +1550,15 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
# In real implementation, you would reconstruct the full session
|
||||
# For now, we'll call the tool manager's execute method
|
||||
try:
|
||||
if source_ref is not None and source_ref['source'] == 'platform':
|
||||
result = await execute_platform_tool(
|
||||
self.ap,
|
||||
self._execution_context(action_context),
|
||||
session,
|
||||
tool_name,
|
||||
parameters,
|
||||
)
|
||||
return handler.ActionResponse.success(data={'result': _serialize_plugin_api_result(result)})
|
||||
query = _resolve_action_query(
|
||||
data,
|
||||
session,
|
||||
@@ -1602,6 +1614,13 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
return error
|
||||
|
||||
try:
|
||||
if source_ref is not None and source_ref['source'] == 'platform':
|
||||
tool_detail = get_platform_tool_detail(session, tool_name)
|
||||
if tool_detail is None:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Tool {tool_name} not found',
|
||||
)
|
||||
return handler.ActionResponse.success(data={'tool': tool_detail})
|
||||
detail_kwargs: dict[str, Any] = {}
|
||||
if source_ref is not None:
|
||||
detail_kwargs['source_ref'] = source_ref
|
||||
|
||||
Reference in New Issue
Block a user