fix: remove approved routes, policy and fallback

This commit is contained in:
Sabiha Khan 2026-07-10 19:50:07 +05:30
parent 6ce1bbcbf8
commit a2f1c670aa
10 changed files with 475 additions and 709 deletions

View file

@ -20,11 +20,6 @@ DEFAULT_MCP_SSE_READ_TIMEOUT_SECS = 300
ToolParameterType = Literal["string", "number", "boolean", "object", "array"]
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
TransferResolverPolicy = Literal[
"approved_routes_only",
"approved_routes_or_static_fallback",
"allow_raw_destination",
]
ToolCategoryValue = Literal[
"http_api",
"end_call",
@ -185,33 +180,15 @@ class EndCallConfig(BaseModel):
)
class TransferApprovedRoute(BaseModel):
"""A pre-approved destination the transfer resolver may select."""
destination: str = Field(
description="Phone number, SIP endpoint, or template for this route."
)
message: Optional[str] = Field(
default=None,
description="Optional message to play before transferring to this route.",
)
timeout_seconds: Optional[int] = Field(
default=None,
ge=5,
le=120,
description="Optional route-specific transfer answer timeout.",
)
metadata: Optional[Dict[str, Any]] = Field(
default=None,
description="Optional non-secret route metadata for logs and resolver context.",
)
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.",
@ -226,9 +203,16 @@ class HttpTransferResolverConfig(BaseModel):
default=None,
description="Optional short message played while Dograh resolves routing.",
)
policy: TransferResolverPolicy = Field(
default="approved_routes_only",
description="Controls what resolver responses are allowed to select.",
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")
@ -242,7 +226,12 @@ class HttpTransferResolverConfig(BaseModel):
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}}."
@ -274,14 +263,6 @@ class TransferCallConfig(BaseModel):
default=None,
description="Optional resolver that determines transfer routing at call time.",
)
approved_routes: Optional[Dict[str, TransferApprovedRoute]] = Field(
default=None,
description="Approved route keys that a resolver may select.",
)
fallback_route: Optional[str] = Field(
default=None,
description="Approved route key to use when resolver resolution fails.",
)
class McpToolConfig(BaseModel):

View file

@ -524,7 +524,10 @@ 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
@ -577,7 +580,17 @@ class CustomToolManager:
return
resolver = config.get("resolver") if isinstance(config, dict) else None
if isinstance(resolver, dict) and resolver.get("wait_message"):
is_dynamic_transfer = (
config.get("destination_source", "static") == "dynamic"
and isinstance(resolver, dict)
)
resolver_phase_muted = False
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"]),
@ -600,6 +613,9 @@ class CustomToolManager:
destination = resolved_transfer.destination
timeout_seconds = resolved_transfer.timeout_seconds
except TransferResolutionError as e:
if resolver_phase_muted:
self._engine.set_mute_pipeline(False)
resolver_phase_muted = False
validation_error_result = {
"status": "failed",
"message": "I'm sorry, but I couldn't find a valid destination for this transfer.",
@ -619,6 +635,9 @@ class CustomToolManager:
"action": "transfer_failed",
"reason": "no_destination",
}
if resolver_phase_muted:
self._engine.set_mute_pipeline(False)
resolver_phase_muted = False
await self._handle_transfer_result(
validation_error_result, function_call_params, properties
)
@ -648,6 +667,9 @@ class CustomToolManager:
"action": "transfer_failed",
"reason": "provider_does_not_support_transfer",
}
if resolver_phase_muted:
self._engine.set_mute_pipeline(False)
resolver_phase_muted = False
await self._handle_transfer_result(
validation_error_result, function_call_params, properties
)
@ -687,7 +709,6 @@ class CustomToolManager:
"Transfer provider call starting "
f"source={resolved_transfer.source} "
f"resolution_id={resolved_transfer.resolution_id or ''} "
f"route={resolved_transfer.route or ''} "
f"destination={masked_destination} timeout={timeout_seconds}"
)
transfer_result = await provider.transfer_call(
@ -720,7 +741,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

View file

@ -33,6 +33,19 @@ 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")
parameters = (
resolver.get("parameters", []) if isinstance(resolver, dict) else []
)
# Build properties and required list from parameters
properties = {}
@ -271,7 +284,7 @@ async def execute_http_tool(
params = None
if method in ("POST", "PUT", "PATCH"):
body = resolved_arguments
elif method in ("GET", "DELETE") and resolved_arguments:
elif method in ("GET", "DELETE"):
params = resolved_arguments
logger.info(

View file

@ -1,4 +1,4 @@
"""Resolve transfer-call destinations from static config or dynamic resolvers."""
"""Resolve transfer-call destinations from static config or HTTP resolvers."""
from __future__ import annotations
@ -11,6 +11,7 @@ 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
@ -21,7 +22,6 @@ class ResolvedTransferConfig:
destination: str
timeout_seconds: int
message: Optional[str] = None
route: Optional[str] = None
source: str = "static"
resolution_id: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
@ -53,8 +53,17 @@ def _render_value(
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 = _normalize_destination_value(destination)
value = "" if destination is None else str(destination).strip()
if not value:
return ""
if len(value) <= 4:
@ -62,7 +71,24 @@ def _mask_destination(destination: Any) -> str:
return f"***{value[-4:]}"
def _safe_log_value(value: Any) -> Any:
_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):
@ -78,104 +104,56 @@ def _safe_log_value(value: Any) -> Any:
def _safe_log_dict(data: Optional[Dict[str, Any]]) -> Dict[str, Any]:
return {str(key): _safe_log_value(value) for key, value in (data or {}).items()}
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 _normalize_destination_value(value: Any) -> str:
if isinstance(value, dict):
value = value.get("value")
if value is None:
return ""
return str(value).strip()
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]],
*,
resolution_id: Optional[str] = None,
source: str = "static",
) -> ResolvedTransferConfig:
return ResolvedTransferConfig(
destination=_render_value(
config.get("destination", ""), call_context_vars, gathered_context_vars
),
timeout_seconds=_base_timeout(config),
source=source,
resolution_id=resolution_id,
)
def _expand_approved_route(
def _resolver_arguments(
*,
route_key: str,
config: dict[str, Any],
resolver: dict[str, Any],
arguments: dict[str, Any],
call_context_vars: Optional[Dict[str, Any]],
gathered_context_vars: Optional[Dict[str, Any]],
resolution_id: Optional[str] = None,
source: str = "approved_route",
) -> ResolvedTransferConfig:
approved_routes = config.get("approved_routes") or {}
if not isinstance(approved_routes, dict) or route_key not in approved_routes:
raise TransferResolutionError(
"unknown_route", f"Resolver returned unknown transfer route '{route_key}'"
)
route = approved_routes[route_key] or {}
if not isinstance(route, dict):
raise TransferResolutionError(
"invalid_route", f"Transfer route '{route_key}' is not configured correctly"
)
destination = _render_value(
route.get("destination", ""), call_context_vars, gathered_context_vars
)
timeout = route.get("timeout_seconds")
if timeout is None:
timeout = _base_timeout(config)
) -> dict[str, Any]:
try:
timeout_int = int(timeout)
except (TypeError, ValueError) as exc:
raise TransferResolutionError(
"invalid_timeout", f"Transfer route '{route_key}' has invalid timeout"
) from exc
return ResolvedTransferConfig(
destination=destination,
timeout_seconds=min(max(timeout_int, 5), 120),
message=route.get("message"),
route=route_key,
source=source,
resolution_id=resolution_id,
metadata=dict(route.get("metadata") or {}),
)
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],
tool: Any,
arguments: dict[str, Any],
call_context_vars: Optional[Dict[str, Any]],
gathered_context_vars: Optional[Dict[str, Any]],
resolved_arguments: dict[str, Any],
organization_id: Optional[int],
workflow_run_id: Optional[int],
resolution_id: str,
) -> dict[str, Any]:
url = resolver.get("url", "")
validate_user_configured_service_url(url, field_name="config.resolver.url")
headers = {"Content-Type": "application/json"}
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(
@ -189,31 +167,25 @@ async def _execute_http_resolver(
"Transfer resolver credential was not found for this organization",
)
payload = {
"event": "transfer_resolution_requested",
"workflow_run_id": workflow_run_id,
"tool": {
"tool_uuid": getattr(tool, "tool_uuid", None),
"name": getattr(tool, "name", None),
},
"arguments": arguments or {},
"initial_context": dict(call_context_vars or {}),
"gathered_context": dict(gathered_context_vars or {}),
}
body = resolved_arguments
timeout_seconds = float(resolver.get("timeout_ms", 3000)) / 1000.0
logger.debug(
"Transfer resolver request prepared "
f"resolution_id={resolution_id} "
f"argument_keys={list((arguments or {}).keys())} "
f"arguments={_safe_log_dict(arguments)} "
f"initial_context_keys={list((call_context_vars or {}).keys())} "
f"gathered_context_keys={list((gathered_context_vars or {}).keys())}"
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.post(url, headers=headers, json=payload)
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(
@ -256,96 +228,39 @@ async def _execute_http_resolver(
return data
def _fallback_resolution(
*,
config: dict[str, Any],
resolver: dict[str, Any],
call_context_vars: Optional[Dict[str, Any]],
gathered_context_vars: Optional[Dict[str, Any]],
resolution_id: Optional[str] = None,
) -> Optional[ResolvedTransferConfig]:
fallback_route = config.get("fallback_route")
if fallback_route:
return _expand_approved_route(
route_key=str(fallback_route),
config=config,
call_context_vars=call_context_vars,
gathered_context_vars=gathered_context_vars,
resolution_id=resolution_id,
source="fallback_route",
)
if resolver.get("policy") == "approved_routes_or_static_fallback":
return _resolve_static_transfer(
config,
call_context_vars,
gathered_context_vars,
resolution_id=resolution_id,
source="static_fallback",
)
return None
def _resolve_from_response(
*,
response_data: dict[str, Any],
config: dict[str, Any],
resolver: dict[str, Any],
call_context_vars: Optional[Dict[str, Any]],
gathered_context_vars: Optional[Dict[str, Any]],
resolution_id: str,
) -> ResolvedTransferConfig:
route_key = response_data.get("route")
if route_key:
resolved = _expand_approved_route(
route_key=str(route_key),
config=config,
call_context_vars=call_context_vars,
gathered_context_vars=gathered_context_vars,
resolution_id=resolution_id,
source="http_resolver_route",
)
resolved.metadata.update(dict(response_data.get("metadata") or {}))
if response_data.get("message"):
resolved.message = str(response_data["message"])
if response_data.get("timeout_seconds") is not None:
try:
resolved.timeout_seconds = min(
max(int(response_data["timeout_seconds"]), 5), 120
)
except (TypeError, ValueError) as exc:
raise TransferResolutionError(
"invalid_timeout", "Transfer resolver returned invalid timeout"
) from exc
return resolved
policy = resolver.get("policy", "approved_routes_only")
if policy != "allow_raw_destination":
logger.warning(
"Transfer resolver rejected response "
f"resolution_id={resolution_id} reason=route_required "
f"policy={policy} response_keys={list(response_data.keys())}"
)
transfer_context = response_data.get("transfer_context")
if not isinstance(transfer_context, dict):
raise TransferResolutionError(
"route_required",
"Transfer resolver must return an approved route for this policy",
"invalid_resolver_response",
"Transfer resolver response must contain transfer_context object",
)
destination = _normalize_destination_value(response_data.get("destination"))
try:
timeout = int(response_data.get("timeout_seconds", _base_timeout(config)))
except (TypeError, ValueError) as exc:
destination = transfer_context.get("destination")
if not isinstance(destination, str) or not destination.strip():
raise TransferResolutionError(
"invalid_timeout", "Transfer resolver returned invalid timeout"
) from exc
"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,
timeout_seconds=min(max(timeout, 5), 120),
message=response_data.get("message"),
source="http_resolver_raw_destination",
destination=destination.strip(),
timeout_seconds=_base_timeout(config),
message=custom_message.strip() if custom_message else None,
source="http_resolver",
resolution_id=resolution_id,
metadata=dict(response_data.get("metadata") or {}),
)
@ -362,7 +277,9 @@ async def resolve_transfer_config(
"""Resolve transfer destination and options for a transfer tool call."""
resolver = config.get("resolver")
if not isinstance(resolver, dict) or resolver.get("type") != "http":
if config.get("destination_source", "static") != "dynamic" or not isinstance(
resolver, dict
):
resolved = _resolve_static_transfer(
config, call_context_vars, gathered_context_vars
)
@ -374,67 +291,36 @@ async def resolve_transfer_config(
return resolved
resolution_id = str(uuid.uuid4())
approved_routes = config.get("approved_routes") or {}
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')} "
f"policy={resolver.get('policy', 'approved_routes_only')} "
f"timeout_ms={resolver.get('timeout_ms', 3000)} "
f"route_count={len(approved_routes) if isinstance(approved_routes, dict) else 0} "
f"fallback_route={config.get('fallback_route') or ''} "
f"static_fallback_available={bool(config.get('destination'))}"
"method=POST "
f"timeout_ms={resolver.get('timeout_ms', 3000)}"
)
try:
response_data = await _execute_http_resolver(
resolver=resolver,
tool=tool,
arguments=arguments,
call_context_vars=call_context_vars,
gathered_context_vars=gathered_context_vars,
organization_id=organization_id,
workflow_run_id=workflow_run_id,
resolution_id=resolution_id,
)
resolved = _resolve_from_response(
response_data=response_data,
config=config,
resolver=resolver,
call_context_vars=call_context_vars,
gathered_context_vars=gathered_context_vars,
resolution_id=resolution_id,
)
except TransferResolutionError as exc:
fallback = _fallback_resolution(
config=config,
resolver=resolver,
call_context_vars=call_context_vars,
gathered_context_vars=gathered_context_vars,
resolution_id=resolution_id,
)
if fallback:
logger.warning(
"Transfer resolver failed; using configured fallback "
f"resolution_id={resolution_id} reason={exc.reason} "
f"fallback_source={fallback.source} route={fallback.route or ''} "
f"destination={_mask_destination(fallback.destination)}"
)
return fallback
logger.warning(
"Transfer resolver failed without fallback "
f"resolution_id={resolution_id} reason={exc.reason}"
)
raise
if not resolved.destination:
raise TransferResolutionError(
"no_destination", "Transfer resolver did not provide a destination"
)
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"route={resolved.route or ''} destination={_mask_destination(resolved.destination)} "
f"timeout={resolved.timeout_seconds}"
f"destination={_mask_destination(resolved.destination)} "
f"timeout={resolved.timeout_seconds} "
f"custom_message_present={bool(resolved.message)}"
)
return resolved

View file

@ -305,21 +305,26 @@ class TestToolToFunctionSchema:
"schema_version": 1,
"type": "transfer_call",
"config": {
"destination_source": "dynamic",
"destination": "",
"parameters": [
{
"name": "state",
"type": "string",
"description": "The caller's US state.",
"required": True,
},
{
"name": "reason",
"type": "string",
"description": "Why transfer is needed.",
"required": False,
},
],
"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,
},
],
},
},
},
)
@ -816,10 +821,10 @@ class TestCoerceParameterValue:
class TestTransferResolver:
"""Tests for dynamic transfer resolution policy behavior."""
"""Tests for dynamic transfer resolution behavior."""
@pytest.mark.asyncio
async def test_http_resolver_uses_static_fallback_when_policy_allows(self):
async def test_http_resolver_resolves_transfer_context_destination(self):
from api.services.workflow.tools.transfer_resolver import resolve_transfer_config
tool = MockToolModel(
@ -830,20 +835,33 @@ class TestTransferResolver:
definition={},
)
config = {
"destination": "{{initial_context.default_destination}}",
"destination_source": "dynamic",
"destination": "",
"timeout": 30,
"resolver": {
"type": "http",
"url": "https://crm.example.com/resolve-transfer",
"policy": "approved_routes_or_static_fallback",
"headers": {"X-Tenant": "ovation"},
"timeout_ms": 3000,
"preset_parameters": [
{
"name": "lead_id",
"type": "string",
"value_template": "{{initial_context.lead_id}}",
"required": True,
}
],
},
"approved_routes": {},
}
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"route": "missing_route"}
mock_response.json.return_value = {
"transfer_context": {
"destination": "+14155550123",
"custom_message": "I will connect you with our Texas partner now.",
}
}
with (
patch(
@ -854,24 +872,31 @@ class TestTransferResolver:
) as mock_client_class,
):
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
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={"default_destination": "+14155550999"},
call_context_vars={"lead_id": "lead-123"},
gathered_context_vars={},
organization_id=1,
workflow_run_id=1,
)
assert resolved.destination == "+14155550999"
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_rejects_raw_destination_for_route_only_policy(self):
async def test_http_resolver_requires_transfer_context_destination(self):
from api.services.workflow.tools.transfer_resolver import (
TransferResolutionError,
resolve_transfer_config,
@ -885,22 +910,19 @@ class TestTransferResolver:
definition={},
)
config = {
"destination_source": "dynamic",
"destination": "",
"timeout": 30,
"resolver": {
"type": "http",
"url": "https://crm.example.com/resolve-transfer",
"policy": "approved_routes_only",
"timeout_ms": 3000,
},
"approved_routes": {},
}
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"destination": {"type": "phone_number", "value": "+14155550123"}
}
mock_response.json.return_value = {"transfer_context": {}}
with (
patch(
@ -911,7 +933,7 @@ class TestTransferResolver:
) as mock_client_class,
):
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.request.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
with pytest.raises(TransferResolutionError) as exc_info:
@ -925,7 +947,7 @@ class TestTransferResolver:
workflow_run_id=1,
)
assert exc_info.value.reason == "route_required"
assert exc_info.value.reason == "no_destination"
class TestAuthHeaders:
@ -1404,8 +1426,8 @@ class TestCustomToolManagerUnit:
assert result_received["status"] == "transfer_failed"
@pytest.mark.asyncio
async def test_transfer_call_http_resolver_uses_approved_route(self):
"""HTTP resolver route responses expand to approved transfer destinations."""
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()
@ -1430,21 +1452,22 @@ class TestCustomToolManagerUnit:
"schema_version": 1,
"type": "transfer_call",
"config": {
"destination": "+15550000000",
"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.",
"policy": "approved_routes_only",
},
"approved_routes": {
"referral_tx": {
"destination": "+14155550123",
"message": "I will connect you with our Texas partner now.",
"timeout_seconds": 45,
}
"parameters": [
{
"name": "state",
"type": "string",
"description": "State to resolve referral partner",
"required": True,
}
],
},
},
},
@ -1485,8 +1508,10 @@ class TestCustomToolManagerUnit:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"route": "referral_tx",
"metadata": {"partner_name": "Texas Injury Partners"},
"transfer_context": {
"destination": "+14155550123",
"custom_message": "I will connect you with our Texas partner now.",
}
}
with (
@ -1514,16 +1539,23 @@ class TestCustomToolManagerUnit:
) as mock_client_class,
):
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
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"] == 45
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

View file

@ -1,22 +1,27 @@
"use client";
import type { RecordingResponseSchema } from "@/client/types.gen";
import { CredentialSelector, ParameterEditor, type ToolParameter } from "@/components/http";
import {
CredentialSelector,
KeyValueEditor,
type KeyValueItem,
ParameterEditor,
PresetParameterEditor,
type PresetToolParameter,
type ToolParameter,
UrlInput,
} from "@/components/http";
import { RecordingSelect, StaticTextWarning } from "@/components/flow/TextOrAudioInput";
import { Button } from "@/components/ui/button";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { Plus, Trash2 } from "lucide-react";
import {
type EndCallMessageType,
type TransferApprovedRouteRow,
type TransferResolverPolicy,
type TransferDestinationSource,
} from "../../config";
export interface TransferCallToolConfigProps {
@ -24,6 +29,8 @@ export interface TransferCallToolConfigProps {
onNameChange: (name: string) => void;
description: string;
onDescriptionChange: (description: string) => void;
destinationSource: TransferDestinationSource;
onDestinationSourceChange: (source: TransferDestinationSource) => void;
destination: string;
onDestinationChange: (destination: string) => void;
messageType: EndCallMessageType;
@ -33,26 +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;
resolverEnabled: boolean;
onResolverEnabledChange: (enabled: boolean) => 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;
resolverPolicy: TransferResolverPolicy;
onResolverPolicyChange: (policy: TransferResolverPolicy) => void;
approvedRoutes: TransferApprovedRouteRow[];
onApprovedRoutesChange: (routes: TransferApprovedRouteRow[]) => void;
fallbackRoute: string;
onFallbackRouteChange: (route: string) => void;
parameters: ToolParameter[];
onParametersChange: (parameters: ToolParameter[]) => void;
presetParameters: PresetToolParameter[];
onPresetParametersChange: (parameters: PresetToolParameter[]) => void;
}
export function TransferCallToolConfig({
@ -60,6 +63,8 @@ export function TransferCallToolConfig({
onNameChange,
description,
onDescriptionChange,
destinationSource,
onDestinationSourceChange,
destination,
onDestinationChange,
messageType,
@ -71,52 +76,21 @@ export function TransferCallToolConfig({
recordings = [],
timeout,
onTimeoutChange,
resolverEnabled,
onResolverEnabledChange,
resolverUrl,
onResolverUrlChange,
resolverCredentialUuid,
onResolverCredentialUuidChange,
resolverHeaders,
onResolverHeadersChange,
resolverTimeoutMs,
onResolverTimeoutMsChange,
resolverWaitMessage,
onResolverWaitMessageChange,
resolverPolicy,
onResolverPolicyChange,
approvedRoutes,
onApprovedRoutesChange,
fallbackRoute,
onFallbackRouteChange,
parameters,
onParametersChange,
presetParameters,
onPresetParametersChange,
}: TransferCallToolConfigProps) {
const updateRoute = (
index: number,
patch: Partial<TransferApprovedRouteRow>,
) => {
onApprovedRoutesChange(
approvedRoutes.map((route, i) =>
i === index ? { ...route, ...patch } : route,
),
);
};
const removeRoute = (index: number) => {
onApprovedRoutesChange(approvedRoutes.filter((_, i) => i !== index));
};
const addRoute = () => {
onApprovedRoutesChange([
...approvedRoutes,
{
key: "",
destination: "",
message: "",
timeout_seconds: 30,
},
]);
};
return (
<Card>
<CardHeader>
@ -151,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}
@ -238,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"
@ -260,36 +211,63 @@ export function TransferCallToolConfig({
</div>
<div className="grid gap-4 pt-4 border-t">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label>Dynamic Transfer Resolver</Label>
<p className="text-xs text-muted-foreground">
Resolve the destination from an HTTP endpoint when the transfer tool is called.
</p>
</div>
<Switch
checked={resolverEnabled}
onCheckedChange={onResolverEnabledChange}
/>
<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>
{resolverEnabled && (
<div className="space-y-5">
<TabsContent value="static" className="space-y-4 mt-4">
<div className="grid gap-2">
<Label>Resolver URL</Label>
<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={resolverUrl}
onChange={(e) => onResolverUrlChange(e.target.value)}
placeholder="https://crm.example.com/resolve-transfer"
value={destination}
onChange={(e) => onDestinationChange(e.target.value)}
placeholder="+1234567890, PJSIP/1234, or {{initial_context.transfer_destination}}"
/>
</div>
</TabsContent>
<CredentialSelector
value={resolverCredentialUuid}
onChange={onResolverCredentialUuidChange}
label="Resolver Credential (Optional)"
description="Select a credential for the resolver endpoint, or leave empty for no auth."
/>
<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>
@ -309,6 +287,13 @@ export function TransferCallToolConfig({
</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
@ -317,143 +302,48 @@ export function TransferCallToolConfig({
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">
<Label>Resolver Policy</Label>
<Select
value={resolverPolicy}
onValueChange={(value) =>
onResolverPolicyChange(value as TransferResolverPolicy)
}
>
<SelectTrigger>
<SelectValue placeholder="Select policy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="approved_routes_only">
Approved routes only
</SelectItem>
<SelectItem value="approved_routes_or_static_fallback">
Approved routes or static fallback
</SelectItem>
<SelectItem value="allow_raw_destination">
Allow raw destination
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-3">
<div>
<Label>Resolver Arguments</Label>
<p className="text-xs text-muted-foreground">
Values the agent should pass when calling this transfer tool, such as state, department, or reason.
</p>
</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">
<Label>Fallback Route</Label>
<Input
value={fallbackRoute}
onChange={(e) => onFallbackRouteChange(e.target.value)}
placeholder="referral_default"
<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="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<Label>Approved Routes</Label>
<p className="text-xs text-muted-foreground">
Route keys the resolver can return, mapped to known transfer destinations.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addRoute}>
<Plus className="h-4 w-4 mr-2" />
Add Route
</Button>
</div>
<div className="space-y-3">
{approvedRoutes.map((route, index) => (
<div key={index} className="grid gap-3 rounded-md border p-3">
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
<div className="grid gap-2">
<Label>Route Key</Label>
<Input
value={route.key}
onChange={(e) =>
updateRoute(index, { key: e.target.value })
}
placeholder="referral_tx"
/>
</div>
<div className="grid gap-2">
<Label>Destination</Label>
<Input
value={route.destination}
onChange={(e) =>
updateRoute(index, { destination: e.target.value })
}
placeholder="+1234567890"
/>
</div>
<div className="flex items-end">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeRoute(index)}
aria-label="Remove route"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_160px]">
<div className="grid gap-2">
<Label>Message</Label>
<Input
value={route.message || ""}
onChange={(e) =>
updateRoute(index, { message: e.target.value })
}
placeholder="Ill transfer you now."
/>
</div>
<div className="grid gap-2">
<Label>Timeout</Label>
<Input
type="number"
value={route.timeout_seconds ?? 30}
min="5"
max="120"
onChange={(e) => {
const value = parseInt(e.target.value) || 30;
updateRoute(index, {
timeout_seconds: Math.min(Math.max(value, 5), 120),
});
}}
/>
</div>
</div>
</div>
))}
{approvedRoutes.length === 0 && (
<p className="text-xs text-muted-foreground">
No approved routes configured.
</p>
)}
</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>
</div>
)}
</TabsContent>
</Tabs>
</div>
</CardContent>
</Card>

View file

@ -51,8 +51,7 @@ import {
getToolTypeLabel,
MCP_URL_PATTERN,
renderToolIcon,
type TransferApprovedRouteRow,
type TransferResolverPolicy,
type TransferDestinationSource,
type ToolCategory,
} from "../config";
import { BuiltinToolConfig, EndCallToolConfig, HttpApiToolConfig, TransferCallToolConfig } from "./components";
@ -69,16 +68,9 @@ function normalizeParameterType(value: string | null | undefined): ParameterType
}
}
function approvedRoutesToRows(
routes: ExtendedTransferCallConfig["approved_routes"] | undefined | null,
): TransferApprovedRouteRow[] {
if (!routes) return [];
return Object.entries(routes).map(([key, route]) => ({
key,
destination: route.destination || "",
message: route.message || "",
timeout_seconds: route.timeout_seconds ?? 30,
}));
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() {
@ -123,20 +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 [transferResolverEnabled, setTransferResolverEnabled] = useState(false);
const [transferResolverUrl, setTransferResolverUrl] = useState("");
const [transferResolverCredentialUuid, setTransferResolverCredentialUuid] = useState("");
const [transferResolverHeaders, setTransferResolverHeaders] = useState<KeyValueItem[]>([]);
const [transferResolverTimeoutMs, setTransferResolverTimeoutMs] = useState(3000);
const [transferResolverWaitMessage, setTransferResolverWaitMessage] = useState("");
const [transferResolverPolicy, setTransferResolverPolicy] =
useState<TransferResolverPolicy>("approved_routes_only");
const [transferApprovedRoutes, setTransferApprovedRoutes] = useState<TransferApprovedRouteRow[]>([]);
const [transferFallbackRoute, setTransferFallbackRoute] = 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');
@ -208,42 +199,48 @@ export default function ToolDetailPage() {
// Populate transfer call specific fields
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);
setTransferResolverEnabled(Boolean(config.resolver));
setTransferResolverUrl(config.resolver?.url || "");
setTransferResolverCredentialUuid(config.resolver?.credential_uuid || "");
setTransferResolverTimeoutMs(config.resolver?.timeout_ms ?? 3000);
setTransferResolverWaitMessage(config.resolver?.wait_message || "");
setTransferResolverPolicy(config.resolver?.policy || "approved_routes_only");
setTransferApprovedRoutes(approvedRoutesToRows(config.approved_routes));
setTransferFallbackRoute(config.fallback_route || "");
setTransferResolverUrl(resolver?.url || "");
setTransferResolverCredentialUuid(resolver?.credential_uuid || "");
setTransferResolverHeaders(headersToRows(resolver?.headers));
setTransferResolverTimeoutMs(resolver?.timeout_ms ?? 3000);
setTransferResolverWaitMessage(resolver?.wait_message || "");
setTransferParameters(
(config.parameters || []).map((p) => ({
(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);
setTransferResolverEnabled(false);
setTransferResolverUrl("");
setTransferResolverCredentialUuid("");
setTransferResolverHeaders([]);
setTransferResolverTimeoutMs(3000);
setTransferResolverWaitMessage("");
setTransferResolverPolicy("approved_routes_only");
setTransferApprovedRoutes([]);
setTransferFallbackRoute("");
setTransferParameters([]);
setTransferPresetParameters([]);
}
} else if (tool.category === "mcp") {
// Populate MCP specific fields
@ -345,45 +342,17 @@ export default function ToolDetailPage() {
if (tool.category === "calculator") {
// No validation needed for built-in tools
} else if (tool.category === "transfer_call") {
if (!transferResolverEnabled && !normalizedTransferDestination) {
if (transferDestinationSource === "static" && !normalizedTransferDestination) {
setError("Please enter a transfer destination");
return;
}
if (transferResolverEnabled) {
if (transferDestinationSource === "dynamic") {
const resolverUrlValidation = validateUrl(transferResolverUrl);
if (!resolverUrlValidation.valid) {
setError(resolverUrlValidation.error || "Invalid resolver URL");
return;
}
if (
transferResolverPolicy === "approved_routes_or_static_fallback" &&
!normalizedTransferDestination
) {
setError("Please enter a transfer destination for static fallback");
return;
}
const routeKeys = transferApprovedRoutes
.map((route) => route.key.trim())
.filter(Boolean);
const invalidRoutes = transferApprovedRoutes.filter(
(route) => !route.key.trim() || !route.destination.trim()
);
if (invalidRoutes.length > 0) {
setError("All approved routes must have a route key and destination");
return;
}
if (new Set(routeKeys).size !== routeKeys.length) {
setError("Approved route keys must be unique");
return;
}
if (
transferFallbackRoute.trim() &&
!routeKeys.includes(transferFallbackRoute.trim())
) {
setError("Fallback route must match an approved route key");
return;
}
const invalidTransferParams = transferParameters.filter(
(p) => !p.name.trim() || !p.description.trim()
);
@ -398,6 +367,20 @@ export default function ToolDetailPage() {
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))
@ -469,56 +452,54 @@ export default function ToolDetailPage() {
},
};
} else if (tool.category === "transfer_call") {
const approvedRoutesObject = transferApprovedRoutes.reduce(
(acc, route) => {
const key = route.key.trim();
if (!key) return acc;
acc[key] = {
destination: route.destination.trim(),
message: route.message?.trim() || undefined,
timeout_seconds: route.timeout_seconds || undefined,
};
return acc;
},
{} as Record<string, {
destination: string;
message?: string;
timeout_seconds?: number;
}>,
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: normalizedTransferDestination,
destination_source: transferDestinationSource,
destination: transferDestinationSource === "static" ? normalizedTransferDestination : "",
messageType: transferMessageType,
customMessage: transferMessageType === "custom" ? customMessage : undefined,
audioRecordingId: transferMessageType === "audio" ? transferAudioRecordingId || undefined : undefined,
timeout: transferTimeout,
resolver: transferResolverEnabled
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,
policy: transferResolverPolicy,
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,
approved_routes:
transferResolverEnabled && Object.keys(approvedRoutesObject).length > 0
? approvedRoutesObject
: undefined,
fallback_route:
transferResolverEnabled && transferFallbackRoute.trim()
? transferFallbackRoute.trim()
: undefined,
parameters:
transferResolverEnabled && transferParameters.length > 0
? transferParameters.map((p) => ({
name: p.name.trim(),
type: p.type,
description: p.description.trim(),
required: p.required,
}))
: undefined,
};
// Build transfer call request body
requestBody = {
@ -791,6 +772,8 @@ const data = await response.json();`;
onNameChange={setName}
description={description}
onDescriptionChange={setDescription}
destinationSource={transferDestinationSource}
onDestinationSourceChange={setTransferDestinationSource}
destination={transferDestination}
onDestinationChange={setTransferDestination}
messageType={transferMessageType}
@ -802,24 +785,20 @@ const data = await response.json();`;
recordings={recordings}
timeout={transferTimeout}
onTimeoutChange={setTransferTimeout}
resolverEnabled={transferResolverEnabled}
onResolverEnabledChange={setTransferResolverEnabled}
resolverUrl={transferResolverUrl}
onResolverUrlChange={setTransferResolverUrl}
resolverCredentialUuid={transferResolverCredentialUuid}
onResolverCredentialUuidChange={setTransferResolverCredentialUuid}
resolverHeaders={transferResolverHeaders}
onResolverHeadersChange={setTransferResolverHeaders}
resolverTimeoutMs={transferResolverTimeoutMs}
onResolverTimeoutMsChange={setTransferResolverTimeoutMs}
resolverWaitMessage={transferResolverWaitMessage}
onResolverWaitMessageChange={setTransferResolverWaitMessage}
resolverPolicy={transferResolverPolicy}
onResolverPolicyChange={setTransferResolverPolicy}
approvedRoutes={transferApprovedRoutes}
onApprovedRoutesChange={setTransferApprovedRoutes}
fallbackRoute={transferFallbackRoute}
onFallbackRouteChange={setTransferFallbackRoute}
parameters={transferParameters}
onParametersChange={setTransferParameters}
presetParameters={transferPresetParameters}
onPresetParametersChange={setTransferPresetParameters}
/>
) : isMcpTool ? (
<Card>

View file

@ -9,41 +9,31 @@ 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 TransferResolverPolicy =
| "approved_routes_only"
| "approved_routes_or_static_fallback"
| "allow_raw_destination";
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;
policy: TransferResolverPolicy;
}
export interface TransferApprovedRoute {
destination: string;
message?: string | null;
timeout_seconds?: number | null;
}
export interface TransferApprovedRouteRow extends TransferApprovedRoute {
key: string;
parameters?: ToolParameter[] | null;
preset_parameters?: PresetToolParameter[] | null;
}
export interface ExtendedTransferCallConfig extends TransferCallConfig {
destination_source?: TransferDestinationSource;
resolver?: TransferResolverConfig | null;
approved_routes?: Record<string, TransferApprovedRoute> | null;
fallback_route?: string | null;
}
export interface ToolCategoryConfig {

File diff suppressed because one or more lines are too long

View file

@ -3006,6 +3006,14 @@ export type HttpTransferResolverConfig = {
* 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
*
@ -3025,11 +3033,17 @@ export type HttpTransferResolverConfig = {
*/
wait_message?: string | null;
/**
* Policy
* Parameters
*
* Controls what resolver responses are allowed to select.
* Parameters the model may provide when calling this transfer tool.
*/
policy?: 'approved_routes_only' | 'approved_routes_or_static_fallback' | 'allow_raw_destination';
parameters?: Array<ToolParameter> | null;
/**
* Preset Parameters
*
* Parameters injected by Dograh from fixed values or workflow context templates.
*/
preset_parameters?: Array<PresetToolParameter> | null;
};
/**
@ -5835,52 +5849,24 @@ export type ToolResponse = {
created_by?: CreatedByResponse | null;
};
/**
* TransferApprovedRoute
*
* A pre-approved destination the transfer resolver may select.
*/
export type TransferApprovedRoute = {
/**
* Destination
*
* Phone number, SIP endpoint, or template for this route.
*/
destination: string;
/**
* Message
*
* Optional message to play before transferring to this route.
*/
message?: string | null;
/**
* Timeout Seconds
*
* Optional route-specific transfer answer timeout.
*/
timeout_seconds?: number | null;
/**
* Metadata
*
* Optional non-secret route metadata for logs and resolver context.
*/
metadata?: {
[key: string]: unknown;
} | null;
};
/**
* TransferCallConfig
*
* 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
*
@ -5915,20 +5901,6 @@ export type TransferCallConfig = {
* Optional resolver that determines transfer routing at call time.
*/
resolver?: HttpTransferResolverConfig | null;
/**
* Approved Routes
*
* Approved route keys that a resolver may select.
*/
approved_routes?: {
[key: string]: TransferApprovedRoute;
} | null;
/**
* Fallback Route
*
* Approved route key to use when resolver resolution fails.
*/
fallback_route?: string | null;
};
/**