mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-04 04:54:36 +00:00
feat(agent-runner): add event-first context facts and pull APIs
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.
This commit is contained in:
@@ -1279,6 +1279,269 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
except Exception as e:
|
||||
return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id)
|
||||
|
||||
# ================= Agent History/Event APIs =================
|
||||
|
||||
@self.action(PluginToRuntimeAction.HISTORY_PAGE)
|
||||
async def history_page(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Page through transcript history for a conversation.
|
||||
|
||||
Requires run_id authorization. Only allows access to current run's conversation.
|
||||
"""
|
||||
run_id = data.get('run_id')
|
||||
conversation_id = data.get('conversation_id')
|
||||
before_cursor = data.get('before_cursor')
|
||||
after_cursor = data.get('after_cursor')
|
||||
limit = data.get('limit', 50)
|
||||
direction = data.get('direction', 'backward')
|
||||
include_artifacts = data.get('include_artifacts', False)
|
||||
caller_plugin_identity = data.get('caller_plugin_identity')
|
||||
|
||||
if not run_id:
|
||||
return handler.ActionResponse.error(message='run_id is required')
|
||||
|
||||
# Validate run session
|
||||
session_registry = get_session_registry()
|
||||
session = await session_registry.get(run_id)
|
||||
if not session:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Run session {run_id} not found or expired'
|
||||
)
|
||||
|
||||
# Validate caller plugin identity
|
||||
if caller_plugin_identity:
|
||||
session_plugin_identity = session.get('plugin_identity')
|
||||
if session_plugin_identity and caller_plugin_identity != session_plugin_identity:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Plugin identity mismatch for run_id {run_id}'
|
||||
)
|
||||
|
||||
# Get conversation from session if not provided
|
||||
if not conversation_id:
|
||||
conversation_id = session.get('conversation_id')
|
||||
|
||||
if not conversation_id:
|
||||
return handler.ActionResponse.success(data={
|
||||
'items': [],
|
||||
'next_cursor': None,
|
||||
'prev_cursor': None,
|
||||
'has_more': False,
|
||||
})
|
||||
|
||||
# Parse cursors
|
||||
before_seq = int(before_cursor) if before_cursor else None
|
||||
after_seq = int(after_cursor) if after_cursor else None
|
||||
|
||||
# Query transcript
|
||||
from ..agent.runner.transcript_store import TranscriptStore
|
||||
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
|
||||
|
||||
try:
|
||||
items, next_seq, prev_seq, has_more = await store.page_transcript(
|
||||
conversation_id=conversation_id,
|
||||
before_seq=before_seq,
|
||||
after_seq=after_seq,
|
||||
limit=limit,
|
||||
direction=direction,
|
||||
include_artifacts=include_artifacts,
|
||||
)
|
||||
|
||||
return handler.ActionResponse.success(data={
|
||||
'items': items,
|
||||
'next_cursor': str(next_seq) if next_seq else None,
|
||||
'prev_cursor': str(prev_seq) if prev_seq else None,
|
||||
'has_more': has_more,
|
||||
})
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'HISTORY_PAGE error: {e}', exc_info=True)
|
||||
return handler.ActionResponse.error(message=f'History page error: {e}')
|
||||
|
||||
@self.action(PluginToRuntimeAction.HISTORY_SEARCH)
|
||||
async def history_search(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Search transcript history.
|
||||
|
||||
Requires run_id authorization. Only searches current run's conversation.
|
||||
Basic implementation using LIKE filtering.
|
||||
"""
|
||||
run_id = data.get('run_id')
|
||||
query_text = data.get('query', '')
|
||||
filters = data.get('filters', {})
|
||||
top_k = data.get('top_k', 10)
|
||||
caller_plugin_identity = data.get('caller_plugin_identity')
|
||||
|
||||
if not run_id:
|
||||
return handler.ActionResponse.error(message='run_id is required')
|
||||
|
||||
# Validate run session
|
||||
session_registry = get_session_registry()
|
||||
session = await session_registry.get(run_id)
|
||||
if not session:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Run session {run_id} not found or expired'
|
||||
)
|
||||
|
||||
# Validate caller plugin identity
|
||||
if caller_plugin_identity:
|
||||
session_plugin_identity = session.get('plugin_identity')
|
||||
if session_plugin_identity and caller_plugin_identity != session_plugin_identity:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Plugin identity mismatch for run_id {run_id}'
|
||||
)
|
||||
|
||||
# Get conversation from session or filters
|
||||
conversation_id = filters.get('conversation_id') or session.get('conversation_id')
|
||||
|
||||
if not conversation_id:
|
||||
return handler.ActionResponse.success(data={
|
||||
'items': [],
|
||||
'total_count': 0,
|
||||
'query': query_text,
|
||||
})
|
||||
|
||||
# Search transcript
|
||||
from ..agent.runner.transcript_store import TranscriptStore
|
||||
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
|
||||
|
||||
try:
|
||||
items = await store.search_transcript(
|
||||
conversation_id=conversation_id,
|
||||
query_text=query_text,
|
||||
filters=filters,
|
||||
top_k=top_k,
|
||||
)
|
||||
|
||||
return handler.ActionResponse.success(data={
|
||||
'items': items,
|
||||
'total_count': len(items),
|
||||
'query': query_text,
|
||||
})
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'HISTORY_SEARCH error: {e}', exc_info=True)
|
||||
return handler.ActionResponse.error(message=f'History search error: {e}')
|
||||
|
||||
@self.action(PluginToRuntimeAction.EVENT_GET)
|
||||
async def event_get(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Get a single event record by ID.
|
||||
|
||||
Requires run_id authorization. Only allows access to events in current run's conversation.
|
||||
"""
|
||||
run_id = data.get('run_id')
|
||||
event_id = data.get('event_id')
|
||||
caller_plugin_identity = data.get('caller_plugin_identity')
|
||||
|
||||
if not run_id:
|
||||
return handler.ActionResponse.error(message='run_id is required')
|
||||
|
||||
if not event_id:
|
||||
return handler.ActionResponse.error(message='event_id is required')
|
||||
|
||||
# Validate run session
|
||||
session_registry = get_session_registry()
|
||||
session = await session_registry.get(run_id)
|
||||
if not session:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Run session {run_id} not found or expired'
|
||||
)
|
||||
|
||||
# Validate caller plugin identity
|
||||
if caller_plugin_identity:
|
||||
session_plugin_identity = session.get('plugin_identity')
|
||||
if session_plugin_identity and caller_plugin_identity != session_plugin_identity:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Plugin identity mismatch for run_id {run_id}'
|
||||
)
|
||||
|
||||
# Get event
|
||||
from ..agent.runner.event_log_store import EventLogStore
|
||||
store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
|
||||
|
||||
try:
|
||||
event = await store.get_event(event_id)
|
||||
if not event:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Event {event_id} not found'
|
||||
)
|
||||
|
||||
# Validate event is in the same conversation as the run
|
||||
session_conversation_id = session.get('conversation_id')
|
||||
if session_conversation_id and event.get('conversation_id') != session_conversation_id:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Event {event_id} is not accessible by this run'
|
||||
)
|
||||
|
||||
return handler.ActionResponse.success(data=event)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'EVENT_GET error: {e}', exc_info=True)
|
||||
return handler.ActionResponse.error(message=f'Event get error: {e}')
|
||||
|
||||
@self.action(PluginToRuntimeAction.EVENT_PAGE)
|
||||
async def event_page(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Page through event records.
|
||||
|
||||
Requires run_id authorization. Only allows access to current run's conversation.
|
||||
"""
|
||||
run_id = data.get('run_id')
|
||||
conversation_id = data.get('conversation_id')
|
||||
event_types = data.get('event_types')
|
||||
before_cursor = data.get('before_cursor')
|
||||
limit = data.get('limit', 50)
|
||||
caller_plugin_identity = data.get('caller_plugin_identity')
|
||||
|
||||
if not run_id:
|
||||
return handler.ActionResponse.error(message='run_id is required')
|
||||
|
||||
# Validate run session
|
||||
session_registry = get_session_registry()
|
||||
session = await session_registry.get(run_id)
|
||||
if not session:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Run session {run_id} not found or expired'
|
||||
)
|
||||
|
||||
# Validate caller plugin identity
|
||||
if caller_plugin_identity:
|
||||
session_plugin_identity = session.get('plugin_identity')
|
||||
if session_plugin_identity and caller_plugin_identity != session_plugin_identity:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Plugin identity mismatch for run_id {run_id}'
|
||||
)
|
||||
|
||||
# Get conversation from session if not provided
|
||||
if not conversation_id:
|
||||
conversation_id = session.get('conversation_id')
|
||||
|
||||
if not conversation_id:
|
||||
return handler.ActionResponse.success(data={
|
||||
'items': [],
|
||||
'next_cursor': None,
|
||||
'prev_cursor': None,
|
||||
'has_more': False,
|
||||
})
|
||||
|
||||
# Parse cursor
|
||||
before_seq = int(before_cursor) if before_cursor else None
|
||||
|
||||
# Query events
|
||||
from ..agent.runner.event_log_store import EventLogStore
|
||||
store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
|
||||
|
||||
try:
|
||||
items, next_seq, has_more = await store.page_events(
|
||||
conversation_id=conversation_id,
|
||||
event_types=event_types,
|
||||
before_seq=before_seq,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return handler.ActionResponse.success(data={
|
||||
'items': items,
|
||||
'next_cursor': str(next_seq) if next_seq else None,
|
||||
'prev_cursor': None,
|
||||
'has_more': has_more,
|
||||
})
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'EVENT_PAGE error: {e}', exc_info=True)
|
||||
return handler.ActionResponse.error(message=f'Event page error: {e}')
|
||||
|
||||
@self.action(CommonAction.PING)
|
||||
async def ping(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Ping"""
|
||||
|
||||
Reference in New Issue
Block a user