Files
LangBot/src/langbot/pkg/agent/runner/interaction_manager.py
T

451 lines
20 KiB
Python

"""Host validation, persistence, and delivery for structured interactions."""
from __future__ import annotations
from langbot.pkg.telemetry import diagnostics
import json
import time
import typing
import pydantic
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from .descriptor import RunnerDescriptor
from .errors import RunnerProtocolError
from .host_models import AgentBinding, AgentEventEnvelope
from .interaction_store import InteractionStore
INTERACTION_REQUESTED_ACTION = 'interaction.requested'
INTERACTION_DELIVERY_API = 'interaction.request'
INTERACTION_ACKNOWLEDGE_API = 'interaction.acknowledge'
INTERACTION_PAYLOAD_MAX_BYTES = 256 * 1024
DEFAULT_INTERACTION_TTL_SECONDS = 30 * 60
MAX_INTERACTION_TTL_SECONDS = 24 * 60 * 60
class HostInteractionOption(pydantic.BaseModel):
"""Host-validated selectable interaction value."""
value: str = pydantic.Field(min_length=1, max_length=512)
label: str = pydantic.Field(min_length=1, max_length=512)
description: str | None = pydantic.Field(default=None, max_length=2000)
model_config = pydantic.ConfigDict(extra='forbid')
class HostInteractionField(pydantic.BaseModel):
"""Host-validated structured interaction field."""
id: str = pydantic.Field(min_length=1, max_length=128)
label: str = pydantic.Field(min_length=1, max_length=512)
type: typing.Literal['text', 'textarea', 'select', 'multiselect', 'number', 'boolean', 'file']
required: bool = False
options: list[HostInteractionOption] = pydantic.Field(default_factory=list, max_length=100)
placeholder: str | None = pydantic.Field(default=None, max_length=2000)
default: typing.Any = None
model_config = pydantic.ConfigDict(extra='forbid')
class HostInteractionAction(pydantic.BaseModel):
"""Host-validated interaction submit action."""
id: str = pydantic.Field(min_length=1, max_length=128)
label: str = pydantic.Field(min_length=1, max_length=512)
style: typing.Literal['default', 'primary', 'danger'] = 'default'
model_config = pydantic.ConfigDict(extra='forbid')
class HostInteractionRequest(pydantic.BaseModel):
"""Defensive Host projection of the SDK interaction request contract."""
interaction_id: str = pydantic.Field(min_length=1, max_length=255)
kind: typing.Literal['form', 'confirmation', 'choice'] = 'form'
title: str = pydantic.Field(min_length=1, max_length=1000)
description: str | None = pydantic.Field(default=None, max_length=10000)
fields: list[HostInteractionField] = pydantic.Field(default_factory=list, max_length=50)
actions: list[HostInteractionAction] = pydantic.Field(default_factory=list, max_length=20)
expires_at: int | None = None
fallback_text: str = pydantic.Field(min_length=1, max_length=20000)
model_config = pydantic.ConfigDict(extra='forbid')
class HostInteractionSubmission(pydantic.BaseModel):
"""Host-normalized platform callback payload."""
interaction_id: str = pydantic.Field(min_length=1, max_length=255)
action_id: str | None = pydantic.Field(default=None, max_length=128)
values: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
submitted_at: int | None = None
model_config = pydantic.ConfigDict(extra='forbid')
class InteractionManager:
"""Consume the interaction action whitelist for one Host application."""
def __init__(self, ap: typing.Any, store: InteractionStore | None = None):
self.ap = ap
self.store = store or InteractionStore(ap.persistence_mgr.get_db_engine())
@diagnostics.observe('interaction', 'interaction.request', source='agent', stage='execute')
async def handle_result(
self,
*,
result_dict: dict[str, typing.Any],
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: RunnerDescriptor,
run_id: str,
adapter_context: dict[str, typing.Any] | None,
) -> bool:
"""Execute an authorized interaction request; return whether it was consumed."""
data = result_dict.get('data')
if not isinstance(data, dict) or data.get('action') != INTERACTION_REQUESTED_ACTION:
return False
self._authorize(descriptor, binding)
payload = data.get('payload')
try:
request = HostInteractionRequest.model_validate(payload)
except pydantic.ValidationError as exc:
raise RunnerProtocolError(descriptor.id, f'invalid interaction request: {exc}') from exc
processor_id = binding.processor_id or binding.agent_id
if not processor_id:
raise RunnerProtocolError(descriptor.id, 'interaction request has no stable processor identity')
adapter = self._resolve_adapter(adapter_context)
if adapter is None:
raise RunnerProtocolError(descriptor.id, 'interaction request has no delivery adapter')
request_data = request.model_dump(mode='json')
now = int(time.time())
expires_at = now + DEFAULT_INTERACTION_TTL_SECONDS if request.expires_at is None else request.expires_at
if expires_at <= now:
raise RunnerProtocolError(descriptor.id, 'interaction request is already expired')
if expires_at > now + MAX_INTERACTION_TTL_SECONDS:
raise RunnerProtocolError(descriptor.id, 'interaction request expiry exceeds 24 hours')
request_data['expires_at'] = expires_at
if len(json.dumps(request_data, ensure_ascii=False).encode('utf-8')) > INTERACTION_PAYLOAD_MAX_BYTES:
raise RunnerProtocolError(descriptor.id, 'interaction request exceeds 256 KiB')
self._validate_request_identity(request, descriptor.id)
reply_target = event.delivery.reply_target or {}
target_type = reply_target.get('target_type')
target_id = reply_target.get('target_id')
callback_conversation_id = f'{target_type}_{target_id}' if target_type and target_id else event.conversation_id
update_target = await self._find_update_target(
event=event,
binding=binding,
descriptor=descriptor,
processor_id=processor_id,
conversation_id=callback_conversation_id,
)
_, callback_token = await self.store.create_request(
interaction_id=request.interaction_id,
run_id=run_id,
binding_id=binding.binding_id,
runner_id=descriptor.id,
processor_type=binding.processor_type,
processor_id=processor_id,
request=request_data,
delivery_target=reply_target,
replaces_interaction_id=(update_target or {}).get('interaction_id'),
bot_id=event.bot_id,
workspace_id=event.workspace_id,
conversation_id=callback_conversation_id,
thread_id=event.thread_id,
actor_id=event.actor.actor_id if event.actor else None,
expires_at=expires_at,
)
try:
if self._supports_interaction_delivery(adapter):
delivery_params = {
'request': request_data,
'callback_token': callback_token,
'reply_target': event.delivery.reply_target,
}
if update_target is not None:
delivery_params['update_target'] = update_target.get('delivery_result')
try:
delivery_result = await adapter.call_platform_api(
INTERACTION_DELIVERY_API,
delivery_params,
)
except Exception as exc:
if update_target is None:
raise
self._warning(f'Interaction update failed; sending a new presentation: {exc}')
fallback_params = dict(delivery_params)
fallback_params.pop('update_target', None)
delivery_result = await adapter.call_platform_api(
INTERACTION_DELIVERY_API,
fallback_params,
)
if isinstance(delivery_result, dict):
try:
await self.store.record_delivery_success(
run_id,
request.interaction_id,
delivery_result,
)
except Exception as exc:
self._warning(f'Failed to persist interaction delivery result: {exc}')
else:
await self._deliver_fallback(adapter, event, request.fallback_text)
except Exception as exc:
await self.store.mark_delivery_failed(run_id, request.interaction_id, str(exc))
raise
diagnostics.set_outcome('waiting', reason_code='waiting')
return True
@diagnostics.observe('interaction', 'interaction.acknowledge', source='platform', stage='ack')
async def acknowledge_submission(self, record: dict[str, typing.Any], adapter: typing.Any) -> None:
"""Best-effort transition of submitted controls into a read-only state."""
delivery_result = record.get('delivery_result')
if not isinstance(delivery_result, dict) or not self._supports_platform_api(
adapter, INTERACTION_ACKNOWLEDGE_API
):
diagnostics.set_outcome('skipped')
return
try:
delivery_result = await adapter.call_platform_api(
INTERACTION_ACKNOWLEDGE_API,
{
'request': record.get('request') or {},
'submission': record.get('submission') or {},
'update_target': delivery_result,
},
)
if isinstance(delivery_result, dict):
await self.store.record_delivery_success(
record['run_id'],
record['interaction_id'],
delivery_result,
)
except Exception as exc:
diagnostics.set_outcome('failed', reason_code='response_error')
diagnostics.annotate(error=exc)
self._warning(f'Failed to acknowledge interaction submission: {exc}')
async def _find_update_target(
self,
*,
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: RunnerDescriptor,
processor_id: str,
conversation_id: str | None,
) -> dict[str, typing.Any] | None:
capabilities = getattr(event.delivery, 'interactions', None)
if not capabilities or not getattr(capabilities, 'supports_updates', False):
return None
prior = getattr(event.input, 'interaction', None)
prior_interaction_id = getattr(prior, 'interaction_id', None)
if not prior_interaction_id:
return None
return await self.store.find_update_target(
interaction_id=str(prior_interaction_id),
binding_id=binding.binding_id,
runner_id=descriptor.id,
processor_type=binding.processor_type,
processor_id=processor_id,
bot_id=event.bot_id,
conversation_id=conversation_id,
actor_id=event.actor.actor_id if event.actor else None,
)
@staticmethod
def _validate_request_identity(request: HostInteractionRequest, runner_id: str) -> None:
field_ids = [field.id for field in request.fields]
action_ids = [action.id for action in request.actions]
if len(field_ids) != len(set(field_ids)):
raise RunnerProtocolError(runner_id, 'interaction request has duplicate field IDs')
if len(action_ids) != len(set(action_ids)):
raise RunnerProtocolError(runner_id, 'interaction request has duplicate action IDs')
for field in request.fields:
if field.type in {'select', 'multiselect'} and not field.options:
raise RunnerProtocolError(
runner_id,
f'interaction field {field.id} requires at least one option',
)
option_values = [option.value for option in field.options]
if len(option_values) != len(set(option_values)):
raise RunnerProtocolError(
runner_id,
f'interaction field {field.id} has duplicate option values',
)
@diagnostics.observe('interaction', 'interaction.consume', source='platform', stage='dispatch')
async def consume_callback(
self,
*,
callback_token: str,
submission: dict[str, typing.Any],
bot_id: str | None,
conversation_id: str | None,
actor_id: str | None,
) -> dict[str, typing.Any]:
"""Validate a platform callback and return the persisted resume target."""
pending = await self.store.get_by_callback_token(callback_token)
if pending is None:
from .interaction_store import InteractionNotFoundError
raise InteractionNotFoundError('Unknown interaction callback token')
normalized_submission = self._resolve_submission_refs(pending, submission)
try:
normalized = HostInteractionSubmission.model_validate(normalized_submission)
except pydantic.ValidationError as exc:
raise ValueError(f'invalid interaction submission: {exc}') from exc
self._validate_submission(pending, normalized)
submission_data = normalized.model_dump(mode='json')
submission_data['submitted_at'] = int(time.time())
if len(json.dumps(submission_data, ensure_ascii=False).encode('utf-8')) > 256 * 1024:
raise ValueError('interaction submission exceeds 256 KiB')
return await self.store.consume_submission(
callback_token=callback_token,
submission=submission_data,
bot_id=bot_id,
conversation_id=conversation_id,
actor_id=actor_id,
submitted_at=submission_data['submitted_at'],
)
@staticmethod
def _resolve_submission_refs(
pending: dict[str, typing.Any],
submission: dict[str, typing.Any],
) -> dict[str, typing.Any]:
"""Resolve compact platform indexes against the persisted request."""
resolved = dict(submission)
resolved['interaction_id'] = pending['interaction_id']
request = pending.get('request') if isinstance(pending.get('request'), dict) else {}
action_ref = resolved.pop('action_ref', None)
if action_ref is not None:
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
try:
action = actions[InteractionManager._callback_index(action_ref, 'action')]
except (IndexError, TypeError, ValueError) as exc:
raise ValueError('interaction action reference is invalid') from exc
if not isinstance(action, dict) or not action.get('id'):
raise ValueError('interaction action reference has no action ID')
resolved['action_id'] = str(action['id'])
field_ref = resolved.pop('field_ref', None)
option_ref = resolved.pop('option_ref', None)
if field_ref is not None or option_ref is not None:
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
try:
field = fields[InteractionManager._callback_index(field_ref, 'field')]
option = field['options'][InteractionManager._callback_index(option_ref, 'option')]
except (IndexError, KeyError, TypeError, ValueError) as exc:
raise ValueError('interaction field option reference is invalid') from exc
if not isinstance(field, dict) or not field.get('id') or not isinstance(option, dict):
raise ValueError('interaction field option reference is malformed')
resolved['values'] = {str(field['id']): option.get('value')}
return resolved
@staticmethod
def _callback_index(value: typing.Any, label: str) -> int:
if isinstance(value, bool):
raise ValueError(f'interaction {label} reference is not an integer')
if isinstance(value, int):
index = value
elif isinstance(value, str) and value.isdigit():
index = int(value)
else:
raise ValueError(f'interaction {label} reference is not an integer')
if index < 0:
raise ValueError(f'interaction {label} reference is negative')
return index
@staticmethod
def _validate_submission(
pending: dict[str, typing.Any],
submission: HostInteractionSubmission,
) -> None:
request = pending.get('request') if isinstance(pending.get('request'), dict) else {}
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
action_ids = {str(action.get('id')) for action in actions if isinstance(action, dict) and action.get('id')}
if submission.action_id is not None and submission.action_id not in action_ids:
raise ValueError('interaction submission action is not present in the request')
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
fields_by_id = {str(field.get('id')): field for field in fields if isinstance(field, dict) and field.get('id')}
for field_id, value in submission.values.items():
field = fields_by_id.get(field_id)
if field is None:
raise ValueError(f'interaction submission field is not present in the request: {field_id}')
field_type = field.get('type')
if field_type not in {'select', 'multiselect'}:
continue
options = field.get('options') if isinstance(field.get('options'), list) else []
allowed_values = {
option.get('value')
for option in options
if isinstance(option, dict) and option.get('value') is not None
}
selected_values = value if field_type == 'multiselect' and isinstance(value, list) else [value]
if field_type == 'multiselect' and not isinstance(value, list):
raise ValueError(f'interaction multiselect field requires a list: {field_id}')
if any(selected not in allowed_values for selected in selected_values):
raise ValueError(f'interaction submission option is not present in the request: {field_id}')
@staticmethod
def _authorize(descriptor: RunnerDescriptor, binding: AgentBinding) -> None:
supports_interactions = bool(getattr(descriptor.capabilities, 'interactions', False))
permissions = set(getattr(descriptor.permissions, 'interactions', []) or [])
if not supports_interactions or 'request' not in permissions:
raise RunnerProtocolError(descriptor.id, 'runner did not declare interaction capability and permission')
if not binding.delivery_policy.enable_interactions:
raise RunnerProtocolError(descriptor.id, 'binding delivery policy does not allow interactions')
@staticmethod
def _resolve_adapter(adapter_context: dict[str, typing.Any] | None) -> typing.Any | None:
if not adapter_context:
return None
query = adapter_context.get('_query')
if query is not None and getattr(query, 'adapter', None) is not None:
return query.adapter
return adapter_context.get('_delivery_adapter')
@staticmethod
def _supports_interaction_delivery(adapter: typing.Any) -> bool:
return InteractionManager._supports_platform_api(adapter, INTERACTION_DELIVERY_API)
@staticmethod
def _supports_platform_api(adapter: typing.Any, api_name: str) -> bool:
get_supported_apis = getattr(adapter, 'get_supported_apis', None)
if not callable(get_supported_apis):
return False
try:
return api_name in (get_supported_apis() or [])
except Exception:
return False
def _warning(self, message: str) -> None:
logger = getattr(self.ap, 'logger', None)
warning = getattr(logger, 'warning', None)
if callable(warning):
warning(message)
@staticmethod
async def _deliver_fallback(adapter: typing.Any, event: AgentEventEnvelope, text: str) -> None:
target = event.delivery.reply_target or {}
target_type = target.get('target_type')
target_id = target.get('target_id')
if not target_type or not target_id:
raise ValueError('interaction fallback has no reply target')
await adapter.send_message(
str(target_type),
str(target_id),
platform_message.MessageChain([platform_message.Plain(text=text)]),
)