feat: add Tuner Integration to Dograh (#311)

* Add tuner integration

* bump pipecat version

* chore: update pipecat submodule to match upstream and use tuner-pipecat-sdk 0.2.0

Update pipecat submodule from 0.0.109.dev23 to 13e98d0d9 (the exact commit
upstream dograh-hq/dograh uses after v1.30.1). This installs pipecat-ai as
1.1.0.post277 via setuptools_scm, satisfying tuner-pipecat-sdk 0.2.0's
pipecat-ai>=1.0.0 requirement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* wire tuner

* feat: refactor integrations into self contained packages

* chore: simplify ensure_public_access_token

* fix: remove NodeSpec and make DTOs the source of truth

* feat: send relevant signal to mcp using to_mcp_dict

* fix: fix tests

* cleanup: remove nango integrations

* feat: add agents.md for integrations

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
Mohamed-Mamdouh 2026-05-20 10:07:33 +01:00 committed by GitHub
parent afa78fe859
commit 5f28c1b2a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 3388 additions and 3414 deletions

View file

@ -0,0 +1,19 @@
from __future__ import annotations
from api.services.integrations.base import IntegrationPackageSpec
from api.services.integrations.registry import register_package
from .completion import run_completion
from .node import NODE
from .runtime import create_runtime_sessions
PACKAGE = register_package(
IntegrationPackageSpec(
name="tuner",
nodes=(NODE,),
create_runtime_sessions=create_runtime_sessions,
run_completion=run_completion,
)
)
__all__ = ["PACKAGE"]

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from typing import Any
import httpx
from loguru import logger
from pydantic import BaseModel, field_validator
class TunerDeliveryConfig(BaseModel):
base_url: str
api_key: str
workspace_id: int
agent_id: str
@field_validator("api_key", "agent_id")
@classmethod
def _must_not_be_empty(cls, value: str) -> str:
if not value or not value.strip():
raise ValueError("must not be empty")
return value
@field_validator("workspace_id")
@classmethod
def _workspace_must_be_positive(cls, value: int) -> int:
if value <= 0:
raise ValueError("must be a positive integer")
return value
async def post_call(
config: TunerDeliveryConfig,
payload: dict[str, Any],
) -> dict[str, Any]:
url = (
f"{config.base_url}/api/v1/public/call"
f"?workspace_id={config.workspace_id}"
f"&agent_remote_identifier={config.agent_id}"
)
headers = {"Authorization": f"Bearer {config.api_key}"}
logger.info(
"[tuner] posting completed call {} to workspace {} / agent {}",
payload.get("call_id"),
config.workspace_id,
config.agent_id,
)
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(url, json=payload, headers=headers)
if response.status_code == 409:
logger.info("[tuner] call {} already exists in tuner", payload.get("call_id"))
return {"status": "duplicate", "status_code": response.status_code}
if response.status_code >= 400:
logger.error(
"[tuner] POST failed for call {} with status {}: {}",
payload.get("call_id"),
response.status_code,
response.text[:200],
)
response.raise_for_status()
logger.info(
"[tuner] POST succeeded for call {} with status {}",
payload.get("call_id"),
response.status_code,
)
return {"status": "delivered", "status_code": response.status_code}

View file

@ -0,0 +1,182 @@
from __future__ import annotations
import time
from collections import deque
from dataclasses import dataclass
from typing import Any, Callable
from loguru import logger
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
CancelFrame,
EndFrame,
FunctionCallInProgressFrame,
FunctionCallResultFrame,
MetricsFrame,
StartFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.observers.base_observer import BaseObserver, FramePushed
from pipecat.observers.turn_tracking_observer import TurnTrackingObserver
from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver
from pipecat.processors.frame_processor import FrameDirection
from tuner_pipecat_sdk.accumulator import CallAccumulator
from tuner_pipecat_sdk.payload_builder import build_payload
from api.enums import WorkflowRunMode
TUNER_RECORDING_PLACEHOLDER = "pipecat://no-recording"
@dataclass(frozen=True)
class _PayloadConfig:
call_id: str
call_type: str
recording_url: str
asr_model: str
llm_model: str
tts_model: str
sip_call_id: str | None = None
sip_headers: dict[str, str] | None = None
agent_version: int | None = None
def mode_to_tuner_call_type(mode: str | None) -> str:
if mode in {
WorkflowRunMode.WEBRTC.value,
WorkflowRunMode.SMALLWEBRTC.value,
}:
return "web_call"
return "phone_call"
class TunerCollector(BaseObserver):
"""Collect runtime call metadata and build a deferred Tuner payload."""
def __init__(
self,
*,
workflow_run_id: int,
call_type: str,
asr_model: str = "",
llm_model: str = "",
tts_model: str = "",
agent_version: int | None = None,
max_frames: int = 500,
) -> None:
super().__init__()
self._call_id = str(workflow_run_id)
self._call_type = call_type
self._asr_model = asr_model
self._llm_model = llm_model
self._tts_model = tts_model
self._agent_version = agent_version
self._acc = CallAccumulator()
self._acc.call_start_abs_ns = time.time_ns()
self._context_provider: Callable[[], list[dict[str, Any]]] | None = None
self._processed_frames: set[int] = set()
self._frame_history: deque[int] = deque(maxlen=max_frames)
def attach_context(self, provider: Callable[[], list[dict[str, Any]]]) -> None:
self._context_provider = provider
def set_disconnection_reason(self, reason: str | None) -> None:
if reason:
self._acc.set_disconnection_reason(reason)
def attach_turn_tracking_observer(
self, turn_tracker: TurnTrackingObserver | None
) -> None:
if turn_tracker is None:
return
@turn_tracker.event_handler("on_turn_started")
async def _on_turn_started(_tracker: Any, turn_number: int) -> None:
self._acc.on_turn_started(turn_number, time.time_ns())
@turn_tracker.event_handler("on_turn_ended")
async def _on_turn_ended(
_tracker: Any, turn_number: int, _duration: float, was_interrupted: bool
) -> None:
self._acc.on_turn_ended(turn_number, was_interrupted)
def attach_latency_observer(
self, latency_observer: UserBotLatencyObserver | None
) -> None:
if latency_observer is None:
return
@latency_observer.event_handler("on_latency_measured")
async def _on_latency_measured(_observer: Any, latency: float) -> None:
self._acc.on_latency_measured(latency)
@latency_observer.event_handler("on_latency_breakdown")
async def _on_latency_breakdown(_observer: Any, breakdown: Any) -> None:
self._acc.on_latency_breakdown(breakdown)
async def on_push_frame(self, data: FramePushed):
if data.direction != FrameDirection.DOWNSTREAM:
return
if data.frame.id in self._processed_frames:
return
self._processed_frames.add(data.frame.id)
self._frame_history.append(data.frame.id)
if len(self._processed_frames) > len(self._frame_history):
self._processed_frames = set(self._frame_history)
frame = data.frame
timestamp_ns = data.timestamp
if isinstance(frame, StartFrame):
self._acc.on_start(timestamp_ns)
elif isinstance(frame, FunctionCallInProgressFrame):
self._acc.on_function_call_in_progress(frame, timestamp_ns)
elif isinstance(frame, FunctionCallResultFrame):
self._acc.on_function_call_result(frame.tool_call_id, timestamp_ns)
elif isinstance(frame, MetricsFrame):
self._acc.on_metrics_frame(frame)
elif isinstance(frame, UserStartedSpeakingFrame):
self._acc.on_user_started_speaking(timestamp_ns)
elif isinstance(frame, UserStoppedSpeakingFrame):
self._acc.on_user_stopped_speaking(timestamp_ns)
self._acc.on_user_turn_stopped(timestamp_ns)
elif isinstance(frame, BotStartedSpeakingFrame):
self._acc.on_bot_started_speaking(timestamp_ns)
elif isinstance(frame, BotStoppedSpeakingFrame):
self._acc.on_bot_stopped(timestamp_ns)
elif isinstance(frame, VADUserStoppedSpeakingFrame):
self._acc.on_vad_stopped(timestamp_ns)
elif isinstance(frame, (CancelFrame, EndFrame)):
self._acc.on_call_end(timestamp_ns)
def build_payload_snapshot(
self,
*,
recording_url: str = TUNER_RECORDING_PLACEHOLDER,
) -> dict[str, Any] | None:
if self._context_provider is None:
logger.warning(
"[tuner] no context provider attached; skipping payload snapshot"
)
return None
transcript = list(self._context_provider())
payload = build_payload(
self._acc,
_PayloadConfig(
call_id=self._call_id,
call_type=self._call_type,
recording_url=recording_url,
asr_model=self._asr_model,
llm_model=self._llm_model,
tts_model=self._tts_model,
agent_version=self._agent_version,
),
transcript,
)
return payload.to_dict()

View file

@ -0,0 +1,76 @@
from __future__ import annotations
import copy
from datetime import UTC, datetime
from typing import Any
from loguru import logger
from api.constants import BACKEND_API_ENDPOINT, TUNER_BASE_URL
from api.services.integrations.base import IntegrationCompletionContext
from .client import TunerDeliveryConfig, post_call
from .collector import TUNER_RECORDING_PLACEHOLDER
from .node import TunerNodeData
def _build_recording_url(
context: IntegrationCompletionContext,
) -> str | None:
workflow_run = context.workflow_run
if context.public_token:
base_url = f"{BACKEND_API_ENDPOINT}/api/v1/public/download/workflow/{context.public_token}"
return f"{base_url}/recording" if workflow_run.recording_url else None
return workflow_run.recording_url
async def run_completion(
nodes: list[dict[str, Any]],
context: IntegrationCompletionContext,
) -> dict[str, Any]:
results: dict[str, Any] = {}
payload_snapshot = (context.workflow_run.logs or {}).get("tuner_payload")
recording_url = _build_recording_url(context) or TUNER_RECORDING_PLACEHOLDER
for node in nodes:
node_id = node.get("id", "unknown")
try:
tuner_data = TunerNodeData.model_validate(node.get("data", {}))
except Exception as exc:
logger.warning(f"Tuner node #{node_id} failed validation, skipping: {exc}")
results[f"tuner_{node_id}"] = {"error": "validation_failed"}
continue
if not tuner_data.tuner_enabled:
logger.debug(f"Tuner node '{tuner_data.name}' is disabled, skipping")
continue
if not payload_snapshot:
logger.warning(
f"Tuner payload snapshot missing for node '{tuner_data.name}' (#{node_id})"
)
results[f"tuner_{node_id}"] = {"error": "missing_payload_snapshot"}
continue
payload = copy.deepcopy(payload_snapshot)
payload["recording_url"] = recording_url
try:
config = TunerDeliveryConfig(
base_url=TUNER_BASE_URL,
api_key=tuner_data.tuner_api_key or "",
workspace_id=tuner_data.tuner_workspace_id or 0,
agent_id=tuner_data.tuner_agent_id or "",
)
delivery = await post_call(config, payload)
results[f"tuner_{node_id}"] = {
**delivery,
"workspace_id": tuner_data.tuner_workspace_id,
"agent_id": tuner_data.tuner_agent_id,
"exported_at": datetime.now(UTC).isoformat(),
}
except Exception as exc:
logger.error(f"Tuner export failed for node '{tuner_data.name}': {exc}")
results[f"tuner_{node_id}"] = {"error": str(exc)}
return results

View file

@ -0,0 +1,139 @@
from __future__ import annotations
from pydantic import model_validator
from api.services.integrations.base import IntegrationNodeRegistration
from api.services.workflow.node_data import BaseNodeData
from api.services.workflow.node_specs._base import (
GraphConstraints,
NodeCategory,
NodeExample,
PropertyType,
)
from api.services.workflow.node_specs.model_spec import (
build_spec,
node_spec,
spec_field,
)
@node_spec(
name="tuner",
display_name="Tuner",
description="Export the completed call to Tuner for Agent Observability",
llm_hint=(
"Tuner is a post-call observability export. It does not participate in the "
"conversation graph and should not be connected to other nodes."
),
category=NodeCategory.integration,
icon="Activity",
examples=[
NodeExample(
name="tuner_export",
data={
"name": "Primary Tuner Export",
"tuner_enabled": True,
"tuner_agent_id": "sales-bot-prod",
"tuner_workspace_id": 42,
"tuner_api_key": "tuner_live_xxxxxxxx",
},
)
],
graph_constraints=GraphConstraints(
min_incoming=0,
max_incoming=0,
min_outgoing=0,
max_outgoing=0,
),
property_order=(
"name",
"tuner_enabled",
"tuner_agent_id",
"tuner_workspace_id",
"tuner_api_key",
),
field_overrides={
"name": {
"spec_default": "Tuner",
"description": "Short identifier for this Tuner export configuration.",
},
"tuner_enabled": {
"display_name": "Enabled",
"description": "When false, Dograh skips exporting this call to Tuner.",
},
"tuner_agent_id": {
"display_name": "Tuner Agent ID",
"description": "The agent identifier registered in your Tuner workspace.",
"required": True,
},
"tuner_workspace_id": {
"display_name": "Tuner Workspace ID",
"description": "Your numeric Tuner workspace ID.",
"required": True,
"min_value": 1,
},
"tuner_api_key": {
"display_name": "Tuner API Key",
"description": "Bearer token used when posting completed calls to Tuner.",
"required": True,
},
},
)
class TunerNodeData(BaseNodeData):
tuner_enabled: bool = spec_field(
default=True,
ui_type=PropertyType.boolean,
display_name="Enabled",
description="When false, Dograh skips exporting this call to Tuner.",
)
tuner_agent_id: str | None = spec_field(
default=None,
ui_type=PropertyType.string,
display_name="Tuner Agent ID",
description="The agent identifier registered in your Tuner workspace.",
)
tuner_workspace_id: int | None = spec_field(
default=None,
gt=0,
ui_type=PropertyType.number,
display_name="Tuner Workspace ID",
description="Your numeric Tuner workspace ID.",
)
tuner_api_key: str | None = spec_field(
default=None,
ui_type=PropertyType.string,
display_name="Tuner API Key",
description="Bearer token used when posting completed calls to Tuner.",
)
@model_validator(mode="after")
def _validate_enabled_config(self):
if not self.tuner_enabled:
return self
missing: list[str] = []
if not self.tuner_agent_id or not self.tuner_agent_id.strip():
missing.append("tuner_agent_id")
if self.tuner_workspace_id is None:
missing.append("tuner_workspace_id")
if not self.tuner_api_key or not self.tuner_api_key.strip():
missing.append("tuner_api_key")
if missing:
fields = ", ".join(missing)
raise ValueError(
f"Tuner node is enabled but missing required fields: {fields}"
)
return self
SPEC = build_spec(TunerNodeData)
NODE = IntegrationNodeRegistration(
type_name="tuner",
data_model=TunerNodeData,
node_spec=SPEC,
sensitive_fields=("tuner_api_key",),
)

View file

@ -0,0 +1,101 @@
from __future__ import annotations
from typing import Any
from api.services.configuration.registry import ServiceProviders
from api.services.integrations.base import (
IntegrationRuntimeContext,
IntegrationRuntimeSession,
)
from .collector import TunerCollector, mode_to_tuner_call_type
def _format_model_label(provider: str | None, model: str | None) -> str:
if provider and model:
return f"{provider}/{model}"
if model:
return model
return provider or ""
def _resolve_model_labels(context: IntegrationRuntimeContext) -> tuple[str, str, str]:
user_config = context.user_config
if context.is_realtime and user_config.realtime:
realtime_provider = user_config.realtime.provider
realtime_model = user_config.realtime.model
llm_model = _format_model_label(realtime_provider, realtime_model)
if realtime_provider in {
ServiceProviders.GOOGLE_REALTIME.value,
ServiceProviders.GOOGLE_VERTEX_REALTIME.value,
ServiceProviders.OPENAI_REALTIME.value,
}:
return "", llm_model, ""
return "", llm_model, ""
return (
_format_model_label(
getattr(user_config.stt, "provider", None),
getattr(user_config.stt, "model", None),
),
_format_model_label(
getattr(user_config.llm, "provider", None),
getattr(user_config.llm, "model", None),
),
_format_model_label(
getattr(user_config.tts, "provider", None),
getattr(user_config.tts, "model", None),
),
)
class TunerRuntimeSession(IntegrationRuntimeSession):
name = "tuner"
def __init__(self, collector: TunerCollector) -> None:
self._collector = collector
def attach(self, task: Any) -> None:
self._collector.attach_turn_tracking_observer(task.turn_tracking_observer)
self._collector.attach_latency_observer(task.user_bot_latency_observer)
task.add_observer(self._collector)
async def on_call_finished(
self,
*,
gathered_context: dict[str, Any],
) -> dict[str, Any] | None:
self._collector.set_disconnection_reason(
gathered_context.get("call_disposition")
)
payload = self._collector.build_payload_snapshot()
if payload is None:
return None
return {"tuner_payload": payload}
def create_runtime_sessions(
context: IntegrationRuntimeContext,
) -> list[IntegrationRuntimeSession]:
tuner_nodes = [
node
for node in context.workflow_graph.nodes.values()
if node.node_type == "tuner" and getattr(node.data, "tuner_enabled", True)
]
if not tuner_nodes:
return []
asr_model, llm_model, tts_model = _resolve_model_labels(context)
collector = TunerCollector(
workflow_run_id=context.workflow_run_id,
call_type=mode_to_tuner_call_type(context.workflow_run.mode),
asr_model=asr_model,
llm_model=llm_model,
tts_model=tts_model,
agent_version=getattr(context.run_definition, "version_number", None),
)
collector.attach_context(context.context_messages_provider)
return [TunerRuntimeSession(collector)]