Files
2026-07-13 13:39:38 +08:00

1920 lines
65 KiB
Python

from __future__ import annotations
import asyncio
import hashlib
import inspect
import json
import os
import re
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from types import MethodType
from typing import TYPE_CHECKING, Any, TypedDict, cast
from livekit.agents.job import get_job_context
from livekit.agents.types import NOT_GIVEN
from livekit.agents.voice.agent_session import AgentSession
from livekit.agents.voice.events import CloseEvent
from ._payload import (
CALL_ID_STRATEGY_CUSTOM,
CALL_ID_STRATEGY_PARTICIPANT_IDENTITY,
CALL_ID_STRATEGY_PARTICIPANT_METADATA,
CALL_ID_STRATEGY_ROOM_NAME,
CallIdResolver,
PayloadBuildConfig,
SessionReportLike,
build_livekit_monitoring_envelope,
)
from ._transport import ConnectionPolicy, HammingTransport
from .log import logger
from .version import __version__
if TYPE_CHECKING:
from livekit.agents.job import JobContext
RECORDING_MODE_NONE = "none"
RECORDING_MODE_SESSION_AUDIO = "session_audio"
RECORDING_MODE_ROOM_COMPOSITE = "room_composite"
RECORDING_MODE_PARTICIPANT_EGRESS = "participant_egress"
SUPPORTED_RECORDING_MODES = {
RECORDING_MODE_NONE,
RECORDING_MODE_SESSION_AUDIO,
RECORDING_MODE_ROOM_COMPOSITE,
RECORDING_MODE_PARTICIPANT_EGRESS,
}
RECORDING_SOURCE_EXTERNAL_URLS = "external_urls"
RECORDING_SOURCE_AUTO_EGRESS = "auto_egress"
LEGACY_RECORDING_SOURCE_CUSTOMER_MANAGED = "customer_managed"
LEGACY_RECORDING_SOURCE_PLUGIN_MANAGED = "plugin_managed"
SUPPORTED_RECORDING_SOURCES = {
RECORDING_SOURCE_EXTERNAL_URLS,
RECORDING_SOURCE_AUTO_EGRESS,
}
RECORDING_DELIVERY_AUTO = "auto"
RECORDING_DELIVERY_URL = "url"
RECORDING_DELIVERY_UPLOAD = "upload"
SUPPORTED_RECORDING_DELIVERIES = {
RECORDING_DELIVERY_AUTO,
RECORDING_DELIVERY_URL,
RECORDING_DELIVERY_UPLOAD,
}
SUPPORTED_CALL_ID_STRATEGIES = {
CALL_ID_STRATEGY_ROOM_NAME,
CALL_ID_STRATEGY_PARTICIPANT_IDENTITY,
CALL_ID_STRATEGY_PARTICIPANT_METADATA,
CALL_ID_STRATEGY_CUSTOM,
}
SUPPORTED_CAPTURE_MODES = {"none", "full"}
SAMPLING_VERSION = "v1"
STREAMING_MODE_NONE = "none"
STREAMING_MODE_TRACK_EGRESS = "track_egress"
SUPPORTED_STREAMING_MODES = {
STREAMING_MODE_NONE,
STREAMING_MODE_TRACK_EGRESS,
}
SESSION_MODE_MONITORING = "monitoring"
SESSION_MODE_TESTING = "testing"
SUPPORTED_SESSION_MODES = {
SESSION_MODE_MONITORING,
SESSION_MODE_TESTING,
}
TEST_CASE_RUN_ID_METADATA_KEYS = (
"test_case_run_id",
"testCaseRunId",
"conversation_id",
"conversationId",
)
PLUGIN_MANAGED_RECORDINGS_DIR = "recordings"
PLUGIN_MANAGED_ROOM_COMPOSITE_FILENAME_PREFIX = "hamming_plugin_room_composite"
PLUGIN_MANAGED_ROOM_COMPOSITE_EXTENSION = "ogg"
class RecordingContext(TypedDict, total=False):
call_id: str
room_name: str
customer_conversation_id: str
provider_recording_id: str
provider_recording_filepath: str
metadata: dict[str, str]
class RecordingArtifacts(TypedDict, total=False):
recording_url: str
agent_recording_url: str
user_recording_url: str
local_paths: list[str]
class RecordingConfigInput(TypedDict, total=False):
mode: str
source: str
delivery: str
resolver: RecordingResolver
class StreamingConfigInput(TypedDict, total=False):
mode: str
source: str
class CaptureConfigInput(TypedDict, total=False):
agent_context: bool
prompts: str
tools: str
interim_transcripts: bool
class RedactConfigInput(TypedDict, total=False):
pii: bool
tool_args_paths: list[str]
tool_output_paths: list[str]
class LiveKitConfigInput(TypedDict, total=False):
url: str
api_key: str
api_secret: str
class ExternalLinkInput(TypedDict, total=False):
label: str
url: str
source: str
description: str
@dataclass(frozen=True)
class RecordingResolutionContext:
session: AgentSession[Any]
report: SessionReportLike
job_ctx: JobContext | None
close_event: CloseEvent | None
call_id: str
room_name: str
participant_identity: str | None
participant_metadata_raw: str | None
external_agent_id: str
recording_context: RecordingContext | None
RecordingResolverResult = RecordingArtifacts | Awaitable[RecordingArtifacts]
RecordingResolver = Callable[[RecordingResolutionContext], RecordingResolverResult]
@dataclass(frozen=True)
class ResolvedRecordingConfig:
mode: str = RECORDING_MODE_NONE
source: str = RECORDING_SOURCE_AUTO_EGRESS
delivery: str = RECORDING_DELIVERY_AUTO
resolver: RecordingResolver | None = None
@dataclass(frozen=True)
class ResolvedStreamingConfig:
mode: str = STREAMING_MODE_NONE
source: str = RECORDING_SOURCE_EXTERNAL_URLS
@dataclass(frozen=True)
class ResolvedCaptureConfig:
agent_context: bool = True
prompts: str = "full"
tools: str = "full"
interim_transcripts: bool = False
@dataclass(frozen=True)
class ResolvedRedactConfig:
pii: bool = True
tool_args_paths: tuple[str, ...] = ()
tool_output_paths: tuple[str, ...] = ()
@dataclass(frozen=True)
class ResolvedLiveKitConfig:
url: str | None = None
api_key: str | None = None
api_secret: str | None = None
@dataclass(frozen=True)
class ResolvedSamplingConfig:
rate: float = 1.0
key: str = "participant_identity"
@dataclass(frozen=True)
class PluginManagedRoomCompositeState:
room_name: str
filepath: str
start_task: asyncio.Task[str]
@dataclass(frozen=True)
class HammingConfig:
api_key: str
base_url: str = "https://app.hamming.ai"
call_review_enabled: bool = True
flush_on_shutdown: bool = True
auto_record_audio: bool = False
external_agent_id: str | None = None
plugin_api_version: str = "1.0.0"
request_timeout_seconds: float = 10.0
max_retries: int = 3
retry_backoff_seconds: float = 0.5
payload_schema_version: str = "2026-03-02"
call_id_strategy: str = CALL_ID_STRATEGY_ROOM_NAME
call_id_metadata_key: str = "call_id"
resolve_call_id: CallIdResolver | None = None
sampling: ResolvedSamplingConfig = ResolvedSamplingConfig()
capture: ResolvedCaptureConfig = ResolvedCaptureConfig()
redact: ResolvedRedactConfig = ResolvedRedactConfig()
livekit: ResolvedLiveKitConfig = ResolvedLiveKitConfig()
recording: ResolvedRecordingConfig = ResolvedRecordingConfig()
streaming: ResolvedStreamingConfig = ResolvedStreamingConfig()
class _SessionMonitor:
def __init__(
self,
*,
runtime: HammingRuntime,
session: AgentSession[Any],
participant_identity: str | None,
participant_metadata: str | None,
external_agent_id: str,
customer_metadata: Mapping[str, Any] | None = None,
external_links: Sequence[ExternalLinkInput] | None = None,
session_mode: str = SESSION_MODE_MONITORING,
job_ctx: JobContext | None,
session_key: int,
recording_context: RecordingContext | None,
) -> None:
self._runtime = runtime
self._session = session
self._participant_identity = participant_identity
self._participant_metadata = participant_metadata
self._external_agent_id = external_agent_id
self._customer_metadata = (
_normalize_json_object(customer_metadata, field_name="customer_metadata")
if customer_metadata
else None
)
self._external_links = _normalize_external_links(external_links)
self._session_mode = _normalize_session_mode(session_mode)
self._job_ctx = job_ctx
self._session_key = session_key
self._recording_context = (
cast(RecordingContext, dict(recording_context)) if recording_context else None
)
self._plugin_managed_room_composite: PluginManagedRoomCompositeState | None = None
self._close_event: CloseEvent | None = None
self._close_event_ready = asyncio.Event()
self._send_lock = asyncio.Lock()
self._send_task: asyncio.Task[None] | None = None
def on_close(self, event: CloseEvent) -> None:
self._close_event = event
self._close_event_ready.set()
self._ensure_send_task(from_shutdown=False)
async def on_shutdown(self, reason: str | None = None) -> None:
if not self._runtime.config.flush_on_shutdown:
return
task = self._ensure_send_task(from_shutdown=True)
try:
await asyncio.wait_for(task, timeout=5.0)
except asyncio.TimeoutError:
logger.warning("timed out waiting for hamming final payload flush")
except asyncio.CancelledError:
logger.warning(
"hamming shutdown callback cancelled before flush completed",
extra={"reason": reason},
)
except Exception:
logger.exception(
"hamming shutdown callback failed while waiting for flush",
extra={"reason": reason},
)
def start_plugin_managed_room_composite_if_needed(self) -> None:
recording = self._runtime.config.recording
if (
recording.mode != RECORDING_MODE_ROOM_COMPOSITE
or recording.source != RECORDING_SOURCE_AUTO_EGRESS
):
return
if self._job_ctx is None:
logger.warning(
"plugin-managed room composite requires JobContext; falling back to resolver path",
)
return
filepath = _build_plugin_managed_room_composite_filepath(
room_name=self._job_ctx.room.name,
participant_metadata_raw=self._participant_metadata,
recording_context=self._recording_context,
session_key=self._session_key,
)
logger.info(
"scheduling plugin-managed room composite egress",
extra={
"room_name": self._job_ctx.room.name,
"filepath": filepath,
**_recording_debug_context(
participant_metadata_raw=self._participant_metadata,
recording_context=self._recording_context,
),
},
)
start_task = asyncio.create_task(
self._start_plugin_managed_room_composite_egress(filepath=filepath)
)
self._runtime.track_task(start_task)
self._plugin_managed_room_composite = PluginManagedRoomCompositeState(
room_name=self._job_ctx.room.name,
filepath=filepath,
start_task=start_task,
)
def _ensure_send_task(self, *, from_shutdown: bool) -> asyncio.Task[None]:
if self._send_task is None:
self._send_task = asyncio.create_task(
self._send_final_payload(wait_for_close_event=from_shutdown)
)
self._runtime.track_task(self._send_task)
return self._send_task
async def _send_final_payload(self, *, wait_for_close_event: bool) -> None:
try:
async with self._send_lock:
close_event = await self._wait_for_close_event_if_needed(
wait_for_close_event=wait_for_close_event
)
report = self._build_report_for_export()
if report is None:
return
envelope = self._build_monitoring_envelope(report=report, close_event=close_event)
payload_record = envelope.get("payload")
if not isinstance(payload_record, dict):
await self._send_envelope(envelope=envelope, report=report, payload_record=None)
return
if self._should_skip_for_sampling(
envelope=envelope,
payload_record=payload_record,
report=report,
):
return
await self._populate_recording_payload(
report=report,
payload_record=payload_record,
close_event=close_event,
)
self._log_recording_resolution_outcome(
report=report,
payload_record=payload_record,
)
await self._send_envelope(
envelope=envelope,
report=report,
payload_record=payload_record,
)
except Exception as exc:
logger.error("unhandled exception in hamming final payload task: %r", exc)
logger.exception("unhandled exception in hamming final payload task")
finally:
self._runtime.remove_monitor(self._session_key)
async def _wait_for_close_event_if_needed(
self,
*,
wait_for_close_event: bool,
) -> CloseEvent | None:
if wait_for_close_event and self._close_event is None:
try:
await asyncio.wait_for(self._close_event_ready.wait(), timeout=0.5)
except asyncio.TimeoutError:
pass
return self._close_event
def _build_report_for_export(self) -> SessionReportLike | None:
if not self._runtime.config.call_review_enabled:
logger.debug("hamming call review export disabled; skipping final payload")
return None
try:
return self._runtime.build_report(self._session, self._job_ctx)
except Exception:
logger.exception("failed to build session report for hamming payload")
return None
def _build_monitoring_envelope(
self,
*,
report: SessionReportLike,
close_event: CloseEvent | None,
) -> dict[str, Any]:
envelope = build_livekit_monitoring_envelope(
config=PayloadBuildConfig(
external_agent_id=self._external_agent_id,
plugin_api_version=self._runtime.config.plugin_api_version,
plugin_version=__version__,
payload_schema_version=self._runtime.config.payload_schema_version,
call_id_strategy=self._runtime.config.call_id_strategy,
call_id_metadata_key=self._runtime.config.call_id_metadata_key,
resolve_call_id=self._runtime.config.resolve_call_id,
capture_manifest=_build_capture_manifest(self._runtime.config),
customer_metadata=self._customer_metadata,
external_links=self._external_links,
session_mode=self._session_mode,
),
report=report,
participant_identity=self._participant_identity,
participant_metadata_raw=self._participant_metadata,
recording_context=self._recording_context,
close_event=close_event,
)
return envelope
def _should_skip_for_sampling(
self,
*,
envelope: dict[str, Any],
payload_record: dict[str, Any],
report: SessionReportLike,
) -> bool:
sampling_metadata = _build_sampling_metadata(
config=self._runtime.config,
call_id=_string_or_fallback(payload_record.get("call_id"), report.room),
room_name=report.room,
participant_identity=self._participant_identity,
participant_metadata_raw=self._participant_metadata,
external_agent_id=self._external_agent_id,
)
metadata_record = envelope.setdefault("metadata", {})
if isinstance(metadata_record, dict):
metadata_record["sampling"] = sampling_metadata
if sampling_metadata["decision"] != "excluded":
return False
logger.info(
"skipping hamming payload due to deterministic sampling",
extra={
"call_id": payload_record.get("call_id"),
"room_name": report.room,
"sampling_key": sampling_metadata["key"],
"sampling_rate": sampling_metadata["rate"],
},
)
return True
async def _send_envelope(
self,
*,
envelope: dict[str, Any],
report: SessionReportLike,
payload_record: dict[str, Any] | None,
) -> None:
try:
await self._runtime.transport.send_capture(envelope)
except Exception:
logger.exception("failed to send hamming payload")
return
logger.info(
"sent hamming payload",
extra={
"call_id": payload_record.get("call_id") if payload_record is not None else None,
"room_name": report.room,
},
)
async def _populate_recording_payload(
self,
*,
report: SessionReportLike,
payload_record: dict[str, Any],
close_event: CloseEvent | None,
) -> None:
recording = self._runtime.config.recording
if recording.mode == RECORDING_MODE_NONE:
return
if recording.mode == RECORDING_MODE_SESSION_AUDIO:
await self._attach_inline_recording_capture(
report=report, payload_record=payload_record
)
return
if recording.mode not in {
RECORDING_MODE_ROOM_COMPOSITE,
RECORDING_MODE_PARTICIPANT_EGRESS,
}:
logger.warning("unsupported hamming recording mode", extra={"mode": recording.mode})
return
resolution_context = self._build_recording_resolution_context(
report=report,
payload_record=payload_record,
close_event=close_event,
)
if await self._populate_builtin_recording_payload(
payload_record=payload_record,
resolution_context=resolution_context,
):
return
resolved_artifacts = await self._resolve_recording_artifacts(
payload_record=payload_record,
resolution_context=resolution_context,
)
if resolved_artifacts is None:
return
self._apply_resolved_recording_artifacts(
payload_record=payload_record,
resolved_artifacts=resolved_artifacts,
)
def _build_recording_resolution_context(
self,
*,
report: SessionReportLike,
payload_record: dict[str, Any],
close_event: CloseEvent | None,
) -> RecordingResolutionContext:
return RecordingResolutionContext(
session=self._session,
report=report,
job_ctx=self._job_ctx,
close_event=close_event,
call_id=_string_or_fallback(payload_record.get("call_id"), report.room),
room_name=report.room,
participant_identity=self._participant_identity,
participant_metadata_raw=self._participant_metadata,
external_agent_id=self._external_agent_id,
recording_context=self._recording_context,
)
async def _populate_builtin_recording_payload(
self,
*,
payload_record: dict[str, Any],
resolution_context: RecordingResolutionContext,
) -> bool:
recording = self._runtime.config.recording
if recording.mode != RECORDING_MODE_ROOM_COMPOSITE:
return False
if recording.source == RECORDING_SOURCE_AUTO_EGRESS:
recording_url = await self._resolve_plugin_managed_room_composite_recording_url(
resolution_context=resolution_context,
)
if recording_url:
payload_record["recording_url"] = recording_url
return True
recording_url = await self._resolve_hamming_room_composite_recording_url(
resolution_context=resolution_context,
)
if recording_url:
payload_record["recording_url"] = recording_url
return True
return False
async def _resolve_recording_artifacts(
self,
*,
payload_record: dict[str, Any],
resolution_context: RecordingResolutionContext,
) -> RecordingArtifacts | None:
recording = self._runtime.config.recording
resolver = recording.resolver
if resolver is None:
logger.warning(
"recording mode requires resolver but none was configured",
extra={"mode": recording.mode, "source": recording.source},
)
return None
try:
resolved_artifacts = resolver(resolution_context)
if inspect.isawaitable(resolved_artifacts):
resolved_artifacts = await resolved_artifacts
except Exception:
logger.exception(
"failed to resolve recording artifacts for hamming payload",
extra={"call_id": payload_record.get("call_id"), "mode": recording.mode},
)
return None
if not isinstance(resolved_artifacts, dict):
logger.warning(
"recording resolver returned unsupported artifact payload",
extra={"mode": recording.mode, "type": type(resolved_artifacts).__name__},
)
return None
return resolved_artifacts
def _apply_resolved_recording_artifacts(
self,
*,
payload_record: dict[str, Any],
resolved_artifacts: RecordingArtifacts,
) -> None:
recording = self._runtime.config.recording
if recording.mode == RECORDING_MODE_ROOM_COMPOSITE:
self._apply_room_composite_recording_artifacts(
payload_record=payload_record,
resolved_artifacts=resolved_artifacts,
)
return
self._apply_participant_egress_recording_artifacts(
payload_record=payload_record,
resolved_artifacts=resolved_artifacts,
)
def _apply_room_composite_recording_artifacts(
self,
*,
payload_record: dict[str, Any],
resolved_artifacts: RecordingArtifacts,
) -> None:
recording_url = resolved_artifacts.get("recording_url")
if isinstance(recording_url, str) and recording_url.strip():
payload_record["recording_url"] = recording_url.strip()
return
logger.warning(
"room_composite resolver did not return recording_url",
extra={"call_id": payload_record.get("call_id")},
)
def _apply_participant_egress_recording_artifacts(
self,
*,
payload_record: dict[str, Any],
resolved_artifacts: RecordingArtifacts,
) -> None:
recording_payload = payload_record.get("recording")
if not isinstance(recording_payload, dict):
recording_payload = {}
payload_record["recording"] = recording_payload
agent_recording_url = resolved_artifacts.get("agent_recording_url")
user_recording_url = resolved_artifacts.get("user_recording_url")
if isinstance(agent_recording_url, str) and agent_recording_url.strip():
recording_payload["agent_recording_url"] = agent_recording_url.strip()
if isinstance(user_recording_url, str) and user_recording_url.strip():
recording_payload["user_recording_url"] = user_recording_url.strip()
if (
"agent_recording_url" not in recording_payload
or "user_recording_url" not in recording_payload
):
logger.warning(
"participant_egress resolver did not return both recording URLs",
extra={"call_id": payload_record.get("call_id")},
)
async def _attach_inline_recording_capture(
self,
*,
report: SessionReportLike,
payload_record: dict[str, Any],
) -> None:
if not report.audio_recording_path:
return
try:
recording_capture = await self._runtime.transport.build_inline_recording_capture(
recording_path=report.audio_recording_path,
)
except Exception:
logger.exception(
"failed to capture inline recording for hamming payload",
extra={"call_id": payload_record.get("call_id")},
)
return
if recording_capture:
payload_record["recording_capture"] = recording_capture
async def _resolve_hamming_room_composite_recording_url(
self,
*,
resolution_context: RecordingResolutionContext,
) -> str | None:
test_case_run_id = _extract_test_case_run_id(
participant_metadata_raw=resolution_context.participant_metadata_raw,
recording_context=resolution_context.recording_context,
)
if not test_case_run_id:
logger.info(
"skipping hamming test-run room composite lookup because no test-case correlation id was found",
extra={
"call_id": resolution_context.call_id,
"room_name": resolution_context.room_name,
**_recording_debug_context(
participant_metadata_raw=resolution_context.participant_metadata_raw,
recording_context=resolution_context.recording_context,
),
},
)
return None
max_attempts = _env_int("HAMMING_ROOM_COMPOSITE_LOOKUP_ATTEMPTS", default=90)
poll_interval_seconds = _env_float(
"HAMMING_ROOM_COMPOSITE_LOOKUP_INTERVAL_SECONDS",
default=1.0,
)
for attempt in range(1, max_attempts + 1):
try:
recording_url = await self._runtime.transport.fetch_test_case_run_recording_url(
test_case_run_id=test_case_run_id,
)
except Exception:
logger.exception(
"failed to fetch room composite recording from hamming test run",
extra={
"test_case_run_id": test_case_run_id,
"call_id": resolution_context.call_id,
"attempt": attempt,
},
)
if attempt >= max_attempts:
return None
await asyncio.sleep(poll_interval_seconds)
continue
if recording_url:
logger.info(
"resolved room composite recording from hamming test run",
extra={
"test_case_run_id": test_case_run_id,
"call_id": resolution_context.call_id,
"attempt": attempt,
},
)
return recording_url
if attempt < max_attempts:
await asyncio.sleep(poll_interval_seconds)
return None
async def _start_plugin_managed_room_composite_egress(
self,
*,
filepath: str,
) -> str:
if self._job_ctx is None:
raise RuntimeError("JobContext is required for plugin-managed room composite egress")
egress_id = await self._runtime.transport.start_plugin_managed_room_composite_egress(
room_name=self._job_ctx.room.name,
filepath=filepath,
)
if self._recording_context is None:
self._recording_context = cast(RecordingContext, {})
self._recording_context["provider_recording_id"] = egress_id
self._recording_context["provider_recording_filepath"] = filepath
self._recording_context["room_name"] = self._job_ctx.room.name
logger.info(
"plugin-managed room composite egress started for session",
extra={
"room_name": self._job_ctx.room.name,
"filepath": filepath,
"egress_id": egress_id,
**_recording_debug_context(
participant_metadata_raw=self._participant_metadata,
recording_context=self._recording_context,
),
},
)
return egress_id
async def _resolve_plugin_managed_room_composite_recording_url(
self,
*,
resolution_context: RecordingResolutionContext,
) -> str | None:
state = self._plugin_managed_room_composite
if state is None:
logger.info(
"plugin-managed room composite state was not initialized for session",
extra={
"call_id": resolution_context.call_id,
"room_name": resolution_context.room_name,
**_recording_debug_context(
participant_metadata_raw=resolution_context.participant_metadata_raw,
recording_context=resolution_context.recording_context,
),
},
)
return None
try:
egress_id = await state.start_task
except Exception:
logger.exception(
"failed to start plugin-managed room composite egress",
extra={"room_name": state.room_name, "filepath": state.filepath},
)
return None
try:
logger.info(
"waiting for plugin-managed room composite egress recording URL",
extra={
"egress_id": egress_id,
"room_name": state.room_name,
"filepath": state.filepath,
"call_id": resolution_context.call_id,
"max_attempts": _env_int(
"HAMMING_PLUGIN_MANAGED_ROOM_COMPOSITE_LOOKUP_ATTEMPTS",
default=90,
),
"poll_interval_seconds": _env_float(
"HAMMING_PLUGIN_MANAGED_ROOM_COMPOSITE_LOOKUP_INTERVAL_SECONDS",
default=1.0,
),
},
)
recording_url = await self._runtime.transport.stop_plugin_managed_room_composite_egress_and_wait_for_url(
egress_id=egress_id,
filepath=state.filepath,
max_attempts=_env_int(
"HAMMING_PLUGIN_MANAGED_ROOM_COMPOSITE_LOOKUP_ATTEMPTS",
default=90,
),
poll_interval_seconds=_env_float(
"HAMMING_PLUGIN_MANAGED_ROOM_COMPOSITE_LOOKUP_INTERVAL_SECONDS",
default=1.0,
),
)
except Exception:
logger.exception(
"failed to finalize plugin-managed room composite egress",
extra={
"egress_id": egress_id,
"room_name": state.room_name,
"call_id": resolution_context.call_id,
},
)
return None
if recording_url:
logger.info(
"resolved room composite recording from plugin-managed egress",
extra={
"egress_id": egress_id,
"room_name": state.room_name,
"call_id": resolution_context.call_id,
"recording_url": recording_url,
},
)
else:
logger.warning(
"plugin-managed room composite egress did not produce a recording URL",
extra={
"egress_id": egress_id,
"room_name": state.room_name,
"call_id": resolution_context.call_id,
"filepath": state.filepath,
},
)
return recording_url
def _log_recording_resolution_outcome(
self,
*,
report: SessionReportLike,
payload_record: dict[str, Any],
) -> None:
recording = self._runtime.config.recording
recording_field_names = (
"recording_url",
"recording_capture",
)
nested_recording_field_names = (
"agent_recording_url",
"user_recording_url",
)
resolved_fields = [
field_name for field_name in recording_field_names if payload_record.get(field_name)
]
nested_recording = payload_record.get("recording")
if isinstance(nested_recording, dict):
resolved_fields.extend(
field_name
for field_name in nested_recording_field_names
if nested_recording.get(field_name)
)
log_method = logger.info if resolved_fields else logger.warning
log_method(
"hamming recording payload resolution summary",
extra={
"call_id": payload_record.get("call_id"),
"room_name": report.room,
"recording_mode": recording.mode,
"recording_source": recording.source,
"resolved_fields": resolved_fields,
**_recording_debug_context(
participant_metadata_raw=self._participant_metadata,
recording_context=self._recording_context,
),
},
)
class HammingRuntime:
def __init__(self, config: HammingConfig) -> None:
self.config = config
self.transport = HammingTransport(
base_url=config.base_url,
api_key=config.api_key,
policy=ConnectionPolicy(
timeout_seconds=config.request_timeout_seconds,
max_retries=config.max_retries,
retry_backoff_seconds=config.retry_backoff_seconds,
),
)
self._monitors: dict[int, _SessionMonitor] = {}
self._tasks: set[asyncio.Task[Any]] = set()
def track_task(self, task: asyncio.Task[Any]) -> None:
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
def remove_monitor(self, session_key: int) -> None:
self._monitors.pop(session_key, None)
if self._monitors:
return
close_task = asyncio.create_task(self.aclose())
self.track_task(close_task)
async def aclose(self) -> None:
await self.transport.aclose()
def attach_session(
self,
session: AgentSession[Any],
*,
job_ctx: JobContext | None = None,
participant_identity: str | None = None,
participant_metadata: str | None = None,
external_agent_id: str | None = None,
customer_metadata: Mapping[str, Any] | None = None,
external_links: Sequence[ExternalLinkInput] | None = None,
session_mode: str = SESSION_MODE_MONITORING,
recording_context: RecordingContext | None = None,
) -> None:
session_key = id(session)
if session_key in self._monitors:
return
resolved_job_ctx = job_ctx or _safe_get_job_context()
resolved_participant_identity = participant_identity or _participant_identity_from_job_ctx(
resolved_job_ctx
)
resolved_participant_metadata = participant_metadata or _participant_metadata_from_job_ctx(
resolved_job_ctx
)
resolved_external_agent_id = _resolve_external_agent_id(
config=self.config,
job_ctx=resolved_job_ctx,
explicit_external_agent_id=external_agent_id,
)
if self.config.auto_record_audio:
_wrap_session_start_for_recording(session)
elif self.config.recording.mode == RECORDING_MODE_SESSION_AUDIO:
_wrap_session_start_for_recording(session)
monitor = _SessionMonitor(
runtime=self,
session=session,
participant_identity=resolved_participant_identity,
participant_metadata=resolved_participant_metadata,
external_agent_id=resolved_external_agent_id,
customer_metadata=customer_metadata,
external_links=external_links,
session_mode=session_mode,
job_ctx=resolved_job_ctx,
session_key=session_key,
recording_context=recording_context,
)
session.on("close", monitor.on_close)
if self.config.flush_on_shutdown and resolved_job_ctx is not None:
resolved_job_ctx.add_shutdown_callback(monitor.on_shutdown)
elif self.config.flush_on_shutdown:
logger.warning(
"flush_on_shutdown is enabled but JobContext is unavailable; final payload will "
"flush only on session close"
)
monitor.start_plugin_managed_room_composite_if_needed()
self._monitors[session_key] = monitor
def build_report(
self,
session: AgentSession[Any],
job_ctx: JobContext | None,
) -> SessionReportLike:
active_job_ctx = job_ctx or _safe_get_job_context()
if active_job_ctx is None:
raise RuntimeError("JobContext not found; cannot build session report")
make_session_report = getattr(active_job_ctx, "make_session_report", None)
if not callable(make_session_report):
raise RuntimeError(
"JobContext.make_session_report() is unavailable; "
"livekit-agents>=1.4.4 is required for Hamming monitoring exports"
)
return cast(SessionReportLike, make_session_report(session))
_RUNTIME: HammingRuntime | None = None
def configure_runtime(config: HammingConfig) -> HammingRuntime:
global _RUNTIME
if _RUNTIME is not None:
logger.warning("hamming already configured, keeping existing runtime")
return _RUNTIME
_validate_config(config)
_apply_runtime_environment_defaults(config)
runtime = HammingRuntime(config)
_RUNTIME = runtime
return runtime
def attach_session(
session: AgentSession[Any],
*,
job_ctx: JobContext | None = None,
participant_identity: str | None = None,
participant_metadata: str | None = None,
external_agent_id: str | None = None,
customer_metadata: Mapping[str, Any] | None = None,
external_links: Sequence[ExternalLinkInput] | None = None,
session_mode: str = SESSION_MODE_MONITORING,
recording_context: RecordingContext | None = None,
) -> None:
runtime = get_runtime()
if runtime is None:
raise RuntimeError("hamming is not configured. Call configure_hamming(...) first.")
runtime.attach_session(
session,
job_ctx=job_ctx,
participant_identity=participant_identity,
participant_metadata=participant_metadata,
external_agent_id=external_agent_id,
customer_metadata=customer_metadata,
external_links=external_links,
session_mode=session_mode,
recording_context=recording_context,
)
def get_runtime() -> HammingRuntime | None:
return _RUNTIME
def build_config(
*,
api_key: str | None = None,
base_url: str | None = None,
call_review_enabled: bool = True,
flush_on_shutdown: bool = True,
auto_record_audio: bool = False,
external_agent_id: str | None = None,
plugin_api_version: str = "1.0.0",
payload_schema_version: str = "2026-03-02",
call_id_strategy: str = CALL_ID_STRATEGY_ROOM_NAME,
call_id_metadata_key: str = "call_id",
resolve_call_id: CallIdResolver | None = None,
recording_mode: str | None = None,
sampling_rate: float = 1.0,
sampling_key: str = "participant_identity",
capture: CaptureConfigInput | None = None,
redact: RedactConfigInput | None = None,
livekit: LiveKitConfigInput | None = None,
livekit_url: str | None = None,
livekit_api_key: str | None = None,
livekit_api_secret: str | None = None,
include_interim_transcripts: bool | None = None,
request_timeout_seconds: float = 10.0,
max_retries: int = 3,
retry_backoff_seconds: float = 0.5,
recording: RecordingConfigInput | None = None,
streaming: StreamingConfigInput | None = None,
) -> HammingConfig:
resolved_api_key = api_key or os.getenv("HAMMING_API_KEY")
if not resolved_api_key:
raise ValueError(
"Hamming API key required. Pass api_key= or set HAMMING_API_KEY environment variable."
)
resolved_base_url = (
base_url or os.getenv("HAMMING_BASE_URL") or "https://app.hamming.ai"
).rstrip("/")
config = HammingConfig(
api_key=resolved_api_key,
base_url=resolved_base_url,
call_review_enabled=call_review_enabled,
flush_on_shutdown=flush_on_shutdown,
auto_record_audio=auto_record_audio,
external_agent_id=external_agent_id,
plugin_api_version=plugin_api_version,
payload_schema_version=payload_schema_version,
call_id_strategy=_build_call_id_strategy(
call_id_strategy=call_id_strategy,
resolve_call_id=resolve_call_id,
),
call_id_metadata_key=_normalize_optional_string(call_id_metadata_key) or "call_id",
resolve_call_id=resolve_call_id,
sampling=_build_sampling_config(rate=sampling_rate, key=sampling_key),
capture=_build_capture_config(
capture=capture,
include_interim_transcripts=include_interim_transcripts,
),
redact=_build_redact_config(redact=redact),
livekit=_build_livekit_config(
livekit=livekit,
livekit_url=livekit_url,
livekit_api_key=livekit_api_key,
livekit_api_secret=livekit_api_secret,
),
request_timeout_seconds=request_timeout_seconds,
max_retries=max_retries,
retry_backoff_seconds=retry_backoff_seconds,
recording=_build_recording_config(
recording=recording,
auto_record_audio=auto_record_audio,
recording_mode=recording_mode,
),
streaming=_build_streaming_config(streaming=streaming),
)
_validate_config(config)
return config
def _validate_config(config: HammingConfig) -> None:
if not config.call_review_enabled:
logger.warning("call_review_enabled is False; hamming payload export is disabled")
_validate_payload_config(config)
_validate_recording_config(config)
_validate_streaming_config(config)
def _validate_payload_config(config: HammingConfig) -> None:
if not config.plugin_api_version:
raise ValueError("plugin_api_version is required")
if not config.payload_schema_version:
raise ValueError("payload_schema_version is required")
if config.call_id_strategy not in SUPPORTED_CALL_ID_STRATEGIES:
raise ValueError(
"Unsupported call_id_strategy. Expected one of: "
f"{', '.join(sorted(SUPPORTED_CALL_ID_STRATEGIES))}."
)
if config.call_id_strategy == CALL_ID_STRATEGY_CUSTOM and config.resolve_call_id is None:
raise ValueError("call_id_strategy='custom' requires resolve_call_id")
def _validate_recording_config(config: HammingConfig) -> None:
recording = config.recording
if recording.mode == RECORDING_MODE_PARTICIPANT_EGRESS:
_validate_participant_egress_config(recording)
return
if recording.mode != RECORDING_MODE_ROOM_COMPOSITE:
return
if recording.delivery == RECORDING_DELIVERY_UPLOAD:
raise ValueError(
f"recording mode '{RECORDING_MODE_ROOM_COMPOSITE}' does not support delivery='upload' yet"
)
if recording.source == RECORDING_SOURCE_EXTERNAL_URLS and recording.resolver is None:
raise ValueError(
f"recording mode '{RECORDING_MODE_ROOM_COMPOSITE}' requires a callable "
"recording resolver when source='external_urls'"
)
if recording.source == RECORDING_SOURCE_AUTO_EGRESS:
missing_env_names = _missing_auto_egress_runtime_env_names(config)
if missing_env_names:
raise ValueError(
"recording_mode='auto_egress' requires runtime configuration for: "
f"{', '.join(missing_env_names)}"
)
def _validate_participant_egress_config(recording: ResolvedRecordingConfig) -> None:
if recording.source != RECORDING_SOURCE_EXTERNAL_URLS:
raise ValueError(
f"recording mode '{RECORDING_MODE_PARTICIPANT_EGRESS}' currently supports only "
f"source='{RECORDING_SOURCE_EXTERNAL_URLS}'"
)
if recording.delivery == RECORDING_DELIVERY_UPLOAD:
raise ValueError(
f"recording mode '{RECORDING_MODE_PARTICIPANT_EGRESS}' does not support delivery='upload' yet"
)
if recording.resolver is None:
raise ValueError(
f"recording mode '{RECORDING_MODE_PARTICIPANT_EGRESS}' requires a callable recording resolver"
)
def _validate_streaming_config(config: HammingConfig) -> None:
if config.streaming.mode == STREAMING_MODE_TRACK_EGRESS:
logger.info(
"track_egress streaming is customer-managed; hamming plugin will not start streaming",
)
def _safe_get_job_context() -> JobContext | None:
try:
return get_job_context()
except RuntimeError:
return None
def _participant_identity_from_job_ctx(job_ctx: JobContext | None) -> str | None:
if job_ctx is None:
return None
for participant in job_ctx.room.remote_participants.values():
identity = getattr(participant, "identity", None)
if isinstance(identity, str) and identity:
return identity
identity = getattr(job_ctx, "local_participant_identity", None)
if isinstance(identity, str) and identity:
return identity
agent = getattr(job_ctx, "agent", None)
agent_identity = getattr(agent, "identity", None)
if isinstance(agent_identity, str) and agent_identity:
return agent_identity
return None
def _participant_metadata_from_job_ctx(job_ctx: JobContext | None) -> str | None:
if job_ctx is None:
return None
for participant in job_ctx.room.remote_participants.values():
metadata = _serialize_participant_metadata(participant)
if metadata:
return metadata
return None
def _serialize_participant_metadata(participant: object) -> str | None:
metadata = getattr(participant, "metadata", None)
attributes = getattr(participant, "attributes", None)
parsed_metadata = _parse_participant_metadata_json(metadata)
merged_metadata = _merge_participant_attributes(parsed_metadata, attributes)
if merged_metadata:
return json.dumps(merged_metadata)
if parsed_metadata:
return json.dumps(parsed_metadata)
if isinstance(metadata, str) and metadata:
return metadata
return None
def _parse_participant_metadata_json(metadata: object) -> dict[str, Any]:
if not isinstance(metadata, str) or not metadata:
return {}
try:
parsed = json.loads(metadata)
except json.JSONDecodeError:
return {}
if isinstance(parsed, dict):
return parsed
return {}
def _merge_participant_attributes(
metadata: dict[str, Any],
attributes: object,
) -> dict[str, Any]:
if not isinstance(attributes, dict) or not attributes:
return {}
merged = {**metadata}
for key, value in attributes.items():
if isinstance(key, str):
merged[key] = value
return merged
def _env_flag(name: str, *, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
normalized = raw.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return default
def _env_int(name: str, *, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return max(1, int(raw.strip()))
except ValueError:
return default
def _env_float(name: str, *, default: float) -> float:
raw = os.getenv(name)
if raw is None:
return default
try:
return max(0.1, float(raw.strip()))
except ValueError:
return default
def _default_auto_recording_options() -> dict[str, bool]:
return {
"audio": True,
"traces": False,
"logs": False,
"transcript": False,
}
def _build_recording_config(
*,
recording: RecordingConfigInput | None,
auto_record_audio: bool,
recording_mode: str | None,
) -> ResolvedRecordingConfig:
if recording is None and recording_mode is not None:
normalized_recording_mode = _normalize_recording_source(
recording_mode,
fallback=RECORDING_SOURCE_AUTO_EGRESS,
)
recording = cast(
RecordingConfigInput,
{
"mode": RECORDING_MODE_ROOM_COMPOSITE,
"source": normalized_recording_mode,
"delivery": RECORDING_DELIVERY_URL,
},
)
if recording is None:
if auto_record_audio:
return ResolvedRecordingConfig(mode=RECORDING_MODE_SESSION_AUDIO)
return ResolvedRecordingConfig()
raw_mode = _normalize_lower_string(recording.get("mode"), fallback=RECORDING_MODE_NONE)
if raw_mode not in SUPPORTED_RECORDING_MODES:
raise ValueError(
"Unsupported recording mode. Expected one of: "
f"{', '.join(sorted(SUPPORTED_RECORDING_MODES))}."
)
default_source = (
RECORDING_SOURCE_AUTO_EGRESS
if raw_mode in {RECORDING_MODE_NONE, RECORDING_MODE_SESSION_AUDIO}
else RECORDING_SOURCE_EXTERNAL_URLS
)
source = _normalize_recording_source(recording.get("source"), fallback=default_source)
if source not in SUPPORTED_RECORDING_SOURCES:
raise ValueError(
"Unsupported recording source. Expected one of: "
f"{', '.join(sorted(SUPPORTED_RECORDING_SOURCES))}."
)
delivery = _normalize_lower_string(recording.get("delivery"), fallback=RECORDING_DELIVERY_AUTO)
if delivery not in SUPPORTED_RECORDING_DELIVERIES:
raise ValueError(
"Unsupported recording delivery. Expected one of: "
f"{', '.join(sorted(SUPPORTED_RECORDING_DELIVERIES))}."
)
resolver = recording.get("resolver")
if resolver is not None and not callable(resolver):
raise ValueError("recording.resolver must be callable")
return ResolvedRecordingConfig(
mode=raw_mode,
source=source,
delivery=delivery,
resolver=resolver,
)
def _build_streaming_config(
*,
streaming: StreamingConfigInput | None,
) -> ResolvedStreamingConfig:
if streaming is None:
return ResolvedStreamingConfig(mode=STREAMING_MODE_NONE)
mode = _normalize_lower_string(streaming.get("mode"), fallback=STREAMING_MODE_NONE)
if mode not in SUPPORTED_STREAMING_MODES:
raise ValueError(
"Unsupported streaming mode. Expected one of: "
f"{', '.join(sorted(SUPPORTED_STREAMING_MODES))}."
)
source = _normalize_lower_string(
streaming.get("source"),
fallback=RECORDING_SOURCE_EXTERNAL_URLS,
)
if source == LEGACY_RECORDING_SOURCE_CUSTOMER_MANAGED:
source = RECORDING_SOURCE_EXTERNAL_URLS
elif source == LEGACY_RECORDING_SOURCE_PLUGIN_MANAGED:
source = RECORDING_SOURCE_AUTO_EGRESS
if source not in SUPPORTED_RECORDING_SOURCES:
raise ValueError(
"Unsupported streaming source. Expected one of: "
f"{', '.join(sorted(SUPPORTED_RECORDING_SOURCES))}."
)
return ResolvedStreamingConfig(mode=mode, source=source)
def _build_call_id_strategy(
*,
call_id_strategy: str,
resolve_call_id: CallIdResolver | None,
) -> str:
normalized_strategy = _normalize_lower_string(
call_id_strategy,
fallback=CALL_ID_STRATEGY_ROOM_NAME,
)
if normalized_strategy not in SUPPORTED_CALL_ID_STRATEGIES:
raise ValueError(
"Unsupported call_id_strategy. Expected one of: "
f"{', '.join(sorted(SUPPORTED_CALL_ID_STRATEGIES))}."
)
if normalized_strategy == CALL_ID_STRATEGY_CUSTOM and resolve_call_id is None:
raise ValueError("call_id_strategy='custom' requires resolve_call_id")
return normalized_strategy
def _build_capture_config(
*,
capture: CaptureConfigInput | None,
include_interim_transcripts: bool | None,
) -> ResolvedCaptureConfig:
capture = capture or {}
prompts = _normalize_lower_string(capture.get("prompts"), fallback="full")
tools = _normalize_lower_string(capture.get("tools"), fallback="full")
if prompts not in SUPPORTED_CAPTURE_MODES:
raise ValueError("capture.prompts must be one of: full, none")
if tools not in SUPPORTED_CAPTURE_MODES:
raise ValueError("capture.tools must be one of: full, none")
interim_transcripts = capture.get("interim_transcripts")
if include_interim_transcripts is not None:
interim_transcripts = include_interim_transcripts
return ResolvedCaptureConfig(
agent_context=bool(capture.get("agent_context", True)),
prompts=prompts,
tools=tools,
interim_transcripts=bool(interim_transcripts),
)
def _build_redact_config(*, redact: RedactConfigInput | None) -> ResolvedRedactConfig:
redact = redact or {}
return ResolvedRedactConfig(
pii=bool(redact.get("pii", True)),
tool_args_paths=_normalize_string_list(redact.get("tool_args_paths")),
tool_output_paths=_normalize_string_list(redact.get("tool_output_paths")),
)
def _build_livekit_config(
*,
livekit: LiveKitConfigInput | None,
livekit_url: str | None,
livekit_api_key: str | None,
livekit_api_secret: str | None,
) -> ResolvedLiveKitConfig:
livekit = livekit or {}
return ResolvedLiveKitConfig(
url=_normalize_optional_string(livekit.get("url") or livekit_url),
api_key=_normalize_optional_string(livekit.get("api_key") or livekit_api_key),
api_secret=_normalize_optional_string(livekit.get("api_secret") or livekit_api_secret),
)
def _build_sampling_config(*, rate: float, key: str) -> ResolvedSamplingConfig:
if rate < 0 or rate > 1:
raise ValueError("sampling_rate must be between 0.0 and 1.0")
normalized_key = _normalize_lower_string(key, fallback="participant_identity")
return ResolvedSamplingConfig(rate=rate, key=normalized_key)
def _wrap_session_start_for_recording(session: AgentSession[Any]) -> None:
if getattr(session, "_hamming_auto_record_wrapped", False):
return
original_start = session.start
async def _wrapped_start(self: AgentSession[Any], *args: Any, **kwargs: Any) -> Any:
record = kwargs.get("record", NOT_GIVEN)
if record is NOT_GIVEN or record is None:
kwargs["record"] = _default_auto_recording_options()
if inspect.ismethod(original_start):
return await cast(Callable[..., Awaitable[Any]], original_start)(*args, **kwargs)
return await cast(Callable[..., Awaitable[Any]], original_start)(self, *args, **kwargs)
session_runtime = cast(Any, session)
session_runtime.start = MethodType(_wrapped_start, session)
session_runtime._hamming_auto_record_wrapped = True
def _resolve_external_agent_id(
*,
config: HammingConfig,
job_ctx: JobContext | None,
explicit_external_agent_id: str | None,
) -> str:
if explicit_external_agent_id:
return explicit_external_agent_id
if config.external_agent_id:
return config.external_agent_id
env_value = os.getenv("HAMMING_EXTERNAL_AGENT_ID")
if env_value:
return env_value
if job_ctx is not None:
agent_name = job_ctx.job.agent_name
if agent_name:
return agent_name
raise ValueError(
"external_agent_id is required. Pass external_agent_id= to configure_hamming(...) or "
"attach_session(...), set HAMMING_EXTERNAL_AGENT_ID, or ensure job.agent_name is set."
)
def _normalize_lower_string(value: object, *, fallback: str) -> str:
if isinstance(value, str):
normalized = value.strip().lower()
if normalized:
return normalized
return fallback
def _normalize_optional_string(value: object) -> str | None:
if isinstance(value, str):
normalized = value.strip()
if normalized:
return normalized
return None
def _normalize_string_list(values: object) -> tuple[str, ...]:
if not isinstance(values, list):
return ()
normalized_values: list[str] = []
for value in values:
if isinstance(value, str):
candidate = value.strip()
if candidate:
normalized_values.append(candidate)
return tuple(normalized_values)
def _normalize_json_object(value: object, *, field_name: str) -> dict[str, Any]:
if value is None:
return {}
if not isinstance(value, dict):
raise ValueError(f"{field_name} must be a JSON object")
try:
normalized = json.loads(json.dumps(value))
except (TypeError, ValueError) as exc:
raise ValueError(f"{field_name} must be JSON serializable") from exc
if not isinstance(normalized, dict):
raise ValueError(f"{field_name} must be a JSON object")
return normalized
def _normalize_external_links(
external_links: Sequence[ExternalLinkInput] | None,
) -> list[dict[str, str]] | None:
if external_links is None:
return None
normalized_links: list[dict[str, str]] = []
for index, link in enumerate(external_links):
if not isinstance(link, dict):
raise ValueError(f"external_links[{index}] must be an object")
label = _normalize_optional_string(link.get("label"))
url = _normalize_optional_string(link.get("url"))
if not label or not url:
continue
normalized_link = {
"label": label,
"url": url,
}
source = _normalize_optional_string(link.get("source"))
if source:
normalized_link["source"] = source
description = _normalize_optional_string(link.get("description"))
if description:
normalized_link["description"] = description
normalized_links.append(normalized_link)
return normalized_links
def _normalize_session_mode(session_mode: object) -> str:
normalized_mode = _normalize_lower_string(session_mode, fallback=SESSION_MODE_MONITORING)
if normalized_mode not in SUPPORTED_SESSION_MODES:
raise ValueError(
"Unsupported session_mode. Expected one of: "
f"{', '.join(sorted(SUPPORTED_SESSION_MODES))}."
)
return normalized_mode
def _normalize_recording_source(value: object, *, fallback: str) -> str:
normalized = _normalize_lower_string(value, fallback=fallback)
if normalized == LEGACY_RECORDING_SOURCE_CUSTOMER_MANAGED:
return RECORDING_SOURCE_EXTERNAL_URLS
if normalized == LEGACY_RECORDING_SOURCE_PLUGIN_MANAGED:
return RECORDING_SOURCE_AUTO_EGRESS
return normalized
def _build_capture_manifest(config: HammingConfig) -> dict[str, Any]:
return {
"agent_context": config.capture.agent_context,
"prompts": config.capture.prompts,
"tools": config.capture.tools,
"interim_transcripts": config.capture.interim_transcripts,
"recording_mode": config.recording.source,
"recording_shape": config.recording.mode,
"fallback_behavior": (
"resolver_required"
if config.recording.source == RECORDING_SOURCE_EXTERNAL_URLS
else "plugin_managed_egress"
),
}
def _build_sampling_metadata(
*,
config: HammingConfig,
call_id: str,
room_name: str,
participant_identity: str | None,
participant_metadata_raw: str | None,
external_agent_id: str,
) -> dict[str, Any]:
sample_key_value = _resolve_sampling_key_value(
key=config.sampling.key,
call_id=call_id,
room_name=room_name,
participant_identity=participant_identity,
participant_metadata_raw=participant_metadata_raw,
external_agent_id=external_agent_id,
)
hash_input = f"{SAMPLING_VERSION}:{config.sampling.key}:{sample_key_value}"
hash_hex = hashlib.sha256(hash_input.encode("utf-8")).hexdigest()
hash_value = int(hash_hex, 16)
threshold = int(config.sampling.rate * ((1 << 256) - 1))
decision = "included" if hash_value <= threshold else "excluded"
return {
"rate": config.sampling.rate,
"key": config.sampling.key,
"decision": decision,
"hash": hash_hex[:8],
"version": SAMPLING_VERSION,
}
def _resolve_sampling_key_value(
*,
key: str,
call_id: str,
room_name: str,
participant_identity: str | None,
participant_metadata_raw: str | None,
external_agent_id: str,
) -> str:
participant_metadata = _parse_json_object(participant_metadata_raw)
if key in {"participant_identity", "identity"} and participant_identity:
return participant_identity
if key in {"call_id", "call"}:
return call_id
if key in {"room_name", "livekit_room_name"}:
return room_name
if key == "external_agent_id":
return external_agent_id
if key.startswith("participant_metadata."):
metadata_key = key.split(".", 1)[1]
value = participant_metadata.get(metadata_key)
if value is not None:
return str(value)
return call_id
def _apply_runtime_environment_defaults(config: HammingConfig) -> None:
env_defaults = {
"LIVEKIT_URL": config.livekit.url,
"LIVEKIT_API_KEY": config.livekit.api_key,
"LIVEKIT_API_SECRET": config.livekit.api_secret,
}
for env_name, env_value in env_defaults.items():
if env_value and not os.getenv(env_name):
os.environ[env_name] = env_value
def _missing_auto_egress_runtime_env_names(config: HammingConfig) -> list[str]:
available_values = {
"LIVEKIT_URL": config.livekit.url or os.getenv("LIVEKIT_URL", "").strip() or None,
"LIVEKIT_API_KEY": config.livekit.api_key
or os.getenv("LIVEKIT_API_KEY", "").strip()
or None,
"LIVEKIT_API_SECRET": config.livekit.api_secret
or os.getenv("LIVEKIT_API_SECRET", "").strip()
or None,
"AWS_RECORDINGS_BUCKET": os.getenv("AWS_RECORDINGS_BUCKET", "").strip() or None,
"AWS_REGION": os.getenv("AWS_REGION", "").strip() or None,
"AWS_ACCESS_KEY_ID": os.getenv("AWS_ACCESS_KEY_ID", "").strip() or None,
"AWS_SECRET_ACCESS_KEY": os.getenv("AWS_SECRET_ACCESS_KEY", "").strip() or None,
}
return [name for name, value in available_values.items() if not value]
def _build_plugin_managed_room_composite_filepath(
*,
room_name: str,
participant_metadata_raw: str | None,
recording_context: RecordingContext | None,
session_key: int,
) -> str:
participant_metadata = _parse_json_object(participant_metadata_raw)
call_id = participant_metadata.get("call_id")
candidate = _extract_test_case_run_id(
participant_metadata_raw=participant_metadata_raw,
recording_context=recording_context,
)
if candidate is None and isinstance(call_id, str) and call_id.strip():
candidate = call_id.strip()
if candidate is None:
candidate = room_name
safe_candidate = _sanitize_recording_path_token(candidate)
return (
f"{PLUGIN_MANAGED_RECORDINGS_DIR}/"
f"{PLUGIN_MANAGED_ROOM_COMPOSITE_FILENAME_PREFIX}_{safe_candidate}_{session_key}."
f"{PLUGIN_MANAGED_ROOM_COMPOSITE_EXTENSION}"
)
def _sanitize_recording_path_token(value: str) -> str:
sanitized = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
sanitized = re.sub(r"-{2,}", "-", sanitized)
sanitized = sanitized.strip("-")
return sanitized or "session"
def _extract_test_case_run_id(
*,
participant_metadata_raw: str | None,
recording_context: RecordingContext | None,
) -> str | None:
participant_metadata = _parse_json_object(participant_metadata_raw)
for key in TEST_CASE_RUN_ID_METADATA_KEYS:
value = participant_metadata.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
if recording_context:
customer_conversation_id = recording_context.get("customer_conversation_id")
if isinstance(customer_conversation_id, str) and customer_conversation_id.strip():
return customer_conversation_id.strip()
return None
def _recording_debug_context(
*,
participant_metadata_raw: str | None,
recording_context: RecordingContext | None,
) -> dict[str, Any]:
participant_metadata = _parse_json_object(participant_metadata_raw)
metadata_keys = sorted(key for key in participant_metadata.keys() if isinstance(key, str))
return {
"test_case_run_id": _extract_test_case_run_id(
participant_metadata_raw=participant_metadata_raw,
recording_context=recording_context,
),
"participant_metadata_keys": metadata_keys,
"customer_conversation_id": (
recording_context.get("customer_conversation_id") if recording_context else None
),
"provider_recording_id": (
recording_context.get("provider_recording_id") if recording_context else None
),
"provider_recording_filepath": (
recording_context.get("provider_recording_filepath") if recording_context else None
),
"recording_context_room_name": (
recording_context.get("room_name") if recording_context else None
),
}
def _parse_json_object(value: str | None) -> dict[str, Any]:
if not value:
return {}
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
if isinstance(parsed, dict):
return parsed
return {}
def _string_or_fallback(value: object, fallback: str) -> str:
if isinstance(value, str) and value.strip():
return value.strip()
return fallback
def _reset_runtime_for_tests() -> None:
global _RUNTIME
runtime = _RUNTIME
_RUNTIME = None
if runtime is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
asyncio.run(runtime.aclose())
return
loop.create_task(runtime.aclose())