mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-24 21:36:06 +00:00
feat(agent-runner): enforce 4.x host-owned execution
This commit is contained in:
@@ -16,7 +16,7 @@ from .context_builder import AgentRunContextBuilder
|
||||
from .resource_builder import AgentResourceBuilder
|
||||
from .result_normalizer import AgentResultNormalizer
|
||||
from .orchestrator import AgentRunOrchestrator
|
||||
from .config_migration import ConfigMigration
|
||||
from .config_resolver import RunnerConfigResolver
|
||||
from .default_config import AgentRunnerDefaultConfigService
|
||||
from .binding_resolver import AgentBindingResolver, AgentBindingResolutionError
|
||||
from .session_registry import (
|
||||
@@ -49,7 +49,7 @@ __all__ = [
|
||||
'AgentResourceBuilder',
|
||||
'AgentResultNormalizer',
|
||||
'AgentRunOrchestrator',
|
||||
'ConfigMigration',
|
||||
'RunnerConfigResolver',
|
||||
'AgentRunnerDefaultConfigService',
|
||||
'AgentBindingResolver',
|
||||
'AgentBindingResolutionError',
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Helpers for the current AgentRunner config shape."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
|
||||
class ConfigMigration:
|
||||
"""Configuration helpers for the current AgentRunner shape.
|
||||
|
||||
Responsibilities:
|
||||
- Resolve runner ID from ai.runner.id
|
||||
- Extract Agent/runner config from ai.runner_config
|
||||
- Read current conversation expiry settings
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def resolve_runner_id(pipeline_config: dict[str, typing.Any]) -> str | None:
|
||||
"""Resolve runner ID from current configuration.
|
||||
|
||||
Args:
|
||||
pipeline_config: Current configuration container
|
||||
|
||||
Returns:
|
||||
Runner ID string, or None if not configured
|
||||
"""
|
||||
ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {}
|
||||
runner = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {}
|
||||
runner_id = runner.get('id') if isinstance(runner, dict) else None
|
||||
return runner_id if isinstance(runner_id, str) and runner_id else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_runner_config(
|
||||
pipeline_config: dict[str, typing.Any],
|
||||
runner_id: str,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Resolve Agent/runner configuration from the current container.
|
||||
|
||||
Args:
|
||||
pipeline_config: Current configuration container
|
||||
runner_id: Resolved runner ID
|
||||
|
||||
Returns:
|
||||
Runner configuration dict (empty if not found)
|
||||
"""
|
||||
ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {}
|
||||
runner_configs = ai_config.get('runner_config', {}) if isinstance(ai_config, dict) else {}
|
||||
runner_config = runner_configs.get(runner_id, {}) if isinstance(runner_configs, dict) else {}
|
||||
return runner_config if isinstance(runner_config, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def get_expire_time(pipeline_config: dict[str, typing.Any]) -> int:
|
||||
"""Get conversation expire time from configuration.
|
||||
|
||||
Args:
|
||||
pipeline_config: Current configuration container
|
||||
|
||||
Returns:
|
||||
Expire time in seconds (0 means no expiry)
|
||||
"""
|
||||
ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {}
|
||||
runner = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {}
|
||||
expire_time = runner.get('expire-time', 0) if isinstance(runner, dict) else 0
|
||||
return expire_time if isinstance(expire_time, int) else 0
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Resolve the current AgentRunner configuration shape."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
|
||||
HOST_SECURITY_BOOLEAN_FIELDS = (
|
||||
'enable-all-tools',
|
||||
'mcp-resource-agent-read-enabled',
|
||||
)
|
||||
|
||||
|
||||
class RunnerConfigResolver:
|
||||
"""Configuration helpers for the current AgentRunner shape.
|
||||
|
||||
Responsibilities:
|
||||
- Resolve runner ID from ai.runner.id
|
||||
- Extract Agent/runner config from ai.runner_config
|
||||
- Read current conversation expiry settings
|
||||
- Validate the persisted 4.x Agent binding container
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def validate_agent_config(config: typing.Any) -> dict[str, typing.Any]:
|
||||
"""Validate and return the persisted 4.x Agent config container."""
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError('Agent config must be an object')
|
||||
|
||||
runner = config.get('runner')
|
||||
if not isinstance(runner, dict):
|
||||
raise ValueError("Agent config field 'runner' must be an object")
|
||||
|
||||
runner_id = runner.get('id')
|
||||
if not isinstance(runner_id, str):
|
||||
raise ValueError("Agent config field 'runner.id' must be a string")
|
||||
|
||||
runner_configs = config.get('runner_config')
|
||||
if not isinstance(runner_configs, dict):
|
||||
raise ValueError("Agent config field 'runner_config' must be an object")
|
||||
|
||||
for configured_runner_id, runner_config in runner_configs.items():
|
||||
if not isinstance(configured_runner_id, str) or not configured_runner_id:
|
||||
raise ValueError("Agent config field 'runner_config' must use non-empty string runner IDs")
|
||||
if not isinstance(runner_config, dict):
|
||||
raise ValueError(f'Agent runner_config[{configured_runner_id!r}] must be an object')
|
||||
|
||||
if runner_id and runner_id not in runner_configs:
|
||||
raise ValueError(f'Agent runner_config is missing selected runner {runner_id!r}')
|
||||
|
||||
if runner_id:
|
||||
RunnerConfigResolver.validate_runner_security_fields(
|
||||
runner_configs[runner_id],
|
||||
context=f'Agent runner_config[{runner_id!r}]',
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def validate_runner_security_fields(
|
||||
runner_config: dict[str, typing.Any],
|
||||
*,
|
||||
context: str = 'Runner config',
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Reject malformed Host-owned security toggles instead of enabling them."""
|
||||
for field_name in HOST_SECURITY_BOOLEAN_FIELDS:
|
||||
if field_name in runner_config and not isinstance(runner_config[field_name], bool):
|
||||
raise ValueError(f'{context} field {field_name!r} must be a boolean')
|
||||
|
||||
RunnerConfigResolver.validate_mcp_resource_attachments(
|
||||
runner_config.get('mcp-resources'),
|
||||
context=context,
|
||||
field_name='mcp-resources',
|
||||
)
|
||||
return runner_config
|
||||
|
||||
@staticmethod
|
||||
def validate_mcp_resource_attachments(
|
||||
resources: typing.Any,
|
||||
*,
|
||||
context: str,
|
||||
field_name: str,
|
||||
) -> typing.Any:
|
||||
"""Validate explicit attachment enable flags shared by Agent and Pipeline inputs."""
|
||||
if not isinstance(resources, list):
|
||||
return resources
|
||||
for index, resource in enumerate(resources):
|
||||
if not isinstance(resource, dict) or 'enabled' not in resource:
|
||||
continue
|
||||
if not isinstance(resource['enabled'], bool):
|
||||
raise ValueError(f"{context} field '{field_name}[{index}].enabled' must be a boolean")
|
||||
return resources
|
||||
|
||||
@classmethod
|
||||
def validate_pipeline_config(cls, config: typing.Any) -> dict[str, typing.Any]:
|
||||
"""Validate the selected Runner container in a current 4.x Pipeline config."""
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError('Pipeline config must be an object')
|
||||
|
||||
ai_config = config.get('ai')
|
||||
if ai_config is None:
|
||||
return config
|
||||
if not isinstance(ai_config, dict):
|
||||
raise ValueError("Pipeline config field 'ai' must be an object")
|
||||
|
||||
runner = ai_config.get('runner')
|
||||
if runner is None:
|
||||
return config
|
||||
if not isinstance(runner, dict):
|
||||
raise ValueError("Pipeline config field 'ai.runner' must be an object")
|
||||
|
||||
runner_id = runner.get('id')
|
||||
if not isinstance(runner_id, str):
|
||||
raise ValueError("Pipeline config field 'ai.runner.id' must be a string")
|
||||
if not runner_id:
|
||||
return config
|
||||
|
||||
runner_configs = ai_config.get('runner_config')
|
||||
if not isinstance(runner_configs, dict):
|
||||
raise ValueError("Pipeline config field 'ai.runner_config' must be an object")
|
||||
if runner_id not in runner_configs:
|
||||
raise ValueError(f'Pipeline runner_config is missing selected runner {runner_id!r}')
|
||||
|
||||
selected_config = runner_configs[runner_id]
|
||||
if not isinstance(selected_config, dict):
|
||||
raise ValueError(f'Pipeline runner_config[{runner_id!r}] must be an object')
|
||||
cls.validate_runner_security_fields(
|
||||
selected_config,
|
||||
context=f'Pipeline runner_config[{runner_id!r}]',
|
||||
)
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def resolve_agent_runner_id(config: dict[str, typing.Any]) -> str | None:
|
||||
"""Resolve a runner ID from a validated persisted Agent config."""
|
||||
runner = config.get('runner', {})
|
||||
runner_id = runner.get('id') if isinstance(runner, dict) else None
|
||||
return runner_id if isinstance(runner_id, str) and runner_id else None
|
||||
|
||||
@classmethod
|
||||
def resolve_agent_runner_config(
|
||||
cls,
|
||||
config: typing.Any,
|
||||
) -> tuple[dict[str, typing.Any], str | None, dict[str, typing.Any]]:
|
||||
"""Validate an Agent config and return its selected runner configuration."""
|
||||
validated = cls.validate_agent_config(config)
|
||||
runner_id = cls.resolve_agent_runner_id(validated)
|
||||
runner_configs = typing.cast(dict[str, typing.Any], validated['runner_config'])
|
||||
runner_config = runner_configs.get(runner_id, {}) if runner_id else {}
|
||||
return validated, runner_id, typing.cast(dict[str, typing.Any], runner_config)
|
||||
|
||||
@staticmethod
|
||||
def resolve_runner_id(pipeline_config: dict[str, typing.Any]) -> str | None:
|
||||
"""Resolve runner ID from current configuration.
|
||||
|
||||
Args:
|
||||
pipeline_config: Current configuration container
|
||||
|
||||
Returns:
|
||||
Runner ID string, or None if not configured
|
||||
"""
|
||||
ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {}
|
||||
runner = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {}
|
||||
runner_id = runner.get('id') if isinstance(runner, dict) else None
|
||||
return runner_id if isinstance(runner_id, str) and runner_id else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_runner_config(
|
||||
pipeline_config: dict[str, typing.Any],
|
||||
runner_id: str,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Resolve Agent/runner configuration from the current container.
|
||||
|
||||
Args:
|
||||
pipeline_config: Current configuration container
|
||||
runner_id: Resolved runner ID
|
||||
|
||||
Returns:
|
||||
Runner configuration dict (empty if not found)
|
||||
"""
|
||||
ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {}
|
||||
runner_configs = ai_config.get('runner_config', {}) if isinstance(ai_config, dict) else {}
|
||||
runner_config = runner_configs.get(runner_id, {}) if isinstance(runner_configs, dict) else {}
|
||||
return runner_config if isinstance(runner_config, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def get_expire_time(pipeline_config: dict[str, typing.Any]) -> int:
|
||||
"""Get conversation expire time from configuration.
|
||||
|
||||
Args:
|
||||
pipeline_config: Current configuration container
|
||||
|
||||
Returns:
|
||||
Expire time in seconds (0 means no expiry)
|
||||
"""
|
||||
ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {}
|
||||
runner = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {}
|
||||
expire_time = runner.get('expire-time', 0) if isinstance(runner, dict) else 0
|
||||
return expire_time if isinstance(expire_time, int) else 0
|
||||
@@ -75,6 +75,9 @@ class ToolResource(typing.TypedDict):
|
||||
tool_type: str | None
|
||||
description: str | None
|
||||
operations: list[str]
|
||||
parameters: dict[str, typing.Any] | None
|
||||
source: typing.NotRequired[str]
|
||||
source_id: typing.NotRequired[str | None]
|
||||
|
||||
|
||||
class KnowledgeBaseResource(typing.TypedDict):
|
||||
|
||||
@@ -7,7 +7,7 @@ import sqlalchemy
|
||||
from ...core import app
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
from . import config_schema
|
||||
from .config_migration import ConfigMigration
|
||||
from .config_resolver import RunnerConfigResolver
|
||||
|
||||
|
||||
class AgentRunnerDefaultConfigService:
|
||||
@@ -53,7 +53,7 @@ class AgentRunnerDefaultConfigService:
|
||||
if not isinstance(pipeline_config, dict):
|
||||
return False
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
|
||||
if not runner_id:
|
||||
return False
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
These are Host-internal models, not exposed to SDK.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
@@ -73,7 +74,7 @@ class AgentEventEnvelope(pydantic.BaseModel):
|
||||
class BindingScope(pydantic.BaseModel):
|
||||
"""Scope for agent binding."""
|
||||
|
||||
scope_type: typing.Literal["agent", "bot", "workspace", "global"] = "agent"
|
||||
scope_type: typing.Literal['agent', 'bot', 'workspace', 'global'] = 'agent'
|
||||
"""Scope type."""
|
||||
|
||||
scope_id: str | None = None
|
||||
@@ -92,6 +93,12 @@ class ResourcePolicy(pydantic.BaseModel):
|
||||
allowed_tool_names: list[str] | None = None
|
||||
"""Additional tool name grants. None means no additional tool grants."""
|
||||
|
||||
allowed_tool_sources: dict[str, dict[str, str | None]] | None = None
|
||||
"""Host-resolved implementation identity for each allowed tool name."""
|
||||
|
||||
allow_all_tools: bool = False
|
||||
"""Whether all tools visible to the current Host scope are granted."""
|
||||
|
||||
allowed_kb_uuids: list[str] | None = None
|
||||
"""Additional knowledge base UUID grants. None means no additional KB grants."""
|
||||
|
||||
@@ -114,8 +121,8 @@ class StatePolicy(pydantic.BaseModel):
|
||||
enable_state: bool = True
|
||||
"""Whether host-owned state is enabled."""
|
||||
|
||||
state_scopes: list[typing.Literal["conversation", "actor", "subject", "runner"]] = (
|
||||
pydantic.Field(default_factory=lambda: ["conversation", "actor"])
|
||||
state_scopes: list[typing.Literal['conversation', 'actor', 'subject', 'runner']] = pydantic.Field(
|
||||
default_factory=lambda: ['conversation', 'actor']
|
||||
)
|
||||
"""Enabled state scopes."""
|
||||
|
||||
@@ -162,7 +169,7 @@ class AgentConfig(pydantic.BaseModel):
|
||||
delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy)
|
||||
"""Delivery policy for this Agent."""
|
||||
|
||||
event_types: list[str] = pydantic.Field(default_factory=lambda: ["message.received"])
|
||||
event_types: list[str] = pydantic.Field(default_factory=lambda: ['message.received'])
|
||||
"""Event types this Agent handles."""
|
||||
|
||||
enabled: bool = True
|
||||
@@ -185,7 +192,7 @@ class AgentBinding(pydantic.BaseModel):
|
||||
scope: BindingScope = pydantic.Field(default_factory=BindingScope)
|
||||
"""Binding scope."""
|
||||
|
||||
event_types: list[str] = pydantic.Field(default_factory=lambda: ["message.received"])
|
||||
event_types: list[str] = pydantic.Field(default_factory=lambda: ['message.received'])
|
||||
"""Event types this binding handles."""
|
||||
|
||||
runner_id: str
|
||||
|
||||
@@ -12,6 +12,14 @@ from ...core import app
|
||||
from .binding_resolver import AgentBindingResolver
|
||||
from .context_builder import AgentRunContextBuilder, AgentRunContextPayload
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .execution_context import (
|
||||
append_mcp_resource_context_to_event,
|
||||
build_mcp_resource_context_addition,
|
||||
build_execution_query,
|
||||
prepare_box_scope,
|
||||
prepare_execution_query,
|
||||
project_mcp_resource_config,
|
||||
)
|
||||
from .host_models import AgentBinding, AgentEventEnvelope
|
||||
from .invoker import AgentRunnerInvoker
|
||||
from .query_bridge import QueryRunBridge
|
||||
@@ -73,6 +81,17 @@ class AgentRunOrchestrator:
|
||||
runner_id = binding.runner_id
|
||||
descriptor = await self.registry.get(runner_id, bound_plugins)
|
||||
|
||||
execution_query = adapter_context.get('_query') if adapter_context else None
|
||||
if execution_query is None:
|
||||
execution_query = build_execution_query(event, [])
|
||||
project_mcp_resource_config(execution_query, binding.runner_config)
|
||||
|
||||
execution_event = event
|
||||
resource_addition = await build_mcp_resource_context_addition(self.ap, execution_query)
|
||||
if resource_addition:
|
||||
execution_event = event.model_copy(deep=True)
|
||||
append_mcp_resource_context_to_event(execution_event, resource_addition)
|
||||
|
||||
resources = await self.resource_builder.build_resources_from_binding(
|
||||
event=event,
|
||||
binding=binding,
|
||||
@@ -80,7 +99,7 @@ class AgentRunOrchestrator:
|
||||
)
|
||||
|
||||
context = await self.context_builder.build_context_from_event(
|
||||
event=event,
|
||||
event=execution_event,
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
resources=resources,
|
||||
@@ -88,19 +107,23 @@ class AgentRunOrchestrator:
|
||||
|
||||
session_query_id = None
|
||||
if adapter_context:
|
||||
query = adapter_context.get('_query')
|
||||
if query is not None:
|
||||
if execution_query is not None:
|
||||
skill_loader.restore_activated_skills_from_state(
|
||||
self.ap,
|
||||
query,
|
||||
execution_query,
|
||||
context.get('state', {}),
|
||||
)
|
||||
session_query_id = adapter_context.get('query_id')
|
||||
if query is not None or session_query_id is not None:
|
||||
if execution_query is not None or session_query_id is not None:
|
||||
context['context']['available_apis']['prompt_get'] = True
|
||||
if 'params' in adapter_context:
|
||||
context['adapter']['extra']['params'] = adapter_context['params']
|
||||
|
||||
authorized_skill_names = [
|
||||
str(skill['skill_name']) for skill in resources.get('skills', []) if skill.get('skill_name')
|
||||
]
|
||||
prepare_execution_query(execution_query, event, authorized_skill_names)
|
||||
|
||||
state_context = build_state_context(event, binding, descriptor)
|
||||
run_id = context['run_id']
|
||||
available_apis = context.get('context', {}).get('available_apis')
|
||||
@@ -152,6 +175,7 @@ class AgentRunOrchestrator:
|
||||
'state_scopes': list(binding.state_policy.state_scopes),
|
||||
},
|
||||
state_context=state_context,
|
||||
execution_query=execution_query,
|
||||
)
|
||||
|
||||
event_log_id = await self.journal.write_event_log(
|
||||
@@ -309,6 +333,9 @@ class AgentRunOrchestrator:
|
||||
adapter_context = dict(plan.adapter_context)
|
||||
adapter_context['_query'] = query
|
||||
|
||||
# Inbound files and subsequent runner tools must share one Host scope.
|
||||
prepare_box_scope(query, plan.event)
|
||||
|
||||
# Materialize inbound attachments into sandbox before running
|
||||
await self._materialize_inbound_attachments(query, plan.event)
|
||||
|
||||
@@ -392,7 +419,13 @@ class AgentRunOrchestrator:
|
||||
if target_run_id is None:
|
||||
return False
|
||||
|
||||
steering_item = self._build_steering_item(event, target_run_id, descriptor.id)
|
||||
execution_event = event
|
||||
resource_addition = await build_mcp_resource_context_addition(self.ap, query)
|
||||
if resource_addition:
|
||||
execution_event = event.model_copy(deep=True)
|
||||
append_mcp_resource_context_to_event(execution_event, resource_addition)
|
||||
|
||||
steering_item = self._build_steering_item(execution_event, target_run_id, descriptor.id)
|
||||
if not await self._session_registry.enqueue_steering(target_run_id, steering_item):
|
||||
return False
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import typing
|
||||
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
||||
|
||||
from .binding_resolver import AgentBindingResolver
|
||||
from .config_migration import ConfigMigration
|
||||
from .config_resolver import RunnerConfigResolver
|
||||
from .errors import RunnerNotFoundError
|
||||
from .host_models import AgentBinding, AgentEventEnvelope
|
||||
from .query_entry_adapter import QueryEntryAdapter
|
||||
@@ -34,7 +34,7 @@ class QueryRunBridge:
|
||||
|
||||
def build_plan(self, query: pipeline_query.Query) -> QueryRunPlan:
|
||||
"""Build an event-first run plan from a Pipeline Query."""
|
||||
runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(query.pipeline_config)
|
||||
if not runner_id:
|
||||
raise RunnerNotFoundError('no runner configured')
|
||||
|
||||
@@ -53,4 +53,4 @@ class QueryRunBridge:
|
||||
|
||||
def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None:
|
||||
"""Resolve runner ID for telemetry/logging without full execution."""
|
||||
return ConfigMigration.resolve_runner_id(query.pipeline_config)
|
||||
return RunnerConfigResolver.resolve_runner_id(query.pipeline_config)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
This adapter bridges the current Query entry point with the event-first
|
||||
Protocol v1 architecture without exposing Query internals to runners.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -23,12 +24,13 @@ from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryCo
|
||||
from .host_models import (
|
||||
AgentConfig,
|
||||
AgentEventEnvelope,
|
||||
ResourcePolicy,
|
||||
StatePolicy,
|
||||
DeliveryPolicy,
|
||||
)
|
||||
from .config_migration import ConfigMigration
|
||||
from .config_resolver import RunnerConfigResolver
|
||||
from .resource_policy import ResourcePolicyProjector
|
||||
from . import events as runner_events
|
||||
from ...provider.tools.toolmgr import TOOL_SOURCE_REFS_QUERY_KEY
|
||||
|
||||
|
||||
class QueryEntryAdapter:
|
||||
@@ -83,7 +85,7 @@ class QueryEntryAdapter:
|
||||
event_id=event.event_id or str(query.query_id),
|
||||
event_type=event.event_type or runner_events.MESSAGE_RECEIVED,
|
||||
event_time=event.event_time,
|
||||
source="host_adapter",
|
||||
source='host_adapter',
|
||||
source_event_type=event.source_event_type,
|
||||
bot_id=query.bot_uuid,
|
||||
workspace_id=None, # Not available in Query
|
||||
@@ -105,21 +107,22 @@ class QueryEntryAdapter:
|
||||
) -> AgentConfig:
|
||||
"""Project the current Pipeline config container into target Agent config."""
|
||||
pipeline_config = query.pipeline_config or {}
|
||||
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
|
||||
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
|
||||
agent_id = getattr(query, 'pipeline_uuid', None)
|
||||
|
||||
# Build resource policy from current config
|
||||
resource_policy = ResourcePolicy(
|
||||
allowed_model_uuids=cls._extract_allowed_models(query),
|
||||
allowed_tool_names=cls._extract_allowed_tools(query),
|
||||
allowed_kb_uuids=cls._extract_allowed_kbs(query),
|
||||
allowed_skill_names=cls._extract_allowed_skills(query),
|
||||
resource_policy = ResourcePolicyProjector.from_runner_config(
|
||||
runner_config,
|
||||
resolved_model_uuids=cls._extract_allowed_models(query),
|
||||
resolved_tool_names=cls._extract_allowed_tools(query),
|
||||
resolved_tool_sources=cls._extract_allowed_tool_sources(query),
|
||||
resolved_kb_uuids=cls._extract_allowed_kbs(query),
|
||||
resolved_skill_names=cls._extract_allowed_skills(query),
|
||||
)
|
||||
|
||||
# Build state policy
|
||||
state_policy = StatePolicy(
|
||||
enable_state=True,
|
||||
state_scopes=["conversation", "actor", "subject", "runner"],
|
||||
state_scopes=['conversation', 'actor', 'subject', 'runner'],
|
||||
)
|
||||
|
||||
# Build delivery policy
|
||||
@@ -181,10 +184,7 @@ class QueryEntryAdapter:
|
||||
if isinstance(value, (list, tuple)):
|
||||
return all(cls.is_json_serializable(item) for item in value)
|
||||
if isinstance(value, dict):
|
||||
return all(
|
||||
isinstance(k, str) and cls.is_json_serializable(v)
|
||||
for k, v in value.items()
|
||||
)
|
||||
return all(isinstance(k, str) and cls.is_json_serializable(v) for k, v in value.items())
|
||||
return False
|
||||
|
||||
# Private helper methods
|
||||
@@ -228,7 +228,7 @@ class QueryEntryAdapter:
|
||||
event_id=cls._build_scoped_event_id(query, source_event_id, event_time),
|
||||
event_type=runner_events.MESSAGE_RECEIVED,
|
||||
event_time=event_time,
|
||||
source="host_adapter",
|
||||
source='host_adapter',
|
||||
source_event_type=source_event_type,
|
||||
data=event_data,
|
||||
)
|
||||
@@ -345,7 +345,7 @@ class QueryEntryAdapter:
|
||||
actor_name = sender.get_name() if sender and hasattr(sender, 'get_name') else None
|
||||
|
||||
return ActorContext(
|
||||
actor_type="user",
|
||||
actor_type='user',
|
||||
actor_id=str(actor_id) if actor_id is not None else None,
|
||||
actor_name=actor_name,
|
||||
metadata={},
|
||||
@@ -371,13 +371,13 @@ class QueryEntryAdapter:
|
||||
launcher_type_value = getattr(launcher_type, 'value', launcher_type)
|
||||
|
||||
return SubjectContext(
|
||||
subject_type="message",
|
||||
subject_type='message',
|
||||
subject_id=str(message_id or query_id or ''),
|
||||
data={
|
||||
"launcher_type": launcher_type_value,
|
||||
"launcher_id": getattr(query, 'launcher_id', None),
|
||||
"sender_id": str(getattr(query, 'sender_id', '')) if getattr(query, 'sender_id', None) else None,
|
||||
"bot_uuid": getattr(query, 'bot_uuid', None),
|
||||
'launcher_type': launcher_type_value,
|
||||
'launcher_id': getattr(query, 'launcher_id', None),
|
||||
'sender_id': str(getattr(query, 'sender_id', '')) if getattr(query, 'sender_id', None) else None,
|
||||
'bot_uuid': getattr(query, 'bot_uuid', None),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -467,31 +467,39 @@ class QueryEntryAdapter:
|
||||
|
||||
if elem_type == 'image_url':
|
||||
image_url = elem.get('image_url') or {}
|
||||
add_attachment({
|
||||
'type': 'image',
|
||||
'source': 'url',
|
||||
'url': image_url.get('url') if isinstance(image_url, dict) else str(image_url),
|
||||
})
|
||||
add_attachment(
|
||||
{
|
||||
'type': 'image',
|
||||
'source': 'url',
|
||||
'url': image_url.get('url') if isinstance(image_url, dict) else str(image_url),
|
||||
}
|
||||
)
|
||||
elif elem_type == 'image_base64':
|
||||
add_attachment({
|
||||
'type': 'image',
|
||||
'source': 'base64',
|
||||
'content': elem.get('image_base64'),
|
||||
})
|
||||
add_attachment(
|
||||
{
|
||||
'type': 'image',
|
||||
'source': 'base64',
|
||||
'content': elem.get('image_base64'),
|
||||
}
|
||||
)
|
||||
elif elem_type == 'file_url':
|
||||
add_attachment({
|
||||
'type': 'file',
|
||||
'source': 'url',
|
||||
'url': elem.get('file_url'),
|
||||
'name': elem.get('file_name'),
|
||||
})
|
||||
add_attachment(
|
||||
{
|
||||
'type': 'file',
|
||||
'source': 'url',
|
||||
'url': elem.get('file_url'),
|
||||
'name': elem.get('file_name'),
|
||||
}
|
||||
)
|
||||
elif elem_type == 'file_base64':
|
||||
add_attachment({
|
||||
'type': 'file',
|
||||
'source': 'base64',
|
||||
'content': elem.get('file_base64'),
|
||||
'name': elem.get('file_name'),
|
||||
})
|
||||
add_attachment(
|
||||
{
|
||||
'type': 'file',
|
||||
'source': 'base64',
|
||||
'content': elem.get('file_base64'),
|
||||
'name': elem.get('file_name'),
|
||||
}
|
||||
)
|
||||
|
||||
message_chain = getattr(query, 'message_chain', None)
|
||||
if message_chain:
|
||||
@@ -505,30 +513,36 @@ class QueryEntryAdapter:
|
||||
image_id = component.image_id or None
|
||||
image_url = component.url or None
|
||||
image_base64 = component.base64 or None
|
||||
add_attachment({
|
||||
'type': 'image',
|
||||
'source': 'message_chain',
|
||||
'id': image_id,
|
||||
'url': image_url,
|
||||
'content': image_base64,
|
||||
})
|
||||
add_attachment(
|
||||
{
|
||||
'type': 'image',
|
||||
'source': 'message_chain',
|
||||
'id': image_id,
|
||||
'url': image_url,
|
||||
'content': image_base64,
|
||||
}
|
||||
)
|
||||
elif isinstance(component, platform_message.File):
|
||||
add_attachment({
|
||||
'type': 'file',
|
||||
'source': 'message_chain',
|
||||
'id': component.id or None,
|
||||
'name': component.name or None,
|
||||
'url': component.url or None,
|
||||
'content': component.base64 or None,
|
||||
})
|
||||
add_attachment(
|
||||
{
|
||||
'type': 'file',
|
||||
'source': 'message_chain',
|
||||
'id': component.id or None,
|
||||
'name': component.name or None,
|
||||
'url': component.url or None,
|
||||
'content': component.base64 or None,
|
||||
}
|
||||
)
|
||||
elif isinstance(component, platform_message.Voice):
|
||||
add_attachment({
|
||||
'type': 'voice',
|
||||
'source': 'message_chain',
|
||||
'id': component.voice_id or None,
|
||||
'url': component.url or None,
|
||||
'content': component.base64 or None,
|
||||
})
|
||||
add_attachment(
|
||||
{
|
||||
'type': 'voice',
|
||||
'source': 'message_chain',
|
||||
'id': component.voice_id or None,
|
||||
'url': component.url or None,
|
||||
'content': component.base64 or None,
|
||||
}
|
||||
)
|
||||
|
||||
return attachments
|
||||
|
||||
@@ -557,9 +571,9 @@ class QueryEntryAdapter:
|
||||
"""Build DeliveryContext from Query."""
|
||||
message_chain = getattr(query, 'message_chain', None)
|
||||
return DeliveryContext(
|
||||
surface="platform",
|
||||
surface='platform',
|
||||
reply_target={
|
||||
"message_id": getattr(message_chain, 'message_id', None),
|
||||
'message_id': getattr(message_chain, 'message_id', None),
|
||||
},
|
||||
supports_streaming=True,
|
||||
supports_edit=False,
|
||||
@@ -601,22 +615,24 @@ class QueryEntryAdapter:
|
||||
) -> list[str] | None:
|
||||
"""Extract allowed tool names from query."""
|
||||
use_funcs = getattr(query, 'use_funcs', None)
|
||||
if not use_funcs:
|
||||
if use_funcs is None:
|
||||
return None
|
||||
try:
|
||||
tool_names = []
|
||||
for func in use_funcs:
|
||||
if isinstance(func, dict):
|
||||
name = func.get('name')
|
||||
elif hasattr(func, 'name'):
|
||||
name = func.name
|
||||
else:
|
||||
continue
|
||||
if name:
|
||||
tool_names.append(name)
|
||||
return tool_names if tool_names else None
|
||||
return ResourcePolicyProjector.extract_tool_names(use_funcs)
|
||||
except (TypeError, AttributeError):
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def _extract_allowed_tool_sources(
|
||||
cls,
|
||||
query: pipeline_query.Query,
|
||||
) -> dict[str, typing.Any] | None:
|
||||
"""Extract the Host-frozen implementation for each allowed tool."""
|
||||
variables = getattr(query, 'variables', None)
|
||||
if not isinstance(variables, dict):
|
||||
return None
|
||||
refs = variables.get(TOOL_SOURCE_REFS_QUERY_KEY)
|
||||
return refs if isinstance(refs, dict) else None
|
||||
|
||||
@classmethod
|
||||
def _extract_allowed_kbs(
|
||||
@@ -627,10 +643,9 @@ class QueryEntryAdapter:
|
||||
variables = getattr(query, 'variables', None)
|
||||
if not variables:
|
||||
return None
|
||||
kb_uuids = variables.get('_knowledge_base_uuids')
|
||||
if kb_uuids:
|
||||
return kb_uuids
|
||||
return None
|
||||
if '_knowledge_base_uuids' not in variables:
|
||||
return None
|
||||
return ResourcePolicyProjector.normalize_names(variables.get('_knowledge_base_uuids'))
|
||||
|
||||
@classmethod
|
||||
def _extract_allowed_skills(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Agent resource builder for constructing authorized resources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
@@ -15,6 +16,9 @@ from .context_builder import (
|
||||
)
|
||||
from . import config_schema
|
||||
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
|
||||
|
||||
|
||||
class AgentResourceBuilder:
|
||||
@@ -66,18 +70,12 @@ class AgentResourceBuilder:
|
||||
manifest_perms = descriptor.permissions
|
||||
|
||||
# Build each resource category
|
||||
models = await self._build_models_from_binding(
|
||||
manifest_perms, resource_policy, descriptor, runner_config
|
||||
)
|
||||
tools = await self._build_tools_from_binding(
|
||||
manifest_perms, resource_policy, descriptor
|
||||
)
|
||||
models = await self._build_models_from_binding(manifest_perms, resource_policy, descriptor, runner_config)
|
||||
tools = await self._build_tools_from_binding(manifest_perms, resource_policy, descriptor, runner_config)
|
||||
knowledge_bases = await self._build_knowledge_bases_from_binding(
|
||||
manifest_perms, resource_policy, descriptor, runner_config
|
||||
)
|
||||
skills = self._build_skills_from_binding(
|
||||
resource_policy, descriptor
|
||||
)
|
||||
skills = self._build_skills_from_binding(resource_policy, descriptor)
|
||||
storage = self._build_storage_from_binding(manifest_perms, binding)
|
||||
|
||||
return {
|
||||
@@ -133,6 +131,7 @@ class AgentResourceBuilder:
|
||||
manifest_perms: typing.Any,
|
||||
resource_policy: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
runner_config: dict[str, typing.Any],
|
||||
) -> list[ToolResource]:
|
||||
"""Build tools list from binding."""
|
||||
tools: list[ToolResource] = []
|
||||
@@ -143,8 +142,43 @@ class AgentResourceBuilder:
|
||||
if not config_schema.uses_host_tools(descriptor):
|
||||
return tools
|
||||
|
||||
# Get tool names from resource policy
|
||||
allowed_names = resource_policy.allowed_tool_names
|
||||
allowed_sources = resource_policy.allowed_tool_sources
|
||||
if resource_policy.allow_all_tools or allowed_sources is None:
|
||||
get_catalog = getattr(getattr(self.ap, 'tool_mgr', None), 'get_resolved_tool_catalog', None)
|
||||
if get_catalog is None:
|
||||
return tools
|
||||
try:
|
||||
catalog = await get_catalog(
|
||||
include_skill_authoring=True,
|
||||
include_mcp_resource_tools=True,
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to resolve visible Host tools: {e}')
|
||||
return tools
|
||||
|
||||
if not resource_policy.allow_all_tools:
|
||||
selected_names = set(allowed_names or [])
|
||||
catalog = [item for item in catalog if item.get('name') in selected_names]
|
||||
allowed_names = ResourcePolicyProjector.extract_tool_names(catalog)
|
||||
allowed_sources = {
|
||||
item['name']: ref for item in catalog if (ref := self._catalog_source_ref(item)) is not None
|
||||
}
|
||||
|
||||
if runner_config.get('mcp-resource-agent-read-enabled', True) is not True:
|
||||
denied_resource_tools = {MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE}
|
||||
allowed_names = [
|
||||
name
|
||||
for name in (allowed_names or [])
|
||||
if not (
|
||||
name in denied_resource_tools
|
||||
and allowed_sources
|
||||
and (source_ref := allowed_sources.get(name)) is not None
|
||||
and source_ref['source'] == 'mcp'
|
||||
and source_ref.get('source_id') is None
|
||||
)
|
||||
]
|
||||
|
||||
tool_operations = [operation for operation in ('detail', 'call') if operation in tool_perms]
|
||||
|
||||
# Prefill full tool schema (best-effort) so runners can build LLM tool
|
||||
@@ -153,20 +187,39 @@ class AgentResourceBuilder:
|
||||
get_tool_schema = getattr(getattr(self.ap, 'tool_mgr', None), 'get_tool_schema', None)
|
||||
if allowed_names:
|
||||
for tool_name in allowed_names:
|
||||
source_ref = allowed_sources.get(tool_name) if allowed_sources else None
|
||||
if source_ref is None:
|
||||
self.ap.logger.warning(f'Tool {tool_name} is not authorized because its Host source is unresolved')
|
||||
continue
|
||||
if get_tool_schema is not None:
|
||||
description, parameters = await get_tool_schema(tool_name)
|
||||
description, parameters = await get_tool_schema(tool_name, source_ref=source_ref)
|
||||
else:
|
||||
description, parameters = None, None
|
||||
tools.append({
|
||||
'tool_name': tool_name,
|
||||
'tool_type': None,
|
||||
'description': description,
|
||||
'operations': tool_operations,
|
||||
'parameters': parameters,
|
||||
})
|
||||
tools.append(
|
||||
{
|
||||
'tool_name': tool_name,
|
||||
'tool_type': source_ref['source'],
|
||||
'description': description,
|
||||
'operations': tool_operations,
|
||||
'parameters': parameters,
|
||||
'source': source_ref['source'],
|
||||
'source_id': source_ref.get('source_id'),
|
||||
}
|
||||
)
|
||||
|
||||
return tools
|
||||
|
||||
@staticmethod
|
||||
def _catalog_source_ref(item: dict[str, typing.Any]) -> ToolSourceRef | None:
|
||||
source = item.get('source')
|
||||
if not isinstance(source, str) or not source:
|
||||
return None
|
||||
source_id = item.get('source_id')
|
||||
return {
|
||||
'source': source,
|
||||
'source_id': source_id if isinstance(source_id, str) and source_id else None,
|
||||
}
|
||||
|
||||
async def _build_knowledge_bases_from_binding(
|
||||
self,
|
||||
manifest_perms: typing.Any,
|
||||
@@ -196,12 +249,16 @@ class AgentResourceBuilder:
|
||||
try:
|
||||
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
||||
if kb:
|
||||
kb_resources.append({
|
||||
'kb_id': kb_uuid,
|
||||
'kb_name': kb.get_name(),
|
||||
'kb_type': kb.knowledge_base_entity.kb_type if hasattr(kb.knowledge_base_entity, 'kb_type') else None,
|
||||
'operations': kb_operations,
|
||||
})
|
||||
kb_resources.append(
|
||||
{
|
||||
'kb_id': kb_uuid,
|
||||
'kb_name': kb.get_name(),
|
||||
'kb_type': kb.knowledge_base_entity.kb_type
|
||||
if hasattr(kb.knowledge_base_entity, 'kb_type')
|
||||
else None,
|
||||
'operations': kb_operations,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to build knowledge base resource {kb_uuid}: {e}')
|
||||
|
||||
@@ -233,11 +290,13 @@ class AgentResourceBuilder:
|
||||
skills: list[SkillResource] = []
|
||||
for skill_name in names:
|
||||
skill_data = loaded_skills.get(skill_name) or {}
|
||||
skills.append({
|
||||
'skill_name': skill_name,
|
||||
'display_name': skill_data.get('display_name') or skill_data.get('name') or skill_name,
|
||||
'description': skill_data.get('description') or None,
|
||||
})
|
||||
skills.append(
|
||||
{
|
||||
'skill_name': skill_name,
|
||||
'display_name': skill_data.get('display_name') or skill_data.get('name') or skill_name,
|
||||
'description': skill_data.get('description') or None,
|
||||
}
|
||||
)
|
||||
return skills
|
||||
|
||||
def _build_storage_from_binding(
|
||||
@@ -285,12 +344,16 @@ class AgentResourceBuilder:
|
||||
try:
|
||||
model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
|
||||
if model and model.model_entity:
|
||||
models.append({
|
||||
'model_id': model_uuid,
|
||||
'model_type': getattr(model.model_entity, 'model_type', None),
|
||||
'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None,
|
||||
'operations': operations,
|
||||
})
|
||||
models.append(
|
||||
{
|
||||
'model_id': model_uuid,
|
||||
'model_type': getattr(model.model_entity, 'model_type', None),
|
||||
'provider': getattr(model.provider_entity, 'name', None)
|
||||
if hasattr(model, 'provider_entity')
|
||||
else None,
|
||||
'operations': operations,
|
||||
}
|
||||
)
|
||||
seen_model_ids.add(model_uuid)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to build LLM model resource {model_uuid}: {e}')
|
||||
@@ -308,12 +371,16 @@ class AgentResourceBuilder:
|
||||
try:
|
||||
model = await self.ap.model_mgr.get_rerank_model_by_uuid(model_uuid)
|
||||
if model and model.model_entity:
|
||||
models.append({
|
||||
'model_id': model_uuid,
|
||||
'model_type': getattr(model.model_entity, 'model_type', 'rerank') or 'rerank',
|
||||
'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None,
|
||||
'operations': ['rerank'],
|
||||
})
|
||||
models.append(
|
||||
{
|
||||
'model_id': model_uuid,
|
||||
'model_type': getattr(model.model_entity, 'model_type', 'rerank') or 'rerank',
|
||||
'provider': getattr(model.provider_entity, 'name', None)
|
||||
if hasattr(model, 'provider_entity')
|
||||
else None,
|
||||
'operations': ['rerank'],
|
||||
}
|
||||
)
|
||||
seen_model_ids.add(model_uuid)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to build rerank model resource {model_uuid}: {e}')
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Project AgentRunner configuration into Host resource policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections.abc
|
||||
import typing
|
||||
|
||||
from .host_models import ResourcePolicy
|
||||
|
||||
|
||||
class ResourcePolicyProjector:
|
||||
"""Build one generic resource policy for Pipeline and Agent bindings."""
|
||||
|
||||
@classmethod
|
||||
def from_runner_config(
|
||||
cls,
|
||||
runner_config: dict[str, typing.Any] | None,
|
||||
*,
|
||||
resolved_model_uuids: collections.abc.Iterable[typing.Any] | None = None,
|
||||
resolved_tool_names: collections.abc.Iterable[typing.Any] | None = None,
|
||||
resolved_tool_sources: typing.Mapping[str, typing.Any] | None = None,
|
||||
resolved_kb_uuids: collections.abc.Iterable[typing.Any] | None = None,
|
||||
resolved_skill_names: collections.abc.Iterable[typing.Any] | None = None,
|
||||
) -> ResourcePolicy:
|
||||
"""Project standard resource fields without depending on a runner ID.
|
||||
|
||||
Resolved values are supplied by entry paths that already narrowed the
|
||||
available resources, such as Pipeline preprocessing. Independent Agent
|
||||
bindings omit them so the Host can resolve an explicit all-tools grant
|
||||
against the live tool catalog when the run starts.
|
||||
"""
|
||||
config = runner_config if isinstance(runner_config, dict) else {}
|
||||
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:
|
||||
available_tool_names = cls.normalize_names(resolved_tool_names)
|
||||
if enable_all_tools:
|
||||
allowed_tool_names = available_tool_names
|
||||
else:
|
||||
selected = set(selected_tool_names)
|
||||
allowed_tool_names = [name for name in available_tool_names if name in selected]
|
||||
allow_all_tools = False
|
||||
elif enable_all_tools:
|
||||
allowed_tool_names = None
|
||||
allow_all_tools = True
|
||||
else:
|
||||
allowed_tool_names = selected_tool_names
|
||||
allow_all_tools = False
|
||||
|
||||
allowed_tool_sources = cls.normalize_tool_sources(resolved_tool_sources)
|
||||
if allowed_tool_sources is not None:
|
||||
allowed = set(allowed_tool_names or [])
|
||||
allowed_tool_sources = {name: ref for name, ref in allowed_tool_sources.items() if name in allowed}
|
||||
|
||||
if resolved_kb_uuids is not None:
|
||||
allowed_kb_uuids = cls.normalize_names(resolved_kb_uuids)
|
||||
elif 'knowledge-bases' in config:
|
||||
allowed_kb_uuids = cls.normalize_names(config.get('knowledge-bases'))
|
||||
else:
|
||||
allowed_kb_uuids = None
|
||||
|
||||
return ResourcePolicy(
|
||||
allowed_model_uuids=cls.normalize_optional_names(resolved_model_uuids),
|
||||
allowed_tool_names=allowed_tool_names,
|
||||
allowed_tool_sources=allowed_tool_sources,
|
||||
allow_all_tools=allow_all_tools,
|
||||
allowed_kb_uuids=allowed_kb_uuids,
|
||||
allowed_skill_names=cls.normalize_optional_names(resolved_skill_names),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_names(values: typing.Any) -> list[str]:
|
||||
"""Return unique, non-empty string identifiers in source order."""
|
||||
if not isinstance(values, collections.abc.Iterable) or isinstance(values, (str, bytes, dict)):
|
||||
return []
|
||||
|
||||
names: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
if not isinstance(value, str) or not value or value in seen:
|
||||
continue
|
||||
seen.add(value)
|
||||
names.append(value)
|
||||
return names
|
||||
|
||||
@classmethod
|
||||
def normalize_optional_names(
|
||||
cls,
|
||||
values: collections.abc.Iterable[typing.Any] | None,
|
||||
) -> list[str] | None:
|
||||
if values is None:
|
||||
return None
|
||||
return cls.normalize_names(values)
|
||||
|
||||
@classmethod
|
||||
def extract_tool_names(cls, tools: collections.abc.Iterable[typing.Any] | None) -> list[str]:
|
||||
"""Extract tool identifiers from dictionary and SDK object shapes."""
|
||||
if tools is None:
|
||||
return []
|
||||
|
||||
names: list[str] = []
|
||||
for tool in tools:
|
||||
name = tool.get('name') if isinstance(tool, dict) else getattr(tool, 'name', None)
|
||||
if isinstance(name, str):
|
||||
names.append(name)
|
||||
return cls.normalize_names(names)
|
||||
|
||||
@staticmethod
|
||||
def normalize_tool_sources(
|
||||
values: typing.Mapping[str, typing.Any] | None,
|
||||
) -> dict[str, dict[str, str | None]] | None:
|
||||
if values is None:
|
||||
return None
|
||||
|
||||
normalized: dict[str, dict[str, str | None]] = {}
|
||||
for name, value in values.items():
|
||||
if not isinstance(name, str) or not name or not isinstance(value, collections.abc.Mapping):
|
||||
continue
|
||||
source = value.get('source')
|
||||
if not isinstance(source, str) or not source:
|
||||
continue
|
||||
source_id = value.get('source_id')
|
||||
normalized[name] = {
|
||||
'source': source,
|
||||
'source_id': source_id if isinstance(source_id, str) and source_id else None,
|
||||
}
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def filter_tools(
|
||||
cls,
|
||||
tools: collections.abc.Iterable[typing.Any],
|
||||
policy: ResourcePolicy,
|
||||
) -> list[typing.Any]:
|
||||
"""Apply a projected policy to an already scoped tool collection."""
|
||||
tool_list = list(tools)
|
||||
if policy.allow_all_tools:
|
||||
return tool_list
|
||||
|
||||
allowed_names = set(policy.allowed_tool_names or [])
|
||||
return [
|
||||
tool
|
||||
for tool in tool_list
|
||||
if (tool.get('name') if isinstance(tool, dict) else getattr(tool, 'name', None)) in allowed_names
|
||||
]
|
||||
@@ -304,13 +304,10 @@ class AgentRunJournal:
|
||||
|
||||
for item in items:
|
||||
event = item.get('event') if isinstance(item.get('event'), dict) else {}
|
||||
input_data = item.get('input') if isinstance(item.get('input'), dict) else {}
|
||||
conversation = item.get('conversation') if isinstance(item.get('conversation'), dict) else {}
|
||||
actor = item.get('actor') if isinstance(item.get('actor'), dict) else {}
|
||||
subject = item.get('subject') if isinstance(item.get('subject'), dict) else {}
|
||||
|
||||
text = input_data.get('text')
|
||||
input_summary = text[:1000] if isinstance(text, str) and text else 'Unconsumed steering input dropped'
|
||||
event_time = None
|
||||
raw_event_time = event.get('event_time')
|
||||
if raw_event_time:
|
||||
@@ -335,12 +332,7 @@ class AgentRunJournal:
|
||||
actor_name=actor.get('actor_name'),
|
||||
subject_type=subject.get('subject_type'),
|
||||
subject_id=subject.get('subject_id'),
|
||||
input_summary=input_summary,
|
||||
input_json={
|
||||
'text': text,
|
||||
'contents': self._sanitize_contents(input_data.get('contents') or []),
|
||||
'attachments': self._sanitize_attachments(input_data.get('attachments') or []),
|
||||
},
|
||||
input_summary='Unconsumed steering input dropped',
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
event_time=event_time,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Agent run session registry for proxy action permission validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -7,7 +8,10 @@ import typing
|
||||
import time
|
||||
import threading
|
||||
|
||||
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
||||
|
||||
from .context_builder import AgentResources
|
||||
from ...provider.tools.toolmgr import ToolSourceRef
|
||||
|
||||
|
||||
MAX_STEERING_QUEUE_ITEMS = 100
|
||||
@@ -22,6 +26,7 @@ DEFAULT_RESOURCE_OPERATIONS: dict[str, set[str]] = {
|
||||
|
||||
class AgentRunSessionStatus(typing.TypedDict):
|
||||
"""Status tracking for agent run session."""
|
||||
|
||||
started_at: int
|
||||
last_activity_at: int
|
||||
|
||||
@@ -58,13 +63,16 @@ class AgentRunSession(typing.TypedDict):
|
||||
run_id: Unique run identifier (UUID from AgentRunContext)
|
||||
runner_id: Runner descriptor ID (plugin:author/name/runner)
|
||||
query_id: Host entry query ID, only present for query-based adapters
|
||||
execution_query: Host-only Query used by providers and tool loaders
|
||||
plugin_identity: Plugin identifier (author/name) of the runner
|
||||
authorization: Run-scoped authorization snapshot; runtime auth truth
|
||||
status: Session status tracking
|
||||
"""
|
||||
|
||||
run_id: str
|
||||
runner_id: str
|
||||
query_id: int | None
|
||||
execution_query: pipeline_query.Query | None
|
||||
plugin_identity: str # author/name
|
||||
authorization: RunAuthorizationSnapshot
|
||||
status: AgentRunSessionStatus
|
||||
@@ -104,6 +112,7 @@ class AgentRunSessionRegistry:
|
||||
available_apis: dict[str, bool] | None = None,
|
||||
state_policy: dict[str, typing.Any] | None = None,
|
||||
state_context: dict[str, typing.Any] | None = None,
|
||||
execution_query: pipeline_query.Query | None = None,
|
||||
) -> None:
|
||||
"""Register a new agent run session.
|
||||
|
||||
@@ -120,6 +129,7 @@ class AgentRunSessionRegistry:
|
||||
available_apis: Run-scoped pull APIs exposed in AgentRunContext
|
||||
state_policy: State policy from binding (enable_state, state_scopes)
|
||||
state_context: Context for state API (scope_keys, binding_identity, etc.)
|
||||
execution_query: Host-only Query used for provider and tool execution
|
||||
"""
|
||||
if not isinstance(plugin_identity, str) or not plugin_identity.strip():
|
||||
raise ValueError('plugin_identity is required for agent run sessions')
|
||||
@@ -153,6 +163,7 @@ class AgentRunSessionRegistry:
|
||||
'run_id': run_id,
|
||||
'runner_id': runner_id,
|
||||
'query_id': query_id,
|
||||
'execution_query': execution_query,
|
||||
'plugin_identity': plugin_identity,
|
||||
'authorization': authorization,
|
||||
'status': {
|
||||
@@ -371,6 +382,26 @@ class AgentRunSessionRegistry:
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_tool_source_ref(
|
||||
session: AgentRunSession,
|
||||
tool_name: str,
|
||||
) -> ToolSourceRef | None:
|
||||
"""Return the implementation identity frozen in this run's grant."""
|
||||
resources = session['authorization']['resources']
|
||||
for tool in resources.get('tools', []):
|
||||
if tool.get('tool_name') != tool_name:
|
||||
continue
|
||||
source = tool.get('source')
|
||||
if not isinstance(source, str) or not source:
|
||||
return None
|
||||
source_id = tool.get('source_id')
|
||||
return {
|
||||
'source': source,
|
||||
'source_id': source_id if isinstance(source_id, str) and source_id else None,
|
||||
}
|
||||
return None
|
||||
|
||||
async def list_active_runs(self) -> list[AgentRunSession]:
|
||||
"""List all active run sessions.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user