Add setup_pipeline_runner hook to PIPECAT_SETUP_FILES

PipelineRunner now picks up an async setup_pipeline_runner(runner) hook
from the same PIPECAT_SETUP_FILES env var that PipelineTask already uses
for setup_pipeline_task. Previously the runner used a separate
PIPECAT_RUNNER_SETUP_FILES variable and a setup_runner function — both
are removed.

A new _setup_files module hosts the loader for both hooks and caches
each setup file's module so a single file defining both hooks (e.g. a
debugger that registers a runner-level task in one hook and a per-task
observer in the other) sees its module-level state preserved across
invocations.
This commit is contained in:
Aleix Conchillo Flaqué
2026-05-20 09:17:46 -07:00
parent a2e58044f2
commit e8bbb5ee09
3 changed files with 87 additions and 54 deletions

View File

@@ -39,12 +39,9 @@ spawned tasks finishing on their own does **not** unblock it.
import asyncio
import gc
import importlib.util
import os
import signal
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from loguru import logger
@@ -62,6 +59,7 @@ from pipecat.bus import (
from pipecat.bus.subscriber import BusSubscriber
from pipecat.pipeline.base_task import BaseTask
from pipecat.pipeline.task import PipelineTask, PipelineTaskParams
from pipecat.pipeline.utils import run_setup_hook
from pipecat.registry import TaskRegistry
from pipecat.registry.types import TaskReadyData, TaskRegistryEntry
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
@@ -342,28 +340,12 @@ class PipelineRunner(BaseObject, BusSubscriber):
await asyncio.gather(*remaining, return_exceptions=True)
async def _load_setup_files(self) -> None:
"""Load setup files from ``PIPECAT_RUNNER_SETUP_FILES``.
"""Run ``setup_pipeline_runner`` from each file in ``PIPECAT_SETUP_FILES``.
Each file should contain an async ``setup_runner(runner)`` function
that receives the runner instance.
A setup file may define ``setup_pipeline_runner(runner)`` to attach
spawned tasks, event handlers, or other runner-level wiring.
"""
setup_files = [f for f in os.environ.get("PIPECAT_RUNNER_SETUP_FILES", "").split(":") if f]
for f in setup_files:
try:
path = Path(f).resolve()
spec = importlib.util.spec_from_file_location(path.stem, str(path))
if spec and spec.loader:
logger.debug(f"PipelineRunner '{self}': running setup from {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if hasattr(module, "setup_runner"):
await module.setup_runner(self)
else:
logger.warning(
f"PipelineRunner '{self}': setup file {path} has no setup_runner function"
)
except Exception as e:
logger.error(f"PipelineRunner '{self}': error running setup from {f}: {e}")
await run_setup_hook(target=self, function_name="setup_pipeline_runner")
async def _start_task(self, entry: _TaskEntry) -> None:
"""Run a registered task as a background asyncio task."""

View File

@@ -12,12 +12,9 @@ including heartbeats, idle detection, and observer integration.
"""
import asyncio
import importlib.util
import os
import warnings
from collections.abc import AsyncIterable, Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypeVar
from loguru import logger
@@ -52,6 +49,7 @@ from pipecat.pipeline.base_pipeline import BasePipeline
from pipecat.pipeline.base_task import BaseTask
from pipecat.pipeline.pipeline import Pipeline, PipelineSink, PipelineSource
from pipecat.pipeline.task_observer import TaskObserver
from pipecat.pipeline.utils import run_setup_hook
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup
from pipecat.processors.frameworks.rtvi import RTVIObserver, RTVIObserverParams, RTVIProcessor
from pipecat.utils.asyncio.task_manager import BaseTaskManager, TaskManager, TaskManagerParams
@@ -1068,36 +1066,12 @@ class PipelineTask(BaseTask):
return True
async def _load_setup_files(self):
"""Dynamically setup pipeline task from files listed in PIPECAT_SETUP_FILES.
Each file should contain a `setup_pipeline_task(task)` async function
that receives the `PipelineTask` instance and can perform any custom
setup (e.g., adding event handlers, observers, or modifying task
configuration).
"""Run ``setup_pipeline_task`` from each file in ``PIPECAT_SETUP_FILES``.
A setup file may define ``setup_pipeline_task(task)`` to attach event
handlers, observers, or other per-task wiring.
"""
setup_files = [f for f in os.environ.get("PIPECAT_SETUP_FILES", "").split(":") if f]
for f in setup_files:
try:
path = Path(f).resolve()
module_name = path.stem
spec = importlib.util.spec_from_file_location(module_name, str(path))
if spec and spec.loader:
logger.debug(f"{self} running setup from {path}")
# Load module.
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Run setup function.
if hasattr(module, "setup_pipeline_task"):
await module.setup_pipeline_task(self)
else:
logger.warning(
f"{self} setup file {path} has no setup_pipeline_task function"
)
except Exception as e:
logger.error(f"{self} error running external setup from {f}: {e}")
await run_setup_hook(target=self, function_name="setup_pipeline_task")
def _print_dangling_tasks(self):
"""Log any dangling tasks that haven't been properly cleaned up."""

View File

@@ -0,0 +1,77 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Shared loader for ``PIPECAT_SETUP_FILES`` hooks.
Each file listed in the ``PIPECAT_SETUP_FILES`` environment variable (colon
separated) may define one or both of the following async functions:
- ``setup_pipeline_runner(runner)`` — invoked once per :class:`PipelineRunner`
before its spawned tasks start.
- ``setup_pipeline_task(task)`` — invoked once per :class:`PipelineTask` while
the task sets up its pipeline.
Setup files are imported at most once per process; module-level state (for
example, a shared debugger instance referenced by both hooks) is preserved
across hook invocations.
"""
import importlib.util
import os
from pathlib import Path
from types import ModuleType
from typing import Any
from loguru import logger
_module_cache: dict[str, ModuleType] = {}
def _setup_file_paths() -> list[Path]:
return [Path(f).resolve() for f in os.environ.get("PIPECAT_SETUP_FILES", "").split(":") if f]
def _load_module(path: Path) -> ModuleType | None:
cache_key = str(path)
cached = _module_cache.get(cache_key)
if cached is not None:
return cached
spec = importlib.util.spec_from_file_location(path.stem, str(path))
if spec is None or spec.loader is None:
logger.error(f"unable to load setup file {path}")
return None
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
except Exception as e:
logger.error(f"error loading setup file {path}: {e}")
return None
_module_cache[cache_key] = module
return module
async def run_setup_hook(*, target: Any, function_name: str) -> None:
"""Run ``function_name(target)`` from every ``PIPECAT_SETUP_FILES`` module.
Args:
target: Object passed to the hook function.
function_name: Name of the async function to call in each setup file.
"""
for path in _setup_file_paths():
module = _load_module(path)
if module is None:
continue
try:
if hasattr(module, function_name):
logger.debug(f"{target} running {function_name} from {path}")
await getattr(module, function_name)(target)
else:
logger.warning(f"{target} setup file {path} has no {function_name} function")
except Exception as e:
logger.error(f"{target} error running {function_name} from {path}: {e}")