mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-17 07:17:18 +00:00
451 lines
20 KiB
Python
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)]),
|
|
)
|