mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
Merge branch 'main' into feat/vici-dial
This commit is contained in:
commit
d6996be920
455 changed files with 33133 additions and 11135 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
326
api/services/workflow/tools/transfer_resolver.py
Normal file
326
api/services/workflow/tools/transfer_resolver.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue