Files
LangBot/src/langbot/pkg/agent/runner/run_journal.py
T
BiFangKNT ce5b7ffea1 fix(agent): preserve terminal events when cancelling runner execution
Emit and persist a Host cancellation event before closing the runner stream.
Preserve existing terminal events and record cancelled runs consistently.

Serialize Host event sequence allocation to prevent conflicts with admin
cancellation audits, and notify observers with the persisted sequence.

Add deterministic cancellation and concurrent event allocation regressions.
2026-10-23 17:23:44 +08:00

461 lines
17 KiB
Python

"""Run-side effects for Runner executions."""
from __future__ import annotations
import typing
from ...core import app
from ...storage.media import MediaCache
from .descriptor import RunnerDescriptor
from .errors import RunnerProtocolError
from .host_models import AgentBinding, AgentEventEnvelope
from .persistent_state_store import PersistentStateStore, get_persistent_state_store
from .run_ledger_store import RunLedgerStore
class AgentRunJournal:
"""Persist run events, transcript records, and state updates."""
ap: app.Application
_persistent_state_store: PersistentStateStore | None
_run_ledger_store: RunLedgerStore | None
def __init__(self, ap: app.Application):
self.ap = ap
self._persistent_state_store = None
self._run_ledger_store = None
def _get_run_ledger_store(self) -> RunLedgerStore:
if self._run_ledger_store is None:
self._run_ledger_store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
return self._run_ledger_store
@staticmethod
def _to_plain_dict(value: typing.Any) -> dict[str, typing.Any]:
if hasattr(value, 'model_dump'):
value = value.model_dump(mode='json')
if isinstance(value, dict):
return dict(value)
return {}
@classmethod
def _sanitize_content_item(cls, value: typing.Any) -> typing.Any:
item = cls._to_plain_dict(value)
if not item:
return value
item_type = item.get('type')
if MediaCache.has_inline_images(item):
item = MediaCache.redact_inline_images(item)
item['content_redacted'] = True
if item_type == 'file_base64' and item.get('file_base64'):
item['file_base64'] = None
item['content_redacted'] = True
return item
@classmethod
def _sanitize_attachment_ref(cls, value: typing.Any) -> dict[str, typing.Any]:
item = cls._to_plain_dict(value)
if item.get('content'):
item['content'] = None
item['content_redacted'] = True
return item
@classmethod
def _sanitize_contents(cls, contents: typing.Iterable[typing.Any]) -> list[typing.Any]:
return [cls._sanitize_content_item(content) for content in contents]
@classmethod
def _sanitize_attachments(cls, attachments: typing.Iterable[typing.Any]) -> list[dict[str, typing.Any]]:
return [cls._sanitize_attachment_ref(attachment) for attachment in attachments]
@classmethod
def _sanitize_event_data(cls, value: typing.Any) -> typing.Any:
"""Retain arbitrary event fields without copying inline binary payloads."""
if hasattr(value, 'model_dump'):
value = value.model_dump(mode='json')
if isinstance(value, list):
return [cls._sanitize_event_data(item) for item in value]
if not isinstance(value, dict):
return value
if value.get('type') in ('Image', 'image', 'image_url', 'image_base64'):
value = cls._sanitize_content_item(value)
result = {}
for key, item in value.items():
if key in ('image_base64', 'file_base64', 'base64') and item:
result[key] = None
result['content_redacted'] = True
elif key == 'attachments' and isinstance(item, list):
result[key] = [cls._sanitize_event_data(cls._sanitize_attachment_ref(ref)) for ref in item]
else:
result[key] = cls._sanitize_event_data(item)
return result
async def create_run(
self,
*,
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: RunnerDescriptor,
context: dict[str, typing.Any],
authorization: dict[str, typing.Any],
) -> dict[str, typing.Any]:
"""Create the Host-owned run ledger record."""
from ...telemetry import trace
trace_state = trace.current()
runtime = context.get('runtime') if isinstance(context, dict) else {}
return await self._get_run_ledger_store().create_run(
run_id=context['run_id'],
event_id=event.event_id,
binding_id=binding.binding_id,
agent_id=binding.agent_id,
runner_id=descriptor.id,
conversation_id=event.conversation_id,
thread_id=event.thread_id,
workspace_id=event.workspace_id,
bot_id=event.bot_id,
deadline_at=runtime.get('deadline_at') if isinstance(runtime, dict) else None,
authorization=authorization,
metadata={
'event_type': event.event_type,
'ingress_event_id': trace_state.event_id if trace_state else event.event_id,
'source': event.source,
'processor_id': binding.processor_id,
'processor_type': binding.processor_type,
**(
{
'input_event': self._sanitize_event_data(event.data),
'delivery': event.delivery.model_dump(mode='json'),
}
if binding.processor_type == 'event_processor'
else {}
),
**(
{
'input_event': self._sanitize_event_data(event.data),
'input': {
'text': context.get('input', {}).get('text', ''),
'contents': self._sanitize_contents(context.get('input', {}).get('contents', [])),
'attachments': self._sanitize_attachments(context.get('input', {}).get('attachments', [])),
},
'delivery': event.delivery.model_dump(mode='json'),
}
if binding.processor_type == 'agent'
else {}
),
},
)
async def append_run_result(
self,
*,
result_dict: dict[str, typing.Any],
run_id: str,
sequence: int | None,
source: str = 'runner',
metadata: dict[str, typing.Any] | None = None,
) -> dict[str, typing.Any]:
"""Persist one RunnerResult in the run ledger."""
usage = result_dict.get('usage')
if hasattr(usage, 'model_dump'):
usage = usage.model_dump(mode='json')
data = result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {}
if MediaCache.has_inline_images(data):
data = await self.ap.storage_mgr.media_cache.externalize_chain_dump(data)
if MediaCache.has_inline_images(metadata):
metadata = MediaCache.redact_inline_images(metadata)
return await self._get_run_ledger_store().append_event(
run_id=run_id,
sequence=sequence,
event_type=str(result_dict.get('type') or 'unknown'),
data=data,
usage=usage if isinstance(usage, dict) else None,
source=source,
metadata=metadata,
)
async def finalize_run(
self,
*,
run_id: str,
status: str,
status_reason: str | None = None,
usage: dict[str, typing.Any] | None = None,
metadata: dict[str, typing.Any] | None = None,
) -> dict[str, typing.Any] | None:
"""Finalize or update the Host-owned run ledger record."""
return await self._get_run_ledger_store().finalize_run(
run_id=run_id,
status=status,
status_reason=status_reason,
usage=usage,
metadata=metadata,
)
async def get_run(self, run_id: str) -> dict[str, typing.Any] | None:
"""Return the persisted run ledger record."""
return await self._get_run_ledger_store().get_run(run_id)
async def handle_state_updated_event(
self,
result_dict: dict[str, typing.Any],
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: RunnerDescriptor,
run_id: str | None = None,
) -> None:
"""Handle state.updated result in event-first mode."""
data = result_dict.get('data', {})
result_run_id = result_dict.get('run_id')
if run_id and result_run_id and result_run_id != run_id:
raise RunnerProtocolError(
descriptor.id,
f'state.updated run_id mismatch: expected {run_id}, got {result_run_id}',
)
scope = data.get('scope')
if not scope:
raise RunnerProtocolError(
descriptor.id,
'state.updated missing required field: scope',
)
key = data.get('key')
value = data.get('value')
if not key:
raise RunnerProtocolError(
descriptor.id,
'state.updated missing required field: key',
)
if self._persistent_state_store is None:
self._persistent_state_store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine())
success, error = await self._persistent_state_store.apply_update_from_event(
event=event,
binding=binding,
descriptor=descriptor,
scope=scope,
key=key,
value=value,
logger=self.ap.logger,
)
if success:
self.ap.logger.debug(f'Runner {descriptor.id} state.updated (event mode): scope={scope}, key={key}')
elif error:
self.ap.logger.warning(f'Runner {descriptor.id} state.updated rejected: {error}')
async def write_event_log(
self,
event: AgentEventEnvelope,
binding: AgentBinding,
run_id: str,
runner_id: str,
metadata: dict[str, typing.Any] | None = None,
) -> str:
"""Write incoming event to EventLog."""
import datetime
from .event_log_store import EventLogStore
store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
input_summary = None
input_json = None
if event.input:
if event.input.text:
input_summary = event.input.text[:1000]
input_json = {
'text': event.input.text,
'contents': self._sanitize_contents(event.input.contents),
'attachments': self._sanitize_attachments(event.input.attachments),
}
return await store.append_event(
event_id=event.event_id,
event_type=event.event_type,
source=event.source,
bot_id=event.bot_id,
workspace_id=event.workspace_id,
conversation_id=event.conversation_id,
thread_id=event.thread_id,
actor_type=event.actor.actor_type if event.actor else None,
actor_id=event.actor.actor_id if event.actor else None,
actor_name=event.actor.actor_name if event.actor else None,
subject_type=event.subject.subject_type if event.subject else None,
subject_id=event.subject.subject_id if event.subject else None,
input_summary=input_summary,
input_json=input_json,
run_id=run_id,
runner_id=runner_id,
event_time=(
datetime.datetime.fromtimestamp(event.event_time, datetime.timezone.utc) if event.event_time else None
),
metadata={**(metadata or {}), 'input_event': self._sanitize_event_data(event.data)},
)
async def write_user_transcript(
self,
event: AgentEventEnvelope,
event_log_id: str,
) -> None:
"""Write user message to Transcript."""
from .transcript_store import TranscriptStore
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
content = event.input.text if event.input else None
content_json = None
if event.input:
content_json = {
'role': 'user',
'content': self._sanitize_contents(event.input.contents) if event.input.contents else [],
}
attachment_refs = []
if event.input and event.input.attachments:
for a in event.input.attachments:
attachment_refs.append(self._sanitize_attachment_ref(a))
await store.append_transcript(
transcript_id=None,
event_id=event_log_id,
conversation_id=event.conversation_id,
role='user',
bot_id=event.bot_id,
workspace_id=event.workspace_id,
content=content,
content_json=content_json,
attachment_refs=attachment_refs if attachment_refs else None,
thread_id=event.thread_id,
item_type='message',
metadata={
'actor_type': event.actor.actor_type if event.actor else None,
'actor_id': event.actor.actor_id if event.actor else None,
},
)
async def write_steering_dropped_audits(
self,
items: list[dict[str, typing.Any]],
run_id: str,
runner_id: str,
*,
reason: str = 'run_ended',
) -> None:
"""Write terminal audit events for steering items left unconsumed."""
if not items:
return
import datetime
import uuid
from .event_log_store import EventLogStore
store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
for item in items:
event = item.get('event') if isinstance(item.get('event'), dict) else {}
conversation = item.get('conversation') if isinstance(item.get('conversation'), dict) else {}
actor = item.get('actor') if isinstance(item.get('actor'), dict) else {}
subject = item.get('subject') if isinstance(item.get('subject'), dict) else {}
event_time = None
raw_event_time = event.get('event_time')
if raw_event_time:
try:
event_time = datetime.datetime.fromtimestamp(
raw_event_time,
datetime.timezone.utc,
)
except (TypeError, ValueError, OSError):
event_time = None
await store.append_event(
event_id=str(uuid.uuid4()),
event_type='steering.dropped',
source='host',
bot_id=conversation.get('bot_id'),
workspace_id=conversation.get('workspace_id'),
conversation_id=conversation.get('conversation_id'),
thread_id=conversation.get('thread_id'),
actor_type=actor.get('actor_type'),
actor_id=actor.get('actor_id'),
actor_name=actor.get('actor_name'),
subject_type=subject.get('subject_type'),
subject_id=subject.get('subject_id'),
input_summary='Unconsumed steering input dropped',
run_id=run_id,
runner_id=runner_id,
event_time=event_time,
metadata={
'steering': {
'status': 'dropped',
'reason': reason,
'original_event_id': event.get('event_id'),
'claimed_run_id': item.get('claimed_run_id'),
'claimed_runner_id': item.get('runner_id'),
'claimed_at': item.get('claimed_at'),
},
},
)
async def write_assistant_transcript(
self,
result_dict: dict[str, typing.Any],
event: AgentEventEnvelope,
run_id: str,
runner_id: str,
) -> None:
"""Write assistant message to Transcript."""
import uuid
from .transcript_store import TranscriptStore
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
data = result_dict.get('data', {})
message = data.get('message', {})
content = None
content_json = None
if isinstance(message.get('content'), str):
content = message['content']
content_json = message
elif isinstance(message.get('content'), list):
text_parts = []
for c in message['content']:
if isinstance(c, dict) and c.get('type') == 'text':
text_parts.append(c.get('text', ''))
content = ' '.join(text_parts) if text_parts else None
content_json = {
**message,
'content': self._sanitize_contents(message['content']),
}
assistant_event_id = str(uuid.uuid4())
await store.append_transcript(
transcript_id=str(uuid.uuid4()),
event_id=assistant_event_id,
conversation_id=event.conversation_id,
role='assistant',
bot_id=event.bot_id,
workspace_id=event.workspace_id,
content=content,
content_json=content_json,
thread_id=event.thread_id,
item_type='message',
run_id=run_id,
runner_id=runner_id,
metadata={
'run_id': run_id,
'runner_id': runner_id,
},
)