Merge branch 'main' into feat/vici-dial

This commit is contained in:
Abhishek Kumar 2026-07-20 14:24:49 +05:30
commit d6996be920
455 changed files with 33133 additions and 11135 deletions

View file

@ -1,10 +1,8 @@
"""Rule-based audit of a workflow definition's nodes + edges.
Pure, dependency-free helpers derived from `NodeSpec.graph_constraints`.
Lives in tracked code so the regression tests in
`test_workflow_graph_constraints.py` can pin it; the admin cleanup
script in `api/services/admin_utils/local_exec.py` is the production
consumer.
Lives in tracked code so `test_workflow_graph_constraints.py` can pin the
verdicts that one-off cleanup tooling needs to share with runtime validation.
"""
from collections import Counter

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

@ -250,7 +250,6 @@ class _ToolDocumentRefsMixin(BaseModel):
"description": (
"Text spoken via TTS at the start of the call. Supports "
"{{template_variables}}. Leave empty to skip the greeting. "
"Not supported with realtime (speech-to-speech) models."
),
"display_options": DisplayOptions(show={"greeting_type": ["text"]}),
"placeholder": "Hi {{first_name}}, this is Sarah from Acme.",

View file

@ -14,7 +14,10 @@ from api.services.workflow.node_specs._base import (
NodeCategory,
NodeExample,
NodeSpec,
NumberInputOptions,
PropertyLayoutOptions,
PropertyOption,
PropertyRendererOptions,
PropertySpec,
PropertyType,
evaluate_display_options,
@ -65,7 +68,10 @@ __all__ = [
"NodeCategory",
"NodeExample",
"NodeSpec",
"NumberInputOptions",
"PropertyLayoutOptions",
"PropertyOption",
"PropertyRendererOptions",
"PropertySpec",
"PropertyType",
"all_specs",

View file

@ -133,6 +133,42 @@ class PropertyOption(BaseModel):
return out
class PropertyLayoutOptions(BaseModel):
"""Renderer layout hints for a property in the node editor."""
column_span: Optional[int] = Field(
default=None,
ge=1,
le=12,
description="Number of columns to occupy in the editor's 12-column grid.",
)
model_config = ConfigDict(extra="forbid")
class NumberInputOptions(BaseModel):
"""Renderer hints for numeric inputs."""
fractional: bool = Field(
default=False,
description="Allow arbitrary fractional values via step='any'.",
)
model_config = ConfigDict(extra="forbid")
class PropertyRendererOptions(BaseModel):
"""Typed renderer metadata for node properties.
Add new renderer behavior here instead of using free-form property metadata.
"""
layout: Optional[PropertyLayoutOptions] = None
number_input: Optional[NumberInputOptions] = None
model_config = ConfigDict(extra="forbid")
class PropertySpec(BaseModel):
"""Single field on a node.
@ -180,8 +216,9 @@ class PropertySpec(BaseModel):
# Renderer hint, e.g. "textarea" vs single-line for `string`.
editor: Optional[str] = None
# Free-form metadata for renderer-specific behavior. Use sparingly.
extra: dict[str, Any] = Field(default_factory=dict)
# Typed metadata for renderer-specific behavior. Extend
# `PropertyRendererOptions` when the renderer needs a new hint.
renderer_options: Optional[PropertyRendererOptions] = None
model_config = ConfigDict(extra="forbid")
@ -192,7 +229,7 @@ class PropertySpec(BaseModel):
description, llm_hint, requiredness, default, enum options, nested
row properties, and validation bounds. UI-rendering concerns
(`display_name`, `placeholder`, `display_options`, `editor`,
`extra`) and null/empty fields are omitted they're noise in the
`renderer_options`) and null/empty fields are omitted they're noise in the
model's context and never appear in authored SDK code.
"""
out: dict[str, Any] = {
@ -263,6 +300,10 @@ class NodeSpec(BaseModel):
default=None,
description="LLM-only guidance; omitted from the UI.",
)
docs_url: Optional[str] = Field(
default=None,
description="Documentation URL shown in the node editor.",
)
category: NodeCategory
icon: str # lucide-react icon name (e.g., "Play")
version: str = "1.0.0"
@ -275,8 +316,8 @@ class NodeSpec(BaseModel):
def to_mcp_dict(self) -> dict[str, Any]:
"""Lean projection of this spec for the `get_node_type` MCP tool.
Drops node-level UI metadata (`display_name`, `category`, `icon`,
`version`) and the per-property rendering concerns trimmed by
Drops node-level UI metadata (`display_name`, `docs_url`, `category`,
`icon`, `version`) and the per-property rendering concerns trimmed by
`PropertySpec.to_mcp_dict`, leaving just the authoring-relevant
schema the LLM consumes when composing a workflow. The full spec is
still served verbatim to the frontend renderer (REST `node-types`

View file

@ -16,6 +16,7 @@ from api.services.workflow.node_specs._base import (
NodeExample,
NodeSpec,
PropertyOption,
PropertyRendererOptions,
PropertySpec,
PropertyType,
)
@ -32,6 +33,7 @@ class NodeSpecMetadata:
category: NodeCategory
icon: str
llm_hint: str | None = None
docs_url: str | None = None
version: str = "1.0.0"
examples: tuple[NodeExample, ...] = ()
graph_constraints: GraphConstraints | None = None
@ -50,7 +52,7 @@ def spec_field(
display_options: DisplayOptions | None = None,
options: list[PropertyOption] | None = None,
editor: str | None = None,
extra: dict[str, Any] | None = None,
renderer_options: PropertyRendererOptions | None = None,
spec_exclude: bool = False,
min_value: float | None = None,
max_value: float | None = None,
@ -69,7 +71,7 @@ def spec_field(
"display_options": display_options,
"options": options,
"editor": editor,
"extra": extra or {},
"renderer_options": renderer_options,
"spec_exclude": spec_exclude,
"min_value": min_value,
"max_value": max_value,
@ -90,6 +92,7 @@ def node_spec(
category: NodeCategory,
icon: str,
llm_hint: str | None = None,
docs_url: str | None = None,
version: str = "1.0.0",
examples: list[NodeExample] | tuple[NodeExample, ...] = (),
graph_constraints: GraphConstraints | None = None,
@ -103,6 +106,7 @@ def node_spec(
category=category,
icon=icon,
llm_hint=llm_hint,
docs_url=docs_url,
version=version,
examples=tuple(examples),
graph_constraints=graph_constraints,
@ -136,6 +140,7 @@ def build_spec(model_cls: type[BaseModel]) -> NodeSpec:
display_name=metadata.display_name,
description=metadata.description,
llm_hint=metadata.llm_hint,
docs_url=metadata.docs_url,
category=metadata.category,
icon=metadata.icon,
version=metadata.version,
@ -206,7 +211,7 @@ def _build_property_spec(
max_length=max_length,
pattern=pattern,
editor=meta.get("editor"),
extra=meta.get("extra") or {},
renderer_options=meta.get("renderer_options"),
)

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:
@ -349,7 +348,11 @@ class PipecatEngine:
)
# Register function with LLM
self.llm.register_function(name, transition_func)
self.llm.register_function(
name,
transition_func,
is_node_transition=True,
)
async def _register_knowledge_base_function(
self, document_uuids: list[str]
@ -773,38 +776,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)

View file

@ -7,7 +7,6 @@ during workflow execution.
from __future__ import annotations
import asyncio
import re
import time
import uuid
from typing import TYPE_CHECKING, Any, Dict, List, Optional
@ -32,12 +31,36 @@ from api.services.workflow.tools.custom_tool import (
execute_http_tool,
tool_to_function_schema,
)
from api.services.workflow.tools.transfer_resolver import (
TransferResolutionError,
resolve_transfer_config,
)
from api.utils.template_renderer import render_template
if TYPE_CHECKING:
from api.services.workflow.mcp_tool_session import McpToolSession
from api.services.workflow.pipecat_engine import PipecatEngine
def _render_transfer_destination(
destination_template: Any,
call_context_vars: Optional[Dict[str, Any]],
gathered_context_vars: Optional[Dict[str, Any]],
) -> str:
"""Resolve a transfer destination template into a concrete provider target."""
initial_context = dict(call_context_vars or {})
render_context: Dict[str, Any] = {
**initial_context,
"initial_context": initial_context,
"gathered_context": dict(gathered_context_vars or {}),
}
rendered = render_template(destination_template, render_context)
if rendered is None:
return ""
return str(rendered).strip()
def get_function_schema(
function_name: str,
description: str,
@ -265,10 +288,19 @@ class CustomToolManager:
# Create and register the handler
handler, timeout_secs = self._create_handler(tool, function_name)
# End-call and transfer-call tools are workflow-control
# boundaries even though they do not necessarily select another
# graph node. Give them the same ordering guarantees as an
# explicit node-transition function.
is_node_transition = tool.category in {
ToolCategory.END_CALL.value,
ToolCategory.TRANSFER_CALL.value,
}
self._engine.llm.register_function(
function_name,
handler,
timeout_secs=timeout_secs,
is_node_transition=is_node_transition,
)
logger.debug(
@ -294,7 +326,7 @@ class CustomToolManager:
if tool.category == ToolCategory.END_CALL.value:
handler = self._create_end_call_handler(tool, function_name)
elif tool.category == ToolCategory.TRANSFER_CALL.value:
timeout_secs = 120.0
timeout_secs = self._transfer_handler_timeout_secs(tool)
handler = self._create_transfer_call_handler(tool, function_name)
else:
timeout_ms = ((tool.definition or {}).get("config", {}) or {}).get(
@ -305,6 +337,27 @@ class CustomToolManager:
return handler, timeout_secs
def _transfer_handler_timeout_secs(self, tool: Any) -> float:
config = (tool.definition or {}).get("config", {}) or {}
try:
transfer_timeout = int(config.get("timeout", 30))
except (TypeError, ValueError):
transfer_timeout = 30
transfer_timeout = min(max(transfer_timeout, 5), 120)
resolver_timeout = 0.0
resolver = config.get("resolver")
if config.get("destination_source", "static") == "dynamic" and isinstance(
resolver, dict
):
try:
resolver_timeout = float(resolver.get("timeout_ms", 3000)) / 1000.0
except (TypeError, ValueError):
resolver_timeout = 3.0
resolver_timeout = min(max(resolver_timeout, 0.5), 5.0)
return float(transfer_timeout) + resolver_timeout + 15.0
def _register_calculator_handler(self) -> None:
"""Register the built-in calculator function with the LLM."""
@ -501,15 +554,16 @@ class CustomToolManager:
function_call_params: FunctionCallParams,
) -> None:
logger.info(f"Transfer Call Tool EXECUTED: {function_name}")
logger.info(f"Arguments: {function_call_params.arguments}")
logger.info(
"Transfer call arguments received "
f"argument_keys={list((function_call_params.arguments or {}).keys())}"
)
try:
# Get the transfer call configuration
config = tool.definition.get("config", {})
destination = config.get("destination", "")
timeout_seconds = config.get(
"timeout", 30
) # Default 30 seconds if not configured
timeout_seconds = config.get("timeout", 30)
# Check if this is a WebRTC call - transfers are not supported
workflow_run = await db_client.get_workflow_run_by_id(
@ -541,6 +595,72 @@ class CustomToolManager:
)
return
# Get organization ID for resolver/provider configuration
organization_id = await self.get_organization_id()
if not organization_id:
validation_error_result = {
"status": "failed",
"message": "I'm sorry, there's an issue with this call transfer. Please contact support.",
"action": "transfer_failed",
"reason": "no_organization_id",
}
await self._handle_transfer_result(
validation_error_result, function_call_params, properties
)
return
resolver = config.get("resolver") if isinstance(config, dict) else None
is_dynamic_transfer = config.get(
"destination_source", "static"
) == "dynamic" and isinstance(resolver, dict)
resolver_phase_muted = False
def clear_transfer_setup_mute_state() -> None:
nonlocal resolver_phase_muted
if resolver_phase_muted:
self._engine.set_mute_pipeline(False)
resolver_phase_muted = False
self._engine._queued_speech_mute_state = "idle"
if is_dynamic_transfer:
self._engine.set_mute_pipeline(True)
resolver_phase_muted = True
if is_dynamic_transfer and resolver.get("wait_message"):
await self._engine.task.queue_frame(
TTSSpeakFrame(
str(resolver["wait_message"]),
append_to_context=False,
persist_to_logs=True,
)
)
self._engine._queued_speech_mute_state = "waiting"
try:
resolved_transfer = await resolve_transfer_config(
tool=tool,
config=config,
arguments=function_call_params.arguments or {},
call_context_vars=self._engine._call_context_vars,
gathered_context_vars=self._engine._gathered_context,
organization_id=organization_id,
workflow_run_id=self._engine._workflow_run_id,
)
destination = resolved_transfer.destination
timeout_seconds = resolved_transfer.timeout_seconds
except TransferResolutionError as e:
clear_transfer_setup_mute_state()
validation_error_result = {
"status": "failed",
"message": "I'm sorry, but I couldn't find a valid destination for this transfer.",
"action": "transfer_failed",
"reason": e.reason,
}
await self._handle_transfer_result(
validation_error_result, function_call_params, properties
)
return
# Validate destination phone number
if not destination or not destination.strip():
validation_error_result = {
@ -549,16 +669,33 @@ class CustomToolManager:
"action": "transfer_failed",
"reason": "no_destination",
}
clear_transfer_setup_mute_state()
await self._handle_transfer_result(
validation_error_result, function_call_params, properties
)
return
if resolved_transfer.message:
await self._engine.task.queue_frame(
TTSSpeakFrame(
resolved_transfer.message,
append_to_context=False,
persist_to_logs=True,
)
)
self._engine._queued_speech_mute_state = "waiting"
else:
played = await self._play_config_message(config)
if played:
self._engine._queued_speech_mute_state = "waiting"
# Upstream-PBX (VICIdial) call: the customer's real leg lives on
# the upstream PBX, so transfer it via the upstream API
# (ra_call_control EXTENSIONTRANSFER / INGROUPTRANSFER) instead of
# dograh's ARI bridge-swap, which assumes dograh owns both legs.
upstream = (workflow_run.initial_context or {}).get("upstream_pbx")
upstream = (getattr(workflow_run, "initial_context", None) or {}).get(
"upstream_pbx"
)
if upstream:
from api.services.telephony.upstream_pbx import (
collect_update_lead_fields,
@ -566,10 +703,6 @@ class CustomToolManager:
update_upstream_lead,
)
# Play the transfer message first so its TTS overlaps the
# extraction latency below.
await self._play_config_message(config)
# Extract variables right before the transfer so any
# X-VICI-UPDATE-LEAD_* values reflect the final conversation
# state, then forward them to the upstream PBX's update_lead
@ -590,8 +723,8 @@ class CustomToolManager:
# Hardcoded address3 routing (see route_upstream_after_call):
# "Y"/"N" transfer the customer into in-group
# dograhtest1/dograhtest2; anything else hangs the customer up
# instead of transferring. The destination configured on the
# transfer node is intentionally ignored for upstream calls.
# instead of transferring. The resolved destination is
# intentionally ignored for upstream calls.
action, ok = await route_upstream_after_call(
upstream, update_fields
)
@ -642,59 +775,6 @@ class CustomToolManager:
)
return
# Validate destination format based on workflow run mode
if workflow_run.mode == WorkflowRunMode.ARI.value:
# For ARI provider, also accept SIP endpoints
SIP_ENDPOINT_REGEX = r"^(PJSIP|SIP)\/[\w\-\.@]+$"
E164_PHONE_REGEX = r"^\+[1-9]\d{1,14}$"
is_valid_sip = re.match(SIP_ENDPOINT_REGEX, destination)
is_valid_e164 = re.match(E164_PHONE_REGEX, destination)
if not (is_valid_sip or is_valid_e164):
validation_error_result = {
"status": "failed",
"message": "I'm sorry, but the transfer destination appears to be invalid. Please contact support to verify the transfer settings.",
"action": "transfer_failed",
"reason": "invalid_destination",
}
await self._handle_transfer_result(
validation_error_result, function_call_params, properties
)
return
else:
# For non-ARI providers (Twilio, etc), use E.164 validation
E164_PHONE_REGEX = r"^\+[1-9]\d{1,14}$"
if not re.match(E164_PHONE_REGEX, destination):
validation_error_result = {
"status": "failed",
"message": "I'm sorry, but the transfer phone number appears to be invalid. Please contact support to verify the transfer settings.",
"action": "transfer_failed",
"reason": "invalid_destination",
}
await self._handle_transfer_result(
validation_error_result, function_call_params, properties
)
return
played = await self._play_config_message(config)
if played:
self._engine._queued_speech_mute_state = "waiting"
# Get organization ID for provider configuration
organization_id = await self.get_organization_id()
if not organization_id:
validation_error_result = {
"status": "failed",
"message": "I'm sorry, there's an issue with this call transfer. Please contact support.",
"action": "transfer_failed",
"reason": "no_organization_id",
}
await self._handle_transfer_result(
validation_error_result, function_call_params, properties
)
return
provider = await get_telephony_provider_for_run(
workflow_run, organization_id
)
@ -705,6 +785,7 @@ class CustomToolManager:
"action": "transfer_failed",
"reason": "provider_does_not_support_transfer",
}
clear_transfer_setup_mute_state()
await self._handle_transfer_result(
validation_error_result, function_call_params, properties
)
@ -736,12 +817,37 @@ class CustomToolManager:
self._engine.set_mute_pipeline(True)
# Initiate transfer via provider with inline TwiML
transfer_result = await provider.transfer_call(
destination=destination,
transfer_id=transfer_id,
conference_name=conference_name,
timeout=timeout_seconds,
)
try:
masked_destination = (
f"***{destination[-4:]}" if len(destination) > 4 else "***"
)
logger.info(
"Transfer provider call starting "
f"source={resolved_transfer.source} "
f"resolution_id={resolved_transfer.resolution_id or ''} "
f"destination={masked_destination} timeout={timeout_seconds}"
)
transfer_result = await provider.transfer_call(
destination=destination,
transfer_id=transfer_id,
conference_name=conference_name,
timeout=timeout_seconds,
)
except Exception as e:
logger.error(f"Transfer provider failed: {e}")
self._engine.set_mute_pipeline(False)
self._engine._queued_speech_mute_state = "idle"
await call_transfer_manager.remove_transfer_context(transfer_id)
provider_error_result = {
"status": "failed",
"message": f"Transfer provider failed: {e}",
"action": "transfer_failed",
"reason": "provider_error",
}
await self._handle_transfer_result(
provider_error_result, function_call_params, properties
)
return
call_sid = transfer_result.get("call_sid")
logger.info(f"Transfer call initiated successfully: {call_sid}")
@ -752,7 +858,9 @@ class CustomToolManager:
# Wait for status callback completion using Redis pub/sub
logger.info(
f"Transfer call initiated for {destination} (transfer_id={transfer_id}), waiting for completion..."
"Transfer call initiated "
f"destination={masked_destination} transfer_id={transfer_id}, "
"waiting for completion..."
)
# Start hold music during transfer waiting period
@ -828,6 +936,7 @@ class CustomToolManager:
f"Transfer call tool '{function_name}' execution failed: {e}"
)
self._engine.set_mute_pipeline(False)
self._engine._queued_speech_mute_state = "idle"
# Handle generic exception with user-friendly message
exception_result = {
@ -897,7 +1006,7 @@ class CustomToolManager:
{
"status": "transfer_failed",
"reason": reason,
"message": "Transfer failed",
"message": result.get("message") or "Transfer failed",
}
)
else:

View file

@ -12,7 +12,7 @@ async def resolve_llm_config(
"""Resolve the LLM provider, model, API key, and extra kwargs for QA analysis.
If the QA node has its own LLM configuration (qa_use_workflow_llm=False),
use those settings directly. Otherwise, fall back to the user's configured LLM.
use those settings directly. Otherwise, fall back to the workflow/org LLM.
Returns:
(provider, model, api_key, service_kwargs) tuple service_kwargs can be
@ -30,7 +30,7 @@ async def resolve_llm_config(
kwargs,
)
# Fall back to user's configured LLM
# Fall back to the workflow/org configured LLM
provider, model, api_key, kwargs = await resolve_user_llm_config(workflow_run)
if qa_data.qa_model and qa_data.qa_model != "default":
@ -42,17 +42,13 @@ async def resolve_llm_config(
async def resolve_user_llm_config(
workflow_run: WorkflowRunModel,
) -> tuple[str, str, str, dict]:
"""Resolve the user's configured LLM (from EffectiveAIModelConfiguration).
"""Resolve the workflow/org configured LLM.
Returns:
(provider, model, api_key, service_kwargs) tuple
"""
user_id = None
if workflow_run.workflow and workflow_run.workflow.user:
user_id = workflow_run.workflow.user.id
llm_config: dict = {}
if user_id:
if workflow_run.workflow:
from api.services.configuration.ai_model_configuration import (
get_effective_ai_model_configuration_for_workflow,
)
@ -68,7 +64,6 @@ async def resolve_user_llm_config(
)
user_configuration = await get_effective_ai_model_configuration_for_workflow(
user_id=user_id,
organization_id=workflow_run.workflow.organization_id
if workflow_run.workflow
else None,

View file

@ -7,6 +7,7 @@ from pipecat.processors.aggregators.llm_context import LLMContext
from api.db import db_client
from api.db.models import WorkflowRunModel
from api.services.managed_model_services import get_mps_correlation_id
from api.services.pipecat.service_factory import create_llm_service_from_provider
from api.services.workflow.dto import NodeType, QANodeData
from api.services.workflow.qa.llm_config import resolve_llm_config
@ -75,7 +76,15 @@ async def ensure_node_summaries(
logger.warning("No API key for node summary generation, skipping")
return existing_summaries
llm = create_llm_service_from_provider(provider, model, api_key, **service_kwargs)
# Reuse the run's MPS correlation id (minted at run start, persisted on
# initial_context) so managed-model-services calls carry billing-v2
# markers — orgs on billing v2 reject managed calls that lack them.
mps_correlation_id = get_mps_correlation_id(
getattr(workflow_run, "initial_context", None)
)
llm = create_llm_service_from_provider(
provider, model, api_key, correlation_id=mps_correlation_id, **service_kwargs
)
updated_summaries = dict(existing_summaries)

View file

@ -6,7 +6,7 @@ from datetime import UTC, datetime
from typing import Any
from fastapi.encoders import jsonable_encoder
from loguru import logger
from pipecat.bus.serializers.json import JSONMessageSerializer
from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
CancelFrame,
@ -56,6 +56,46 @@ TEXT_CHAT_IDLE_SETTLE_SECONDS = 0.2
TEXT_CHAT_INTERNAL_CANCEL_REASON = "text_chat_turn_complete"
def _pipecat_type_tag(type_: type) -> str:
return f"{type_.__module__}.{type_.__name__}"
def _pipecat_json_serializer() -> JSONMessageSerializer:
return JSONMessageSerializer()
def _serialize_text_chat_checkpoint_messages(messages: list[Any]) -> list[Any]:
"""Serialize Pipecat context messages for JSONB checkpoint storage."""
# Pipecat's bus JSON serializer already knows how to preserve LLMContext,
# LLMSpecificMessage, and binary provider fields such as Gemini signatures.
# Keep the serializer shape dependency contained to these checkpoint helpers.
encoded_context = _pipecat_json_serializer()._serialize_value(
LLMContext(messages=list(messages))
)
encoded_data = (
encoded_context.get("__data__") if isinstance(encoded_context, dict) else None
)
encoded_messages = (
encoded_data.get("messages") if isinstance(encoded_data, dict) else None
)
if not isinstance(encoded_messages, list):
raise TypeError("Pipecat LLMContext serializer returned an unexpected shape")
return encoded_messages
def _deserialize_text_chat_checkpoint_messages(messages: list[Any]) -> list[Any]:
"""Restore JSONB checkpoint messages to Pipecat context message objects."""
restored_context = _pipecat_json_serializer()._deserialize_value(
{
"__type__": _pipecat_type_tag(LLMContext),
"__data__": {"messages": list(messages)},
}
)
if not isinstance(restored_context, LLMContext):
raise TypeError("Pipecat LLMContext deserializer returned an unexpected type")
return restored_context.get_messages()
def text_chat_trace_id(workflow_run_id: int) -> str:
"""Deterministic Langfuse trace id for a text-chat session.
@ -391,7 +431,6 @@ async def execute_text_chat_pending_turn(
if pending_turn.get("user_message") is not None
else None
)
workflow_run, _ = await db_client.get_workflow_run_with_context(workflow_run_id)
if not workflow_run or workflow_run.workflow_id != workflow_id:
raise ValueError("Workflow run not found for text chat execution")
@ -405,7 +444,6 @@ async def execute_text_chat_pending_turn(
)
if workflow is None:
raise ValueError("Workflow not found for text chat execution")
# Stamp the async context so OTEL spans are tagged with this org and routed
# to its Langfuse project (the voice paths do this in run_pipeline /
# webrtc_signaling; the text path previously skipped it, so its spans never
@ -420,13 +458,11 @@ async def execute_text_chat_pending_turn(
)
user_config = await get_effective_ai_model_configuration_for_workflow(
user_id=workflow_run.workflow.user.id,
organization_id=workflow.organization_id,
workflow_configurations=run_configs,
)
if user_config.llm is None:
raise ValueError("Text chat requires an LLM configuration")
from api.services.managed_model_services import (
MPS_CORRELATION_ID_CONTEXT_KEY,
ensure_mps_correlation_id,
@ -462,9 +498,10 @@ async def execute_text_chat_pending_turn(
skip_instance_constraints_for={"trigger"},
)
base_checkpoint = _resolve_checkpoint_for_pending_turn(session_data, checkpoint)
context = LLMContext()
context.set_messages(base_checkpoint["messages"])
context.set_messages(
_deserialize_text_chat_checkpoint_messages(base_checkpoint["messages"])
)
response_window = _ResponseWindowState()
capture_processor = _TextChatCaptureProcessor(response_window, context)
@ -494,6 +531,9 @@ async def execute_text_chat_pending_turn(
embeddings_api_key = None
embeddings_model = None
embeddings_base_url = None
embeddings_provider = None
embeddings_endpoint = None
embeddings_api_version = None
if user_config.embeddings:
from api.services.configuration.ai_model_configuration import (
apply_managed_embeddings_base_url,
@ -506,12 +546,13 @@ async def execute_text_chat_pending_turn(
provider=embeddings_provider,
base_url=getattr(user_config.embeddings, "base_url", None),
)
embeddings_endpoint = getattr(user_config.embeddings, "endpoint", None)
embeddings_api_version = getattr(user_config.embeddings, "api_version", None)
has_recordings = await db_client.has_active_recordings(workflow.organization_id)
context_compaction_enabled = (workflow.workflow_configurations or {}).get(
"context_compaction_enabled", False
)
engine = PipecatEngine(
llm=llm,
inference_llm=inference_llm,
@ -523,6 +564,9 @@ async def execute_text_chat_pending_turn(
embeddings_api_key=embeddings_api_key,
embeddings_model=embeddings_model,
embeddings_base_url=embeddings_base_url,
embeddings_provider=embeddings_provider,
embeddings_endpoint=embeddings_endpoint,
embeddings_api_version=embeddings_api_version,
has_recordings=has_recordings,
context_compaction_enabled=context_compaction_enabled,
)
@ -557,7 +601,6 @@ async def execute_text_chat_pending_turn(
trace_span_attributes = {
"langfuse.trace.name": workflow_run.name or f"text-chat-{workflow_run_id}"
}
pipeline = Pipeline(
[
llm,
@ -634,20 +677,13 @@ async def execute_text_chat_pending_turn(
activity_marker=generation_marker,
)
finally:
if not task.has_finished():
await task.cancel(reason=TEXT_CHAT_INTERNAL_CANCEL_REASON)
try:
if not task.has_finished():
await task.cancel(reason=TEXT_CHAT_INTERNAL_CANCEL_REASON)
await runner_task
except Exception:
logger.exception(
"Transportless text chat pipeline failed while closing run {}",
workflow_run_id,
)
finally:
await engine.close_mcp_sessions()
await engine.cleanup()
raise
await engine.close_mcp_sessions()
await engine.cleanup()
gathered_context = await engine.get_gathered_context()
assistant_text = (
@ -658,29 +694,36 @@ async def execute_text_chat_pending_turn(
assistant_created_at = datetime.now(UTC).isoformat()
usage = pipeline_metrics_aggregator.get_all_usage_metrics_serialized()
current_node = getattr(engine, "_current_node", None)
context_messages = context.get_messages()
encoded_messages = _serialize_text_chat_checkpoint_messages(context_messages)
encoded_gathered_context = jsonable_encoder(gathered_context)
encoded_tool_state = jsonable_encoder(base_checkpoint.get("tool_state") or {})
updated_checkpoint = {
"version": TEXT_CHAT_CHECKPOINT_VERSION,
"anchor_turn_id": pending_turn.get("id"),
"current_node_id": current_node.id if current_node else None,
"messages": jsonable_encoder(context.get_messages()),
"gathered_context": jsonable_encoder(gathered_context),
"tool_state": jsonable_encoder(base_checkpoint.get("tool_state") or {}),
"messages": encoded_messages,
"gathered_context": encoded_gathered_context,
"tool_state": encoded_tool_state,
}
encoded_gathered_context = jsonable_encoder(gathered_context)
trace_url = get_trace_url(trace_id, org_id=workflow.organization_id)
if trace_url:
encoded_gathered_context = {**encoded_gathered_context, "trace_url": trace_url}
encoded_events = jsonable_encoder(capture_processor.events)
encoded_usage = jsonable_encoder(usage)
encoded_initial_context = jsonable_encoder(initial_context)
return TextChatTurnExecutionResult(
assistant_text=assistant_text,
assistant_created_at=assistant_created_at,
events=jsonable_encoder(capture_processor.events),
usage=jsonable_encoder(usage),
events=encoded_events,
usage=encoded_usage,
checkpoint=updated_checkpoint,
gathered_context=encoded_gathered_context,
initial_context=jsonable_encoder(initial_context),
initial_context=encoded_initial_context,
state=(
WorkflowRunState.COMPLETED.value
if engine.is_call_disposed()

View file

@ -33,6 +33,20 @@ def tool_to_function_schema(tool: Any) -> Dict[str, Any]:
definition = tool.definition or {}
config = definition.get("config", {})
parameters = config.get("parameters", []) or []
if (
definition.get("type") == "transfer_call"
and config.get("destination_source", "static") != "dynamic"
):
parameters = []
elif (
definition.get("type") == "transfer_call"
and config.get("destination_source", "static") == "dynamic"
):
resolver = config.get("resolver")
if isinstance(resolver, dict):
parameters = resolver.get("parameters", []) or []
else:
parameters = []
# Build properties and required list from parameters
properties = {}

View file

@ -13,8 +13,7 @@ from loguru import logger
from opentelemetry import trace
from api.db import db_client
from api.services.configuration.registry import ServiceProviders
from api.services.gen_ai import AzureOpenAIEmbeddingService, OpenAIEmbeddingService
from api.services.gen_ai import build_embedding_service
from api.services.pipecat.tracing_config import ensure_tracing
@ -266,33 +265,18 @@ async def _perform_retrieval(
"Model Configurations > Embedding."
)
if (
embeddings_provider == ServiceProviders.AZURE.value
and embeddings_endpoint
):
embedding_service = AzureOpenAIEmbeddingService(
db_client=db_client,
api_key=embeddings_api_key,
endpoint=embeddings_endpoint,
model_id=embeddings_model or "text-embedding-3-small",
api_version=embeddings_api_version or "2024-02-15-preview",
)
else:
default_headers = None
if (
embeddings_provider == ServiceProviders.DOGRAH.value
and correlation_id
):
default_headers = {
"X-Dograh-Correlation-Id": correlation_id,
}
embedding_service = OpenAIEmbeddingService(
db_client=db_client,
api_key=embeddings_api_key,
model_id=embeddings_model or "text-embedding-3-small",
base_url=embeddings_base_url,
default_headers=default_headers,
)
# Search runs inside a workflow run: reuse the run's MPS correlation
# id. The Dograh-managed path forwards it via request metadata.
embedding_service = await build_embedding_service(
db_client=db_client,
provider=embeddings_provider,
api_key=embeddings_api_key,
model=embeddings_model,
base_url=embeddings_base_url,
endpoint=embeddings_endpoint,
api_version=embeddings_api_version,
correlation_id=correlation_id,
)
results = await embedding_service.search_similar_chunks(
query=query,

View file

@ -0,0 +1,326 @@
"""Resolve transfer-call destinations from static config or HTTP resolvers."""
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
import httpx
from loguru import logger
from api.db import db_client
from api.services.workflow.tools.custom_tool import _resolve_preset_parameters
from api.utils.credential_auth import build_auth_header
from api.utils.template_renderer import render_template
from api.utils.url_security import validate_user_configured_service_url
@dataclass
class ResolvedTransferConfig:
destination: str
timeout_seconds: int
message: Optional[str] = None
source: str = "static"
resolution_id: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
class TransferResolutionError(ValueError):
"""Raised when a transfer destination cannot be resolved safely."""
def __init__(self, reason: str, message: str):
super().__init__(message)
self.reason = reason
self.message = message
def _render_value(
value: Any,
call_context_vars: Optional[Dict[str, Any]],
gathered_context_vars: Optional[Dict[str, Any]],
) -> str:
initial_context = dict(call_context_vars or {})
render_context: Dict[str, Any] = {
**initial_context,
"initial_context": initial_context,
"gathered_context": dict(gathered_context_vars or {}),
}
rendered = render_template(value, render_context)
if rendered is None:
return ""
return str(rendered).strip()
def _base_timeout(config: dict[str, Any]) -> int:
timeout = config.get("timeout", 30)
try:
timeout_int = int(timeout)
except (TypeError, ValueError):
timeout_int = 30
return min(max(timeout_int, 5), 120)
def _mask_destination(destination: Any) -> str:
value = "" if destination is None else str(destination).strip()
if not value:
return ""
if len(value) <= 4:
return "***"
return f"***{value[-4:]}"
_SENSITIVE_LOG_KEY_PARTS = (
"authorization",
"auth",
"card",
"destination",
"email",
"password",
"phone",
"secret",
"ssn",
"token",
)
def _safe_log_value(key: str, value: Any) -> Any:
key_lower = key.lower()
if any(part in key_lower for part in _SENSITIVE_LOG_KEY_PARTS):
return "<redacted>"
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, str):
stripped = value.strip()
if len(stripped) > 80:
return f"{stripped[:77]}..."
return stripped
if isinstance(value, list):
return f"<array:{len(value)}>"
if isinstance(value, dict):
return f"<object:{len(value)}>"
return f"<{type(value).__name__}>"
def _safe_log_dict(data: Optional[Dict[str, Any]]) -> Dict[str, Any]:
return {
str(key): _safe_log_value(str(key), value)
for key, value in (data or {}).items()
}
def _resolve_static_transfer(
config: dict[str, Any],
call_context_vars: Optional[Dict[str, Any]],
gathered_context_vars: Optional[Dict[str, Any]],
) -> ResolvedTransferConfig:
return ResolvedTransferConfig(
destination=_render_value(
config.get("destination", ""), call_context_vars, gathered_context_vars
),
timeout_seconds=_base_timeout(config),
)
def _resolver_arguments(
*,
resolver: dict[str, Any],
arguments: dict[str, Any],
call_context_vars: Optional[Dict[str, Any]],
gathered_context_vars: Optional[Dict[str, Any]],
) -> dict[str, Any]:
try:
preset_arguments = _resolve_preset_parameters(
resolver, call_context_vars, gathered_context_vars
)
except ValueError as exc:
raise TransferResolutionError("preset_parameter_error", str(exc)) from exc
return {**(arguments or {}), **preset_arguments}
async def _execute_http_resolver(
*,
resolver: dict[str, Any],
resolved_arguments: dict[str, Any],
organization_id: Optional[int],
resolution_id: str,
) -> dict[str, Any]:
url = resolver.get("url", "")
validate_user_configured_service_url(url, field_name="config.resolver.url")
method = "POST"
headers = dict(resolver.get("headers", {}) or {})
if method in ("POST", "PUT", "PATCH"):
headers.setdefault("Content-Type", "application/json")
credential_uuid = resolver.get("credential_uuid")
if credential_uuid and organization_id:
credential = await db_client.get_credential_by_uuid(
credential_uuid, organization_id
)
if credential:
headers.update(build_auth_header(credential))
else:
raise TransferResolutionError(
"credential_not_found",
"Transfer resolver credential was not found for this organization",
)
body = resolved_arguments
timeout_seconds = float(resolver.get("timeout_ms", 3000)) / 1000.0
logger.debug(
"Transfer resolver request prepared "
f"resolution_id={resolution_id} method={method} "
f"argument_keys={list(resolved_arguments.keys())} "
f"arguments={_safe_log_dict(resolved_arguments)}"
)
try:
started_at = time.monotonic()
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
response = await client.request(
method=method,
url=url,
headers=headers,
json=body,
)
duration_ms = int((time.monotonic() - started_at) * 1000)
except httpx.TimeoutException as exc:
raise TransferResolutionError(
"resolver_timeout",
f"Transfer resolver timed out after {timeout_seconds:.1f} seconds",
) from exc
except httpx.RequestError as exc:
raise TransferResolutionError(
"resolver_request_failed", f"Transfer resolver request failed: {exc}"
) from exc
if response.status_code < 200 or response.status_code >= 300:
logger.warning(
"Transfer resolver HTTP error "
f"resolution_id={resolution_id} status_code={response.status_code} "
f"duration_ms={duration_ms}"
)
raise TransferResolutionError(
"resolver_http_error",
f"Transfer resolver returned HTTP {response.status_code}",
)
try:
data = response.json()
except Exception as exc:
raise TransferResolutionError(
"invalid_resolver_response", "Transfer resolver returned non-JSON response"
) from exc
if not isinstance(data, dict):
raise TransferResolutionError(
"invalid_resolver_response",
"Transfer resolver response must be a JSON object",
)
logger.info(
"Transfer resolver HTTP completed "
f"resolution_id={resolution_id} status_code={response.status_code} "
f"duration_ms={duration_ms} response_keys={list(data.keys())}"
)
return data
def _resolve_from_response(
*,
response_data: dict[str, Any],
config: dict[str, Any],
resolution_id: str,
) -> ResolvedTransferConfig:
transfer_context = response_data.get("transfer_context")
if not isinstance(transfer_context, dict):
raise TransferResolutionError(
"invalid_resolver_response",
"Transfer resolver response must contain transfer_context object",
)
destination = transfer_context.get("destination")
if not isinstance(destination, str) or not destination.strip():
raise TransferResolutionError(
"no_destination",
"Transfer resolver response must contain transfer_context.destination",
)
custom_message = transfer_context.get("custom_message")
if custom_message is not None and not isinstance(custom_message, str):
raise TransferResolutionError(
"invalid_custom_message",
"transfer_context.custom_message must be a string when provided",
)
return ResolvedTransferConfig(
destination=destination.strip(),
timeout_seconds=_base_timeout(config),
message=custom_message.strip() if custom_message else None,
source="http_resolver",
resolution_id=resolution_id,
)
async def resolve_transfer_config(
*,
tool: Any,
config: dict[str, Any],
arguments: dict[str, Any],
call_context_vars: Optional[Dict[str, Any]],
gathered_context_vars: Optional[Dict[str, Any]],
organization_id: Optional[int],
workflow_run_id: Optional[int],
) -> ResolvedTransferConfig:
"""Resolve transfer destination and options for a transfer tool call."""
resolver = config.get("resolver")
if config.get("destination_source", "static") != "dynamic" or not isinstance(
resolver, dict
):
resolved = _resolve_static_transfer(
config, call_context_vars, gathered_context_vars
)
logger.info(
"Transfer destination resolved "
f"source={resolved.source} destination={_mask_destination(resolved.destination)} "
f"timeout={resolved.timeout_seconds}"
)
return resolved
resolution_id = str(uuid.uuid4())
logger.info(
"Transfer resolver started "
f"resolution_id={resolution_id} tool_uuid={getattr(tool, 'tool_uuid', None)} "
f"workflow_run_id={workflow_run_id} type={resolver.get('type')} "
"method=POST "
f"timeout_ms={resolver.get('timeout_ms', 3000)}"
)
resolved_arguments = _resolver_arguments(
resolver=resolver,
arguments=arguments,
call_context_vars=call_context_vars,
gathered_context_vars=gathered_context_vars,
)
response_data = await _execute_http_resolver(
resolver=resolver,
resolved_arguments=resolved_arguments,
organization_id=organization_id,
resolution_id=resolution_id,
)
resolved = _resolve_from_response(
response_data=response_data,
config=config,
resolution_id=resolution_id,
)
logger.info(
"Transfer destination resolved "
f"resolution_id={resolution_id} source={resolved.source} "
f"destination={_mask_destination(resolved.destination)} "
f"timeout={resolved.timeout_seconds} "
f"custom_message_present={bool(resolved.message)}"
)
return resolved