mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-13 09:16:04 +00:00
feat(agent-runner): enforce typed host permissions
This commit is contained in:
@@ -26,49 +26,26 @@ def iter_schema_items(
|
||||
yield item
|
||||
|
||||
|
||||
def has_permission(
|
||||
descriptor: AgentRunnerDescriptor | None,
|
||||
name: str,
|
||||
actions: set[str],
|
||||
) -> bool:
|
||||
"""Return whether a runner descriptor requests one of the given actions."""
|
||||
if descriptor is None:
|
||||
return False
|
||||
configured_actions = descriptor.permissions.get(name, [])
|
||||
return any(action in configured_actions for action in actions)
|
||||
|
||||
|
||||
def uses_host_models(descriptor: AgentRunnerDescriptor | None) -> bool:
|
||||
"""Return whether LangBot should resolve model resources for this runner."""
|
||||
return (
|
||||
has_permission(descriptor, 'models', {'invoke', 'stream', 'list'})
|
||||
and any(True for _ in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES))
|
||||
)
|
||||
return any(True for _ in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES))
|
||||
|
||||
|
||||
def uses_host_tools(descriptor: AgentRunnerDescriptor | None) -> bool:
|
||||
"""Return whether LangBot should expose tool resources to this runner."""
|
||||
return (
|
||||
descriptor is not None
|
||||
and descriptor.supports_tool_calling()
|
||||
and has_permission(descriptor, 'tools', {'list', 'detail', 'call'})
|
||||
)
|
||||
return descriptor is not None and descriptor.supports_tool_calling()
|
||||
|
||||
|
||||
def uses_host_knowledge_bases(descriptor: AgentRunnerDescriptor | None) -> bool:
|
||||
"""Return whether LangBot should expose knowledge-base resources to this runner."""
|
||||
return (
|
||||
descriptor is not None
|
||||
and descriptor.supports_knowledge_retrieval()
|
||||
and has_permission(descriptor, 'knowledge_bases', {'list', 'retrieve'})
|
||||
)
|
||||
return descriptor is not None and descriptor.supports_knowledge_retrieval()
|
||||
|
||||
|
||||
def supports_skill_authoring(descriptor: AgentRunnerDescriptor | None) -> bool:
|
||||
"""Return whether the runner wants Host skill-authoring tools."""
|
||||
if descriptor is None:
|
||||
return False
|
||||
return bool(descriptor.capabilities.get('skill_authoring', False))
|
||||
return descriptor.capabilities.skill_authoring
|
||||
|
||||
|
||||
def extract_prompt_config(
|
||||
|
||||
@@ -44,7 +44,6 @@ class AgentInput(typing.TypedDict):
|
||||
|
||||
text: str | None
|
||||
contents: list[dict[str, typing.Any]]
|
||||
message_chain: dict[str, typing.Any] | None
|
||||
attachments: list[dict[str, typing.Any]]
|
||||
|
||||
|
||||
@@ -254,7 +253,6 @@ class AgentRunContextBuilder:
|
||||
input: AgentInput = {
|
||||
'text': event.input.text,
|
||||
'contents': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents],
|
||||
'message_chain': event.input.message_chain,
|
||||
'attachments': [
|
||||
a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments
|
||||
],
|
||||
@@ -361,28 +359,33 @@ class AgentRunContextBuilder:
|
||||
ContextAccess dict
|
||||
"""
|
||||
conversation_id = event.conversation_id
|
||||
permissions = descriptor.permissions
|
||||
history_perms = set(permissions.history)
|
||||
event_perms = set(permissions.events)
|
||||
artifact_perms = set(permissions.artifacts)
|
||||
storage_perms = set(permissions.storage)
|
||||
|
||||
# Check if history APIs are available for this runner
|
||||
# Based on runner permissions
|
||||
permissions = descriptor.permissions or {}
|
||||
history_permissions = permissions.get('history', [])
|
||||
event_permissions = permissions.get('events', [])
|
||||
artifact_permissions = permissions.get('artifacts', [])
|
||||
|
||||
history_page_enabled = 'page' in history_permissions and conversation_id is not None
|
||||
history_search_enabled = 'search' in history_permissions and conversation_id is not None
|
||||
event_get_enabled = 'get' in event_permissions
|
||||
event_page_enabled = 'page' in event_permissions and conversation_id is not None
|
||||
artifact_metadata_enabled = 'metadata' in artifact_permissions
|
||||
artifact_read_enabled = 'read' in artifact_permissions
|
||||
history_page_enabled = 'page' in history_perms and conversation_id is not None
|
||||
history_search_enabled = 'search' in history_perms and conversation_id is not None
|
||||
event_get_enabled = 'get' in event_perms
|
||||
event_page_enabled = 'page' in event_perms and conversation_id is not None
|
||||
artifact_metadata_enabled = 'metadata' in artifact_perms
|
||||
artifact_read_enabled = 'read' in artifact_perms
|
||||
|
||||
# Determine state API availability based on binding state_policy.
|
||||
state_enabled = False
|
||||
storage_enabled = False
|
||||
if binding is not None:
|
||||
state_policy = binding.state_policy
|
||||
if state_policy.enable_state and state_policy.state_scopes:
|
||||
state_enabled = True
|
||||
|
||||
resource_policy = binding.resource_policy
|
||||
storage_enabled = (
|
||||
('plugin' in storage_perms and resource_policy.allow_plugin_storage)
|
||||
or ('workspace' in storage_perms and resource_policy.allow_workspace_storage)
|
||||
)
|
||||
|
||||
# Get latest cursor and has_history_before if conversation exists
|
||||
latest_cursor = None
|
||||
has_history_before = False
|
||||
@@ -411,7 +414,7 @@ class AgentRunContextBuilder:
|
||||
'delivered_count': 0,
|
||||
'source_total_count': None,
|
||||
'messages_complete': False,
|
||||
'reason': 'self_managed_context',
|
||||
'reason': 'current_event_only',
|
||||
},
|
||||
'available_apis': {
|
||||
'history_page': history_page_enabled,
|
||||
@@ -421,6 +424,6 @@ class AgentRunContextBuilder:
|
||||
'artifact_metadata': artifact_metadata_enabled,
|
||||
'artifact_read': artifact_read_enabled,
|
||||
'state': state_enabled,
|
||||
'storage': True,
|
||||
'storage': storage_enabled,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ from __future__ import annotations
|
||||
import typing
|
||||
import pydantic
|
||||
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.manifest import (
|
||||
AgentRunnerCapabilities,
|
||||
AgentRunnerPermissions,
|
||||
)
|
||||
|
||||
|
||||
class AgentRunnerDescriptor(pydantic.BaseModel):
|
||||
"""Descriptor for an agent runner.
|
||||
@@ -36,16 +41,20 @@ class AgentRunnerDescriptor(pydantic.BaseModel):
|
||||
plugin_version: str | None = None
|
||||
"""Optional plugin version"""
|
||||
|
||||
config_schema: list[dict[str, typing.Any]] = []
|
||||
config_schema: list[dict[str, typing.Any]] = pydantic.Field(default_factory=list)
|
||||
"""Configuration schema using DynamicForm format"""
|
||||
|
||||
capabilities: dict[str, bool] = {}
|
||||
capabilities: AgentRunnerCapabilities = pydantic.Field(
|
||||
default_factory=AgentRunnerCapabilities
|
||||
)
|
||||
"""Runner capabilities: streaming, tool_calling, knowledge_retrieval, etc."""
|
||||
|
||||
permissions: dict[str, list[str]] = {}
|
||||
"""Requested permissions: models, tools, knowledge_bases, storage, files, platform_api"""
|
||||
permissions: AgentRunnerPermissions = pydantic.Field(
|
||||
default_factory=AgentRunnerPermissions
|
||||
)
|
||||
"""Requested LangBot resource permissions."""
|
||||
|
||||
raw_manifest: dict[str, typing.Any] = {}
|
||||
raw_manifest: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
||||
"""Original manifest for reference"""
|
||||
|
||||
model_config = pydantic.ConfigDict(
|
||||
@@ -58,12 +67,12 @@ class AgentRunnerDescriptor(pydantic.BaseModel):
|
||||
|
||||
def supports_streaming(self) -> bool:
|
||||
"""Check if runner supports streaming output."""
|
||||
return self.capabilities.get('streaming', False)
|
||||
return self.capabilities.streaming
|
||||
|
||||
def supports_tool_calling(self) -> bool:
|
||||
"""Check if runner supports tool calling."""
|
||||
return self.capabilities.get('tool_calling', False)
|
||||
return self.capabilities.tool_calling
|
||||
|
||||
def supports_knowledge_retrieval(self) -> bool:
|
||||
"""Check if runner supports knowledge retrieval."""
|
||||
return self.capabilities.get('knowledge_retrieval', False)
|
||||
return self.capabilities.knowledge_retrieval
|
||||
|
||||
@@ -106,7 +106,7 @@ class AgentRunOrchestrator:
|
||||
query_id=session_query_id,
|
||||
plugin_identity=descriptor.get_plugin_id(),
|
||||
resources=resources,
|
||||
permissions=descriptor.permissions or {},
|
||||
available_apis=context.get('context', {}).get('available_apis'),
|
||||
conversation_id=event.conversation_id,
|
||||
state_policy={
|
||||
'enable_state': binding.state_policy.enable_state,
|
||||
@@ -137,6 +137,12 @@ class AgentRunOrchestrator:
|
||||
try:
|
||||
async for result_dict in self.invoker.invoke(descriptor, context):
|
||||
result_type = result_dict.get('type')
|
||||
if result_type and not self.result_normalizer.validate_payload(
|
||||
result_type,
|
||||
result_dict.get('data', {}),
|
||||
descriptor,
|
||||
):
|
||||
continue
|
||||
|
||||
if result_type == 'artifact.created':
|
||||
artifact_ref = await self.journal.handle_artifact_created(
|
||||
|
||||
@@ -396,18 +396,11 @@ class QueryEntryAdapter:
|
||||
if text_parts:
|
||||
text = ''.join(text_parts)
|
||||
|
||||
message_chain_dict = None
|
||||
message_chain = getattr(query, 'message_chain', None)
|
||||
if message_chain:
|
||||
if hasattr(message_chain, 'model_dump'):
|
||||
message_chain_dict = message_chain.model_dump(mode='json')
|
||||
|
||||
attachments = cls._build_attachments(query, contents)
|
||||
|
||||
return AgentInput(
|
||||
text=text,
|
||||
contents=contents,
|
||||
message_chain=message_chain_dict,
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ from __future__ import annotations
|
||||
import typing
|
||||
import asyncio
|
||||
|
||||
import pydantic
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.manifest import (
|
||||
AgentRunnerManifest,
|
||||
)
|
||||
|
||||
from ...core import app
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .id import parse_runner_id, format_runner_id
|
||||
@@ -79,7 +84,7 @@ class AgentRunnerRegistry:
|
||||
Args:
|
||||
runner_data: Raw runner data from plugin runtime with fields:
|
||||
- plugin_author, plugin_name, runner_name
|
||||
- manifest (full component manifest dict)
|
||||
- manifest (typed AgentRunnerManifest or legacy component manifest)
|
||||
- capabilities, permissions, config (extracted from spec)
|
||||
|
||||
Returns:
|
||||
@@ -93,17 +98,77 @@ class AgentRunnerRegistry:
|
||||
return None
|
||||
|
||||
manifest = runner_data.get('manifest', {})
|
||||
runner_id = format_runner_id(
|
||||
source='plugin',
|
||||
plugin_author=plugin_author,
|
||||
plugin_name=plugin_name,
|
||||
runner_name=runner_name,
|
||||
)
|
||||
|
||||
is_typed_manifest = self._looks_like_typed_manifest(manifest)
|
||||
if is_typed_manifest:
|
||||
typed_manifest = AgentRunnerManifest.model_validate(manifest)
|
||||
else:
|
||||
typed_manifest = self._build_typed_manifest_from_legacy_data(
|
||||
runner_id=runner_id,
|
||||
runner_name=runner_name,
|
||||
runner_data=runner_data,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
if runner_data.get('config'):
|
||||
config_schema = runner_data['config']
|
||||
elif not is_typed_manifest and isinstance(manifest.get('spec'), dict):
|
||||
config_schema = manifest['spec'].get('config', [])
|
||||
else:
|
||||
config_schema = [
|
||||
item.model_dump(mode='json') for item in typed_manifest.config_schema
|
||||
]
|
||||
|
||||
return AgentRunnerDescriptor(
|
||||
id=runner_id,
|
||||
source='plugin',
|
||||
label=typed_manifest.label,
|
||||
description=typed_manifest.description or runner_data.get('runner_description'),
|
||||
plugin_author=plugin_author,
|
||||
plugin_name=plugin_name,
|
||||
runner_name=runner_name,
|
||||
plugin_version=runner_data.get('plugin_version'),
|
||||
config_schema=config_schema,
|
||||
capabilities=typed_manifest.capabilities,
|
||||
permissions=typed_manifest.permissions,
|
||||
raw_manifest=manifest,
|
||||
)
|
||||
|
||||
def _looks_like_typed_manifest(self, manifest: dict[str, typing.Any]) -> bool:
|
||||
"""Return whether manifest is the SDK typed AgentRunnerManifest shape."""
|
||||
return (
|
||||
isinstance(manifest, dict)
|
||||
and 'id' in manifest
|
||||
and 'name' in manifest
|
||||
and 'label' in manifest
|
||||
)
|
||||
|
||||
def _build_typed_manifest_from_legacy_data(
|
||||
self,
|
||||
*,
|
||||
runner_id: str,
|
||||
runner_name: str,
|
||||
runner_data: dict[str, typing.Any],
|
||||
manifest: dict[str, typing.Any],
|
||||
) -> AgentRunnerManifest:
|
||||
"""Validate legacy raw component manifest data as typed runner manifest."""
|
||||
|
||||
# Validate kind
|
||||
kind = manifest.get('kind', '')
|
||||
if kind != 'AgentRunner':
|
||||
return None
|
||||
raise ValueError(f'Invalid AgentRunner kind: {kind or "<missing>"}')
|
||||
|
||||
# Validate metadata
|
||||
metadata = manifest.get('metadata', {})
|
||||
name = metadata.get('name', '')
|
||||
if not name:
|
||||
return None
|
||||
raise ValueError('Missing AgentRunner metadata.name')
|
||||
|
||||
# metadata.label must exist
|
||||
label = metadata.get('label', {})
|
||||
@@ -118,28 +183,20 @@ class AgentRunnerRegistry:
|
||||
capabilities = runner_data.get('capabilities') or spec.get('capabilities', {})
|
||||
permissions = runner_data.get('permissions') or spec.get('permissions', {})
|
||||
|
||||
# Build descriptor
|
||||
runner_id = format_runner_id(
|
||||
source='plugin',
|
||||
plugin_author=plugin_author,
|
||||
plugin_name=plugin_name,
|
||||
runner_name=runner_name,
|
||||
)
|
||||
|
||||
return AgentRunnerDescriptor(
|
||||
id=runner_id,
|
||||
source='plugin',
|
||||
label=label,
|
||||
description=metadata.get('description') or runner_data.get('runner_description'),
|
||||
plugin_author=plugin_author,
|
||||
plugin_name=plugin_name,
|
||||
runner_name=runner_name,
|
||||
plugin_version=runner_data.get('plugin_version'),
|
||||
config_schema=config_schema,
|
||||
capabilities=capabilities,
|
||||
permissions=permissions,
|
||||
raw_manifest=manifest,
|
||||
)
|
||||
try:
|
||||
return AgentRunnerManifest(
|
||||
id=runner_id,
|
||||
name=runner_name,
|
||||
label=label,
|
||||
description=metadata.get('description') or runner_data.get('runner_description'),
|
||||
capabilities=capabilities,
|
||||
permissions=permissions,
|
||||
config_schema=config_schema,
|
||||
)
|
||||
except pydantic.ValidationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ValueError(f'Invalid AgentRunner manifest: {exc}') from exc
|
||||
|
||||
async def refresh(self) -> None:
|
||||
"""Refresh runner cache.
|
||||
|
||||
@@ -18,17 +18,14 @@ from .host_models import AgentEventEnvelope, AgentBinding
|
||||
|
||||
|
||||
class AgentResourceBuilder:
|
||||
"""Builder for constructing AgentResources with permission filtering.
|
||||
"""Builder for constructing run-scoped AgentResources with permission filtering.
|
||||
|
||||
Responsibilities:
|
||||
- Apply 3-layer permission filtering:
|
||||
1. Runner manifest declared permissions
|
||||
2. Pipeline extensions_preference (bound plugins/MCP servers)
|
||||
3. Agent/runner config selected resources
|
||||
- Apply manifest permissions intersected with binding resource policy
|
||||
- Build models list from authorized models
|
||||
- Build tools list from bound plugins/MCP servers
|
||||
- Build knowledge_bases list from config
|
||||
- Build storage and files permissions summary
|
||||
- Build storage and files access summary
|
||||
|
||||
Note: This only builds the resource declaration. The actual proxy actions
|
||||
in handler.py must still validate against ctx.resources at runtime.
|
||||
@@ -59,26 +56,21 @@ class AgentResourceBuilder:
|
||||
Args:
|
||||
event: Event envelope
|
||||
binding: Agent binding with resource policy
|
||||
descriptor: Runner descriptor with permissions and capabilities
|
||||
descriptor: Runner descriptor with capabilities, permissions, and config schema
|
||||
|
||||
Returns:
|
||||
AgentResources dict with filtered resource lists
|
||||
"""
|
||||
# Layer 1: Runner manifest permissions
|
||||
manifest_perms = descriptor.permissions
|
||||
|
||||
# Layer 2: Binding resource policy
|
||||
resource_policy = binding.resource_policy
|
||||
|
||||
# Layer 3: Agent/runner config
|
||||
runner_config = binding.runner_config
|
||||
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, binding
|
||||
manifest_perms, resource_policy, descriptor
|
||||
)
|
||||
knowledge_bases = await self._build_knowledge_bases_from_binding(
|
||||
manifest_perms, resource_policy, descriptor, runner_config
|
||||
@@ -100,7 +92,7 @@ class AgentResourceBuilder:
|
||||
|
||||
async def _build_models_from_binding(
|
||||
self,
|
||||
manifest_perms: dict[str, list[str]],
|
||||
manifest_perms: typing.Any,
|
||||
resource_policy: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
runner_config: dict[str, typing.Any],
|
||||
@@ -109,10 +101,10 @@ class AgentResourceBuilder:
|
||||
models: list[ModelResource] = []
|
||||
seen_model_ids: set[str] = set()
|
||||
|
||||
model_perms = manifest_perms.get('models', [])
|
||||
allow_llm = 'invoke' in model_perms or 'stream' in model_perms
|
||||
allow_rerank = 'rerank' in model_perms
|
||||
if not allow_llm and not allow_rerank:
|
||||
model_perms = set(manifest_perms.models)
|
||||
include_llm = bool({'invoke', 'stream'} & model_perms)
|
||||
include_rerank = 'rerank' in model_perms
|
||||
if not include_llm and not include_rerank:
|
||||
return models
|
||||
|
||||
# Get additional model UUID grants from resource policy.
|
||||
@@ -124,12 +116,12 @@ class AgentResourceBuilder:
|
||||
seen_model_ids=seen_model_ids,
|
||||
descriptor=descriptor,
|
||||
runner_config=runner_config,
|
||||
include_llm=allow_llm,
|
||||
include_rerank=allow_rerank,
|
||||
include_llm=include_llm,
|
||||
include_rerank=include_rerank,
|
||||
)
|
||||
|
||||
# Add explicitly allowed models
|
||||
if allowed_uuids and allow_llm:
|
||||
if allowed_uuids and include_llm:
|
||||
for model_uuid in allowed_uuids:
|
||||
await self._append_llm_model_resource(models, seen_model_ids, model_uuid)
|
||||
|
||||
@@ -137,16 +129,17 @@ class AgentResourceBuilder:
|
||||
|
||||
async def _build_tools_from_binding(
|
||||
self,
|
||||
manifest_perms: dict[str, list[str]],
|
||||
manifest_perms: typing.Any,
|
||||
resource_policy: typing.Any,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> list[ToolResource]:
|
||||
"""Build tools list from binding."""
|
||||
tools: list[ToolResource] = []
|
||||
tool_perms = set(manifest_perms.tools)
|
||||
if not ({'detail', 'call'} & tool_perms):
|
||||
return tools
|
||||
|
||||
# Check manifest permission
|
||||
tool_perms = manifest_perms.get('tools', [])
|
||||
if 'detail' not in tool_perms and 'call' not in tool_perms:
|
||||
if not config_schema.uses_host_tools(descriptor):
|
||||
return tools
|
||||
|
||||
# Get tool names from resource policy
|
||||
@@ -164,17 +157,18 @@ class AgentResourceBuilder:
|
||||
|
||||
async def _build_knowledge_bases_from_binding(
|
||||
self,
|
||||
manifest_perms: dict[str, list[str]],
|
||||
manifest_perms: typing.Any,
|
||||
resource_policy: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
runner_config: dict[str, typing.Any],
|
||||
) -> list[KnowledgeBaseResource]:
|
||||
"""Build knowledge bases list from binding."""
|
||||
kb_resources: list[KnowledgeBaseResource] = []
|
||||
kb_perms = set(manifest_perms.knowledge_bases)
|
||||
if not ({'list', 'retrieve'} & kb_perms):
|
||||
return kb_resources
|
||||
|
||||
# Check manifest permission
|
||||
kb_perms = manifest_perms.get('knowledge_bases', [])
|
||||
if 'list' not in kb_perms and 'retrieve' not in kb_perms:
|
||||
if not config_schema.uses_host_knowledge_bases(descriptor):
|
||||
return kb_resources
|
||||
|
||||
# Get KB UUID grants from schema-defined config fields.
|
||||
@@ -231,12 +225,12 @@ class AgentResourceBuilder:
|
||||
|
||||
def _build_storage_from_binding(
|
||||
self,
|
||||
manifest_perms: dict[str, list[str]],
|
||||
manifest_perms: typing.Any,
|
||||
binding: AgentBinding,
|
||||
) -> StorageResource:
|
||||
"""Build storage permissions from binding."""
|
||||
storage_perms = manifest_perms.get('storage', [])
|
||||
"""Build storage access summary from manifest and binding policy."""
|
||||
resource_policy = binding.resource_policy
|
||||
storage_perms = set(manifest_perms.storage)
|
||||
|
||||
return {
|
||||
'plugin_storage': 'plugin' in storage_perms and resource_policy.allow_plugin_storage,
|
||||
|
||||
@@ -3,6 +3,16 @@ from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import pydantic
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.result import (
|
||||
ActionRequestedPayload,
|
||||
ArtifactCreatedPayload,
|
||||
MessageCompletedPayload,
|
||||
MessageDeltaPayload,
|
||||
RunCompletedPayload,
|
||||
RunFailedPayload,
|
||||
StateUpdatedPayload,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
|
||||
from ...core import app
|
||||
@@ -13,6 +23,16 @@ from .errors import RunnerExecutionError, RunnerProtocolError
|
||||
# Maximum size for a single result payload (prevent memory exhaustion)
|
||||
MAX_RESULT_SIZE_BYTES = 1024 * 1024 # 1 MB
|
||||
|
||||
STRICT_RESULT_PAYLOADS: dict[str, type[pydantic.BaseModel]] = {
|
||||
'message.delta': MessageDeltaPayload,
|
||||
'message.completed': MessageCompletedPayload,
|
||||
'state.updated': StateUpdatedPayload,
|
||||
'artifact.created': ArtifactCreatedPayload,
|
||||
'action.requested': ActionRequestedPayload,
|
||||
'run.completed': RunCompletedPayload,
|
||||
'run.failed': RunFailedPayload,
|
||||
}
|
||||
|
||||
|
||||
class AgentResultNormalizer:
|
||||
"""Normalizer for converting AgentRunResult to Pipeline messages.
|
||||
@@ -87,6 +107,9 @@ class AgentResultNormalizer:
|
||||
# Handle each result type
|
||||
data = result_dict.get('data', {})
|
||||
|
||||
if not self.validate_payload(result_type, data, descriptor):
|
||||
return None
|
||||
|
||||
if result_type == 'message.delta':
|
||||
return self._normalize_message_delta(data, descriptor)
|
||||
|
||||
@@ -160,6 +183,31 @@ class AgentResultNormalizer:
|
||||
)
|
||||
return None
|
||||
|
||||
def validate_payload(
|
||||
self,
|
||||
result_type: str,
|
||||
data: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> bool:
|
||||
"""Validate typed payloads that affect Host state or delivery.
|
||||
|
||||
Tool-call telemetry stays intentionally loose so older runners can keep
|
||||
emitting diagnostic fields. Unknown result types are handled by the
|
||||
caller and are not validated here.
|
||||
"""
|
||||
payload_model = STRICT_RESULT_PAYLOADS.get(result_type)
|
||||
if payload_model is None:
|
||||
return True
|
||||
|
||||
try:
|
||||
payload_model.model_validate(data)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} returned invalid {result_type} payload; dropping result: {e}'
|
||||
)
|
||||
return False
|
||||
|
||||
def _normalize_message_delta(
|
||||
self,
|
||||
data: dict[str, typing.Any],
|
||||
|
||||
@@ -25,7 +25,7 @@ class RunAuthorizationSnapshot(typing.TypedDict):
|
||||
"""
|
||||
|
||||
resources: AgentResources
|
||||
permissions: dict[str, list[str]]
|
||||
available_apis: dict[str, bool]
|
||||
conversation_id: str | None
|
||||
state_policy: dict[str, typing.Any]
|
||||
state_context: dict[str, typing.Any]
|
||||
@@ -80,7 +80,7 @@ class AgentRunSessionRegistry:
|
||||
plugin_identity: str,
|
||||
resources: AgentResources,
|
||||
conversation_id: str | None = None,
|
||||
permissions: dict[str, list[str]] | None = None,
|
||||
available_apis: dict[str, bool] | None = None,
|
||||
state_policy: dict[str, typing.Any] | None = None,
|
||||
state_context: dict[str, typing.Any] | None = None,
|
||||
) -> None:
|
||||
@@ -93,14 +93,13 @@ class AgentRunSessionRegistry:
|
||||
plugin_identity: Plugin identifier (author/name)
|
||||
resources: Authorized resources for this run
|
||||
conversation_id: Conversation ID for history/event access
|
||||
permissions: Runner permissions from descriptor (artifacts, history, events, etc.)
|
||||
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.)
|
||||
"""
|
||||
now = int(time.time())
|
||||
|
||||
# Normalize permissions to empty dict if None
|
||||
permissions = permissions or {}
|
||||
available_apis = copy.deepcopy(available_apis or {})
|
||||
|
||||
# Normalize state_policy to defaults if None
|
||||
if state_policy is None:
|
||||
@@ -112,7 +111,7 @@ class AgentRunSessionRegistry:
|
||||
resources_snapshot = copy.deepcopy(resources)
|
||||
authorization: RunAuthorizationSnapshot = {
|
||||
'resources': resources_snapshot,
|
||||
'permissions': copy.deepcopy(permissions),
|
||||
'available_apis': available_apis,
|
||||
'conversation_id': conversation_id,
|
||||
'state_policy': copy.deepcopy(state_policy),
|
||||
'state_context': copy.deepcopy(state_context),
|
||||
|
||||
Reference in New Issue
Block a user