mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-25 05:46:13 +00:00
feat(agent): integrate structured runner interactions
This commit is contained in:
@@ -30,16 +30,10 @@ class AgentBindingResolver:
|
||||
Agent and does not carry enough scope metadata to make that decision
|
||||
safely here.
|
||||
"""
|
||||
matches = [
|
||||
agent
|
||||
for agent in agents
|
||||
if agent.enabled and event.event_type in agent.event_types
|
||||
]
|
||||
matches = [agent for agent in agents if agent.enabled and event.event_type in agent.event_types]
|
||||
|
||||
if not matches:
|
||||
raise AgentBindingResolutionError(
|
||||
f'No Agent binding matches event_type={event.event_type}'
|
||||
)
|
||||
raise AgentBindingResolutionError(f'No Agent binding matches event_type={event.event_type}')
|
||||
|
||||
if len(matches) > 1:
|
||||
agent_ids = ', '.join(agent.agent_id or '<anonymous>' for agent in matches)
|
||||
@@ -57,7 +51,7 @@ class AgentBindingResolver:
|
||||
)
|
||||
|
||||
return AgentBinding(
|
||||
binding_id=f"agent_{agent.agent_id or 'default'}_{agent.runner_id}",
|
||||
binding_id=f'agent_{agent.agent_id or "default"}_{agent.runner_id}',
|
||||
scope=scope,
|
||||
event_types=list(agent.event_types),
|
||||
runner_id=agent.runner_id,
|
||||
@@ -67,4 +61,6 @@ class AgentBindingResolver:
|
||||
delivery_policy=agent.delivery_policy,
|
||||
enabled=agent.enabled,
|
||||
agent_id=agent.agent_id,
|
||||
processor_type=agent.processor_type,
|
||||
processor_id=agent.processor_id or agent.agent_id,
|
||||
)
|
||||
|
||||
@@ -45,6 +45,7 @@ class AgentInput(typing.TypedDict):
|
||||
text: str | None
|
||||
contents: list[dict[str, typing.Any]]
|
||||
attachments: list[dict[str, typing.Any]]
|
||||
interaction: typing.NotRequired[dict[str, typing.Any] | None]
|
||||
|
||||
|
||||
class AgentRunState(typing.TypedDict):
|
||||
@@ -299,6 +300,11 @@ class AgentRunContextBuilder:
|
||||
a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments
|
||||
],
|
||||
}
|
||||
if getattr(event.input, 'interaction', None) is not None:
|
||||
interaction = event.input.interaction
|
||||
input['interaction'] = (
|
||||
interaction.model_dump(mode='json') if hasattr(interaction, 'model_dump') else interaction
|
||||
)
|
||||
|
||||
# Build context access (no history inlined by default for Protocol v1)
|
||||
# Populate with actual values from stores
|
||||
@@ -333,6 +339,11 @@ class AgentRunContextBuilder:
|
||||
'max_message_size': event.delivery.max_message_size,
|
||||
'platform_capabilities': event.delivery.platform_capabilities,
|
||||
}
|
||||
if getattr(event.delivery, 'interactions', None) is not None:
|
||||
interactions = event.delivery.interactions
|
||||
delivery_context['interactions'] = (
|
||||
interactions.model_dump(mode='json') if hasattr(interactions, 'model_dump') else interactions
|
||||
)
|
||||
|
||||
# Build adapter context (empty for event-first)
|
||||
adapter_context = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Canonical AgentRunner event names reserved for future EBA integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -14,6 +15,9 @@ GROUP_MEMBER_JOINED = 'group.member_joined'
|
||||
FRIEND_REQUEST_RECEIVED = 'friend.request_received'
|
||||
"""A new friend/contact request was received."""
|
||||
|
||||
INTERACTION_SUBMITTED = 'interaction.submitted'
|
||||
"""A user submitted a Host-rendered structured interaction."""
|
||||
|
||||
|
||||
RESERVED_EVENT_TYPES = frozenset(
|
||||
{
|
||||
@@ -21,5 +25,6 @@ RESERVED_EVENT_TYPES = frozenset(
|
||||
MESSAGE_RECALLED,
|
||||
GROUP_MEMBER_JOINED,
|
||||
FRIEND_REQUEST_RECEIVED,
|
||||
INTERACTION_SUBMITTED,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -139,6 +139,9 @@ class DeliveryPolicy(pydantic.BaseModel):
|
||||
enable_reply: bool = True
|
||||
"""Whether reply is enabled."""
|
||||
|
||||
enable_interactions: bool = False
|
||||
"""Whether the binding permits structured interaction delivery."""
|
||||
|
||||
max_message_size: int | None = None
|
||||
"""Maximum message size."""
|
||||
|
||||
@@ -154,6 +157,12 @@ class AgentConfig(pydantic.BaseModel):
|
||||
agent_id: str | None = None
|
||||
"""Host-side Agent/config identifier."""
|
||||
|
||||
processor_type: typing.Literal['agent', 'pipeline'] = 'agent'
|
||||
"""Product processor kind represented by this runtime config."""
|
||||
|
||||
processor_id: str | None = None
|
||||
"""Stable product processor identifier."""
|
||||
|
||||
runner_id: str
|
||||
"""Runner ID to invoke."""
|
||||
|
||||
@@ -215,3 +224,9 @@ class AgentBinding(pydantic.BaseModel):
|
||||
|
||||
agent_id: str | None = None
|
||||
"""Host-side Agent/config identifier for this binding."""
|
||||
|
||||
processor_type: typing.Literal['agent', 'pipeline'] = 'agent'
|
||||
"""Product processor kind selected for this binding."""
|
||||
|
||||
processor_id: str | None = None
|
||||
"""Stable product processor identifier used for callback resume."""
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Host validation, persistence, and delivery for structured interactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import typing
|
||||
|
||||
import pydantic
|
||||
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
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())
|
||||
|
||||
async def handle_result(
|
||||
self,
|
||||
*,
|
||||
result_dict: dict[str, typing.Any],
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
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
|
||||
return True
|
||||
|
||||
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
|
||||
):
|
||||
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:
|
||||
self._warning(f'Failed to acknowledge interaction submission: {exc}')
|
||||
|
||||
async def _find_update_target(
|
||||
self,
|
||||
*,
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
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',
|
||||
)
|
||||
|
||||
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: AgentRunnerDescriptor, 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)]),
|
||||
)
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Persistent Host store for structured interaction requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import typing
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from ...entity.persistence.agent_interaction import AgentInteraction
|
||||
|
||||
|
||||
UTC = datetime.timezone.utc
|
||||
INTERACTION_STATUSES = {'pending', 'submitted', 'expired', 'cancelled', 'delivery_failed'}
|
||||
PROCESSOR_TYPES = {'agent', 'pipeline'}
|
||||
|
||||
|
||||
class InteractionStoreError(Exception):
|
||||
"""Base error for interaction persistence operations."""
|
||||
|
||||
|
||||
class InteractionNotFoundError(InteractionStoreError):
|
||||
"""Raised when a callback token does not identify an interaction."""
|
||||
|
||||
|
||||
class InteractionScopeError(InteractionStoreError):
|
||||
"""Raised when a callback does not belong to the stored delivery scope."""
|
||||
|
||||
|
||||
class InteractionExpiredError(InteractionStoreError):
|
||||
"""Raised when an interaction is no longer accepting submissions."""
|
||||
|
||||
|
||||
class InteractionAlreadySubmittedError(InteractionStoreError):
|
||||
"""Raised when an interaction callback is replayed."""
|
||||
|
||||
|
||||
class DuplicateInteractionError(InteractionStoreError):
|
||||
"""Raised when one run emits the same interaction ID more than once."""
|
||||
|
||||
|
||||
def _utc_now() -> datetime.datetime:
|
||||
return datetime.datetime.now(UTC)
|
||||
|
||||
|
||||
def _as_utc(value: datetime.datetime | None) -> datetime.datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def _epoch_to_datetime(value: int | float | None) -> datetime.datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.datetime.fromtimestamp(float(value), UTC)
|
||||
except (TypeError, ValueError, OSError) as exc:
|
||||
raise ValueError('expires_at must be a valid epoch timestamp') from exc
|
||||
|
||||
|
||||
def _datetime_to_epoch(value: datetime.datetime | None) -> int | None:
|
||||
normalized = _as_utc(value)
|
||||
return int(normalized.timestamp()) if normalized is not None else None
|
||||
|
||||
|
||||
def _json_dumps(value: typing.Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
|
||||
|
||||
|
||||
def _json_loads(value: str | None, default: typing.Any) -> typing.Any:
|
||||
if not value:
|
||||
return default
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _token_hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
class InteractionStore:
|
||||
"""Store interaction correlation and consume callbacks exactly once."""
|
||||
|
||||
def __init__(self, engine: AsyncEngine):
|
||||
self.engine = engine
|
||||
self._session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async def create_request(
|
||||
self,
|
||||
*,
|
||||
interaction_id: str,
|
||||
run_id: str,
|
||||
binding_id: str,
|
||||
runner_id: str,
|
||||
processor_type: str,
|
||||
processor_id: str,
|
||||
request: dict[str, typing.Any],
|
||||
delivery_target: dict[str, typing.Any] | None,
|
||||
replaces_interaction_id: str | None = None,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
actor_id: str | None = None,
|
||||
expires_at: int | float | None = None,
|
||||
) -> tuple[dict[str, typing.Any], str]:
|
||||
"""Persist a request and return its record plus one-time callback token."""
|
||||
if not interaction_id or not run_id or not binding_id or not runner_id or not processor_id:
|
||||
raise ValueError('interaction, run, binding, runner, and processor IDs are required')
|
||||
if processor_type not in PROCESSOR_TYPES:
|
||||
raise ValueError(f'Unsupported processor type: {processor_type}')
|
||||
|
||||
request_json = _json_dumps(request)
|
||||
delivery_target_json = _json_dumps(delivery_target) if delivery_target is not None else None
|
||||
expires_at_dt = _epoch_to_datetime(expires_at)
|
||||
now = _utc_now()
|
||||
if expires_at_dt is not None and expires_at_dt <= now:
|
||||
raise ValueError('expires_at must be in the future')
|
||||
|
||||
callback_token = secrets.token_urlsafe(18)
|
||||
row = AgentInteraction(
|
||||
interaction_id=interaction_id,
|
||||
run_id=run_id,
|
||||
binding_id=binding_id,
|
||||
runner_id=runner_id,
|
||||
processor_type=processor_type,
|
||||
processor_id=processor_id,
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_id,
|
||||
conversation_id=conversation_id,
|
||||
thread_id=thread_id,
|
||||
actor_id=actor_id,
|
||||
status='pending',
|
||||
request_json=request_json,
|
||||
delivery_target_json=delivery_target_json,
|
||||
replaces_interaction_id=replaces_interaction_id,
|
||||
callback_token_hash=_token_hash(callback_token),
|
||||
expires_at=expires_at_dt,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
existing = await self._get_by_run_interaction(session, run_id, interaction_id)
|
||||
if existing is not None:
|
||||
raise DuplicateInteractionError(f'Interaction {interaction_id} already exists for run {run_id}')
|
||||
session.add(row)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
raise DuplicateInteractionError(
|
||||
f'Interaction {interaction_id} could not be created for run {run_id}'
|
||||
) from exc
|
||||
return self._to_dict(row), callback_token
|
||||
|
||||
async def record_delivery_success(
|
||||
self,
|
||||
run_id: str,
|
||||
interaction_id: str,
|
||||
delivery_result: dict[str, typing.Any],
|
||||
) -> bool:
|
||||
"""Persist the platform presentation handle returned after delivery."""
|
||||
payload = _json_dumps(delivery_result)
|
||||
if len(payload.encode('utf-8')) > 64 * 1024:
|
||||
raise ValueError('interaction delivery result exceeds 64 KiB')
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.update(AgentInteraction)
|
||||
.where(
|
||||
AgentInteraction.run_id == run_id,
|
||||
AgentInteraction.interaction_id == interaction_id,
|
||||
AgentInteraction.status.in_(['pending', 'submitted']),
|
||||
)
|
||||
.values(delivery_result_json=payload, updated_at=_utc_now())
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount == 1
|
||||
|
||||
async def find_update_target(
|
||||
self,
|
||||
*,
|
||||
interaction_id: str,
|
||||
binding_id: str,
|
||||
runner_id: str,
|
||||
processor_type: str,
|
||||
processor_id: str,
|
||||
bot_id: str | None,
|
||||
conversation_id: str | None,
|
||||
actor_id: str | None,
|
||||
) -> dict[str, typing.Any] | None:
|
||||
"""Find a submitted interaction whose platform presentation can be replaced."""
|
||||
if not interaction_id:
|
||||
return None
|
||||
conditions = [
|
||||
AgentInteraction.interaction_id == interaction_id,
|
||||
AgentInteraction.binding_id == binding_id,
|
||||
AgentInteraction.runner_id == runner_id,
|
||||
AgentInteraction.processor_type == processor_type,
|
||||
AgentInteraction.processor_id == processor_id,
|
||||
AgentInteraction.status == 'submitted',
|
||||
AgentInteraction.delivery_result_json.is_not(None),
|
||||
]
|
||||
for column, value in (
|
||||
(AgentInteraction.bot_id, bot_id),
|
||||
(AgentInteraction.conversation_id, conversation_id),
|
||||
(AgentInteraction.actor_id, actor_id),
|
||||
):
|
||||
conditions.append(column.is_(None) if value is None else column == value)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.select(AgentInteraction)
|
||||
.where(*conditions)
|
||||
.order_by(AgentInteraction.submitted_at.desc(), AgentInteraction.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
return self._to_dict(row) if row is not None else None
|
||||
|
||||
async def get_request(self, run_id: str, interaction_id: str) -> dict[str, typing.Any] | None:
|
||||
"""Return a request by the runner-visible identity."""
|
||||
async with self._session_factory() as session:
|
||||
row = await self._get_by_run_interaction(session, run_id, interaction_id)
|
||||
return self._to_dict(row) if row is not None else None
|
||||
|
||||
async def get_by_callback_token(self, callback_token: str) -> dict[str, typing.Any] | None:
|
||||
"""Resolve callback metadata without exposing the stored token hash."""
|
||||
if not callback_token:
|
||||
return None
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.select(AgentInteraction).where(
|
||||
AgentInteraction.callback_token_hash == _token_hash(callback_token)
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
return self._to_dict(row) if row is not None else None
|
||||
|
||||
async def consume_submission(
|
||||
self,
|
||||
*,
|
||||
callback_token: str,
|
||||
submission: dict[str, typing.Any],
|
||||
bot_id: str | None,
|
||||
conversation_id: str | None,
|
||||
actor_id: str | None,
|
||||
submitted_at: int | float | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Validate and atomically consume one platform callback."""
|
||||
if not callback_token:
|
||||
raise InteractionNotFoundError('Missing interaction callback token')
|
||||
now = _utc_now()
|
||||
submission_time = _epoch_to_datetime(submitted_at) or now
|
||||
token_hash = _token_hash(callback_token)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.select(AgentInteraction).where(AgentInteraction.callback_token_hash == token_hash)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
raise InteractionNotFoundError('Unknown interaction callback token')
|
||||
if row.status == 'submitted':
|
||||
raise InteractionAlreadySubmittedError('Interaction was already submitted')
|
||||
if row.status != 'pending':
|
||||
raise InteractionExpiredError(f'Interaction is not pending: {row.status}')
|
||||
|
||||
if submission.get('interaction_id') != row.interaction_id:
|
||||
raise InteractionScopeError('Interaction ID does not match callback token')
|
||||
|
||||
expires_at = _as_utc(row.expires_at)
|
||||
if expires_at is not None and expires_at <= now:
|
||||
row.status = 'expired'
|
||||
row.status_reason = 'interaction expired before submission'
|
||||
row.updated_at = now
|
||||
await session.commit()
|
||||
raise InteractionExpiredError('Interaction has expired')
|
||||
|
||||
self._validate_scope(row, bot_id, conversation_id, actor_id)
|
||||
|
||||
update_result = await session.execute(
|
||||
sqlalchemy.update(AgentInteraction)
|
||||
.where(
|
||||
AgentInteraction.id == row.id,
|
||||
AgentInteraction.status == 'pending',
|
||||
)
|
||||
.values(
|
||||
status='submitted',
|
||||
submitted_at=submission_time,
|
||||
submission_json=_json_dumps(submission),
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
if update_result.rowcount != 1:
|
||||
await session.rollback()
|
||||
raise InteractionAlreadySubmittedError('Interaction callback lost an idempotency race')
|
||||
await session.commit()
|
||||
|
||||
refreshed = await session.get(AgentInteraction, row.id)
|
||||
if refreshed is None:
|
||||
raise InteractionNotFoundError('Interaction disappeared after submission')
|
||||
return self._to_dict(refreshed)
|
||||
|
||||
async def mark_delivery_failed(self, run_id: str, interaction_id: str, reason: str) -> bool:
|
||||
"""Mark an undelivered pending interaction terminal."""
|
||||
now = _utc_now()
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.update(AgentInteraction)
|
||||
.where(
|
||||
AgentInteraction.run_id == run_id,
|
||||
AgentInteraction.interaction_id == interaction_id,
|
||||
AgentInteraction.status == 'pending',
|
||||
)
|
||||
.values(status='delivery_failed', status_reason=reason, updated_at=now)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount == 1
|
||||
|
||||
async def expire_pending(self, *, now: datetime.datetime | None = None) -> int:
|
||||
"""Expire pending interactions whose deadline has elapsed."""
|
||||
cutoff = _as_utc(now) or _utc_now()
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.update(AgentInteraction)
|
||||
.where(
|
||||
AgentInteraction.status == 'pending',
|
||||
AgentInteraction.expires_at.is_not(None),
|
||||
AgentInteraction.expires_at <= cutoff,
|
||||
)
|
||||
.values(
|
||||
status='expired',
|
||||
status_reason='interaction expired',
|
||||
updated_at=cutoff,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
@staticmethod
|
||||
def _validate_scope(
|
||||
row: AgentInteraction,
|
||||
bot_id: str | None,
|
||||
conversation_id: str | None,
|
||||
actor_id: str | None,
|
||||
) -> None:
|
||||
checks = (
|
||||
('bot', row.bot_id, bot_id),
|
||||
('conversation', row.conversation_id, conversation_id),
|
||||
('actor', row.actor_id, actor_id),
|
||||
)
|
||||
for label, expected, actual in checks:
|
||||
if expected is not None and expected != actual:
|
||||
raise InteractionScopeError(f'Interaction {label} scope mismatch')
|
||||
|
||||
@staticmethod
|
||||
async def _get_by_run_interaction(
|
||||
session: AsyncSession,
|
||||
run_id: str,
|
||||
interaction_id: str,
|
||||
) -> AgentInteraction | None:
|
||||
result = await session.execute(
|
||||
sqlalchemy.select(AgentInteraction).where(
|
||||
AgentInteraction.run_id == run_id,
|
||||
AgentInteraction.interaction_id == interaction_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(row: AgentInteraction) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'id': row.id,
|
||||
'interaction_id': row.interaction_id,
|
||||
'run_id': row.run_id,
|
||||
'binding_id': row.binding_id,
|
||||
'runner_id': row.runner_id,
|
||||
'processor_type': row.processor_type,
|
||||
'processor_id': row.processor_id,
|
||||
'bot_id': row.bot_id,
|
||||
'workspace_id': row.workspace_id,
|
||||
'conversation_id': row.conversation_id,
|
||||
'thread_id': row.thread_id,
|
||||
'actor_id': row.actor_id,
|
||||
'status': row.status,
|
||||
'request': _json_loads(row.request_json, {}),
|
||||
'delivery_target': _json_loads(row.delivery_target_json, None),
|
||||
'delivery_result': _json_loads(row.delivery_result_json, None),
|
||||
'replaces_interaction_id': row.replaces_interaction_id,
|
||||
'expires_at': _datetime_to_epoch(row.expires_at),
|
||||
'submitted_at': _datetime_to_epoch(row.submitted_at),
|
||||
'submission': _json_loads(row.submission_json, None),
|
||||
'status_reason': row.status_reason,
|
||||
'created_at': _datetime_to_epoch(row.created_at),
|
||||
'updated_at': _datetime_to_epoch(row.updated_at),
|
||||
}
|
||||
@@ -22,6 +22,7 @@ from .execution_context import (
|
||||
)
|
||||
from .host_models import AgentBinding, AgentEventEnvelope
|
||||
from .invoker import AgentRunnerInvoker
|
||||
from .interaction_manager import InteractionManager
|
||||
from .query_bridge import QueryRunBridge
|
||||
from .registry import AgentRunnerRegistry
|
||||
from .resource_builder import AgentResourceBuilder
|
||||
@@ -51,6 +52,7 @@ class AgentRunOrchestrator:
|
||||
binding_resolver: AgentBindingResolver
|
||||
query_bridge: QueryRunBridge
|
||||
invoker: AgentRunnerInvoker
|
||||
interaction_manager: InteractionManager
|
||||
journal: AgentRunJournal
|
||||
_session_registry: AgentRunSessionRegistry
|
||||
|
||||
@@ -67,6 +69,7 @@ class AgentRunOrchestrator:
|
||||
self.binding_resolver = AgentBindingResolver()
|
||||
self.query_bridge = QueryRunBridge(self.binding_resolver)
|
||||
self.invoker = AgentRunnerInvoker(ap)
|
||||
self.interaction_manager = InteractionManager(ap)
|
||||
self.journal = AgentRunJournal(ap)
|
||||
self._session_registry = get_session_registry()
|
||||
|
||||
@@ -130,6 +133,8 @@ class AgentRunOrchestrator:
|
||||
run_authorization = {
|
||||
'runner_id': descriptor.id,
|
||||
'binding_id': binding.binding_id,
|
||||
'processor_type': binding.processor_type,
|
||||
'processor_id': binding.processor_id,
|
||||
'plugin_identity': descriptor.get_plugin_id(),
|
||||
'resources': resources,
|
||||
'available_apis': available_apis,
|
||||
@@ -252,6 +257,17 @@ class AgentRunOrchestrator:
|
||||
await self.result_normalizer.normalize(result_dict, descriptor)
|
||||
continue
|
||||
|
||||
if result_type == 'action.requested':
|
||||
if await self.interaction_manager.handle_result(
|
||||
result_dict=result_dict,
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
run_id=run_id,
|
||||
adapter_context=adapter_context,
|
||||
):
|
||||
continue
|
||||
|
||||
if result_type == 'run.completed':
|
||||
terminal_status = 'completed'
|
||||
terminal_reason = (
|
||||
|
||||
@@ -7,6 +7,7 @@ Protocol v1 architecture without exposing Query internals to runners.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
||||
@@ -129,16 +130,25 @@ class QueryEntryAdapter:
|
||||
delivery_policy = DeliveryPolicy(
|
||||
enable_streaming=True,
|
||||
enable_reply=True,
|
||||
enable_interactions=True,
|
||||
)
|
||||
variables = getattr(query, 'variables', None) or {}
|
||||
event_type = (
|
||||
runner_events.INTERACTION_SUBMITTED
|
||||
if isinstance(variables.get('_interaction_submission'), dict)
|
||||
else runner_events.MESSAGE_RECEIVED
|
||||
)
|
||||
|
||||
return AgentConfig(
|
||||
agent_id=agent_id,
|
||||
processor_type='pipeline',
|
||||
processor_id=agent_id,
|
||||
runner_id=runner_id,
|
||||
runner_config=runner_config,
|
||||
resource_policy=resource_policy,
|
||||
state_policy=state_policy,
|
||||
delivery_policy=delivery_policy,
|
||||
event_types=[runner_events.MESSAGE_RECEIVED],
|
||||
event_types=[event_type],
|
||||
enabled=True,
|
||||
metadata={'source': 'pipeline_adapter'},
|
||||
)
|
||||
@@ -217,22 +227,37 @@ class QueryEntryAdapter:
|
||||
if message_id == -1:
|
||||
message_id = None
|
||||
|
||||
event_time = None
|
||||
if message_event:
|
||||
event_time = getattr(message_event, 'time', None)
|
||||
if isinstance(event_time, (int, float)):
|
||||
event_time = int(event_time)
|
||||
event_time = cls._normalize_event_time(getattr(message_event, 'time', None) if message_event else None)
|
||||
|
||||
variables = getattr(query, 'variables', None) or {}
|
||||
interaction = variables.get('_interaction_submission')
|
||||
event_type = runner_events.MESSAGE_RECEIVED
|
||||
if isinstance(interaction, dict):
|
||||
event_type = runner_events.INTERACTION_SUBMITTED
|
||||
event_data['interaction'] = interaction
|
||||
|
||||
source_event_id = str(message_id or query.query_id)
|
||||
return AgentEventContext(
|
||||
event_id=cls._build_scoped_event_id(query, source_event_id, event_time),
|
||||
event_type=runner_events.MESSAGE_RECEIVED,
|
||||
event_type=event_type,
|
||||
event_time=event_time,
|
||||
source='host_adapter',
|
||||
source_event_type=source_event_type,
|
||||
data=event_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_event_time(value: typing.Any) -> int | None:
|
||||
"""Normalize legacy second/millisecond/microsecond Unix timestamps."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
timestamp = float(value)
|
||||
if not math.isfinite(timestamp):
|
||||
return None
|
||||
while abs(timestamp) > 10_000_000_000:
|
||||
timestamp /= 1000
|
||||
return int(timestamp)
|
||||
|
||||
@classmethod
|
||||
def _compact_event_data(
|
||||
cls,
|
||||
@@ -370,6 +395,15 @@ class QueryEntryAdapter:
|
||||
if launcher_type is not None:
|
||||
launcher_type_value = getattr(launcher_type, 'value', launcher_type)
|
||||
|
||||
variables = getattr(query, 'variables', None) or {}
|
||||
interaction = variables.get('_interaction_submission')
|
||||
if isinstance(interaction, dict):
|
||||
return SubjectContext(
|
||||
subject_type='interaction',
|
||||
subject_id=str(interaction.get('interaction_id') or ''),
|
||||
data={'action_id': interaction.get('action_id')},
|
||||
)
|
||||
|
||||
return SubjectContext(
|
||||
subject_type='message',
|
||||
subject_id=str(message_id or query_id or ''),
|
||||
@@ -435,11 +469,16 @@ class QueryEntryAdapter:
|
||||
|
||||
attachments = cls._build_attachments(query, contents)
|
||||
|
||||
return AgentInput(
|
||||
text=text,
|
||||
contents=contents,
|
||||
attachments=attachments,
|
||||
)
|
||||
input_data: dict[str, typing.Any] = {
|
||||
'text': text,
|
||||
'contents': contents,
|
||||
'attachments': attachments,
|
||||
}
|
||||
variables = getattr(query, 'variables', None) or {}
|
||||
interaction = variables.get('_interaction_submission')
|
||||
if isinstance(interaction, dict) and 'interaction' in AgentInput.model_fields:
|
||||
input_data['interaction'] = interaction
|
||||
return AgentInput.model_validate(input_data)
|
||||
|
||||
@classmethod
|
||||
def _build_attachments(
|
||||
@@ -570,16 +609,42 @@ class QueryEntryAdapter:
|
||||
) -> DeliveryContext:
|
||||
"""Build DeliveryContext from Query."""
|
||||
message_chain = getattr(query, 'message_chain', None)
|
||||
return DeliveryContext(
|
||||
surface='platform',
|
||||
reply_target={
|
||||
launcher_type = getattr(query, 'launcher_type', None)
|
||||
target_type = getattr(launcher_type, 'value', launcher_type)
|
||||
target_id = getattr(query, 'launcher_id', None)
|
||||
adapter = getattr(query, 'adapter', None)
|
||||
supported_apis: list[str] = []
|
||||
get_supported_apis = getattr(adapter, 'get_supported_apis', None)
|
||||
if callable(get_supported_apis):
|
||||
try:
|
||||
declared_apis = get_supported_apis()
|
||||
if isinstance(declared_apis, (list, tuple, set)):
|
||||
supported_apis = list(dict.fromkeys(api for api in declared_apis if isinstance(api, str) and api))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
delivery_data: dict[str, typing.Any] = {
|
||||
'surface': 'platform',
|
||||
'reply_target': {
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'message_id': getattr(message_chain, 'message_id', None),
|
||||
},
|
||||
supports_streaming=True,
|
||||
supports_edit=False,
|
||||
supports_reaction=False,
|
||||
platform_capabilities={},
|
||||
)
|
||||
'supports_streaming': True,
|
||||
'supports_edit': 'edit_message' in supported_apis,
|
||||
'supports_reaction': bool({'add_reaction', 'remove_reaction'} & set(supported_apis)),
|
||||
'platform_capabilities': {
|
||||
'adapter': adapter.__class__.__name__ if adapter is not None else None,
|
||||
'supported_apis': supported_apis,
|
||||
},
|
||||
}
|
||||
if 'interactions' in DeliveryContext.model_fields and 'interaction.request' in supported_apis:
|
||||
get_capabilities = getattr(adapter, 'get_interaction_capabilities', None)
|
||||
if callable(get_capabilities):
|
||||
capabilities = get_capabilities()
|
||||
if isinstance(capabilities, dict):
|
||||
delivery_data['interactions'] = capabilities
|
||||
return DeliveryContext.model_validate(delivery_data)
|
||||
|
||||
@classmethod
|
||||
def _build_raw_ref(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Agent result normalizer for converting AgentRunResult to Pipeline messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
@@ -57,7 +58,7 @@ class AgentResultNormalizer:
|
||||
- state.updated
|
||||
- run.completed
|
||||
- run.failed
|
||||
- action.requested (log only, don't execute)
|
||||
- action.requested (non-whitelisted actions are telemetry only)
|
||||
"""
|
||||
|
||||
ap: app.Application
|
||||
@@ -91,6 +92,7 @@ class AgentResultNormalizer:
|
||||
# Validate result size
|
||||
try:
|
||||
import json
|
||||
|
||||
result_json = json.dumps(result_dict)
|
||||
if len(result_json) > MAX_RESULT_SIZE_BYTES:
|
||||
self.ap.logger.warning(
|
||||
@@ -120,16 +122,12 @@ class AgentResultNormalizer:
|
||||
|
||||
elif result_type == 'tool.call.started':
|
||||
# Log only, don't yield to pipeline
|
||||
self.ap.logger.debug(
|
||||
f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}'
|
||||
)
|
||||
self.ap.logger.debug(f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}')
|
||||
return None
|
||||
|
||||
elif result_type == 'tool.call.completed':
|
||||
# Log only, don't yield to pipeline
|
||||
self.ap.logger.debug(
|
||||
f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}'
|
||||
)
|
||||
self.ap.logger.debug(f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}')
|
||||
return None
|
||||
|
||||
elif result_type == 'state.updated':
|
||||
@@ -161,10 +159,9 @@ class AgentResultNormalizer:
|
||||
)
|
||||
|
||||
elif result_type == 'action.requested':
|
||||
# Reserved for EBA - log only, don't execute
|
||||
# The orchestrator consumes whitelisted actions before normalization.
|
||||
self.ap.logger.info(
|
||||
f'Runner {descriptor.id} requested action (not executed in current phase): '
|
||||
f'{data.get("action", "unknown")}'
|
||||
f'Runner {descriptor.id} requested unsupported action (not executed): {data.get("action", "unknown")}'
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user