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:
huanghuoguoguo
2026-05-23 16:07:46 +08:00
parent 8063303cfa
commit 8db23bf950
18 changed files with 3705 additions and 60 deletions

View File

@@ -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"""