feat(agent-runner): enforce 4.x host-owned execution

This commit is contained in:
huanghuoguoguo
2026-07-12 20:36:32 +08:00
parent 9aa71d54e3
commit d88d05e27c
170 changed files with 6952 additions and 5381 deletions
+2 -2
View File
@@ -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
+12 -5
View File
@@ -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
+39 -6
View File
@@ -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
+3 -3
View File
@@ -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(
+108 -41
View File
@@ -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
]
+1 -9
View File
@@ -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.