mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Add EventLog and Transcript persistence entities for storing auditable event facts and conversation history projection. Implement event-first AgentRunContext builder that produces Protocol v1 compliant context payloads with required fields: event, delivery, context (ContextAccess). Key changes: - EventLog ORM: auditable event records with indexes - Transcript ORM: conversation history projection with composite indexes - AgentRunContextBuilder: Protocol v1 payload with delivery, context, bootstrap - EventLogStore/TranscriptStore: async stores for fact sources - Host action handlers: HISTORY_PAGE, HISTORY_SEARCH, EVENT_GET, EVENT_PAGE - Context validation: build_context output validates via SDK AgentRunContext - Alembic migration for event_log and transcript tables - Alembic env.py imports all ORM models for autogenerate discovery Legacy compatibility: max-round messages go into bootstrap.messages and compatibility.legacy_messages, not top-level messages field.
580 lines
20 KiB
Python
580 lines
20 KiB
Python
"""Pipeline compatibility adapter for converting Query to event-first envelope.
|
|
|
|
This adapter bridges the legacy Query/Pipeline approach with the new
|
|
event-first Protocol v1 architecture. It is a compatibility layer only.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import typing
|
|
import time
|
|
|
|
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
|
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
|
from langbot_plugin.api.entities.builtin.agent_runner.event import (
|
|
AgentEventContext,
|
|
ConversationContext,
|
|
ActorContext,
|
|
SubjectContext,
|
|
RawEventRef,
|
|
)
|
|
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
|
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
|
from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger
|
|
|
|
from .host_models import (
|
|
AgentEventEnvelope,
|
|
AgentBinding,
|
|
BindingScope,
|
|
ResourcePolicy,
|
|
StatePolicy,
|
|
DeliveryPolicy,
|
|
)
|
|
from . import events as runner_events
|
|
|
|
|
|
class PipelineCompatAdapter:
|
|
"""Adapter for converting Pipeline Query to event-first envelope.
|
|
|
|
This adapter is responsible for:
|
|
- Converting Query to AgentEventEnvelope
|
|
- Converting Pipeline config to temporary AgentBinding
|
|
- Handling legacy max-round as bootstrap policy
|
|
- Putting Query-only fields into compatibility context
|
|
"""
|
|
|
|
@classmethod
|
|
def query_to_event(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
) -> AgentEventEnvelope:
|
|
"""Convert Pipeline Query to AgentEventEnvelope.
|
|
|
|
Args:
|
|
query: Pipeline query
|
|
|
|
Returns:
|
|
AgentEventEnvelope for event-first processing
|
|
"""
|
|
# Build event context
|
|
event = cls._build_event_context(query)
|
|
|
|
# Build conversation context
|
|
conversation = cls._build_conversation_context(query)
|
|
|
|
# Build actor context
|
|
actor = cls._build_actor_context(query)
|
|
|
|
# Build subject context
|
|
subject = cls._build_subject_context(query)
|
|
|
|
# Build input
|
|
input = cls._build_input(query)
|
|
|
|
# Build delivery context
|
|
delivery = cls._build_delivery_context(query)
|
|
|
|
# Build raw ref
|
|
raw_ref = cls._build_raw_ref(query)
|
|
|
|
return AgentEventEnvelope(
|
|
event_id=event.event_id or str(query.query_id),
|
|
event_type=event.event_type or runner_events.MESSAGE_RECEIVED,
|
|
event_time=event.event_time,
|
|
source="pipeline_compat",
|
|
bot_id=query.bot_uuid,
|
|
workspace_id=None, # Not available in Query
|
|
conversation_id=conversation.conversation_id,
|
|
thread_id=conversation.thread_id,
|
|
actor=actor,
|
|
subject=subject,
|
|
input=input,
|
|
delivery=delivery,
|
|
raw_ref=raw_ref,
|
|
)
|
|
|
|
@classmethod
|
|
def pipeline_config_to_binding(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
runner_id: str,
|
|
) -> AgentBinding:
|
|
"""Convert Pipeline config to temporary AgentBinding.
|
|
|
|
Args:
|
|
query: Pipeline query
|
|
runner_id: Resolved runner ID
|
|
|
|
Returns:
|
|
AgentBinding for this run
|
|
"""
|
|
pipeline_config = query.pipeline_config or {}
|
|
ai_config = pipeline_config.get('ai', {})
|
|
runner_config = ai_config.get('runner_config', {}).get(runner_id, {})
|
|
|
|
# Extract max_round for compatibility (used in bootstrap, not Protocol v1)
|
|
max_round = runner_config.get('max_round') or ai_config.get('max-round')
|
|
|
|
# Build scope
|
|
scope = BindingScope(
|
|
scope_type="pipeline",
|
|
scope_id=query.pipeline_uuid,
|
|
)
|
|
|
|
# Build resource policy from pipeline config
|
|
resource_policy = ResourcePolicy(
|
|
allowed_model_uuids=cls._extract_allowed_models(query),
|
|
allowed_tool_names=cls._extract_allowed_tools(query),
|
|
allowed_kb_uuids=cls._extract_allowed_kbs(query),
|
|
)
|
|
|
|
# Build state policy
|
|
state_policy = StatePolicy(
|
|
enable_state=True,
|
|
state_scopes=["conversation", "actor", "subject", "runner"],
|
|
)
|
|
|
|
# Build delivery policy
|
|
output_config = pipeline_config.get('output', {})
|
|
delivery_policy = DeliveryPolicy(
|
|
enable_streaming=True,
|
|
enable_reply=True,
|
|
)
|
|
|
|
return AgentBinding(
|
|
binding_id=f"pipeline_{query.pipeline_uuid or 'default'}_{runner_id}",
|
|
scope=scope,
|
|
event_types=[runner_events.MESSAGE_RECEIVED],
|
|
runner_id=runner_id,
|
|
runner_config=runner_config,
|
|
resource_policy=resource_policy,
|
|
state_policy=state_policy,
|
|
delivery_policy=delivery_policy,
|
|
enabled=True,
|
|
pipeline_uuid=query.pipeline_uuid,
|
|
max_round=max_round,
|
|
)
|
|
|
|
@classmethod
|
|
def build_bootstrap_from_binding(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
binding: AgentBinding,
|
|
) -> dict[str, typing.Any]:
|
|
"""Build bootstrap context from binding for legacy max-round.
|
|
|
|
This method handles the legacy max-round -> bootstrap conversion.
|
|
max-round is NOT part of Protocol v1, only used by compatibility adapter.
|
|
|
|
Args:
|
|
query: Pipeline query
|
|
binding: Agent binding with max_round
|
|
|
|
Returns:
|
|
Bootstrap context data
|
|
"""
|
|
max_round = binding.max_round
|
|
|
|
# If no max_round or self_managed_context, return empty bootstrap
|
|
if max_round is None or max_round <= 0:
|
|
return {
|
|
"messages": [],
|
|
"summary": None,
|
|
"artifacts": [],
|
|
"metadata": {
|
|
"policy": "self_managed",
|
|
"legacy_max_round": None,
|
|
},
|
|
}
|
|
|
|
# Legacy max-round packaging (will be handled by context_packager)
|
|
return {
|
|
"messages": [], # Will be filled by context_packager
|
|
"summary": None,
|
|
"artifacts": [],
|
|
"metadata": {
|
|
"policy": "legacy_max_round",
|
|
"legacy_max_round": max_round,
|
|
},
|
|
}
|
|
|
|
@classmethod
|
|
def build_compatibility_context(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
) -> dict[str, typing.Any]:
|
|
"""Build compatibility context for legacy Query/Pipeline fields.
|
|
|
|
These fields are for migration purposes only.
|
|
Runners should NOT depend on them for long-term capabilities.
|
|
|
|
Args:
|
|
query: Pipeline query
|
|
|
|
Returns:
|
|
Compatibility context data
|
|
"""
|
|
return {
|
|
"query_id": query.query_id,
|
|
"pipeline_uuid": query.pipeline_uuid,
|
|
"max_round": None, # Moved to binding, not here
|
|
"legacy_messages": [], # Will be filled by context_packager
|
|
"extra": {
|
|
"bot_uuid": query.bot_uuid,
|
|
"sender_id": str(query.sender_id) if query.sender_id else None,
|
|
"launcher_type": query.launcher_type.value if query.launcher_type else None,
|
|
"launcher_id": query.launcher_id,
|
|
},
|
|
}
|
|
|
|
# Private helper methods
|
|
|
|
@classmethod
|
|
def _build_event_context(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
) -> AgentEventContext:
|
|
"""Build AgentEventContext from Query."""
|
|
message_event = getattr(query, 'message_event', None)
|
|
|
|
event_data: dict[str, typing.Any] = {}
|
|
if message_event and hasattr(message_event, 'model_dump'):
|
|
try:
|
|
event_data = message_event.model_dump(mode='json')
|
|
except TypeError:
|
|
event_data = message_event.model_dump()
|
|
except Exception:
|
|
event_data = {}
|
|
event_data.pop('source_platform_object', None)
|
|
|
|
source_event_type = None
|
|
if message_event:
|
|
source_event_type = getattr(message_event, 'type', None)
|
|
|
|
message_chain = getattr(query, 'message_chain', None)
|
|
message_id = getattr(message_chain, 'message_id', None)
|
|
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)
|
|
|
|
return AgentEventContext(
|
|
event_id=str(message_id or query.query_id),
|
|
event_type=runner_events.MESSAGE_RECEIVED,
|
|
event_time=event_time,
|
|
source="pipeline_compat",
|
|
source_event_type=source_event_type,
|
|
data=event_data,
|
|
)
|
|
|
|
@classmethod
|
|
def _build_conversation_context(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
) -> ConversationContext:
|
|
"""Build ConversationContext from Query."""
|
|
# Handle session and conversation_id
|
|
conversation_id = None
|
|
session = getattr(query, 'session', None)
|
|
if session:
|
|
conversation = getattr(session, 'using_conversation', None)
|
|
if conversation:
|
|
conversation_id = getattr(conversation, 'uuid', None)
|
|
|
|
# Handle launcher_type safely
|
|
launcher_type = getattr(query, 'launcher_type', None)
|
|
launcher_type_value = None
|
|
if launcher_type is not None:
|
|
launcher_type_value = getattr(launcher_type, 'value', launcher_type)
|
|
|
|
# Handle launcher_id
|
|
launcher_id = getattr(query, 'launcher_id', None)
|
|
|
|
# Handle sender_id
|
|
sender_id = getattr(query, 'sender_id', None)
|
|
if sender_id is not None:
|
|
sender_id = str(sender_id)
|
|
|
|
# Handle bot_uuid
|
|
bot_uuid = getattr(query, 'bot_uuid', None)
|
|
|
|
# Handle pipeline_uuid
|
|
pipeline_uuid = getattr(query, 'pipeline_uuid', None)
|
|
|
|
# Build session_id from launcher info if available
|
|
session_id = None
|
|
if launcher_type_value and launcher_id:
|
|
session_id = f'{launcher_type_value}_{launcher_id}'
|
|
|
|
return ConversationContext(
|
|
conversation_id=conversation_id,
|
|
thread_id=None,
|
|
launcher_type=launcher_type_value,
|
|
launcher_id=launcher_id,
|
|
sender_id=sender_id,
|
|
bot_id=bot_uuid,
|
|
workspace_id=None,
|
|
session_id=session_id,
|
|
pipeline_uuid=pipeline_uuid,
|
|
)
|
|
|
|
@classmethod
|
|
def _build_actor_context(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
) -> ActorContext:
|
|
"""Build ActorContext from Query."""
|
|
message_event = getattr(query, 'message_event', None)
|
|
sender = getattr(message_event, 'sender', None) if message_event else None
|
|
sender_id = getattr(query, 'sender_id', None)
|
|
actor_id = getattr(sender, 'id', None) if sender else None
|
|
if actor_id is None:
|
|
actor_id = sender_id
|
|
actor_name = sender.get_name() if sender and hasattr(sender, 'get_name') else None
|
|
|
|
return ActorContext(
|
|
actor_type="user",
|
|
actor_id=str(actor_id) if actor_id is not None else None,
|
|
actor_name=actor_name,
|
|
metadata={},
|
|
)
|
|
|
|
@classmethod
|
|
def _build_subject_context(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
) -> SubjectContext:
|
|
"""Build SubjectContext from Query."""
|
|
message_chain = getattr(query, 'message_chain', None)
|
|
message_id = getattr(message_chain, 'message_id', None) if message_chain else None
|
|
if message_id == -1:
|
|
message_id = None
|
|
|
|
query_id = getattr(query, 'query_id', None)
|
|
|
|
# Safely get launcher_type
|
|
launcher_type = getattr(query, 'launcher_type', None)
|
|
launcher_type_value = None
|
|
if launcher_type is not None:
|
|
launcher_type_value = getattr(launcher_type, 'value', launcher_type)
|
|
|
|
return SubjectContext(
|
|
subject_type="message",
|
|
subject_id=str(message_id or query_id or ''),
|
|
data={
|
|
"launcher_type": launcher_type_value,
|
|
"launcher_id": getattr(query, 'launcher_id', None),
|
|
"sender_id": str(getattr(query, 'sender_id', '')) if getattr(query, 'sender_id', None) else None,
|
|
"bot_uuid": getattr(query, 'bot_uuid', None),
|
|
"pipeline_uuid": getattr(query, 'pipeline_uuid', None),
|
|
},
|
|
)
|
|
|
|
@classmethod
|
|
def _build_input(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
) -> AgentInput:
|
|
"""Build AgentInput from Query."""
|
|
text = None
|
|
text_parts: list[str] = []
|
|
contents: list[dict[str, typing.Any]] = []
|
|
|
|
user_message = getattr(query, 'user_message', None)
|
|
if user_message:
|
|
content = getattr(user_message, 'content', None)
|
|
if isinstance(content, list):
|
|
for elem in content:
|
|
# Handle both real objects and mocks
|
|
if hasattr(elem, 'model_dump'):
|
|
contents.append(elem.model_dump(mode='json'))
|
|
elif isinstance(elem, dict):
|
|
contents.append(elem)
|
|
else:
|
|
# For mocks, extract type and text attributes
|
|
elem_type = getattr(elem, 'type', None)
|
|
if elem_type == 'text':
|
|
elem_text = getattr(elem, 'text', None)
|
|
contents.append({'type': 'text', 'text': elem_text})
|
|
if elem_text:
|
|
text_parts.append(elem_text)
|
|
continue
|
|
|
|
# Extract text for the text field
|
|
if hasattr(elem, 'type') and getattr(elem, 'type', None) == 'text':
|
|
elem_text = getattr(elem, 'text', None)
|
|
if elem_text:
|
|
text_parts.append(elem_text)
|
|
elif content is not None:
|
|
text = str(content)
|
|
contents.append({'type': 'text', 'text': text})
|
|
|
|
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,
|
|
)
|
|
|
|
@classmethod
|
|
def _build_attachments(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
contents: list[dict[str, typing.Any]],
|
|
) -> list[dict[str, typing.Any]]:
|
|
"""Extract attachments from query."""
|
|
import uuid
|
|
|
|
attachments: list[dict[str, typing.Any]] = []
|
|
|
|
for elem in contents:
|
|
elem_type = elem.get('type')
|
|
artifact_id = str(uuid.uuid4()) # Generate unique ID
|
|
|
|
if elem_type == 'image_url':
|
|
image_url = elem.get('image_url') or {}
|
|
attachments.append({
|
|
'artifact_id': artifact_id,
|
|
'artifact_type': 'image',
|
|
'source': 'url',
|
|
'url': image_url.get('url') if isinstance(image_url, dict) else str(image_url),
|
|
})
|
|
elif elem_type == 'image_base64':
|
|
attachments.append({
|
|
'artifact_id': artifact_id,
|
|
'artifact_type': 'image',
|
|
'source': 'base64',
|
|
'content': elem.get('image_base64'),
|
|
})
|
|
elif elem_type == 'file_url':
|
|
attachments.append({
|
|
'artifact_id': artifact_id,
|
|
'artifact_type': 'file',
|
|
'source': 'url',
|
|
'url': elem.get('file_url'),
|
|
'name': elem.get('file_name'),
|
|
})
|
|
elif elem_type == 'file_base64':
|
|
attachments.append({
|
|
'artifact_id': artifact_id,
|
|
'artifact_type': 'file',
|
|
'source': 'base64',
|
|
'content': elem.get('file_base64'),
|
|
'name': elem.get('file_name'),
|
|
})
|
|
|
|
message_chain = getattr(query, 'message_chain', None)
|
|
if message_chain:
|
|
try:
|
|
for component in message_chain:
|
|
artifact_id = str(uuid.uuid4()) # Generate unique ID
|
|
|
|
if isinstance(component, platform_message.Image):
|
|
attachments.append({
|
|
'artifact_id': artifact_id,
|
|
'artifact_type': 'image',
|
|
'source': 'message_chain',
|
|
'id': component.image_id or None,
|
|
'url': component.url or None,
|
|
})
|
|
elif isinstance(component, platform_message.File):
|
|
attachments.append({
|
|
'artifact_id': artifact_id,
|
|
'artifact_type': 'file',
|
|
'source': 'message_chain',
|
|
'id': component.id or None,
|
|
'name': component.name or None,
|
|
})
|
|
elif isinstance(component, platform_message.Voice):
|
|
attachments.append({
|
|
'artifact_id': artifact_id,
|
|
'artifact_type': 'voice',
|
|
'source': 'message_chain',
|
|
'id': component.voice_id or None,
|
|
'url': component.url or None,
|
|
})
|
|
except TypeError:
|
|
# message_chain is not iterable (e.g., a Mock object)
|
|
pass
|
|
|
|
return attachments
|
|
|
|
@classmethod
|
|
def _build_delivery_context(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
) -> DeliveryContext:
|
|
"""Build DeliveryContext from Query."""
|
|
return DeliveryContext(
|
|
surface="platform",
|
|
reply_target={
|
|
"message_id": getattr(query.message_chain, 'message_id', None),
|
|
},
|
|
supports_streaming=True,
|
|
supports_edit=False,
|
|
supports_reaction=False,
|
|
platform_capabilities={},
|
|
)
|
|
|
|
@classmethod
|
|
def _build_raw_ref(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
) -> RawEventRef | None:
|
|
"""Build RawEventRef from Query."""
|
|
# For now, we don't store raw event payload
|
|
return None
|
|
|
|
@classmethod
|
|
def _extract_allowed_models(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
) -> list[str] | None:
|
|
"""Extract allowed model UUIDs from query."""
|
|
model_uuid = getattr(query, 'use_llm_model_uuid', None)
|
|
if model_uuid:
|
|
return [model_uuid]
|
|
return None
|
|
|
|
@classmethod
|
|
def _extract_allowed_tools(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
) -> list[str] | None:
|
|
"""Extract allowed tool names from query."""
|
|
use_funcs = getattr(query, 'use_funcs', None)
|
|
if not use_funcs:
|
|
return None
|
|
try:
|
|
return [func.get('name') for func in use_funcs if isinstance(func, dict) and func.get('name')]
|
|
except (TypeError, AttributeError):
|
|
return None
|
|
|
|
@classmethod
|
|
def _extract_allowed_kbs(
|
|
cls,
|
|
query: pipeline_query.Query,
|
|
) -> list[str] | None:
|
|
"""Extract allowed knowledge base UUIDs from query."""
|
|
variables = getattr(query, 'variables', None)
|
|
if not variables:
|
|
return None
|
|
kb_uuids = variables.get('_knowledge_base_uuids')
|
|
if kb_uuids:
|
|
return kb_uuids
|
|
return None
|