mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-13 11:22:14 +02:00
Feat/dybamic transfer (#521)
* feat: enable dynamic transfer destination resolution * fix: remove approved routes, policy and fallback * fix: review comments
This commit is contained in:
parent
aa04a41ff3
commit
2801c3156e
12 changed files with 1534 additions and 86 deletions
|
|
@ -180,10 +180,58 @@ class EndCallConfig(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class HttpTransferResolverConfig(BaseModel):
|
||||
"""HTTP endpoint used to resolve transfer destination at call time."""
|
||||
|
||||
type: Literal["http"] = Field(default="http", description="Resolver type.")
|
||||
url: str = Field(description="HTTP or HTTPS endpoint for transfer resolution.")
|
||||
headers: Optional[Dict[str, str]] = Field(
|
||||
default=None,
|
||||
description="Static headers to include with every resolver request.",
|
||||
)
|
||||
credential_uuid: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Reference to an external credential for resolver authentication.",
|
||||
)
|
||||
timeout_ms: int = Field(
|
||||
default=3000,
|
||||
ge=500,
|
||||
le=5000,
|
||||
description="Resolver request timeout in milliseconds.",
|
||||
)
|
||||
wait_message: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Optional short message played while Dograh resolves routing.",
|
||||
)
|
||||
parameters: Optional[List[ToolParameter]] = Field(
|
||||
default=None,
|
||||
description="Parameters the model may provide when calling this transfer tool.",
|
||||
)
|
||||
preset_parameters: Optional[List[PresetToolParameter]] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Parameters injected by Dograh from fixed values or workflow context "
|
||||
"templates."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def validate_url(cls, v: str) -> str:
|
||||
if not isinstance(v, str) or not v.startswith(("http://", "https://")):
|
||||
raise ValueError("config.resolver.url must be an http(s) URL")
|
||||
return v
|
||||
|
||||
|
||||
class TransferCallConfig(BaseModel):
|
||||
"""Configuration for Transfer Call tools."""
|
||||
|
||||
destination_source: Literal["static", "dynamic"] = Field(
|
||||
default="static",
|
||||
description="Whether transfer destination is static/template or resolved by HTTP.",
|
||||
)
|
||||
destination: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Phone number, SIP endpoint, or template to transfer the call to, e.g. "
|
||||
"+1234567890, PJSIP/1234, or {{initial_context.transfer_destination}}."
|
||||
|
|
@ -204,6 +252,29 @@ class TransferCallConfig(BaseModel):
|
|||
le=120,
|
||||
description="Maximum seconds to wait for the destination to answer.",
|
||||
)
|
||||
parameters: Optional[List[ToolParameter]] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Parameters the model may provide when calling this transfer tool, "
|
||||
"for example state, department, or transfer reason."
|
||||
),
|
||||
)
|
||||
resolver: Optional[HttpTransferResolverConfig] = Field(
|
||||
default=None,
|
||||
description="Optional resolver that determines transfer routing at call time.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_destination_source_config(self):
|
||||
if self.destination_source == "static" and not self.destination.strip():
|
||||
raise ValueError(
|
||||
"config.destination is required when destination_source is static"
|
||||
)
|
||||
if self.destination_source == "dynamic" and self.resolver is None:
|
||||
raise ValueError(
|
||||
"config.resolver is required when destination_source is dynamic"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class McpToolConfig(BaseModel):
|
||||
|
|
|
|||
|
|
@ -71,6 +71,23 @@ def _credential_uuid_from_definition(definition: dict[str, Any]) -> Optional[str
|
|||
return credential_uuid if isinstance(credential_uuid, str) else None
|
||||
|
||||
|
||||
def _credential_uuids_from_definition(definition: dict[str, Any]) -> list[str]:
|
||||
credential_uuids: list[str] = []
|
||||
top_level = _credential_uuid_from_definition(definition)
|
||||
if top_level:
|
||||
credential_uuids.append(top_level)
|
||||
|
||||
config = definition.get("config")
|
||||
if isinstance(config, dict):
|
||||
resolver = config.get("resolver")
|
||||
if isinstance(resolver, dict):
|
||||
resolver_credential_uuid = resolver.get("credential_uuid")
|
||||
if isinstance(resolver_credential_uuid, str) and resolver_credential_uuid:
|
||||
credential_uuids.append(resolver_credential_uuid)
|
||||
|
||||
return list(dict.fromkeys(credential_uuids))
|
||||
|
||||
|
||||
async def fetch_credential(credential_uuid: Optional[str], organization_id: int):
|
||||
"""Best-effort credential lookup for MCP auth/discovery."""
|
||||
if not credential_uuid:
|
||||
|
|
@ -86,22 +103,20 @@ async def validate_tool_credential_references(
|
|||
definition: dict[str, Any], *, organization_id: int
|
||||
) -> None:
|
||||
"""Ensure credential UUID references belong to the caller's organization."""
|
||||
credential_uuid = _credential_uuid_from_definition(definition)
|
||||
if not credential_uuid:
|
||||
return
|
||||
|
||||
credential = await db_client.get_credential_by_uuid(
|
||||
credential_uuid, organization_id
|
||||
)
|
||||
if not credential:
|
||||
raise ToolManagementError(
|
||||
"credential_not_found",
|
||||
(
|
||||
f"Credential '{credential_uuid}' was not found in this organization. "
|
||||
"Create it in the UI first, then retry with its credential_uuid."
|
||||
),
|
||||
status_code=404,
|
||||
for credential_uuid in _credential_uuids_from_definition(definition):
|
||||
credential = await db_client.get_credential_by_uuid(
|
||||
credential_uuid, organization_id
|
||||
)
|
||||
if not credential:
|
||||
raise ToolManagementError(
|
||||
"credential_not_found",
|
||||
(
|
||||
f"Credential '{credential_uuid}' was not found in this "
|
||||
"organization. Create it in the UI first, then retry with its "
|
||||
"credential_uuid."
|
||||
),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
|
||||
async def populate_discovered_tools(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ 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:
|
||||
|
|
@ -313,7 +317,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(
|
||||
|
|
@ -324,6 +328,28 @@ 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."""
|
||||
|
||||
|
|
@ -520,15 +546,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(
|
||||
|
|
@ -560,30 +587,7 @@ class CustomToolManager:
|
|||
)
|
||||
return
|
||||
|
||||
destination = _render_transfer_destination(
|
||||
destination,
|
||||
self._engine._call_context_vars,
|
||||
self._engine._gathered_context,
|
||||
)
|
||||
|
||||
# Validate destination phone number
|
||||
if not destination or not destination.strip():
|
||||
validation_error_result = {
|
||||
"status": "failed",
|
||||
"message": "I'm sorry, but I don't have a phone number configured for the transfer. Please contact support to set up call transfer.",
|
||||
"action": "transfer_failed",
|
||||
"reason": "no_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
|
||||
# Get organization ID for resolver/provider configuration
|
||||
organization_id = await self.get_organization_id()
|
||||
if not organization_id:
|
||||
validation_error_result = {
|
||||
|
|
@ -597,6 +601,87 @@ class CustomToolManager:
|
|||
)
|
||||
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 = {
|
||||
"status": "failed",
|
||||
"message": "I'm sorry, but I don't have a phone number configured for the transfer. Please contact support to set up call transfer.",
|
||||
"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"
|
||||
|
||||
provider = await get_telephony_provider_for_run(
|
||||
workflow_run, organization_id
|
||||
)
|
||||
|
|
@ -607,6 +692,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
|
||||
)
|
||||
|
|
@ -639,6 +725,15 @@ class CustomToolManager:
|
|||
|
||||
# Initiate transfer via provider with inline TwiML
|
||||
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,
|
||||
|
|
@ -648,6 +743,7 @@ class CustomToolManager:
|
|||
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",
|
||||
|
|
@ -669,7 +765,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
|
||||
|
|
@ -745,6 +843,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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
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
|
||||
|
|
@ -49,6 +49,25 @@ class MockToolModel:
|
|||
definition: Dict[str, Any]
|
||||
|
||||
|
||||
def test_empty_resolver_credential_uuid_is_ignored():
|
||||
from api.services.tool_management import _credential_uuids_from_definition
|
||||
|
||||
definition = {
|
||||
"schema_version": 1,
|
||||
"type": "transfer_call",
|
||||
"config": {
|
||||
"destination_source": "dynamic",
|
||||
"resolver": {
|
||||
"type": "http",
|
||||
"url": "https://crm.example.com/resolve-transfer",
|
||||
"credential_uuid": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert _credential_uuids_from_definition(definition) == []
|
||||
|
||||
|
||||
class TestToolToFunctionSchema:
|
||||
"""Tests for tool_to_function_schema function."""
|
||||
|
||||
|
|
@ -294,6 +313,74 @@ class TestToolToFunctionSchema:
|
|||
|
||||
assert schema["function"]["description"] == "Execute My Tool tool"
|
||||
|
||||
def test_transfer_tool_schema_includes_configured_parameters(self):
|
||||
"""Transfer tools can expose resolver inputs to the model."""
|
||||
tool = MockToolModel(
|
||||
tool_uuid="transfer-uuid",
|
||||
name="Transfer To Partner",
|
||||
description="Transfer to the correct referral partner",
|
||||
category="transfer_call",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "transfer_call",
|
||||
"config": {
|
||||
"destination_source": "dynamic",
|
||||
"destination": "",
|
||||
"resolver": {
|
||||
"type": "http",
|
||||
"url": "https://crm.example.com/resolve-transfer",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "state",
|
||||
"type": "string",
|
||||
"description": "The caller's US state.",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"name": "reason",
|
||||
"type": "string",
|
||||
"description": "Why transfer is needed.",
|
||||
"required": False,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
schema = tool_to_function_schema(tool)
|
||||
|
||||
params = schema["function"]["parameters"]
|
||||
assert params["properties"]["state"]["type"] == "string"
|
||||
assert params["properties"]["reason"]["type"] == "string"
|
||||
assert params["required"] == ["state"]
|
||||
|
||||
def test_transfer_tool_schema_handles_null_resolver_parameters(self):
|
||||
"""Dynamic transfer tools should remain available with no parameters."""
|
||||
tool = MockToolModel(
|
||||
tool_uuid="transfer-uuid",
|
||||
name="Transfer To Partner",
|
||||
description="Transfer to the correct referral partner",
|
||||
category="transfer_call",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "transfer_call",
|
||||
"config": {
|
||||
"destination_source": "dynamic",
|
||||
"resolver": {
|
||||
"type": "http",
|
||||
"url": "https://crm.example.com/resolve-transfer",
|
||||
"parameters": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
schema = tool_to_function_schema(tool)
|
||||
|
||||
assert schema["function"]["parameters"]["properties"] == {}
|
||||
assert schema["function"]["parameters"]["required"] == []
|
||||
|
||||
|
||||
class TestExecuteHttpTool:
|
||||
"""Tests for execute_http_tool function."""
|
||||
|
|
@ -531,6 +618,43 @@ class TestExecuteHttpTool:
|
|||
|
||||
assert result["status"] == "success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_request_without_arguments_preserves_url_query_params(self):
|
||||
"""Empty runtime args should not override query params already in the URL."""
|
||||
tool = MockToolModel(
|
||||
tool_uuid="test-uuid",
|
||||
name="Search Users",
|
||||
description="Search for users",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
"config": {
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/users/search?tenant=abc",
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"api.services.workflow.tools.custom_tool.httpx.AsyncClient"
|
||||
) as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"users": []}
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
result = await execute_http_tool(tool, {})
|
||||
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
assert call_kwargs["method"] == "GET"
|
||||
assert call_kwargs["url"].endswith("?tenant=abc")
|
||||
assert call_kwargs["params"] is None
|
||||
assert result["status"] == "success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_request_sends_query_params(self):
|
||||
"""Test that DELETE requests send arguments as query parameters."""
|
||||
|
|
@ -778,6 +902,164 @@ class TestCoerceParameterValue:
|
|||
_coerce_parameter_value(value, "array")
|
||||
|
||||
|
||||
class TestTransferResolver:
|
||||
"""Tests for dynamic transfer resolution behavior."""
|
||||
|
||||
def test_dynamic_transfer_handler_timeout_includes_resolver_budget(self):
|
||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||
|
||||
manager = CustomToolManager(Mock())
|
||||
tool = MockToolModel(
|
||||
tool_uuid="transfer-tool-uuid",
|
||||
name="Transfer Call",
|
||||
description="Transfer the caller",
|
||||
category="transfer_call",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "transfer_call",
|
||||
"config": {
|
||||
"destination_source": "dynamic",
|
||||
"timeout": 120,
|
||||
"resolver": {
|
||||
"type": "http",
|
||||
"url": "https://crm.example.com/resolve-transfer",
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
_handler, timeout_secs = manager._create_handler(tool, "transfer_call")
|
||||
|
||||
assert timeout_secs == 140.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_resolver_resolves_transfer_context_destination(self):
|
||||
from api.services.workflow.tools.transfer_resolver import resolve_transfer_config
|
||||
|
||||
tool = MockToolModel(
|
||||
tool_uuid="transfer-tool-uuid",
|
||||
name="Transfer Call",
|
||||
description="Transfer the caller",
|
||||
category="transfer_call",
|
||||
definition={},
|
||||
)
|
||||
config = {
|
||||
"destination_source": "dynamic",
|
||||
"destination": "",
|
||||
"timeout": 30,
|
||||
"resolver": {
|
||||
"type": "http",
|
||||
"url": "https://crm.example.com/resolve-transfer",
|
||||
"headers": {"X-Tenant": "ovation"},
|
||||
"timeout_ms": 3000,
|
||||
"preset_parameters": [
|
||||
{
|
||||
"name": "lead_id",
|
||||
"type": "string",
|
||||
"value_template": "{{initial_context.lead_id}}",
|
||||
"required": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"transfer_context": {
|
||||
"destination": "+14155550123",
|
||||
"custom_message": "I will connect you with our Texas partner now.",
|
||||
}
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.services.workflow.tools.transfer_resolver.validate_user_configured_service_url"
|
||||
),
|
||||
patch(
|
||||
"api.services.workflow.tools.transfer_resolver.httpx.AsyncClient"
|
||||
) as mock_client_class,
|
||||
):
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
resolved = await resolve_transfer_config(
|
||||
tool=tool,
|
||||
config=config,
|
||||
arguments={"state": "TX"},
|
||||
call_context_vars={"lead_id": "lead-123"},
|
||||
gathered_context_vars={},
|
||||
organization_id=1,
|
||||
workflow_run_id=1,
|
||||
)
|
||||
|
||||
assert resolved.destination == "+14155550123"
|
||||
assert resolved.message == "I will connect you with our Texas partner now."
|
||||
assert resolved.timeout_seconds == 30
|
||||
assert resolved.source == "http_resolver"
|
||||
mock_client.request.assert_awaited_once()
|
||||
request_kwargs = mock_client.request.await_args.kwargs
|
||||
assert request_kwargs["method"] == "POST"
|
||||
assert request_kwargs["json"] == {"state": "TX", "lead_id": "lead-123"}
|
||||
assert request_kwargs["headers"]["X-Tenant"] == "ovation"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_resolver_requires_transfer_context_destination(self):
|
||||
from api.services.workflow.tools.transfer_resolver import (
|
||||
TransferResolutionError,
|
||||
resolve_transfer_config,
|
||||
)
|
||||
|
||||
tool = MockToolModel(
|
||||
tool_uuid="transfer-tool-uuid",
|
||||
name="Transfer Call",
|
||||
description="Transfer the caller",
|
||||
category="transfer_call",
|
||||
definition={},
|
||||
)
|
||||
config = {
|
||||
"destination_source": "dynamic",
|
||||
"destination": "",
|
||||
"timeout": 30,
|
||||
"resolver": {
|
||||
"type": "http",
|
||||
"url": "https://crm.example.com/resolve-transfer",
|
||||
"timeout_ms": 3000,
|
||||
},
|
||||
}
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"transfer_context": {}}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.services.workflow.tools.transfer_resolver.validate_user_configured_service_url"
|
||||
),
|
||||
patch(
|
||||
"api.services.workflow.tools.transfer_resolver.httpx.AsyncClient"
|
||||
) as mock_client_class,
|
||||
):
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
with pytest.raises(TransferResolutionError) as exc_info:
|
||||
await resolve_transfer_config(
|
||||
tool=tool,
|
||||
config=config,
|
||||
arguments={},
|
||||
call_context_vars={},
|
||||
gathered_context_vars={},
|
||||
organization_id=1,
|
||||
workflow_run_id=1,
|
||||
)
|
||||
|
||||
assert exc_info.value.reason == "no_destination"
|
||||
|
||||
|
||||
class TestAuthHeaders:
|
||||
"""Tests for auth header building utilities."""
|
||||
|
||||
|
|
@ -1253,6 +1535,227 @@ class TestCustomToolManagerUnit:
|
|||
assert first_context.target_number == "+14155550123"
|
||||
assert result_received["status"] == "transfer_failed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transfer_call_http_resolver_uses_transfer_context_destination(self):
|
||||
"""HTTP resolver transfer_context.destination is passed to the provider."""
|
||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||
|
||||
mock_engine = Mock()
|
||||
mock_engine._workflow_run_id = 1
|
||||
mock_engine._call_context_vars = {}
|
||||
mock_engine._gathered_context = {"state": "TX"}
|
||||
mock_engine._fetch_recording_audio = None
|
||||
mock_engine._audio_config = SimpleNamespace(transport_out_sample_rate=8000)
|
||||
mock_engine._transport_output = SimpleNamespace(queue_frame=AsyncMock())
|
||||
mock_engine._get_organization_id = AsyncMock(return_value=1)
|
||||
mock_engine.task = SimpleNamespace(queue_frame=AsyncMock())
|
||||
mock_engine.set_mute_pipeline = Mock()
|
||||
mock_engine.end_call_with_reason = AsyncMock()
|
||||
|
||||
manager = CustomToolManager(mock_engine)
|
||||
tool = MockToolModel(
|
||||
tool_uuid="transfer-tool-uuid",
|
||||
name="Transfer Call",
|
||||
description="Transfer the caller",
|
||||
category="transfer_call",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "transfer_call",
|
||||
"config": {
|
||||
"destination_source": "dynamic",
|
||||
"destination": "",
|
||||
"timeout": 30,
|
||||
"resolver": {
|
||||
"type": "http",
|
||||
"url": "https://crm.example.com/resolve-transfer",
|
||||
"timeout_ms": 3000,
|
||||
"wait_message": "One moment while I find the right team.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "state",
|
||||
"type": "string",
|
||||
"description": "State to resolve referral partner",
|
||||
"required": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handler, _timeout_secs = manager._create_handler(tool, "transfer_call")
|
||||
|
||||
workflow_run = SimpleNamespace(
|
||||
mode=WorkflowRunMode.TWILIO.value,
|
||||
gathered_context={"call_id": "caller-call-sid"},
|
||||
)
|
||||
provider = Mock()
|
||||
provider.supports_transfers.return_value = True
|
||||
provider.validate_config.return_value = True
|
||||
provider.transfer_call = AsyncMock(return_value={"call_sid": "dest-call-sid"})
|
||||
|
||||
transfer_event = Mock()
|
||||
transfer_event.to_result_dict.return_value = {
|
||||
"status": "failed",
|
||||
"action": "transfer_failed",
|
||||
"reason": "test_complete",
|
||||
}
|
||||
transfer_manager = Mock()
|
||||
transfer_manager.store_transfer_context = AsyncMock()
|
||||
transfer_manager.wait_for_transfer_completion = AsyncMock(
|
||||
return_value=transfer_event
|
||||
)
|
||||
|
||||
result_received = None
|
||||
|
||||
async def mock_result_callback(result, properties=None):
|
||||
nonlocal result_received
|
||||
result_received = result
|
||||
|
||||
mock_params = Mock()
|
||||
mock_params.arguments = {"state": "TX"}
|
||||
mock_params.result_callback = mock_result_callback
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"transfer_context": {
|
||||
"destination": "+14155550123",
|
||||
"custom_message": "I will connect you with our Texas partner now.",
|
||||
}
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.services.workflow.pipecat_engine_custom_tools.db_client.get_workflow_run_by_id",
|
||||
new=AsyncMock(return_value=workflow_run),
|
||||
),
|
||||
patch(
|
||||
"api.services.workflow.pipecat_engine_custom_tools.get_telephony_provider_for_run",
|
||||
new=AsyncMock(return_value=provider),
|
||||
),
|
||||
patch(
|
||||
"api.services.workflow.pipecat_engine_custom_tools.get_call_transfer_manager",
|
||||
new=AsyncMock(return_value=transfer_manager),
|
||||
),
|
||||
patch(
|
||||
"api.services.workflow.pipecat_engine_custom_tools.play_audio_loop",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"api.services.workflow.tools.transfer_resolver.validate_user_configured_service_url"
|
||||
),
|
||||
patch(
|
||||
"api.services.workflow.tools.transfer_resolver.httpx.AsyncClient"
|
||||
) as mock_client_class,
|
||||
):
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
await handler(mock_params)
|
||||
|
||||
mock_client.request.assert_awaited_once()
|
||||
assert mock_client.request.await_args.kwargs["json"] == {"state": "TX"}
|
||||
provider.transfer_call.assert_awaited_once()
|
||||
transfer_kwargs = provider.transfer_call.await_args.kwargs
|
||||
assert transfer_kwargs["destination"] == "+14155550123"
|
||||
assert transfer_kwargs["timeout"] == 30
|
||||
assert result_received["status"] == "transfer_failed"
|
||||
assert [call.args[0] for call in mock_engine.set_mute_pipeline.call_args_list] == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
]
|
||||
|
||||
spoken_texts = [
|
||||
call.args[0].text for call in mock_engine.task.queue_frame.await_args_list
|
||||
]
|
||||
assert "One moment while I find the right team." in spoken_texts
|
||||
assert "I will connect you with our Texas partner now." in spoken_texts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transfer_call_resolver_failure_resets_queued_speech_mute(self):
|
||||
"""Resolver failure after a wait message should not leave user input muted."""
|
||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||
|
||||
mock_engine = Mock()
|
||||
mock_engine._workflow_run_id = 1
|
||||
mock_engine._call_context_vars = {}
|
||||
mock_engine._gathered_context = {"state": "TX"}
|
||||
mock_engine._fetch_recording_audio = None
|
||||
mock_engine._get_organization_id = AsyncMock(return_value=1)
|
||||
mock_engine.task = SimpleNamespace(queue_frame=AsyncMock())
|
||||
mock_engine.set_mute_pipeline = Mock()
|
||||
mock_engine.end_call_with_reason = AsyncMock()
|
||||
mock_engine._queued_speech_mute_state = "idle"
|
||||
|
||||
manager = CustomToolManager(mock_engine)
|
||||
tool = MockToolModel(
|
||||
tool_uuid="transfer-tool-uuid",
|
||||
name="Transfer Call",
|
||||
description="Transfer the caller",
|
||||
category="transfer_call",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "transfer_call",
|
||||
"config": {
|
||||
"destination_source": "dynamic",
|
||||
"timeout": 30,
|
||||
"resolver": {
|
||||
"type": "http",
|
||||
"url": "https://crm.example.com/resolve-transfer",
|
||||
"timeout_ms": 3000,
|
||||
"wait_message": "One moment while I find the right team.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handler, _timeout_secs = manager._create_handler(tool, "transfer_call")
|
||||
|
||||
workflow_run = SimpleNamespace(
|
||||
mode=WorkflowRunMode.TWILIO.value,
|
||||
gathered_context={"call_id": "caller-call-sid"},
|
||||
)
|
||||
result_received = None
|
||||
|
||||
async def mock_result_callback(result, properties=None):
|
||||
nonlocal result_received
|
||||
result_received = result
|
||||
|
||||
mock_params = Mock()
|
||||
mock_params.arguments = {"state": "TX"}
|
||||
mock_params.result_callback = mock_result_callback
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"transfer_context": {}}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.services.workflow.pipecat_engine_custom_tools.db_client.get_workflow_run_by_id",
|
||||
new=AsyncMock(return_value=workflow_run),
|
||||
),
|
||||
patch(
|
||||
"api.services.workflow.tools.transfer_resolver.validate_user_configured_service_url"
|
||||
),
|
||||
patch(
|
||||
"api.services.workflow.tools.transfer_resolver.httpx.AsyncClient"
|
||||
) as mock_client_class,
|
||||
):
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
await handler(mock_params)
|
||||
|
||||
assert result_received["status"] == "transfer_failed"
|
||||
assert result_received["reason"] == "no_destination"
|
||||
assert mock_engine._queued_speech_mute_state == "idle"
|
||||
assert [call.args[0] for call in mock_engine.set_mute_pipeline.call_args_list] == [
|
||||
True,
|
||||
False,
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transfer_call_propagates_provider_destination_error(self):
|
||||
"""Provider-specific destination failures are returned through the tool result."""
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import pytest
|
||||
|
||||
from api.schemas.tool import TransferCallConfig
|
||||
|
||||
|
||||
|
|
@ -13,3 +15,28 @@ def test_transfer_call_destination_accepts_provider_specific_literal():
|
|||
config = TransferCallConfig(destination="provider-specific-destination")
|
||||
|
||||
assert config.destination == "provider-specific-destination"
|
||||
|
||||
|
||||
def test_transfer_call_static_requires_destination():
|
||||
with pytest.raises(ValueError, match="destination is required"):
|
||||
TransferCallConfig(destination_source="static", destination="")
|
||||
|
||||
|
||||
def test_transfer_call_dynamic_requires_resolver():
|
||||
with pytest.raises(ValueError, match="resolver is required"):
|
||||
TransferCallConfig(destination_source="dynamic", destination="")
|
||||
|
||||
|
||||
def test_transfer_call_dynamic_accepts_resolver_without_destination():
|
||||
config = TransferCallConfig(
|
||||
destination_source="dynamic",
|
||||
destination="",
|
||||
resolver={
|
||||
"type": "http",
|
||||
"url": "https://crm.example.com/resolve-transfer",
|
||||
},
|
||||
)
|
||||
|
||||
assert config.destination_source == "dynamic"
|
||||
assert config.destination == ""
|
||||
assert config.resolver is not None
|
||||
|
|
|
|||
|
|
@ -1,20 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import {
|
||||
CredentialSelector,
|
||||
KeyValueEditor,
|
||||
type KeyValueItem,
|
||||
ParameterEditor,
|
||||
PresetParameterEditor,
|
||||
type PresetToolParameter,
|
||||
type ToolParameter,
|
||||
UrlInput,
|
||||
} from "@/components/http";
|
||||
import { RecordingSelect, StaticTextWarning } from "@/components/flow/TextOrAudioInput";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import { type EndCallMessageType } from "../../config";
|
||||
import {
|
||||
type EndCallMessageType,
|
||||
type TransferDestinationSource,
|
||||
} from "../../config";
|
||||
|
||||
export interface TransferCallToolConfigProps {
|
||||
name: string;
|
||||
onNameChange: (name: string) => void;
|
||||
description: string;
|
||||
onDescriptionChange: (description: string) => void;
|
||||
destinationSource: TransferDestinationSource;
|
||||
onDestinationSourceChange: (source: TransferDestinationSource) => void;
|
||||
destination: string;
|
||||
onDestinationChange: (destination: string) => void;
|
||||
messageType: EndCallMessageType;
|
||||
|
|
@ -24,8 +40,22 @@ export interface TransferCallToolConfigProps {
|
|||
audioRecordingId: string;
|
||||
onAudioRecordingIdChange: (id: string) => void;
|
||||
recordings?: RecordingResponseSchema[];
|
||||
timeout?: number; // Make optional to match API type
|
||||
timeout?: number;
|
||||
onTimeoutChange: (timeout: number) => void;
|
||||
resolverUrl: string;
|
||||
onResolverUrlChange: (url: string) => void;
|
||||
resolverCredentialUuid: string;
|
||||
onResolverCredentialUuidChange: (uuid: string) => void;
|
||||
resolverHeaders: KeyValueItem[];
|
||||
onResolverHeadersChange: (headers: KeyValueItem[]) => void;
|
||||
resolverTimeoutMs: number;
|
||||
onResolverTimeoutMsChange: (timeoutMs: number) => void;
|
||||
resolverWaitMessage: string;
|
||||
onResolverWaitMessageChange: (message: string) => void;
|
||||
parameters: ToolParameter[];
|
||||
onParametersChange: (parameters: ToolParameter[]) => void;
|
||||
presetParameters: PresetToolParameter[];
|
||||
onPresetParametersChange: (parameters: PresetToolParameter[]) => void;
|
||||
}
|
||||
|
||||
export function TransferCallToolConfig({
|
||||
|
|
@ -33,6 +63,8 @@ export function TransferCallToolConfig({
|
|||
onNameChange,
|
||||
description,
|
||||
onDescriptionChange,
|
||||
destinationSource,
|
||||
onDestinationSourceChange,
|
||||
destination,
|
||||
onDestinationChange,
|
||||
messageType,
|
||||
|
|
@ -44,6 +76,20 @@ export function TransferCallToolConfig({
|
|||
recordings = [],
|
||||
timeout,
|
||||
onTimeoutChange,
|
||||
resolverUrl,
|
||||
onResolverUrlChange,
|
||||
resolverCredentialUuid,
|
||||
onResolverCredentialUuidChange,
|
||||
resolverHeaders,
|
||||
onResolverHeadersChange,
|
||||
resolverTimeoutMs,
|
||||
onResolverTimeoutMsChange,
|
||||
resolverWaitMessage,
|
||||
onResolverWaitMessageChange,
|
||||
parameters,
|
||||
onParametersChange,
|
||||
presetParameters,
|
||||
onPresetParametersChange,
|
||||
}: TransferCallToolConfigProps) {
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -79,31 +125,10 @@ export function TransferCallToolConfig({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 pt-4 border-t">
|
||||
<Label>Transfer Destination</Label>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>Enter one of these destination formats:</p>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li>SIP endpoint, e.g. PJSIP/1234</li>
|
||||
<li>E.164 phone number, e.g. +1234567890</li>
|
||||
<li>
|
||||
Template variable, e.g. {"{{initial_context.transfer_destination}}"}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Input
|
||||
value={destination}
|
||||
onChange={(e) => onDestinationChange(e.target.value)}
|
||||
placeholder={
|
||||
"+1234567890, PJSIP/1234, or {{initial_context.transfer_destination}}"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 pt-4 border-t">
|
||||
<Label>Pre-Transfer Message</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Choose whether to play a message before transferring
|
||||
Choose whether to play a configured message before transferring. In dynamic mode, resolver custom_message overrides this when returned.
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={messageType}
|
||||
|
|
@ -166,16 +191,14 @@ export function TransferCallToolConfig({
|
|||
<div className="grid gap-2 pt-4 border-t">
|
||||
<Label>Transfer Timeout</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Maximum time to wait for destination to answer (5-120 seconds)
|
||||
Maximum time to wait for destination to answer after the transfer starts (5-120 seconds)
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={timeout ?? 30}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 30;
|
||||
// Clamp value between 5 and 120 seconds
|
||||
const clampedValue = Math.min(Math.max(value, 5), 120);
|
||||
onTimeoutChange(clampedValue);
|
||||
onTimeoutChange(Math.min(Math.max(value, 5), 120));
|
||||
}}
|
||||
placeholder="30"
|
||||
min="5"
|
||||
|
|
@ -186,6 +209,142 @@ export function TransferCallToolConfig({
|
|||
Default: 30 seconds
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 pt-4 border-t">
|
||||
<div>
|
||||
<Label>Destination Source</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose whether the transfer uses a configured destination or resolves one from an HTTP endpoint.
|
||||
</p>
|
||||
</div>
|
||||
<Tabs
|
||||
value={destinationSource}
|
||||
onValueChange={(v) => onDestinationSourceChange(v as TransferDestinationSource)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="static">Static / Template</TabsTrigger>
|
||||
<TabsTrigger value="dynamic">Dynamic HTTP Resolver</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="static" className="space-y-4 mt-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Transfer Destination</Label>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>Use a fixed number, SIP endpoint, or context template.</p>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li>SIP endpoint, e.g. PJSIP/1234</li>
|
||||
<li>E.164 phone number, e.g. +1234567890</li>
|
||||
<li>
|
||||
Template variable, e.g. {"{{initial_context.transfer_destination}}"}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Input
|
||||
value={destination}
|
||||
onChange={(e) => onDestinationChange(e.target.value)}
|
||||
placeholder="+1234567890, PJSIP/1234, or {{initial_context.transfer_destination}}"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="dynamic" className="space-y-5 mt-4">
|
||||
<div>
|
||||
<Label>Dynamic Transfer Resolver</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Dograh sends the resolved argument dictionary to this endpoint. The endpoint must return transfer_context.destination and may return transfer_context.custom_message.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Resolver URL</Label>
|
||||
<UrlInput
|
||||
value={resolverUrl}
|
||||
onChange={onResolverUrlChange}
|
||||
placeholder="https://crm.example.com/resolve-transfer"
|
||||
showValidation
|
||||
/>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Dograh sends a POST request with the resolved argument dictionary.
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Resolver Timeout</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={resolverTimeoutMs}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 3000;
|
||||
onResolverTimeoutMsChange(Math.min(Math.max(value, 500), 5000));
|
||||
}}
|
||||
min="500"
|
||||
max="5000"
|
||||
className="w-36"
|
||||
/>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Default: 3000 ms. Maximum: 5000 ms.
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<CredentialSelector
|
||||
value={resolverCredentialUuid}
|
||||
onChange={onResolverCredentialUuidChange}
|
||||
label="Resolver Credential (Optional)"
|
||||
description="Select a credential for the resolver endpoint, or leave empty for no auth."
|
||||
/>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Resolver Wait Message</Label>
|
||||
<Textarea
|
||||
value={resolverWaitMessage}
|
||||
onChange={(e) => onResolverWaitMessageChange(e.target.value)}
|
||||
placeholder="One moment while I find the right team."
|
||||
rows={2}
|
||||
/>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Spoken while Dograh waits for the resolver response.
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 pt-4 border-t">
|
||||
<Label>LLM Parameters</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Define values the agent should provide when calling this transfer tool, such as state, department, or reason.
|
||||
</Label>
|
||||
<ParameterEditor
|
||||
parameters={parameters}
|
||||
onChange={onParametersChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 pt-4 border-t">
|
||||
<Label>Preset Parameters</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Add values Dograh injects at runtime. These are not exposed to the LLM and can use templates like {`{{initial_context.state}}`} or {`{{gathered_context.state}}`}.
|
||||
</Label>
|
||||
<PresetParameterEditor
|
||||
parameters={presetParameters}
|
||||
onChange={onPresetParametersChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 pt-4 border-t">
|
||||
<Label>Custom Headers</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Add custom headers for authentication or routing metadata.
|
||||
</Label>
|
||||
<KeyValueEditor
|
||||
items={resolverHeaders}
|
||||
onChange={onResolverHeadersChange}
|
||||
keyPlaceholder="Header name"
|
||||
valuePlaceholder="Header value"
|
||||
addButtonText="Add Header"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import type {
|
|||
HttpApiToolDefinition,
|
||||
RecordingResponseSchema,
|
||||
ToolResponse,
|
||||
TransferCallConfig as APITransferCallConfig,
|
||||
UpdateToolRequest,
|
||||
} from "@/client/types.gen";
|
||||
import {
|
||||
|
|
@ -46,11 +45,13 @@ import { useAuth } from "@/lib/auth";
|
|||
import {
|
||||
createMcpDefinition,
|
||||
DEFAULT_END_CALL_REASON_DESCRIPTION,
|
||||
type ExtendedTransferCallConfig,
|
||||
type EndCallMessageType,
|
||||
getCategoryConfig,
|
||||
getToolTypeLabel,
|
||||
MCP_URL_PATTERN,
|
||||
renderToolIcon,
|
||||
type TransferDestinationSource,
|
||||
type ToolCategory,
|
||||
} from "../config";
|
||||
import { BuiltinToolConfig, EndCallToolConfig, HttpApiToolConfig, TransferCallToolConfig } from "./components";
|
||||
|
|
@ -67,6 +68,11 @@ function normalizeParameterType(value: string | null | undefined): ParameterType
|
|||
}
|
||||
}
|
||||
|
||||
function headersToRows(headers: Record<string, string> | undefined | null): KeyValueItem[] {
|
||||
if (!headers) return [];
|
||||
return Object.entries(headers).map(([key, value]) => ({ key, value }));
|
||||
}
|
||||
|
||||
export default function ToolDetailPage() {
|
||||
const { toolUuid } = useParams<{ toolUuid: string }>();
|
||||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||
|
|
@ -109,10 +115,19 @@ export default function ToolDetailPage() {
|
|||
};
|
||||
|
||||
// Transfer Call form state
|
||||
const [transferDestinationSource, setTransferDestinationSource] =
|
||||
useState<TransferDestinationSource>("static");
|
||||
const [transferDestination, setTransferDestination] = useState("");
|
||||
const [transferMessageType, setTransferMessageType] = useState<EndCallMessageType>("none");
|
||||
const [transferTimeout, setTransferTimeout] = useState(30);
|
||||
const [transferAudioRecordingId, setTransferAudioRecordingId] = useState("");
|
||||
const [transferResolverUrl, setTransferResolverUrl] = useState("");
|
||||
const [transferResolverCredentialUuid, setTransferResolverCredentialUuid] = useState("");
|
||||
const [transferResolverHeaders, setTransferResolverHeaders] = useState<KeyValueItem[]>([]);
|
||||
const [transferResolverTimeoutMs, setTransferResolverTimeoutMs] = useState(3000);
|
||||
const [transferResolverWaitMessage, setTransferResolverWaitMessage] = useState("");
|
||||
const [transferParameters, setTransferParameters] = useState<ToolParameter[]>([]);
|
||||
const [transferPresetParameters, setTransferPresetParameters] = useState<PresetToolParameter[]>([]);
|
||||
|
||||
// HTTP API form state - custom message type
|
||||
const [customMessageType, setCustomMessageType] = useState<'text' | 'audio'>('text');
|
||||
|
|
@ -182,19 +197,50 @@ export default function ToolDetailPage() {
|
|||
}
|
||||
} else if (tool.category === "transfer_call") {
|
||||
// Populate transfer call specific fields
|
||||
const config = tool.definition?.config as APITransferCallConfig | undefined;
|
||||
const config = tool.definition?.config as ExtendedTransferCallConfig | undefined;
|
||||
if (config) {
|
||||
const resolver = config.resolver || undefined;
|
||||
setTransferDestinationSource(config.destination_source || (resolver ? "dynamic" : "static"));
|
||||
setTransferDestination(config.destination || "");
|
||||
setTransferMessageType(config.messageType || "none");
|
||||
setCustomMessage(config.customMessage || "");
|
||||
setTransferAudioRecordingId(config.audioRecordingId || "");
|
||||
setTransferTimeout(config.timeout ?? 30);
|
||||
setTransferResolverUrl(resolver?.url || "");
|
||||
setTransferResolverCredentialUuid(resolver?.credential_uuid || "");
|
||||
setTransferResolverHeaders(headersToRows(resolver?.headers));
|
||||
setTransferResolverTimeoutMs(resolver?.timeout_ms ?? 3000);
|
||||
setTransferResolverWaitMessage(resolver?.wait_message || "");
|
||||
setTransferParameters(
|
||||
(resolver?.parameters || config.parameters || []).map((p) => ({
|
||||
name: p.name || "",
|
||||
type: normalizeParameterType(p.type),
|
||||
description: p.description || "",
|
||||
required: p.required ?? true,
|
||||
})),
|
||||
);
|
||||
setTransferPresetParameters(
|
||||
(resolver?.preset_parameters || []).map((p) => ({
|
||||
name: p.name || "",
|
||||
type: normalizeParameterType(p.type),
|
||||
valueTemplate: p.value_template || "",
|
||||
required: p.required ?? true,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
setTransferDestinationSource("static");
|
||||
setTransferDestination("");
|
||||
setTransferMessageType("none");
|
||||
setCustomMessage("");
|
||||
setTransferAudioRecordingId("");
|
||||
setTransferTimeout(30);
|
||||
setTransferResolverUrl("");
|
||||
setTransferResolverCredentialUuid("");
|
||||
setTransferResolverHeaders([]);
|
||||
setTransferResolverTimeoutMs(3000);
|
||||
setTransferResolverWaitMessage("");
|
||||
setTransferParameters([]);
|
||||
setTransferPresetParameters([]);
|
||||
}
|
||||
} else if (tool.category === "mcp") {
|
||||
// Populate MCP specific fields
|
||||
|
|
@ -296,10 +342,46 @@ export default function ToolDetailPage() {
|
|||
if (tool.category === "calculator") {
|
||||
// No validation needed for built-in tools
|
||||
} else if (tool.category === "transfer_call") {
|
||||
if (!normalizedTransferDestination) {
|
||||
if (transferDestinationSource === "static" && !normalizedTransferDestination) {
|
||||
setError("Please enter a transfer destination");
|
||||
return;
|
||||
}
|
||||
if (transferDestinationSource === "dynamic") {
|
||||
const resolverUrlValidation = validateUrl(transferResolverUrl);
|
||||
if (!resolverUrlValidation.valid) {
|
||||
setError(resolverUrlValidation.error || "Invalid resolver URL");
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidTransferParams = transferParameters.filter(
|
||||
(p) => !p.name.trim() || !p.description.trim()
|
||||
);
|
||||
if (invalidTransferParams.length > 0) {
|
||||
setError("All resolver arguments must have a name and description");
|
||||
return;
|
||||
}
|
||||
const transferParamNames = transferParameters
|
||||
.map((p) => p.name.trim())
|
||||
.filter(Boolean);
|
||||
if (new Set(transferParamNames).size !== transferParamNames.length) {
|
||||
setError("Resolver argument names must be unique");
|
||||
return;
|
||||
}
|
||||
const invalidPresetTransferParams = transferPresetParameters.filter(
|
||||
(p) => !p.name.trim() || !p.valueTemplate.trim()
|
||||
);
|
||||
if (invalidPresetTransferParams.length > 0) {
|
||||
setError("All resolver preset parameters must have a name and a value");
|
||||
return;
|
||||
}
|
||||
const transferPresetParamNames = transferPresetParameters
|
||||
.map((p) => p.name.trim())
|
||||
.filter(Boolean);
|
||||
if (new Set(transferPresetParamNames).size !== transferPresetParamNames.length) {
|
||||
setError("Resolver preset parameter names must be unique");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (tool.category === "mcp") {
|
||||
// Validate MCP server URL (must be http(s))
|
||||
if (!mcpUrl.trim()) {
|
||||
|
|
@ -370,6 +452,55 @@ export default function ToolDetailPage() {
|
|||
},
|
||||
};
|
||||
} else if (tool.category === "transfer_call") {
|
||||
const resolverHeadersObject: Record<string, string> = {};
|
||||
transferResolverHeaders.filter((h) => h.key && h.value).forEach((h) => {
|
||||
resolverHeadersObject[h.key] = h.value;
|
||||
});
|
||||
|
||||
const validTransferParameters = transferParameters.filter((p) => p.name.trim());
|
||||
const validTransferPresetParameters = transferPresetParameters.filter(
|
||||
(p) => p.name.trim() && p.valueTemplate.trim()
|
||||
);
|
||||
|
||||
const transferConfig: ExtendedTransferCallConfig = {
|
||||
destination_source: transferDestinationSource,
|
||||
destination: transferDestinationSource === "static" ? normalizedTransferDestination : "",
|
||||
messageType: transferMessageType,
|
||||
customMessage: transferMessageType === "custom" ? customMessage : undefined,
|
||||
audioRecordingId: transferMessageType === "audio" ? transferAudioRecordingId || undefined : undefined,
|
||||
timeout: transferTimeout,
|
||||
resolver: transferDestinationSource === "dynamic"
|
||||
? {
|
||||
type: "http",
|
||||
url: transferResolverUrl.trim(),
|
||||
credential_uuid: transferResolverCredentialUuid || undefined,
|
||||
headers:
|
||||
Object.keys(resolverHeadersObject).length > 0
|
||||
? resolverHeadersObject
|
||||
: undefined,
|
||||
timeout_ms: transferResolverTimeoutMs,
|
||||
wait_message: transferResolverWaitMessage.trim() || undefined,
|
||||
parameters:
|
||||
validTransferParameters.length > 0
|
||||
? validTransferParameters.map((p) => ({
|
||||
name: p.name.trim(),
|
||||
type: p.type,
|
||||
description: p.description.trim(),
|
||||
required: p.required,
|
||||
}))
|
||||
: undefined,
|
||||
preset_parameters:
|
||||
validTransferPresetParameters.length > 0
|
||||
? validTransferPresetParameters.map((p) => ({
|
||||
name: p.name.trim(),
|
||||
type: p.type,
|
||||
value_template: p.valueTemplate.trim(),
|
||||
required: p.required,
|
||||
}))
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
// Build transfer call request body
|
||||
requestBody = {
|
||||
name,
|
||||
|
|
@ -377,14 +508,8 @@ export default function ToolDetailPage() {
|
|||
definition: {
|
||||
schema_version: 1,
|
||||
type: "transfer_call",
|
||||
config: {
|
||||
destination: normalizedTransferDestination,
|
||||
messageType: transferMessageType,
|
||||
customMessage: transferMessageType === "custom" ? customMessage : undefined,
|
||||
audioRecordingId: transferMessageType === "audio" ? transferAudioRecordingId || undefined : undefined,
|
||||
timeout: transferTimeout,
|
||||
},
|
||||
},
|
||||
config: transferConfig,
|
||||
} as UpdateToolRequest["definition"],
|
||||
};
|
||||
} else if (tool.category === "mcp") {
|
||||
requestBody = {
|
||||
|
|
@ -647,6 +772,8 @@ const data = await response.json();`;
|
|||
onNameChange={setName}
|
||||
description={description}
|
||||
onDescriptionChange={setDescription}
|
||||
destinationSource={transferDestinationSource}
|
||||
onDestinationSourceChange={setTransferDestinationSource}
|
||||
destination={transferDestination}
|
||||
onDestinationChange={setTransferDestination}
|
||||
messageType={transferMessageType}
|
||||
|
|
@ -658,6 +785,20 @@ const data = await response.json();`;
|
|||
recordings={recordings}
|
||||
timeout={transferTimeout}
|
||||
onTimeoutChange={setTransferTimeout}
|
||||
resolverUrl={transferResolverUrl}
|
||||
onResolverUrlChange={setTransferResolverUrl}
|
||||
resolverCredentialUuid={transferResolverCredentialUuid}
|
||||
onResolverCredentialUuidChange={setTransferResolverCredentialUuid}
|
||||
resolverHeaders={transferResolverHeaders}
|
||||
onResolverHeadersChange={setTransferResolverHeaders}
|
||||
resolverTimeoutMs={transferResolverTimeoutMs}
|
||||
onResolverTimeoutMsChange={setTransferResolverTimeoutMs}
|
||||
resolverWaitMessage={transferResolverWaitMessage}
|
||||
onResolverWaitMessageChange={setTransferResolverWaitMessage}
|
||||
parameters={transferParameters}
|
||||
onParametersChange={setTransferParameters}
|
||||
presetParameters={transferPresetParameters}
|
||||
onPresetParametersChange={setTransferPresetParameters}
|
||||
/>
|
||||
) : isMcpTool ? (
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -9,13 +9,32 @@ import type {
|
|||
EndCallToolDefinition,
|
||||
HttpApiToolDefinition,
|
||||
McpToolDefinition,
|
||||
PresetToolParameter,
|
||||
TransferCallConfig,
|
||||
TransferCallToolDefinition,
|
||||
ToolParameter,
|
||||
} from "@/client/types.gen";
|
||||
|
||||
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "calculator" | "native" | "integration" | "mcp";
|
||||
|
||||
export type EndCallMessageType = "none" | "custom" | "audio";
|
||||
export type TransferDestinationSource = "static" | "dynamic";
|
||||
|
||||
export interface TransferResolverConfig {
|
||||
type: "http";
|
||||
url: string;
|
||||
headers?: Record<string, string> | null;
|
||||
credential_uuid?: string | null;
|
||||
timeout_ms: number;
|
||||
wait_message?: string | null;
|
||||
parameters?: ToolParameter[] | null;
|
||||
preset_parameters?: PresetToolParameter[] | null;
|
||||
}
|
||||
|
||||
export interface ExtendedTransferCallConfig extends TransferCallConfig {
|
||||
destination_source?: TransferDestinationSource;
|
||||
resolver?: TransferResolverConfig | null;
|
||||
}
|
||||
|
||||
export interface ToolCategoryConfig {
|
||||
value: ToolCategory;
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -2988,6 +2988,64 @@ export type HttpApiToolDefinition = {
|
|||
config: HttpApiConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* HttpTransferResolverConfig
|
||||
*
|
||||
* HTTP endpoint used to resolve transfer destination at call time.
|
||||
*/
|
||||
export type HttpTransferResolverConfig = {
|
||||
/**
|
||||
* Type
|
||||
*
|
||||
* Resolver type.
|
||||
*/
|
||||
type?: 'http';
|
||||
/**
|
||||
* Url
|
||||
*
|
||||
* HTTP or HTTPS endpoint for transfer resolution.
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Headers
|
||||
*
|
||||
* Static headers to include with every resolver request.
|
||||
*/
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
/**
|
||||
* Credential Uuid
|
||||
*
|
||||
* Reference to an external credential for resolver authentication.
|
||||
*/
|
||||
credential_uuid?: string | null;
|
||||
/**
|
||||
* Timeout Ms
|
||||
*
|
||||
* Resolver request timeout in milliseconds.
|
||||
*/
|
||||
timeout_ms?: number;
|
||||
/**
|
||||
* Wait Message
|
||||
*
|
||||
* Optional short message played while Dograh resolves routing.
|
||||
*/
|
||||
wait_message?: string | null;
|
||||
/**
|
||||
* Parameters
|
||||
*
|
||||
* Parameters the model may provide when calling this transfer tool.
|
||||
*/
|
||||
parameters?: Array<ToolParameter> | null;
|
||||
/**
|
||||
* Preset Parameters
|
||||
*
|
||||
* Parameters injected by Dograh from fixed values or workflow context templates.
|
||||
*/
|
||||
preset_parameters?: Array<PresetToolParameter> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hugging Face
|
||||
*
|
||||
|
|
@ -5797,12 +5855,18 @@ export type ToolResponse = {
|
|||
* Configuration for Transfer Call tools.
|
||||
*/
|
||||
export type TransferCallConfig = {
|
||||
/**
|
||||
* Destination Source
|
||||
*
|
||||
* Whether transfer destination is static/template or resolved by HTTP.
|
||||
*/
|
||||
destination_source?: 'static' | 'dynamic';
|
||||
/**
|
||||
* Destination
|
||||
*
|
||||
* Phone number, SIP endpoint, or template to transfer the call to, e.g. +1234567890, PJSIP/1234, or {{initial_context.transfer_destination}}.
|
||||
*/
|
||||
destination: string;
|
||||
destination?: string;
|
||||
/**
|
||||
* Messagetype
|
||||
*
|
||||
|
|
@ -5827,6 +5891,16 @@ export type TransferCallConfig = {
|
|||
* Maximum seconds to wait for the destination to answer.
|
||||
*/
|
||||
timeout?: number;
|
||||
/**
|
||||
* Parameters
|
||||
*
|
||||
* Parameters the model may provide when calling this transfer tool, for example state, department, or transfer reason.
|
||||
*/
|
||||
parameters?: Array<ToolParameter> | null;
|
||||
/**
|
||||
* Optional resolver that determines transfer routing at call time.
|
||||
*/
|
||||
resolver?: HttpTransferResolverConfig | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue