mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-27 02:45:24 +08:00
feat(agent-runner): enforce 4.x host-owned execution
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
"""Host-only Query compatibility views for AgentRunner tool execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
from langbot_plugin.api.entities.builtin.provider import session as provider_session
|
||||
|
||||
from ...utils import constants
|
||||
from .host_models import AgentEventEnvelope
|
||||
|
||||
|
||||
HOST_BOX_SCOPE_VARIABLE = '_host_box_scope'
|
||||
AUTHORIZED_SKILLS_VARIABLE = '_pipeline_bound_skills'
|
||||
MCP_RESOURCE_ATTACHMENTS_VARIABLE = '_pipeline_mcp_resource_attachments'
|
||||
MCP_RESOURCE_AGENT_READ_ENABLED_VARIABLE = '_pipeline_mcp_resource_agent_read_enabled'
|
||||
|
||||
|
||||
def project_mcp_resource_config(
|
||||
query: pipeline_query.Query,
|
||||
runner_config: dict[str, typing.Any],
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Attach standard MCP resource settings to a Host execution Query."""
|
||||
variables = getattr(query, 'variables', None)
|
||||
if not isinstance(variables, dict):
|
||||
variables = {}
|
||||
query.variables = variables
|
||||
|
||||
attachments = runner_config.get('mcp-resources', [])
|
||||
variables.setdefault(
|
||||
MCP_RESOURCE_ATTACHMENTS_VARIABLE,
|
||||
list(attachments) if isinstance(attachments, list) else [],
|
||||
)
|
||||
variables.setdefault(
|
||||
MCP_RESOURCE_AGENT_READ_ENABLED_VARIABLE,
|
||||
runner_config.get('mcp-resource-agent-read-enabled', True) is True,
|
||||
)
|
||||
return variables
|
||||
|
||||
|
||||
async def build_mcp_resource_context_addition(
|
||||
ap: typing.Any,
|
||||
query: pipeline_query.Query,
|
||||
) -> str:
|
||||
"""Build model-facing MCP context without mutating the canonical input."""
|
||||
tool_mgr = getattr(ap, 'tool_mgr', None)
|
||||
if tool_mgr is None:
|
||||
return ''
|
||||
mcp_loader = getattr(tool_mgr, '__dict__', {}).get('mcp_tool_loader')
|
||||
if mcp_loader is None:
|
||||
return ''
|
||||
|
||||
resource_context = await mcp_loader.build_resource_context_for_query(query)
|
||||
if not resource_context:
|
||||
return ''
|
||||
|
||||
addition = (
|
||||
'\n\nMCP resource context selected by LangBot host:\n'
|
||||
f'{resource_context}\n\n'
|
||||
'Use this context as read-only reference material. If it conflicts with the user message, '
|
||||
'ask for clarification before taking external actions.'
|
||||
)
|
||||
return addition
|
||||
|
||||
|
||||
def append_mcp_resource_context_to_event(event: AgentEventEnvelope, addition: str) -> None:
|
||||
"""Append pinned context only to the run-scoped execution input."""
|
||||
if not addition:
|
||||
return
|
||||
has_text = event.input.text is not None
|
||||
has_structured_content = bool(event.input.contents)
|
||||
if has_text:
|
||||
event.input.text += addition
|
||||
if has_structured_content or not has_text:
|
||||
if not _append_text_to_content(event.input.contents, addition):
|
||||
event.input.contents.append(provider_message.ContentElement.from_text(addition.strip()))
|
||||
|
||||
|
||||
def _append_text_to_content(content: typing.Any, addition: str) -> bool:
|
||||
if isinstance(content, str):
|
||||
return False
|
||||
if not isinstance(content, list):
|
||||
return False
|
||||
for content_element in content:
|
||||
if getattr(content_element, 'type', None) == 'text':
|
||||
content_element.text = (content_element.text or '') + addition
|
||||
return True
|
||||
content.append(provider_message.ContentElement.from_text(addition.strip()))
|
||||
return True
|
||||
|
||||
|
||||
def prepare_execution_query(
|
||||
query: pipeline_query.Query,
|
||||
event: AgentEventEnvelope,
|
||||
authorized_skill_names: list[str],
|
||||
) -> pipeline_query.Query:
|
||||
"""Attach Host-owned execution metadata without changing Query identity."""
|
||||
variables = prepare_box_scope(query, event, preserve_existing=True)
|
||||
variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names))
|
||||
return query
|
||||
|
||||
|
||||
def prepare_box_scope(
|
||||
query: pipeline_query.Query,
|
||||
event: AgentEventEnvelope,
|
||||
*,
|
||||
preserve_existing: bool = False,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Attach the Host Box scope before any Query-side file staging."""
|
||||
variables = getattr(query, 'variables', None)
|
||||
if not isinstance(variables, dict):
|
||||
variables = {}
|
||||
query.variables = variables
|
||||
|
||||
existing_scope = variables.get(HOST_BOX_SCOPE_VARIABLE)
|
||||
if preserve_existing and isinstance(existing_scope, str) and existing_scope.strip():
|
||||
return variables
|
||||
|
||||
variables[HOST_BOX_SCOPE_VARIABLE] = build_host_box_scope(event, query=query)
|
||||
return variables
|
||||
|
||||
|
||||
def build_execution_query(
|
||||
event: AgentEventEnvelope,
|
||||
authorized_skill_names: list[str],
|
||||
) -> pipeline_query.Query:
|
||||
"""Build the minimum Query view required by Host-owned tool loaders."""
|
||||
launcher_type, launcher_id, sender_id = _resolve_session_identity(event)
|
||||
message_chain = _build_message_chain(event)
|
||||
message_event = platform_events.MessageEvent(
|
||||
type=event.source_event_type or event.event_type,
|
||||
message_chain=message_chain,
|
||||
time=float(event.event_time) if event.event_time is not None else None,
|
||||
)
|
||||
session = provider_session.Session(
|
||||
launcher_type=launcher_type,
|
||||
launcher_id=launcher_id,
|
||||
sender_id=sender_id,
|
||||
)
|
||||
|
||||
contents = copy.deepcopy(event.input.contents)
|
||||
user_content: str | list[provider_message.ContentElement]
|
||||
if contents:
|
||||
user_content = contents
|
||||
else:
|
||||
user_content = event.input.text or ''
|
||||
|
||||
query = pipeline_query.Query(
|
||||
query_id=_synthetic_query_id(event.event_id),
|
||||
launcher_type=launcher_type,
|
||||
launcher_id=launcher_id,
|
||||
sender_id=sender_id,
|
||||
message_event=message_event,
|
||||
message_chain=message_chain,
|
||||
bot_uuid=event.bot_id,
|
||||
pipeline_uuid=None,
|
||||
pipeline_config=None,
|
||||
session=session,
|
||||
messages=[],
|
||||
user_message=provider_message.Message(role='user', content=user_content),
|
||||
variables={},
|
||||
resp_messages=[],
|
||||
)
|
||||
query.variables[HOST_BOX_SCOPE_VARIABLE] = build_host_box_scope(event)
|
||||
query.variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names))
|
||||
return query
|
||||
|
||||
|
||||
def build_host_box_scope(
|
||||
event: AgentEventEnvelope,
|
||||
*,
|
||||
query: pipeline_query.Query | None = None,
|
||||
) -> str | None:
|
||||
"""Return a stable Host scope for a Query session or event."""
|
||||
target_type, target_id = _resolve_box_target(event, query)
|
||||
if target_type is None or target_id is None:
|
||||
return None
|
||||
|
||||
capabilities = event.delivery.platform_capabilities or {}
|
||||
adapter_identity = None
|
||||
if query is not None:
|
||||
adapter = getattr(query, 'adapter', None)
|
||||
if adapter is not None:
|
||||
adapter_identity = adapter.__class__.__name__
|
||||
adapter_identity = _first_present(
|
||||
adapter_identity,
|
||||
capabilities.get('adapter'),
|
||||
capabilities.get('source'),
|
||||
event.source if query is None else None,
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
'instance_id': _nonempty(constants.instance_id),
|
||||
'workspace_id': _nonempty(event.workspace_id),
|
||||
'bot_id': _nonempty(event.bot_id),
|
||||
'platform_adapter': adapter_identity,
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'thread_id': _nonempty(event.thread_id),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_session_identity(
|
||||
event: AgentEventEnvelope,
|
||||
) -> tuple[provider_session.LauncherTypes, str, str]:
|
||||
subject_data = event.subject.data if event.subject is not None else {}
|
||||
reply_target = event.delivery.reply_target or {}
|
||||
|
||||
launcher_type_value = _first_present(
|
||||
reply_target.get('target_type'),
|
||||
subject_data.get('launcher_type'),
|
||||
event.data.get('launcher_type'),
|
||||
reply_target.get('launcher_type'),
|
||||
)
|
||||
if launcher_type_value == provider_session.LauncherTypes.GROUP.value:
|
||||
launcher_type_value = provider_session.LauncherTypes.GROUP.value
|
||||
else:
|
||||
launcher_type_value = provider_session.LauncherTypes.PERSON.value
|
||||
|
||||
launcher_id = _first_nonempty(
|
||||
reply_target.get('target_id'),
|
||||
subject_data.get('launcher_id'),
|
||||
event.data.get('launcher_id'),
|
||||
reply_target.get('launcher_id'),
|
||||
event.conversation_id,
|
||||
event.subject.subject_id if event.subject is not None else None,
|
||||
event.actor.actor_id if event.actor is not None else None,
|
||||
event.event_id,
|
||||
)
|
||||
sender_id = _first_nonempty(
|
||||
subject_data.get('sender_id'),
|
||||
event.data.get('sender_id'),
|
||||
event.actor.actor_id if event.actor is not None else None,
|
||||
launcher_id,
|
||||
)
|
||||
|
||||
return provider_session.LauncherTypes(launcher_type_value), launcher_id, sender_id
|
||||
|
||||
|
||||
def _resolve_box_target(
|
||||
event: AgentEventEnvelope,
|
||||
query: pipeline_query.Query | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
if query is not None:
|
||||
launcher_type = getattr(query, 'launcher_type', None)
|
||||
if hasattr(launcher_type, 'value'):
|
||||
launcher_type = launcher_type.value
|
||||
launcher_id = getattr(query, 'launcher_id', None)
|
||||
normalized_type = _nonempty(launcher_type)
|
||||
normalized_id = _nonempty(launcher_id)
|
||||
if normalized_type is not None and normalized_id is not None:
|
||||
return normalized_type, normalized_id
|
||||
|
||||
reply_target = event.delivery.reply_target or {}
|
||||
target_type = _first_present(
|
||||
reply_target.get('target_type'),
|
||||
reply_target.get('launcher_type'),
|
||||
)
|
||||
target_id = _first_present(
|
||||
reply_target.get('target_id'),
|
||||
reply_target.get('launcher_id'),
|
||||
)
|
||||
if target_type is not None and target_id is not None:
|
||||
return target_type, target_id
|
||||
|
||||
conversation_id = _nonempty(event.conversation_id)
|
||||
if conversation_id is not None:
|
||||
return 'conversation', conversation_id
|
||||
|
||||
event_id = _nonempty(event.event_id)
|
||||
if event_id is not None:
|
||||
return 'event', event_id
|
||||
return None, None
|
||||
|
||||
|
||||
def _build_message_chain(event: AgentEventEnvelope) -> platform_message.MessageChain:
|
||||
text = event.input.to_text()
|
||||
if not text:
|
||||
return platform_message.MessageChain([])
|
||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||
|
||||
|
||||
def _synthetic_query_id(event_id: str) -> int:
|
||||
digest = hashlib.sha256(event_id.encode('utf-8')).hexdigest()
|
||||
return int(digest[:15], 16) or 1
|
||||
|
||||
|
||||
def _first_nonempty(*values: typing.Any) -> str:
|
||||
normalized = _first_present(*values)
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
raise ValueError('Agent event does not contain a usable execution identity')
|
||||
|
||||
|
||||
def _first_present(*values: typing.Any) -> str | None:
|
||||
for value in values:
|
||||
normalized = _nonempty(value)
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def _nonempty(value: typing.Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
return normalized or None
|
||||
Reference in New Issue
Block a user