chore: refactor status processor (#465)

* chore: refactor status processor

* fix: fix billing duration when billsec is None for Cloudonix
This commit is contained in:
Abhishek 2026-06-24 22:07:35 +05:30 committed by GitHub
parent d817d50056
commit 29c5be298c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 910 additions and 809 deletions

View file

@ -22,6 +22,9 @@ from typing import Any
from loguru import logger
from api.services.pipecat.gemini_json_schema_adapter import (
DograhGeminiJSONSchemaAdapter,
)
from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
Frame,
@ -35,10 +38,6 @@ from pipecat.services.google.gemini_live.llm import GeminiLiveLLMService
from pipecat.services.llm_service import FunctionCallFromLLM
from pipecat.utils.tracing.service_decorators import traced_gemini_live
from api.services.pipecat.gemini_json_schema_adapter import (
DograhGeminiJSONSchemaAdapter,
)
class DograhGeminiLiveLLMService(GeminiLiveLLMService):
"""Gemini Live with Dograh engine integration quirks. See module docstring."""

View file

@ -14,7 +14,7 @@ from fastapi import HTTPException
from loguru import logger
from api.db import db_client
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -205,12 +205,12 @@ class ARIProvider(TelephonyProvider):
"""
# Map ARI channel states to common status format
state_map = {
"Up": "answered",
"Down": "completed",
"Ringing": "ringing",
"Ring": "ringing",
"Busy": "busy",
"Unavailable": "failed",
"Up": TelephonyCallStatus.ANSWERED,
"Down": TelephonyCallStatus.COMPLETED,
"Ringing": TelephonyCallStatus.RINGING,
"Ring": TelephonyCallStatus.RINGING,
"Busy": TelephonyCallStatus.BUSY,
"Unavailable": TelephonyCallStatus.FAILED,
}
channel_state = data.get("channel", {}).get("state", "")
@ -218,11 +218,11 @@ class ARIProvider(TelephonyProvider):
# Determine status from event type
if event_type == "StasisStart":
status = "answered"
status = TelephonyCallStatus.ANSWERED
elif event_type == "StasisEnd":
status = "completed"
status = TelephonyCallStatus.COMPLETED
elif event_type == "ChannelDestroyed":
status = "completed"
status = TelephonyCallStatus.COMPLETED
else:
status = state_map.get(channel_state, channel_state.lower())

View file

@ -11,7 +11,7 @@ from fastapi import HTTPException
from loguru import logger
from api.db import db_client
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -348,15 +348,15 @@ class CloudonixProvider(TelephonyProvider):
# Map Cloudonix status values to common format
# These mappings may need adjustment based on actual Cloudonix callback format
status_map = {
"initiated": "initiated",
"ringing": "ringing",
"answered": "answered",
"completed": "completed",
"failed": "failed",
"busy": "busy",
"no-answer": "no-answer",
"canceled": "canceled",
"error": "error",
"initiated": TelephonyCallStatus.INITIATED,
"ringing": TelephonyCallStatus.RINGING,
"answered": TelephonyCallStatus.ANSWERED,
"completed": TelephonyCallStatus.COMPLETED,
"failed": TelephonyCallStatus.FAILED,
"busy": TelephonyCallStatus.BUSY,
"no-answer": TelephonyCallStatus.NO_ANSWER,
"canceled": TelephonyCallStatus.CANCELED,
"error": TelephonyCallStatus.ERROR,
}
call_status = data.get("status", "")
@ -374,6 +374,33 @@ class CloudonixProvider(TelephonyProvider):
"extra": data, # Include all original data
}
@staticmethod
def parse_cdr_status_callback(data: Dict[str, Any]) -> Dict[str, Any]:
"""Parse Cloudonix CDR data into generic status callback format."""
disposition_map = {
"ANSWER": TelephonyCallStatus.COMPLETED,
"BUSY": TelephonyCallStatus.BUSY,
"CANCEL": TelephonyCallStatus.CANCELED,
"FAILED": TelephonyCallStatus.FAILED,
"CONGESTION": TelephonyCallStatus.FAILED,
"NOANSWER": TelephonyCallStatus.NO_ANSWER,
}
disposition = data.get("disposition") or ""
session = data.get("session")
billsec = data.get("billsec")
return {
"call_id": session.get("token") if isinstance(session, dict) else "",
"status": disposition_map.get(disposition.upper(), disposition.lower()),
"from_number": data.get("from"),
"to_number": data.get("to"),
"duration": str(
billsec if billsec is not None else (data.get("duration") or 0)
),
"extra": data,
}
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
) -> str:

View file

@ -12,6 +12,7 @@ from pipecat.utils.run_context import set_current_run_id
from api.db import db_client
from api.services.telephony.factory import get_telephony_provider_for_run
from api.services.telephony.providers.cloudonix.provider import CloudonixProvider
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
@ -120,8 +121,15 @@ async def handle_cloudonix_cdr(request: Request):
set_current_run_id(workflow_run_id)
logger.info(f"[run {workflow_run_id}] Processing Cloudonix CDR for call {call_id}")
# Convert CDR to status update using StatusCallbackRequest
status_update = StatusCallbackRequest.from_cloudonix_cdr(cdr_data)
parsed_data = CloudonixProvider.parse_cdr_status_callback(cdr_data)
status_update = StatusCallbackRequest(
call_id=parsed_data["call_id"],
status=parsed_data["status"],
from_number=parsed_data.get("from_number"),
to_number=parsed_data.get("to_number"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {}),
)
# Process the status update
await _process_status_update(workflow_run_id, status_update)

View file

@ -15,7 +15,7 @@ from fastapi import HTTPException
from loguru import logger
from api.db import db_client
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -281,17 +281,17 @@ class PlivoProvider(TelephonyProvider):
def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]:
status_map = {
"in-progress": "answered",
"ringing": "ringing",
"ring": "ringing",
"completed": "completed",
"hangup": "completed",
"stopstream": "completed",
"busy": "busy",
"no-answer": "no-answer",
"cancel": "canceled",
"cancelled": "canceled",
"timeout": "no-answer",
"in-progress": TelephonyCallStatus.ANSWERED,
"ringing": TelephonyCallStatus.RINGING,
"ring": TelephonyCallStatus.RINGING,
"completed": TelephonyCallStatus.COMPLETED,
"hangup": TelephonyCallStatus.COMPLETED,
"stopstream": TelephonyCallStatus.COMPLETED,
"busy": TelephonyCallStatus.BUSY,
"no-answer": TelephonyCallStatus.NO_ANSWER,
"cancel": TelephonyCallStatus.CANCELED,
"cancelled": TelephonyCallStatus.CANCELED,
"timeout": TelephonyCallStatus.NO_ANSWER,
}
call_status = (data.get("CallStatus") or data.get("Event") or "").lower()

View file

@ -25,7 +25,7 @@ TELNYX_TIMESTAMP_TOLERANCE_SECONDS = 300
TELNYX_PUBLIC_KEY_BYTES = 32
TELNYX_SIGNATURE_BYTES = 64
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -305,23 +305,25 @@ class TelnyxProvider(TelephonyProvider):
}
@staticmethod
def _resolve_status(event_type: str, payload: Dict[str, Any]) -> str:
def _resolve_status(
event_type: str, payload: Dict[str, Any]
) -> TelephonyCallStatus | str:
"""Map a Telnyx event type (and hangup cause) to a normalized status."""
EVENT_STATUS = {
"call.initiated": "initiated",
"call.answered": "in-progress",
"call.hangup": "completed",
"call.initiated": TelephonyCallStatus.INITIATED,
"call.answered": TelephonyCallStatus.IN_PROGRESS,
"call.hangup": TelephonyCallStatus.COMPLETED,
"call.machine.detection.ended": "machine-detected",
"streaming.started": "streaming-started",
"streaming.stopped": "streaming-stopped",
}
HANGUP_STATUS = {
"busy": "busy",
"no_answer": "no-answer",
"timeout": "no-answer",
"call_rejected": "failed",
"unallocated_number": "failed",
"busy": TelephonyCallStatus.BUSY,
"no_answer": TelephonyCallStatus.NO_ANSWER,
"timeout": TelephonyCallStatus.NO_ANSWER,
"call_rejected": TelephonyCallStatus.FAILED,
"unallocated_number": TelephonyCallStatus.FAILED,
}
status = EVENT_STATUS.get(event_type, event_type)

View file

@ -11,7 +11,7 @@ from fastapi import HTTPException
from loguru import logger
from twilio.request_validator import RequestValidator
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -230,9 +230,10 @@ class TwilioProvider(TelephonyProvider):
"""
Parse Twilio status callback data into generic format.
"""
call_status = data.get("CallStatus", "")
return {
"call_id": data.get("CallSid", ""),
"status": data.get("CallStatus", ""),
"status": TelephonyCallStatus.from_raw(call_status) or call_status,
"from_number": data.get("From"),
"to_number": data.get("To"),
"direction": data.get("Direction"),

View file

@ -14,7 +14,7 @@ import aiohttp
from fastapi import HTTPException
from loguru import logger
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -335,9 +335,10 @@ class VobizProvider(TelephonyProvider):
- call_uuid (instead of CallSid)
- status, from, to, duration, etc.
"""
call_status = data.get("CallStatus", "")
return {
"call_id": data.get("CallUUID", ""),
"status": data.get("CallStatus", ""),
"status": TelephonyCallStatus.from_raw(call_status) or call_status,
"from_number": data.get("From"),
"to_number": data.get("To"),
"direction": data.get("Direction"),

View file

@ -12,7 +12,7 @@ import jwt
from fastapi import HTTPException, Response
from loguru import logger
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -291,14 +291,14 @@ class VonageProvider(TelephonyProvider):
"""
# Map Vonage status to common format
status_map = {
"started": "initiated",
"ringing": "ringing",
"answered": "answered",
"complete": "completed",
"failed": "failed",
"busy": "busy",
"timeout": "no-answer",
"rejected": "busy",
"started": TelephonyCallStatus.INITIATED,
"ringing": TelephonyCallStatus.RINGING,
"answered": TelephonyCallStatus.ANSWERED,
"complete": TelephonyCallStatus.COMPLETED,
"failed": TelephonyCallStatus.FAILED,
"busy": TelephonyCallStatus.BUSY,
"timeout": TelephonyCallStatus.NO_ANSWER,
"rejected": TelephonyCallStatus.BUSY,
}
return {

View file

@ -12,25 +12,96 @@ from loguru import logger
from pydantic import BaseModel
from api.db import db_client
from api.enums import WorkflowRunState
from api.enums import TelephonyCallStatus, WorkflowRunState
from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatcher
from api.services.campaign.campaign_event_publisher import (
get_campaign_event_publisher,
)
from api.services.campaign.circuit_breaker import circuit_breaker
from api.tasks.arq import enqueue_job
from api.tasks.function_names import FunctionNames
TERMINAL_NOT_CONNECTED_STATUSES = frozenset(
{
TelephonyCallStatus.FAILED,
TelephonyCallStatus.BUSY,
TelephonyCallStatus.NO_ANSWER,
TelephonyCallStatus.CANCELED,
TelephonyCallStatus.ERROR,
}
)
IN_FLIGHT_STATUSES = frozenset(
{
TelephonyCallStatus.INITIATED,
TelephonyCallStatus.RINGING,
TelephonyCallStatus.IN_PROGRESS,
TelephonyCallStatus.ANSWERED,
}
)
RETRYABLE_NOT_CONNECTED_STATUSES = frozenset(
{TelephonyCallStatus.BUSY, TelephonyCallStatus.NO_ANSWER}
)
FAILURE_NOT_CONNECTED_STATUSES = frozenset(
{TelephonyCallStatus.ERROR, TelephonyCallStatus.FAILED}
)
def _status_value(value: object) -> str:
status = TelephonyCallStatus.from_raw(value)
if status is not None:
return status.value
return str(value or "").lower()
def _duration_seconds(duration: str | None) -> int | float:
if duration in (None, ""):
return 0
try:
parsed = float(duration)
except (TypeError, ValueError):
return 0
return int(parsed) if parsed.is_integer() else parsed
def _append_unique_tags(existing_tags: object, new_tags: list[str]) -> list[str]:
tags = existing_tags if isinstance(existing_tags, list) else []
merged = list(tags)
for tag in new_tags:
if tag not in merged:
merged.append(tag)
return merged
async def _enqueue_integrations_for_unconnected_run(
workflow_run_id: int,
status: str,
) -> None:
"""Fire post-call integrations (e.g. webhooks) when a call ends before the
Pipecat pipeline ever starts.
Enqueues integrations only -- deliberately *not*
``PROCESS_WORKFLOW_COMPLETION`` -- so an unconnected call still triggers the
configured webhooks without incurring platform-usage billing.
"""
await enqueue_job(FunctionNames.RUN_INTEGRATIONS_POST_WORKFLOW_RUN, workflow_run_id)
logger.info(
f"[run {workflow_run_id}] Enqueued post-call integrations after terminal "
f"telephony status: {status}"
)
class StatusCallbackRequest(BaseModel):
"""Normalized status callback shape used across all telephony providers.
Per-provider converters live as classmethods (``from_twilio``, ``from_plivo``,
``from_vonage``, ``from_cloudonix_cdr``) so the route handler for each
provider can map raw webhook payloads into this shape and hand off to
:func:`_process_status_update`.
Provider-specific route handlers map raw webhook payloads into this shape,
then hand it off to :func:`_process_status_update`.
"""
call_id: str
status: str
status: TelephonyCallStatus | str
from_number: Optional[str] = None
to_number: Optional[str] = None
direction: Optional[str] = None
@ -38,102 +109,14 @@ class StatusCallbackRequest(BaseModel):
extra: dict = {}
@classmethod
def from_twilio(cls, data: dict):
"""Convert Twilio callback to generic format."""
return cls(
call_id=data.get("CallSid", ""),
status=data.get("CallStatus", ""),
from_number=data.get("From"),
to_number=data.get("To"),
direction=data.get("Direction"),
duration=data.get("CallDuration") or data.get("Duration"),
extra=data,
)
@classmethod
def from_plivo(cls, data: dict):
"""Convert Plivo callback to generic format."""
status_map = {
"in-progress": "answered",
"ringing": "ringing",
"ring": "ringing",
"completed": "completed",
"hangup": "completed",
"stopstream": "completed",
"busy": "busy",
"no-answer": "no-answer",
"cancel": "canceled",
"cancelled": "canceled",
"timeout": "no-answer",
}
call_status = (data.get("CallStatus") or data.get("Event") or "").lower()
return cls(
call_id=data.get("CallUUID", "") or data.get("RequestUUID", ""),
status=status_map.get(call_status, call_status),
from_number=data.get("From"),
to_number=data.get("To"),
direction=data.get("Direction"),
duration=data.get("Duration"),
extra=data,
)
@classmethod
def from_vonage(cls, data: dict):
"""Convert Vonage event to generic format."""
status_map = {
"started": "initiated",
"ringing": "ringing",
"answered": "answered",
"complete": "completed",
"failed": "failed",
"busy": "busy",
"timeout": "no-answer",
"rejected": "busy",
}
return cls(
call_id=data.get("uuid", ""),
status=status_map.get(data.get("status", ""), data.get("status", "")),
from_number=data.get("from"),
to_number=data.get("to"),
direction=data.get("direction"),
duration=data.get("duration"),
extra=data,
)
@classmethod
def from_cloudonix_cdr(cls, data: dict):
"""Convert Cloudonix CDR to generic format."""
disposition_map = {
"ANSWER": "completed",
"BUSY": "busy",
"CANCEL": "canceled",
"FAILED": "failed",
"CONGESTION": "failed",
"NOANSWER": "no-answer",
}
disposition = data.get("disposition") or ""
status = disposition_map.get(disposition.upper(), disposition.lower())
session = data.get("session")
call_id = session.get("token") if isinstance(session, dict) else ""
return cls(
call_id=call_id or "",
status=status,
from_number=data.get("from"),
to_number=data.get("to"),
duration=str(data.get("billsec") or data.get("duration") or 0),
extra=data,
)
async def _process_status_update(workflow_run_id: int, status: StatusCallbackRequest):
"""Process status updates from telephony providers.
Idempotent: handles repeated callbacks (e.g. from both webhook and CDR).
"""
normalized_status = TelephonyCallStatus.from_raw(status.status)
status_value = _status_value(status.status)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(
@ -143,7 +126,7 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", [])
telephony_callback_log = {
"status": status.status,
"status": status_value,
"timestamp": datetime.now(UTC).isoformat(),
"call_id": status.call_id,
"duration": status.duration,
@ -156,7 +139,7 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
logs={"telephony_status_callbacks": telephony_callback_logs},
)
if status.status == "completed":
if normalized_status == TelephonyCallStatus.COMPLETED:
logger.info(
f"[run {workflow_run_id}] Call completed with duration: {status.duration}s"
)
@ -174,26 +157,29 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
state=WorkflowRunState.COMPLETED.value,
)
elif status.status in ["failed", "busy", "no-answer", "canceled", "error"]:
elif normalized_status in TERMINAL_NOT_CONNECTED_STATUSES:
logger.warning(
f"[run {workflow_run_id}] Call failed with status: {status.status}"
f"[run {workflow_run_id}] Call failed with status: {normalized_status.value}"
)
if workflow_run.campaign_id:
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
is_failure = status.status in ("error", "failed")
is_failure = normalized_status in FAILURE_NOT_CONNECTED_STATUSES
await circuit_breaker.record_and_evaluate(
workflow_run.campaign_id,
is_failure=is_failure,
workflow_run_id=workflow_run_id if is_failure else None,
reason=status.status if is_failure else None,
reason=normalized_status.value if is_failure else None,
)
if status.status in ["busy", "no-answer"] and workflow_run.campaign_id:
if (
normalized_status in RETRYABLE_NOT_CONNECTED_STATUSES
and workflow_run.campaign_id
):
publisher = await get_campaign_event_publisher()
await publisher.publish_retry_needed(
workflow_run_id=workflow_run_id,
reason=status.status.replace("-", "_"),
reason=normalized_status.value.replace("-", "_"),
campaign_id=workflow_run.campaign_id,
queued_run_id=workflow_run.queued_run_id,
)
@ -203,15 +189,42 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
if workflow_run.gathered_context
else []
)
call_tags.extend(["not_connected", f"telephony_{status.status.lower()}"])
await db_client.update_workflow_run(
run_id=workflow_run_id,
is_completed=True,
state=WorkflowRunState.COMPLETED.value,
gathered_context={"call_tags": call_tags},
call_tags = _append_unique_tags(
call_tags,
["not_connected", f"telephony_{normalized_status.value}"],
)
elif status.status in ["in-progress", "initiated", "ringing"]:
gathered_context = {
"call_tags": call_tags,
"call_disposition": normalized_status.value,
"mapped_call_disposition": normalized_status.value,
}
if status.call_id:
gathered_context["call_id"] = status.call_id
should_run_post_call_integrations = (
workflow_run.state == WorkflowRunState.INITIALIZED.value
and not workflow_run.is_completed
)
update_kwargs = {
"run_id": workflow_run_id,
"is_completed": True,
"state": WorkflowRunState.COMPLETED.value,
"gathered_context": gathered_context,
}
if should_run_post_call_integrations:
update_kwargs["usage_info"] = {
"call_duration_seconds": _duration_seconds(status.duration)
}
await db_client.update_workflow_run(**update_kwargs)
if should_run_post_call_integrations:
await _enqueue_integrations_for_unconnected_run(
workflow_run_id, normalized_status.value
)
elif normalized_status in IN_FLIGHT_STATUSES:
# No-op while the call is in flight.
pass
else:

View file

@ -1,46 +0,0 @@
"""Utility module for applying disposition code mapping."""
from loguru import logger
from api.db import db_client
from api.enums import OrganizationConfigurationKey
async def apply_disposition_mapping(value: str, organization_id: int | None) -> str:
"""Apply disposition code mapping if configured.
Args:
value: The original disposition value to map
organization_id: The organization ID
Returns:
The mapped value if found in configuration, otherwise the original value
"""
if not organization_id or not value:
return value
try:
disposition_mapping = await db_client.get_configuration_value(
organization_id,
OrganizationConfigurationKey.DISPOSITION_CODE_MAPPING.value,
default={},
)
if not disposition_mapping:
return value
# Return mapped value if exists, otherwise original
# DISPOSITION_CODE_MAPPING looks like {"user_idle_max_duration_exceeded": "DAIR"} etc.
mapped_value = disposition_mapping.get(value, value)
if mapped_value != value:
logger.debug(
f"Mapped disposition code from '{value}' to '{mapped_value}' "
f"for organization {organization_id}"
)
return mapped_value
except Exception as e:
logger.error(f"Error applying disposition mapping: {e}")
return value

View file

@ -19,7 +19,6 @@ from pipecat.utils.enums import EndTaskReason
from api.db import db_client
from api.enums import ToolCategory
from api.services.pipecat.audio_playback import play_audio
from api.services.workflow.disposition_mapper import apply_disposition_mapping
from api.services.workflow.workflow_graph import Node, WorkflowGraph
if TYPE_CHECKING:
@ -751,38 +750,21 @@ class PipecatEngine:
CancelFrame(reason=reason) if abort_immediately else EndFrame(reason=reason)
)
# Apply disposition mapping - first try call_disposition if it is,
# extracted from the call conversation then fall back to reason
call_disposition = self._gathered_context.get("call_disposition", "")
organization_id = await self._get_organization_id()
# Record the call disposition: prefer one extracted from the conversation,
# otherwise fall back to the disconnect reason.
call_disposition = self._gathered_context.get("call_disposition", "") or reason
self._gathered_context["call_disposition"] = call_disposition
self._gathered_context["mapped_call_disposition"] = call_disposition
if call_disposition:
# If call_disposition exists, map it
mapped_disposition = await apply_disposition_mapping(
call_disposition, organization_id
)
# Store the original and mapped values
self._gathered_context["extracted_call_disposition"] = call_disposition
self._gathered_context["call_disposition"] = call_disposition
self._gathered_context["mapped_call_disposition"] = mapped_disposition
else:
# Otherwise, map the disconnect reason
mapped_disposition = await apply_disposition_mapping(
reason, organization_id
)
# Store the mapped disconnect reason
self._gathered_context["call_disposition"] = reason
self._gathered_context["mapped_call_disposition"] = mapped_disposition
effective_disposition = self._gathered_context.get("call_disposition", "")
if effective_disposition:
call_tags = self._gathered_context.get("call_tags", [])
if effective_disposition not in call_tags:
call_tags.append(effective_disposition)
if call_disposition not in call_tags:
call_tags.append(call_disposition)
self._gathered_context["call_tags"] = call_tags
logger.debug(
f"Finishing run with reason: {reason}, disposition: {mapped_disposition} queueing frame {frame_to_push}"
f"Finishing run with reason: {reason}, disposition: {call_disposition} "
f"queueing frame {frame_to_push}"
)
await self.task.queue_frame(frame_to_push)