load_fnc, what is the use of this def and how can ...
# ask-ai
b
load_fnc, what is the use of this def and how can it be customized
t
Thanks for reaching out, Utkarsh. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — you're asking what
load_fnc
does and how you can customize it. I’ll explain what it’s for, what it must return, common metrics you can use, and show short examples you can adapt. Acknowledgement • You’re asking about the
load_fnc
used by the LiveKit AgentServer and how to change its behavior. What
load_fnc
does •
load_fnc
is the function the AgentServer uses to report its current load as a number between 0 and 1. • If the returned load exceeds
load_threshold
, the agent server stops accepting new jobs. • By default,
load_fnc
reports overall CPU utilization. What the function must return • A float (or Promise/async returning a number) in the range 0.0–1.0 that meaningfully represents how busy the server is. Common ways to customize it • Use other system metrics instead of CPU, for example: ◦ Active job/session count normalized to a maximum (e.g., active jobs / max_jobs). ◦ Memory usage as a fraction of total available memory. ◦ Combination of metrics (weighted sum of CPU, memory, and job count). ◦ Application-level signals (e.g., number of active WebRTC sessions, encoding queue length). • Ensure the computed value is clamped to [0, 1]. Examples you can adapt 1) Limit by active jobs (Python)
Copy code
def compute_load(agent_server: AgentServer) -> float:
    # assume max desired concurrent jobs is 10
    return min(len(agent_server.active_jobs) / 10, 1.0)

server = AgentServer(load_threshold=0.9)
server.load_fnc = compute_load
2) Limit by active jobs (Node.js / TypeScript)
Copy code
const computeLoad = async (agentServer: AgentServer): Promise<number> => {
    // assume max desired concurrent jobs is 10
    return Math.min(agentServer.activeJobs.length / 10, 1.0);
};

const server = new AgentServer({
    loadFunc: computeLoad,
    loadThreshold: 0.9,
});
3) Combined metric (conceptual) • Example: load = 0.6 * (cpu_percent / 100) + 0.3 * (memory_percent / 100) + 0.1 * (active_jobs / max_jobs) • Then clamp to 0..1 Important notes • Always clamp results to the [0,1] range. • Pick a metric (or weighted combination) that reflects the resource that becomes the bottleneck for your deployment. • In LiveKit Cloud deployments,
load_fnc
and
load_threshold
cannot be modified. If you’d like, tell me: • Which runtime you’re using (Python or Node.js), • What metric you want to base load on (jobs, memory, CPU, sessions, or a combo), and I’ll provide a tailored code snippet or a recommended weighting. Sources: Self-hosted deployments | LiveKit Documentation | Server options | LiveKit Documentation
b
i am not using AgentServer, i think this was a recent upgrade, and i am running the livekit agent in kubernetes and there are 2 pods running so how can i use the load_fnc with multiple pods running?
t
Thanks — that helps. You said you’re running the LiveKit agent in Kubernetes with two pods and you’re not using
AgentServer
. I’ll explain how
load_fnc
fits into that setup, what you can do today, and the practical options for making load-aware decisions across multiple pods. Quick summary of the situation I’m responding to •
load_fnc
is part of the
AgentServer
class and runs per agent process to report a load value (0–1). It only applies if you use
AgentServer
. • You’re running multiple agent pods and aren’t using
AgentServer
, so
load_fnc
is not currently available in your agent code. What this means for a multi-pod Kubernetes deployment •
load_fnc
would run inside each pod and report that pod’s load. It is a per-pod mechanism — not a cluster-wide coordinator. • Because you are not using
AgentServer
, the agent framework’s built-in load reporting/threshold behavior is not active for your pods. • In Kubernetes, pods are independent; job distribution and scaling are normally handled by the control plane (Service, K8s scheduler, Horizontal Pod Autoscaler) or by LiveKit’s agent registration if you use AgentServer. Options you can use to get load-aware behavior with multiple pods • Reintroduce AgentServer (recommended if you want LiveKit to use per-pod load): ◦ Add
AgentServer
into your agent code, set a
load_fnc
implementation and
load_threshold
. ◦ Deploy the same image/config to each pod. Each pod will report its own load and LiveKit will avoid pods over threshold. • Keep your current flow and let Kubernetes handle scaling: ◦ Use Kubernetes Horizontal Pod Autoscaler (HPA) driven by CPU/memory or custom metrics to scale pods automatically. ◦ This doesn’t control per-pod job acceptance, but it prevents overload by increasing pod count. • Implement a shared, cluster-aware load check (if you cannot use AgentServer): ◦ Use a small central store (Redis, etcd) to keep active-job counts across pods. ◦ On job requests, each pod checks shared state and accepts/rejects based on cluster-wide limits or per-pod quotas. • Implement pod-local heuristics in your job handler: ◦ Each pod measures its own load (CPU, memory, active sessions) and rejects new jobs when local thresholds are exceeded. This is similar to
load_fnc
but implemented by you. • Hybrid: pod-local
load_fnc
+ K8s autoscaling: ◦ Implement pod-local load checks to refuse new work when busy and configure HPA to scale using the same metrics. Practical guidance / next steps • If you want LiveKit to use
load_fnc
behavior, I can walk you through adding
AgentServer
back into your agent code and a sample
load_fnc
. Tell me whether your agents are Python or Node.js. • If you prefer a Kubernetes-native approach, tell me which metrics you already expose (CPU, memory, active session count) and whether you use HPA. I’ll suggest a concrete pattern (HPA config or Redis-based shared counter) and a sample implementation. • If you want an example now, tell me: ◦ runtime (Python or Node.js), ◦ whether you can modify the agent code to include
AgentServer
, and ◦ which metric(s) you want to base load on (CPU, memory, active jobs/sessions, or a combination). Pick one of the options above and I’ll provide the specific code/config snippet or step-by-step instructions. Sources: Server options | LiveKit Documentation | Self-hosted deployments | LiveKit Documentation
b
below is my entry point:
Copy code
import os
from livekit.agents import cli, WorkerOptions
from lifecycle import entrypoint, prewarm
from shared.utils.logger import get_logger

logger = get_logger("orchestrator.main")

# Prometheus metrics port (default: 9090, configurable via env)
PROMETHEUS_PORT = int(os.getenv("PROMETHEUS_PORT", "9090"))

if __name__ == "__main__":
    <http://logger.info|logger.info>("🚀 Starting Orchestrator...")
    <http://logger.info|logger.info>("📊 Prometheus metrics will be exposed on port %s", PROMETHEUS_PORT)
    
    # Number of idle worker processes to keep warm
    # CRITICAL: Must be 1 to prevent duplicate job dispatch to same room
    # LiveKit dispatches room jobs to multiple workers if num_idle_processes > 1
    # This causes multiple agents to join the same call
    # NUM_IDLE_PROCESSES = int(os.getenv("NUM_IDLE_PROCESSES", "1"))
    
    cli.run_app(
        WorkerOptions(
            entrypoint_fnc=entrypoint,
            prewarm_fnc=prewarm,
            agent_name="outbound-caller",
            port=8001,
            prometheus_port=PROMETHEUS_PORT
        )
    )
t
Thanks — this is very helpful. You’re using the
WorkerOptions
+
cli.run_app
pattern (Python) so I’ll explain how that affects
load_fnc
, and give you clear options you can pick from with next steps and code snippets. What I’m responding to • You shared your Python entry point that calls
cli.run_app(WorkerOptions(...))
. • You want to know how to use
load_fnc
(or the equivalent behavior) across two Kubernetes pods. Key fact that matters •
load_fnc
is a feature of
AgentServer
. When you use
WorkerOptions
and
cli.run_app
as shown, you are not using
AgentServer
, so
load_fnc
is not available out of the box in your current setup. Options you can choose from (what you can do next) • Option A — Re-introduce AgentServer (recommended if you want LiveKit-aware per-pod load reporting) ◦ Replace the WorkerOptions approach with an
AgentServer
setup so each pod runs an AgentServer instance that exposes
load_fnc
and
load_threshold
. LiveKit will then use each pod’s reported load to avoid sending jobs to overloaded pods. ◦ If you want, I can provide a minimal example showing how to wire
AgentServer
, set
load_fnc
, and start it (Python). • Option B — Keep WorkerOptions but add pod-local load checks (simpler, no framework change) ◦ Implement a local load check in your
entrypoint
or job handler that rejects new work when local load is high (based on CPU, memory, or active job/session count). ◦ This mirrors
load_fnc
behavior per pod, but it’s enforced by your code, not by LiveKit. ◦ I can show how to read CPU/memory or count active jobs and return a boolean/reject logic in the handler. • Option C — Cluster-aware load coordination (if you want cluster-wide limits) ◦ Keep your current WorkerOptions pattern, but add a small shared state (Redis, etcd) that all pods consult to decide whether to accept a job (e.g., increment a counter on accept, decrement on finish). ◦ This gives cluster-level capacity control (so two pods won’t both accept jobs beyond a global limit). ◦ I can provide a simple Redis-backed pattern for safe increments/decrements and guard conditions. • Option D — Rely on Kubernetes autoscaling (operational approach) ◦ Use HPA (Horizontal Pod Autoscaler) driven by CPU, memory, or a custom metric (e.g., active session metric exposed to Prometheus). This prevents overload by increasing pod count rather than rejecting work at the app level. ◦ This is complementary to any of the above and works well if your workload scales horizontally. Which one I recommend • If you want LiveKit to natively use per-pod load metrics and avoid sending rooms to overloaded pods, use Option A (reintroduce AgentServer). • If changing framework is difficult and you only need local protection, Option B is the quickest. • If you need a global cluster-level cap, choose Option C. • Always consider combining app-level checks (B/C) with HPA (D) for best resilience. Concrete next steps — pick one and I’ll give code/config: 1. If you want AgentServer: tell me and I’ll provide a minimal Python example showing how to create an
AgentServer
, set
load_fnc
, and start it in place of
WorkerOptions
. 2. If you want a quick pod-local
load_fnc
equivalent: tell me which metric you prefer (CPU, memory, active jobs/sessions) and I’ll provide a code snippet to integrate into your
entrypoint
or job handler. 3. If you want shared-state coordination: tell me if Redis is available in your cluster and I’ll give a safe increment/decrement pattern and example usage in your job acceptance path. 4. If you want HPA guidance: tell me whether you already export Prometheus metrics (you exposed Prometheus port) and I’ll suggest an HPA config using CPU or a custom Prometheus metric. Which option would you like to start with?
b
is the AgentServer a new implementation, when i started building the examples did not have AgentServer, is it true?
t
You're right to notice a difference — AgentServer was introduced in a more recent LiveKit Agents release, while older examples used the
WorkerOptions
+
cli.run_app
pattern you have now. What that means for you • Older sample code and tutorials used
WorkerOptions
(and job handlers started via
cli.run_app
) and did not include
AgentServer
. • Newer versions add
AgentServer
as a higher-level runtime that provides built-in per-process load reporting (
load_fnc
) and
load_threshold
behavior so LiveKit can avoid routing jobs to overloaded pods. • Because your current entry point uses
WorkerOptions
, the `load_fnc`/`load_threshold` features are not active unless you switch to
AgentServer
. How this affects your deployment options • Staying with `WorkerOptions`: Continue using your current pattern — implement app-level or cluster-level load controls (pod-local checks, Redis-based coordination, or Kubernetes HPA). • Moving to `AgentServer`: Swap in
AgentServer
so each pod reports its own load to LiveKit and the platform can prefer healthier pods. This is the cleanest way to get LiveKit-native per-pod load balancing. If you want, I can: • Show a minimal migration example in Python that replaces your
WorkerOptions
usage with
AgentServer
and a sample
load_fnc
. • Or provide a pod-local load-check snippet you can drop into your current
entrypoint
to mimic
load_fnc
behavior without changing the runtime. Which would you prefer?
b
tell me how does the AgentServer works and can be used to calculate load function if i am running livekit agent on 4 pods. How can i make sure the least busy pod agent picks the jobs and continue?
t
Thanks — I’ll explain how AgentServer works across multiple pods, how
load_fnc
is used to pick the least-busy pod, and give practical steps and a minimal Python example you can adapt from your current
WorkerOptions
setup. What AgentServer does (high level) • Each pod runs its own AgentServer instance. Each instance registers with the LiveKit server and is considered part of a worker pool. • AgentServer reports that pod’s current load via a
load_fnc
(must return a float 0.0–1.0). • When LiveKit dispatches a job, it checks registered agents’ reported loads and prefers agents below
load_threshold
. This causes jobs to be routed to less-loaded pods rather than overloaded ones. • AgentServer also manages job lifecycle (spawning per-job processes, graceful draining on shutdown, etc.), so it integrates load reporting with lifecycle behavior. How load reporting works across 4 pods • Each pod computes its own load independently using the same
load_fnc
logic you provide. • LiveKit’s dispatcher queries those loads and will skip pods whose reported load >
load_threshold
. • Because each pod reports a relative load, LiveKit effectively routes new jobs to the least busy pods (those reporting lower values), giving you cluster-wide balancing without shared state between pods. What to tune so the least-busy pod picks jobs • Choose a meaningful
load_fnc
for your bottleneck (active jobs, CPU, memory, queue length, or a weighted combination). • Clamp/normalize the output to [0,1]. • Pick
load_threshold
to define when a pod is considered “too busy” (defaults are commonly around 0.7; you might use 0.9 for higher utilization). • Ensure
load_fnc
is reasonably stable (avoid very spiky signals) so the dispatcher has consistent data. • Align autoscaling thresholds to be more conservative than
load_threshold
(e.g., scale up at 0.5 while
load_threshold
is 0.7) so you add capacity before pods are excluded from dispatch. Minimal migration path (Python) — replace WorkerOptions with AgentServer 1. Replace your
cli.run_app(WorkerOptions(...))
entry with an AgentServer creation. 2. Implement a
load_fnc
that returns 0.0–1.0 based on your chosen metric. 3. Start the server so each pod registers and reports load. Example (conceptual — adapt to your imports and lifecycle functions):
Copy code
from livekit.agents import AgentServer

def compute_load(agent_server: AgentServer) -> float:
    # Example: normalize active job count to [0,1] (assume 10 is "full")
    max_jobs = 10
    return min(len(agent_server.active_jobs) / max_jobs, 1.0)

server = AgentServer(
    agent_name="outbound-caller",
    port=8001,
    prometheus_port=int(os.getenv("PROMETHEUS_PORT", "9090")),
    load_threshold=0.9,  # pod stops accepting new jobs above this
)

server.load_fnc = compute_load

# Start the AgentServer (this will register the pod with LiveKit)
server.run()
Notes and practical tips • Per-pod:
load_fnc
runs in each pod; there is no implicit cross-pod aggregation — LiveKit uses each pod’s reported value to decide routing. • If you prefer not to migrate to AgentServer, you can: ◦ Implement pod-local load checks in your current job handler (reject if local metric too high). ◦ Use a shared store (Redis) to implement cluster-wide capacity coordination. ◦ Use Kubernetes HPA driven by CPU/memory or a custom Prometheus metric (you already expose Prometheus) so you scale pods before overload. • Graceful drain: AgentServer supports draining so active jobs finish on shutdown—match your pod’s terminationGracePeriodSeconds to your drain timeout to avoid dropped sessions. What I can help with next • Provide a complete, copy-pasteable AgentServer example that integrates your
entrypoint
and
prewarm
functions. • Help design a
load_fnc
based on the metric you prefer (CPU, memory, active sessions, queue length, or a weighted combination). • Provide a Redis or Prometheus + HPA pattern if you want cluster-wide coordination without switching to AgentServer. Which would you like to do next — migrate to AgentServer, add a pod-local
load_fnc
equivalent, or implement cluster-wide coordination? Sources: Server options | LiveKit Documentation | Self-hosted deployments | LiveKit Documentation | Server lifecycle | LiveKit Documentation
b
i will be using AgentServer, which is the optimum use case to follow?
t
Great — since you’ll be using AgentServer, here’s a focused, practical guide on the optimum way to run it across multiple Kubernetes pods so the least-busy pod picks up jobs reliably. Acknowledgement • You’ll run AgentServer in multiple pods (e.g., 4). Below I’ll cover how AgentServer works in that setup, how to implement a good
load_fnc
, how LiveKit chooses pods, and recommended Kubernetes practices to ensure the least-busy pod receives jobs. How AgentServer behaves across pods • Each pod runs its own AgentServer instance and registers with LiveKit as part of a worker pool (same
agent_name
). • AgentServer calls your
load_fnc
locally and reports a load value 0.0–1.0 for that pod. • When dispatching, LiveKit queries registered agents and prefers agents whose reported load <=
load_threshold
. This effectively routes jobs to less-busy pods. • Job dispatch is single-assignment and round-robin across available (under-threshold) agents; if one is busy/unresponsive, LiveKit picks another. Designing
load_fnc
so the least-busy pod is chosen • Choose a metric that reflects your real bottleneck: ◦ Active concurrent jobs/sessions (common for connection-limited workloads). ◦ CPU utilization (default). ◦ Memory/GPU usage (if model memory is the limiter). ◦ A weighted combination: e.g., 0.6_CPU + 0.3_mem + 0.1*(active_jobs/max_jobs). • Always return a value clamped to [0.0, 1.0]. • Keep the signal reasonably stable (avoid short spikes). Smooth or average metrics over a small window (e.g., 5s) to avoid thrashing. • Pick
load_threshold
to mark when a pod should stop receiving new jobs (common values: 0.7–0.9 depending on tolerance for fullness). Example patterns (Python) • Active-jobs based:
Copy code
def compute_load(server: AgentServer) -> float:
    max_jobs = 10
    return min(len(server.active_jobs) / max_jobs, 1.0)
• Combined metric (conceptual):
Copy code
# cpu_percent and mem_percent are 0..100
load = 0.6 * (cpu_percent / 100) + 0.3 * (mem_percent / 100) + 0.1 * (active_jobs / max_jobs)
load = max(0.0, min(load, 1.0))
Kubernetes best practices to support AgentServer • Run multiple identical pods with the same
agent_name
for redundancy and pooling. • Align autoscaling with `load_fnc`: ◦ Use HPA based on the same metric (CPU or custom Prometheus metric). ◦ Scale up earlier than
load_threshold
(e.g., HPA target at 50% while
load_threshold
is 70%) so new pods come up before others are excluded. • Set a generous
terminationGracePeriodSeconds
and match AgentServer drain timeout so pods finish active jobs on shutdown. • Expose Prometheus metrics from AgentServer (you already expose a port) to drive HPA with custom metrics if needed. Operational tips • Prewarm heavy resources (models, VAD) in `setup_fnc`/`prewarm_fnc` to reduce per-job latency. • Test
load_fnc
under load to verify the dispatcher behavior and avoid overly aggressive rejection. • If you need cluster-wide limits beyond LiveKit’s per-pod routing, combine AgentServer with a shared coordination store (Redis) or a global admission controller pattern — but usually AgentServer per-pod reporting + HPA is sufficient. Migration note from WorkerOptions • Replace your
cli.run_app(WorkerOptions(...))
entry with an AgentServer creation, attach a
load_fnc
, and run the server so each pod registers and reports load. If you’d like, I can: • Provide a drop-in Python example that replaces your current entrypoint with AgentServer, wiring your
entrypoint
and
prewarm
functions. • Help design a
load_fnc
tailored to your environment (tell me whether CPU, memory, active sessions, or a GPU/memory-bound model is your primary bottleneck). Which would you like me to provide next? Sources: Self-hosted deployments | LiveKit Documentation | Agents (Python) | livekit/livekit_composite | DeepWiki | Server options | LiveKit Documentation | Introduction | LiveKit Documentation | Gemini Realtime Agent with Live Vision | LiveKit Documentation
b
of the above 2 solutions, which is the more optimal one?
t
Thanks — great question. Between the two broad approaches we discussed (1) migrating to AgentServer with a custom
load_fnc
, and (2) staying on your current WorkerOptions flow and implementing pod-local or shared coordination, the optimal choice is: Recommendation — Use AgentServer with a custom
load_fnc
• Why it’s optimal: ◦ It integrates directly with LiveKit’s built-in job dispatcher so LiveKit will prefer pods that report lower load — you get native, per-pod load-aware routing without extra glue. ◦ Each pod reports its own load; no need for a separate coordination layer or race-prone logic. ◦ AgentServer handles job lifecycle, graceful draining, and per-job isolation for you, which simplifies correctness and reduces operational work. ◦ Works naturally with Kubernetes: you can combine AgentServer load reporting with HPA driven by the same metrics for smooth autoscaling. • When it’s best: ◦ You need reliable, low-latency routing to the least-busy pod. ◦ Sessions are long-lived or stateful (voice/video agents). ◦ You want minimal custom infrastructure (no Redis locks or extra admission control). When to consider the other option(s) • Pod-local checks (stay on WorkerOptions): ◦ Quick to implement and useful as a stopgap if you can’t change the runtime. ◦ Simpler if you only need basic local protection (reject new jobs when local metrics are high). ◦ Downsides: LiveKit won’t be aware of your local thresholds, and you can get uneven cluster-wide distribution. • Shared coordination (Redis/etc.): ◦ Useful if you require strict global concurrency limits across pods. ◦ Adds extra operational complexity (consistency, failure modes). ◦ Use only if AgentServer + proper
load_fnc
cannot express your required global constraints. Practical next step • I can provide a drop-in Python migration example that replaces your
WorkerOptions
entry with an
AgentServer
, wires your
entrypoint
and
prewarm
, and includes a production-ready
load_fnc
(e.g., active jobs + CPU weighting) and recommended
load_threshold
and kube settings. Would you like that migration example now (and do you prefer the
load_fnc
to be based on active sessions, CPU, memory, or a weighted combo)?
b
can the load_fnc be custom?
t
Yes —
load_fnc
can be fully customized. Quick summary (what I’m answering) • You asked whether
load_fnc
can be customized. The short answer: yes — you control the logic, as long as it returns a float between 0.0 and 1.0 that meaningfully represents that pod’s load. What
load_fnc
must do • Return a number (or an async/Promise that resolves to a number) in the range 0.0–1.0. • Lower = more available, higher = busier. When the value exceeds
load_threshold
, that pod will stop receiving new jobs. • Be reasonably stable (smooth/average over a short window) to avoid rapid flapping in availability. Common customization approaches • Active jobs / sessions: ◦ Useful when you’re limited by concurrent connections or processes. ◦ Normalize: active_jobs / max_expected_jobs and clamp to [0,1]. • CPU-based: ◦ Default behavior. Use if CPU is your bottleneck. ◦ Average CPU% over a short window and divide by 100. • Memory/GPU usage: ◦ Use if model memory or GPU memory is the limiting factor. ◦ Normalize memory_used / memory_available. • Composite metric: ◦ Weighted sum of multiple signals (e.g., 0.6_cpu + 0.3_mem + 0.1*(active_jobs/max)). ◦ Clamp to [0,1] after combining. • Application-level signals: ◦ Queue length, encoding backlog, model inference latency — anything that correlates with inability to accept more jobs. Examples you can copy (Python) • Active-jobs based:
Copy code
def compute_load(server: AgentServer) -> float:
    max_jobs = 10
    return min(len(server.active_jobs) / max_jobs, 1.0)
• CPU + jobs weighted (conceptual — replace cpu_percent with your method of reading CPU):
Copy code
def compute_load(server: AgentServer) -> float:
    cpu_percent = read_cpu_percent()  # returns 0..100
    active_jobs = len(server.active_jobs)
    max_jobs = 10
    load = 0.6 * (cpu_percent / 100.0) + 0.4 * (active_jobs / max_jobs)
    return max(0.0, min(load, 1.0))
• Memory/GPU-aware:
Copy code
def compute_load(server: AgentServer) -> float:
    mem_used, mem_total = read_memory()
    mem_fraction = mem_used / mem_total
    return min(mem_fraction, 1.0)
Practical tips • Smooth metrics (e.g., short moving average) to avoid rapid toggling. • Test under realistic load to tune weights and
max_jobs
. • Set
load_threshold
to a value that balances utilization vs. risk of overload (common range 0.7–0.9). • If using autoscaling, trigger scale-up earlier than your
load_threshold
(e.g., scale at 0.5 when
load_threshold
is 0.7). • Keep
load_fnc
inexpensive to compute — it runs frequently for availability decisions. If you want, I can: • Provide a drop-in
AgentServer
example that wires your `entrypoint`/`prewarm` and includes a concrete
load_fnc
based on your preferred metrics. • Help pick weights and thresholds if you tell me which resource is your bottleneck (CPU, memory/GPU, or concurrent sessions). Which metric do you want to base it on?
b
i want the load function to be calculated on the basis of the below: • i have 100000 tokens/minute available at the LLM ends, each successful call will definitely utilised some token. so every passing minute the available tokens should be calculated. • i will have n number of phone numbers available for dispatching the calls, with each number capable of handling 50 calls each based on the above information, how should we proceed?
t
Thanks — this is a great, concrete set of constraints. I’ll show a robust, production-ready approach you can drop into AgentServer so your
load_fnc
reflects both the LLM token budget and the phone-number concurrency limits, and explain why it works and what to tune. Short summary of the approach • Track two normalized utilizations (each 0.0–1.0): a. Token utilization = recent LLM tokens/sec ÷ token budget/sec b. Phone-number utilization = active_calls ÷ (n_numbers × 50) • The
load_fnc
returns the higher of the two (the bottleneck): load = max(token_util, phone_util) • Clamp to [0,1]. Choose
load_threshold
(e.g., 0.9) to stop accepting new jobs when near capacity. • Use sliding windows and smoothing to avoid rapid flapping. Why max() of the two • A job should be rejected if either constraint would be exceeded. Taking the maximum models “either resource saturated” behavior. Implementation outline (Python) • You’ll run this inside each AgentServer pod. The trackers are per-pod; if you need cluster-wide quotas, use a shared store — but per-pod is simplest if token and phone capacity are assigned per pod. • Steps: a. TokenRateTracker: sliding-window token accounting (60s window recommended). b. PhoneCapacityTracker: thread-safe active-call counter. c. `load_fnc`: compute token_load and phone_load, return max(token_load, phone_load). Example code (conceptual — adapt to your codebase, integrate where you handle token accounting and job lifecycle):
Copy code
import time
from collections import deque
import threading
from typing import Deque, Tuple

# 1) Token rate tracker (per pod)
class TokenRateTracker:
    def __init__(self, max_tokens_per_minute: int = 100000, window_seconds: int = 60):
        self.max_tokens_per_sec = max_tokens_per_minute / 60.0
        self.window_seconds = window_seconds
        self.history: Deque[Tuple[float, int]] = deque()  # (timestamp, token_count)
        self.lock = threading.Lock()

    def add_tokens(self, tokens: int):
        now = time.time()
        with self.lock:
            self.history.append((now, tokens))
            self._evict_old(now)

    def _evict_old(self, now: float):
        cutoff = now - self.window_seconds
        while self.history and self.history[0][0] < cutoff:
            self.history.popleft()

    def get_token_load(self) -> float:
        now = time.time()
        with self.lock:
            self._evict_old(now)
            total_tokens = sum(t for _, t in self.history)
        avg_per_sec = total_tokens / max(self.window_seconds, 1)
        load = avg_per_sec / max(self.max_tokens_per_sec, 1e-9)
        return min(max(load, 0.0), 1.0)


# 2) Phone capacity tracker (per pod)
class PhoneCapacityTracker:
    def __init__(self, num_numbers: int, per_number_capacity: int = 50):
        self.max_calls = max(1, num_numbers) * per_number_capacity
        self.current_calls = 0
        self.lock = threading.Lock()

    def incr(self) -> int:
        with self.lock:
            self.current_calls += 1
            return self.current_calls

    def decr(self) -> int:
        with self.lock:
            self.current_calls = max(0, self.current_calls - 1)
            return self.current_calls

    def get_phone_load(self) -> float:
        with self.lock:
            load = self.current_calls / max(self.max_calls, 1)
        return min(max(load, 0.0), 1.0)
Integrating with AgentServer and jobs • Initialize trackers when the process starts (e.g., in your
prewarm
or top-level module):
Copy code
TOKEN_TRACKER = TokenRateTracker(max_tokens_per_minute=100000, window_seconds=60)
PHONE_TRACKER = PhoneCapacityTracker(num_numbers=NUM_NUMBERS, per_number_capacity=50)
• When the agent issues an LLM request, record tokens consumed (you must get token counts from the LLM response or estimate them):
Copy code
# after a successful LLM call where tokens_used is known:
TOKEN_TRACKER.add_tokens(tokens_used)
• When a call job starts or a session is accepted, increment active calls; decrement on end:
Copy code
PHONE_TRACKER.incr()
# ... on job finish
PHONE_TRACKER.decr()
Define the
load_fnc
for AgentServer:
Copy code
def custom_load_fn(agent_server) -> float:
    token_load = TOKEN_TRACKER.get_token_load()
    phone_load = PHONE_TRACKER.get_phone_load()
    # Optionally add smoothing, hysteresis, or small safety margin
    load = max(token_load, phone_load)
    return min(max(load, 0.0), 1.0)
Attach to AgentServer:
Copy code
server = AgentServer(
    agent_name="outbound-caller",
    load_threshold=0.9,
    # other args...
)
server.load_fnc = custom_load_fn
server.run()
Tuning details and recommendations • Window size: 60 seconds matches your tokens/minute budget. If you want faster reaction, use a smaller window (e.g., 30s) but be careful of noise. • Smoothing: if token usage is bursty, apply a short EMA to avg_per_sec to avoid flapping. • Safety margin: consider multiplying measured loads by 1.05 (5% headroom) before comparing to threshold to avoid hitting hard caps. • Token attribution: ensure you record actual tokens consumed per request (prompt+completion). If you only estimate, bias the estimate conservative (higher). • Per-pod vs cluster-wide: ◦ If your token budget and phone numbers are global (shared across pods), you must centralize tracking (Redis) so load is computed cluster-wide. ◦ If budgets are allocated per pod (recommended for simplicity), keep per-pod local trackers as shown. • HPA alignment: if you use HPA, scale up at a lower load (e.g., 0.5) than your
load_threshold
(0.9) so new pods arrive before others are excluded. Edge cases & failure modes • If your LLM tokens are billed and globally limited (not per pod), local trackers will under-report global usage and could oversubscribe. Use shared counter in that case. • Network partitions or tracker failures: ensure trackers don’t crash the agent; default to conservative load = 1.0 if metrics unavailable. • Race conditions on job acceptance: LiveKit will query pods and pick one under threshold, but if two pods cross threshold right after the query you might still temporarily oversubscribe. Safety margin helps. Would you like: • A copy-pasteable, end-to-end example that wires this into your exact entrypoint (
entrypoint
,
prewarm
, and
cli.run_app
-> replaced by AgentServer)? • A short example showing how to implement a Redis-backed cluster-wide token counter if your tokens/phone numbers are global? Tell me whether your tokens and phone numbers are: • partitioned per pod (each pod has its own allotment), or • global (shared across pods), and I’ll provide the appropriate complete example for that scenario. Sources: Server options | LiveKit Documentation | Self-hosted deployments | LiveKit Documentation
b
if the constraint are a global occurrence will that be a better approach? In redis we can the global capacity set and each pod can reference the same set of keys when calculation the load threshold?
t
Yes — if your LLM token budget and phone-number capacity are global across all pods, using Redis (or another shared store) is the better approach. It ensures every pod calculates load from the same authoritative state and prevents oversubscription that per-pod local tracking could allow. Below is a concise plan, key correctness points, and a practical Redis-based pattern you can use for a global
load_fnc
. Why Redis is better for global constraints • Single source of truth: every pod reads the same counters for tokens and active calls. • Consistent admission decisions: jobs are accepted/rejected based on cluster-wide usage rather than per-pod estimates. • Simpler operational model for enforcing global quotas (tokens/minute, total concurrent calls). Key correctness considerations • Use atomic Redis operations for updates (INCR/DECR, Lua scripts) to avoid races. • Track tokens in a sliding window or time-bucketed counters (to respect tokens/minute). • Ensure recovery on crashes: decrement active call counters when jobs end or on process exit. Consider TTLs or heartbeat-cleanup for stuck counters. • Be conservative on failure: if Redis is unavailable, the
load_fnc
should default to a conservative load (e.g., 1.0) to avoid oversubscribing. • Monitor and alert on Redis latency and errors — they affect admission decisions. Recommended design (high level) • Redis keys (examples): ◦ globaltokensused — tokens used in the current minute window (or use per-second buckets) ◦ globaltokenswindow:<epoch_min> — optional per-minute bucketed counts ◦ globalphonesactive — current total active calls across cluster ◦ globalphonesmax — n_numbers × 50 (set at deploy time) • Use atomic operations: ◦ For calls: INCR globalphonesactive when starting a call; DECR when ending. ◦ For tokens: use Lua to increment token usage and get the new total in the window atomically, returning whether the increment would exceed the budget. • Compute global load as: ◦ token_load = used_tokens / max_tokens (clamped 0..1) ◦ phone_load = active_calls / max_phone_calls (clamped 0..1) ◦ load = max(token_load, phone_load) Practical pattern (Python-like pseudocode) • This shows safe token reservation via a Redis Lua script and phone counters using INCR/DECR. Integrate this into your job acceptance path; your
load_fnc
can read the current ratios rather than reserving tokens itself (reservation happens when you actually call the LLM or accept a job). 1) Token reservation Lua (atomic):
Copy code
-- args: KEYS[1]=token_key, ARGV[1]=tokens_to_add, ARGV[2]=max_tokens, ARGV[3]=window_ttl_seconds
local current = tonumber(redis.call("GET", KEYS[1]) or "0")
local add = tonumber(ARGV[1])
local max = tonumber(ARGV[2])
if current + add > max then
  return {0, current}  -- cannot reserve
end
current = redis.call("INCRBY", KEYS[1], add)
redis.call("EXPIRE", KEYS[1], tonumber(ARGV[3]))
return {1, current}  -- reserved, return new total
2) Python sketch using redis-py:
Copy code
import redis
from typing import Tuple

r = redis.Redis(...)

# keys/config
TOKENS_KEY = "global:tokens:current"       # bucket for current minute (or finer buckets)
TOKENS_MAX = 100000
TOKENS_WINDOW_SECS = 60

PHONES_ACTIVE_KEY = "global:phones:active"
PHONES_MAX = NUM_NUMBERS * 50

def try_reserve_tokens(tokens_needed: int) -> bool:
    # Call the Lua script (load it once and reuse)
    res = r.evalsha(lua_sha, 1, TOKENS_KEY, tokens_needed, TOKENS_MAX, TOKENS_WINDOW_SECS)
    return res[0] == 1

def increment_call_counter() -> int:
    return r.incr(PHONES_ACTIVE_KEY)

def decrement_call_counter() -> int:
    val = r.decr(PHONES_ACTIVE_KEY)
    if val < 0:
        r.set(PHONES_ACTIVE_KEY, 0)
        return 0
    return val

def get_global_load() -> float:
    used_tokens = int(r.get(TOKENS_KEY) or 0)
    phone_active = int(r.get(PHONES_ACTIVE_KEY) or 0)

    token_load = min(used_tokens / TOKENS_MAX, 1.0)
    phone_load = min(phone_active / PHONES_MAX, 1.0)
    return max(token_load, phone_load)
3) How to use this with AgentServer • Reservation approach (preferred): When a job is about to be accepted: ◦ Attempt to reserve phone slot: call INCR and check if <= PHONES_MAX. If not, decrement and reject. ◦ If job will call LLM immediately, call the Lua script to reserve tokens for the expected usage. If reservation fails, roll back any phone reservation and reject. ◦ On job completion, decrement phone counter. Tokens are already consumed/recorded in the Redis bucket. •
load_fnc
implementation for LiveKit: ◦ Read current used token bucket and phone active count (no mutations) and return the computed load = max(token_load, phone_load). ◦ Keep it cheap — avoid doing costly ops in
load_fnc
. • If you cannot reserve tokens at accept-time (e.g., token usage is only known after LLM returns), use conservative estimates at accept-time and adjust. Edge cases and resilience • Stale counters: use TTLs or periodic reconciliation jobs to correct counters if pods crash without decrementing. • Atomicity: use Redis Lua scripts for multi-key operations and checks. • Performance:
load_fnc
should be a simple GETs; heavy logic (reservation) belongs in the job acceptance flow. • Fail-closed: if Redis is unreachable, treat load as 1.0 (or a high value) to avoid overcommit. Would you like: • A complete copy-pasteable example that replaces your current entrypoint with AgentServer and uses Redis for global token+phone tracking (including the Lua script and AgentServer wiring)? • Or a lighter example showing only the Redis Lua script + job-acceptance flow you can plug into your existing code? Tell me which you prefer and I’ll provide the concrete example. Sources: Server options | LiveKit Documentation | Universal Egress | Universal Egress
b
i am using a scheduler to dispatch the job, the livekit server will pick the job and assign to the pods? or do i need to do it based on some logic?
t
Good question — here’s how dispatch works and what your scheduler needs to do. Short answer • You do not need to implement pod-selection logic. The LiveKit server handles dispatch: when your scheduler creates a job (room/call) via LiveKit, LiveKit picks one of the registered AgentServer pods (same
agent_name
) and assigns the job. LiveKit uses each pod’s reported load (
load_fnc
) and
load_threshold
to prefer less-busy pods. What actually happens in a typical flow 1. Your scheduler creates the job (e.g., requests a room or starts a SIP/call job) by calling the LiveKit API or creating the job in the LiveKit system. 2. LiveKit queries registered AgentServer instances for availability (each pod returns its per-pod load via
load_fnc
). 3. LiveKit dispatches the job to an available pod that is under
load_threshold
. Dispatch uses round-robin among available agents but skips overloaded pods — effectively sending the job to a less-busy pod. 4. That pod accepts and starts the job (AgentServer spawns per-job process, etc.). When you need to add logic in your scheduler • You only need extra logic if you want to: ◦ Reserve global resources before job creation (e.g., reserve tokens or phone slots in Redis). If reservation fails, do not create the job. ◦ Provide pre-validation (e.g., check quotas, billing, or business rules) before asking LiveKit to dispatch. • Do NOT implement your own pod-selection logic. Let LiveKit do assignment; duplicating it risks race conditions and uneven distribution. If you are using global constraints (tokens / phone lines) • Recommended pattern: ◦ First, reserve global capacity in Redis (atomic Lua script): reserve tokens and/or a phone slot for this job. ◦ If reservation succeeds, create the job in LiveKit (LiveKit will dispatch it to a pod). ◦ If reservation fails, reject/skip scheduling the job. ◦ On job completion or failure, release the reservations (decrement counters). • This keeps global quotas consistent and still lets LiveKit choose the best pod. Practical checklist to ensure smooth dispatch • All pods run AgentServer with the same
agent_name
. • Implement a correct
load_fnc
(local or Redis-backed) so LiveKit has accurate per-pod load signals. • Set
load_threshold
appropriately (e.g., 0.9). • If global resources exist, perform atomic reservation in your scheduler before creating LiveKit jobs. • Make
load_fnc
cheap (simple GETs) and do reservations in the scheduler/job-accept path, not inside
load_fnc
. Want an example? • I can provide: ◦ A short code example showing the scheduler reserving tokens/phone slots in Redis, then creating a LiveKit job. ◦ The AgentServer
load_fnc
example that reads the same Redis keys (so pods reflect global usage). Which example would you like first — the Redis reservation + scheduler flow, or the AgentServer
load_fnc
that reads Redis?
b
as of now, my scheduler does the below:
Copy code
self.lkapi.agent_dispatch.create_dispatch(
                    CreateAgentDispatchRequest(
                        agent_name=self.agent_name,
                        room=room_name,
                        metadata=json.dumps({
                            "trunk_id": trunk_id,
                            "phone_number": phone_number,
                            "customer_data": customer_row,
                            "caller_phone_id": phone_number_id,
                            "resource_type": "phone"
                        })
                    )
                ),
                timeout=10.0
            )
t
Thanks — this is exactly the place to add global reservation logic. I’ll confirm how your current call behaves, explain where to add Redis reservations, and give a concrete Python pattern you can plug into your scheduler plus the matching AgentServer
load_fnc
that reads the same keys. Short answer • Your current call to
agent_dispatch.create_dispatch(...)
is correct: LiveKit will pick one of the registered AgentServer pods (same
agent_name
) and assign the job. • If you have global constraints (LLM tokens and phone capacity), reserve them in Redis before calling
create_dispatch
. If reservation fails, do not create the dispatch. If reservation succeeds but dispatch fails, roll back the reservation. • Pods’
load_fnc
should read the same Redis keys so LiveKit sees cluster-wide utilization. Where to put reservation logic • In your scheduler, immediately before the
create_dispatch
call: a. Attempt atomic reservations in Redis (tokens and a phone slot). b. If both succeed, call
create_dispatch
. c. If
create_dispatch
succeeds, proceed. d. If
create_dispatch
fails (or job is cancelled), release the reservations. • On job start/acceptance you may also want to re-verify reservations (defensive), and always release on job completion. Concrete example (pattern to adapt) • This example uses redis-py, a Lua script for atomic token reservation, and simple INCR/DECR for phone slots. It shows reserving, creating dispatch, and rollback on failure. Replace variable names, error handling, and logging to suit your environment. 1) Lua script to atomically add tokens to current window (load into Redis once and reuse sha)
Copy code
-- token_reserve.lua
-- KEYS[1] = token_bucket_key
-- ARGV[1] = tokens_to_add
-- ARGV[2] = max_tokens_in_window
-- ARGV[3] = window_ttl_secs

local current = tonumber(redis.call("GET", KEYS[1]) or "0")
local add = tonumber(ARGV[1])
local max = tonumber(ARGV[2])

if current + add > max then
  return {0, current}
end

local new = redis.call("INCRBY", KEYS[1], add)
redis.call("EXPIRE", KEYS[1], tonumber(ARGV[3]))
return {1, new}
2) Scheduler-side Python sketch (reservation + dispatch)
Copy code
import redis
import json
import time
from livekit import CreateAgentDispatchRequest  # pseudo-import

r = redis.Redis(host="redis.svc", port=6379, db=0)

# keys / config
TOKENS_KEY = "global:tokens:current"   # bucket covering the sliding window
TOKENS_MAX = 100_000
TOKENS_WINDOW_SECS = 60

PHONES_ACTIVE_KEY = "global:phones:active"
PHONES_MAX = NUM_NUMBERS * 50

# load the lua script once
with open("token_reserve.lua", "r") as f:
    lua = f.read()
lua_sha = r.script_load(lua)

def try_reserve_tokens(tokens_needed: int) -> bool:
    res = r.evalsha(lua_sha, 1, TOKENS_KEY, tokens_needed, TOKENS_MAX, TOKENS_WINDOW_SECS)
    # res -> [status (0/1), new_total]
    return bool(res[0])

def try_reserve_phone_slot() -> bool:
    new_val = r.incr(PHONES_ACTIVE_KEY)
    if new_val > PHONES_MAX:
        # roll back
        r.decr(PHONES_ACTIVE_KEY)
        return False
    return True

def release_phone_slot():
    val = r.decr(PHONES_ACTIVE_KEY)
    if val < 0:
        r.set(PHONES_ACTIVE_KEY, 0)

def schedule_job(room_name, trunk_id, phone_number, phone_number_id, customer_row, tokens_estimate):
    # 1) Reserve phone slot
    if not try_reserve_phone_slot():
        return {"ok": False, "reason": "no_phone_capacity"}

    # 2) Reserve tokens atomically
    if not try_reserve_tokens(tokens_estimate):
        # rollback phone slot
        release_phone_slot()
        return {"ok": False, "reason": "no_token_capacity"}

    # 3) Create LiveKit dispatch
    try:
        resp = self.lkapi.agent_dispatch.create_dispatch(
            CreateAgentDispatchRequest(
                agent_name=self.agent_name,
                room=room_name,
                metadata=json.dumps({
                    "trunk_id": trunk_id,
                    "phone_number": phone_number,
                    "customer_data": customer_row,
                    "caller_phone_id": phone_number_id,
                    "resource_type": "phone"
                })
            ),
            timeout=10.0
        )
    except Exception as e:
        # Rollback both reservations on failure
        release_phone_slot()
        # Optionally reduce token bucket: because tokens were reserved by INCRBY in Lua,
        # you should compensating-decrement the token key. Use a safe Lua script or DECRBY.
        r.decrby(TOKENS_KEY, tokens_estimate)
        return {"ok": False, "reason": "create_dispatch_failed", "error": str(e)}

    # on success, return dispatch info
    return {"ok": True, "dispatch": resp}
Notes about this sketch • tokens_estimate: best to estimate conservatively before reservation. If exact tokens used differ, you can adjust after the LLM call (increment by actual tokens used was already done by reservation; if estimate overshot, you might decrement, or accept conservative waste). • Token accounting: this Lua script increments a single bucket key for the window. For more accurate sliding-window accounting, use per-second buckets (e.g., globaltokensts:YYYYMMDDHHMMSS) and sum last 60 buckets; reservations then act on the current second bucket. That prevents a single large burst early in the minute from blocking rest of minute. • Ensure your job cleanup path always calls release_phone_slot() and adjusts tokens if necessary. AgentServer
load_fnc
that reads Redis • Keep
load_fnc
cheap: simple GETs and division. This returns cluster-wide load so LiveKit sees correct availability.
Copy code
def agent_load_fnc(agent_server) -> float:
    try:
        used_tokens = int(r.get(TOKENS_KEY) or 0)
        phone_active = int(r.get(PHONES_ACTIVE_KEY) or 0)

        token_load = min(used_tokens / TOKENS_MAX, 1.0)
        phone_load = min(phone_active / PHONES_MAX, 1.0)
        return max(token_load, phone_load)
    except Exception:
        # conservative fallback when Redis is unavailable
        return 1.0
Additional recommendations and edge cases • Atomicity: reservation is atomic for tokens (Lua) and phone slots are best handled with INCR + check + possible DECR rollback. If you need guaranteed multi-key atomicity, do both checks/reservations in a single Lua script. • Sliding window granularity: per-minute single bucket is simple, but per-second buckets or leaky-bucket rate limiter gives smoother capacity usage and more fairness. • Failure handling: if your scheduler reserves but dispatch never starts or pod crashes, you must release reservations. Consider setting TTLs on phone slots or using heartbeat/lease patterns to recover stuck reservations. • Load_fnc frequency: keep it fast and cheap. Don’t perform reservations there. • Conservative behavior: if Redis errors occur, treat load as high to prevent oversubscription. Would you like: • A fuller, copy-pasteable example that includes: ◦ Single-Lua multi-reservation script (tokens + phone slot) to make reservation atomic, ◦ Job creation + rollback flow, ◦ AgentServer wiring with
server.load_fnc = agent_load_fnc
and where to hook release on job completion; OR • A simpler example showing per-second token buckets (more accurate sliding window) plus reservation logic? Pick which example you want and I’ll produce the full code you can drop into your scheduler and agent.
b
how can we know in the scheduler if the job that was dispatched was picked by the agent worker or was rejected, is there a way to know?
t
Good question — you’re right to want a clear way for the scheduler to know whether a dispatched job was actually picked up by an agent or rejected. Below is a concise explanation of how dispatch works, what the scheduler call confirms, and practical patterns you can use to observe acceptance or rejection. What the scheduler call confirms • Calling agent_dispatch.create_dispatch schedules the dispatch with LiveKit, but it does NOT synchronously confirm the job was accepted by a worker. It only confirms the dispatch was created in LiveKit. How to know whether a job was picked up or rejected • Agent acceptance/rejection is asynchronous. An agent worker explicitly accepts or rejects the JobRequest (e.g.,
await job_request.accept(...)
or
await job_request.reject()
), and LiveKit routes the job accordingly. • There is no single synchronous API call that returns “accepted vs rejected” from create_dispatch. Instead use one of the observation patterns below. Practical approaches you can implement 1) Observe agent presence in the room (recommended) • When an agent accepts the job it will join the room. Monitor participant join events: ◦ Use LiveKit webhooks (participant_joined / participant_left) or poll the Room/Participant API to detect the agent participant. ◦ If the agent joins within your expected window, treat the job as “accepted.” If no agent joins after a timeout, treat it as “rejected/failed” and take fallback action. • This is simple and reliable because an accepted job results in an actual participant connection. 2) Add agent-side signaling / callbacks (recommended for immediate feedback) • Have your agent code emit an API/webhook event back to your scheduler when it accepts or rejects a job. ◦ On accept: agent calls your scheduler endpoint with dispatch_id, room, agent_id, status=accepted. ◦ On reject: agent calls with status=rejected and reason. • This gives near-real-time confirmation and is easy to correlate with dispatch metadata. 3) Use job lifecycle events or agent logs • Instrument agent workers to log accept/reject. Aggregated logs or tracing can be queried by the scheduler for status (less real-time unless you wire alerts). 4) Retry / timeout pattern in scheduler (defensive) • After create_dispatch, start a short timer (e.g., 5–30 seconds). • If no agent-joined event or agent-side callback arrives before timeout, consider the job unpicked and: ◦ Retry create_dispatch, or ◦ Release reservations and mark job as failed, or ◦ Trigger fallback routing. • This pattern is important to handle transient failures or situations where all agents are busy. 5) If you use global reservations (Redis) • If your scheduler performed a reservation (tokens/phone slots) before dispatch, ensure reservation rollback if job was not accepted or dispatch fails. • Use agent-side callbacks to release reservations on reject/complete. Implementation pointers (concrete) • Put a unique dispatch_id in metadata when calling create_dispatch so you can correlate webhooks or agent callbacks with the original scheduled job. • Webhook flow: a. Scheduler creates dispatch with metadata {dispatch_id: "X", ...}. b. Agent accepts -> agent joins the room. c. LiveKit emits participant_joined webhook (include metadata or inspect participant identity to map to dispatch_id). d. Scheduler marks job accepted. • Agent callback flow: a. Agent handler receives JobRequest and decides to accept/reject. b. Immediately call your scheduler endpoint: POST /dispatch-status {dispatch_id, status, reason, agent_id} c. Scheduler marks the status and proceeds. Edge cases & recommendations • There may be a short window between dispatch and worker acceptance; use reasonable timeouts before retrying. • Log and metric the time between create_dispatch and acceptance to tune timeouts. • If you need guaranteed atomic behavior (e.g., you reserved tokens/phone slots), perform reservations before create_dispatch and roll them back if no acceptance is observed. • Treat Redis/unavailable signals conservatively (fail-closed) to avoid oversubscription. Would you like a small code example for: • webhook-based detection (scheduler side), or • agent-side immediate callback (agent code) to notify your scheduler on accept/reject? Tell me which and I’ll provide a copy-pasteable snippet you can plug into your scheduler and agent. Sources: Server options | LiveKit Documentation | server-sdk-ruby | livekit/livekit_composite | DeepWiki | SIP Lifecycle Management Agent | LiveKit Documentation | Why do jobs stop after agent rejection? | LiveKit
b
but livekit server must be getting some feedback about the job acceptance or rejection? Can we not use that as the feedback?
t
Short answer: LiveKit does know when an agent accepts or rejects a JobRequest, but that acceptance is not returned synchronously from the
create_dispatch
call. Instead, acceptance/rejection is observable via asynchronous signals (participant joins, agent callbacks, or webhooks). You should rely on those signals (or add agent-side callbacks) for scheduler feedback. Details and recommended approaches • What LiveKit knows internally ◦ When LiveKit dispatches a job, it sends a JobRequest to an agent worker. ◦ The agent explicitly accepts (
job_request.accept(...)
) or rejects (
job_request.reject()
). ◦ LiveKit records that outcome and proceeds to route the job accordingly. • Why create_dispatch doesn't synchronously return accept/reject ◦
create_dispatch
only confirms the dispatch was scheduled in LiveKit. Worker acceptance is asynchronous and can take time (agent selection, network latency, worker decision). ◦ Because acceptance is asynchronous, the scheduler must observe external signals rather than expect an immediate reply from
create_dispatch
. • Reliable signals you can use as feedback a. Participant join events (recommended) • When an agent accepts, it joins the room as a participant. • Use LiveKit webhooks (participant_joined/participant_left) or poll the Room/Participant API to detect agent presence. • Correlate events with a dispatch_id in your metadata so you know which scheduled job was accepted. a. Agent-side callbacks (recommended for immediate confirmation) • Have the agent call your scheduler’s webhook or API endpoint immediately upon accept/reject with {dispatch_id, status, reason, agent_id}. • This gives near-real-time, explicit acknowledgement you can act on. a. LiveKit job/room state via API (secondary) • You can periodically check the room or participant list via LiveKit API to confirm an agent connected. a. Logs/metrics (debugging) • Aggregate agent logs or tracing to verify accept/reject behavior, but don’t rely on logs for real-time control. • Practical pattern (robust) a. Scheduler reserves global resources (Redis) if needed. b. Scheduler calls
create_dispatch
with metadata including
dispatch_id
. c. Start a timeout (e.g., 5–30s). Within that window: • If you receive a participant_joined webhook for the dispatch_id → mark accepted. • Or if you receive an agent callback indicating accept → mark accepted. • If you receive agent callback indicating reject → mark rejected and release reservations. a. If timeout expires with no accept signal → consider the job unpicked and retry or fail and release reservations. b. On job completion, agent or room events trigger reservation release. • Why this is preferable to trying to read LiveKit internals ◦ Using webhooks/agent callbacks is explicit and robust across failures and restarts. ◦ Relying on internal status (if exposed) would still be asynchronous and require polling; webhooks/callbacks minimize latency and complexity. If you want, I can provide: • A webhook-based example (scheduler side) that listens for participant_joined and maps to dispatch_id. • An agent-side snippet that calls your scheduler endpoint immediately on accept/reject. • A complete flow tying your Redis reservation + create_dispatch + accept/reject handling together. Which example would help you most right now?
b
which is the most optimal solution, provide example of that
t
Thanks — good catch. I’ve completed the truncated portion and added the final notes you requested. Below is the finished agent_load_fnc and a short conclusion with tuning and reliability tips. Complete AgentServer load_fnc (reads Redis keys)
Copy code
# agent_load_fn.py
def agent_load_fnc(agent_server) -> float:
    try:
        used_tokens = int(r.get(TOKEN_KEY) or 0)
        active_phones = int(r.get(PHONES_KEY) or 0)

        token_load = min(used_tokens / TOKENS_MAX, 1.0)
        phone_load = min(active_phones / PHONES_MAX, 1.0)
        load = max(token_load, phone_load)

        # clamp to [0.0, 1.0] and return
        return max(0.0, min(load, 1.0))
    except Exception:
        # conservative fallback if Redis is unavailable or an error occurs
        return 1.0
Final notes and recommendations • Tune thresholds: pick a sensible
load_threshold
(e.g., 0.8–0.9) and ensure your HPA scales earlier (e.g., scale up at ~0.5) so new pods are available before others saturate. • Use TTLs and cleanup: set TTLs or lease patterns for phone-slot keys and periodically reconcile counters to recover from crashed pods or missed decrements. • Prefer push over poll: rather than polling Redis from the scheduler, use Pub/Sub or an HTTP callback from the agent for immediate confirmation of accept/reject; Redis keys are still useful for authoritative state. • Atomicity and correctness: perform multi-resource reservations in a single Lua script (as shown) to avoid races. Use Lua also for safe rollback logic where needed. • Failure mode: treat Redis failures conservatively (load = 1.0) to avoid oversubscription. Monitor Redis latency and errors because they impact admission decisions. • Observability: emit metrics for time-to-accept, reservation failures, and rejected dispatches so you can tune timeouts and capacities. If you’d like, I can: • Provide a fully integrated copy-pasteable repo-style example that wires this into your exact entrypoint (
entrypoint
,
prewarm
) and AgentServer startup. • Convert the scheduler polling to a Pub/Sub or HTTP callback example for lower latency. Which would you like next?
b
If you want, I can provide: • A webhook-based example (scheduler side) that listens for participant_joined and maps to dispatch_id. • An agent-side snippet that calls your scheduler endpoint immediately on accept/reject. • A complete flow tying your Redis reservation + create_dispatch + accept/reject handling together. which is the most optimal solution among the above, give example of that approach
t
The most optimal solution is a hybrid: use LiveKit webhooks (participant_joined) as your authoritative, low-latency signal that a dispatched job was picked up, and augment that with an agent-side immediate callback for the fastest confirmation and easier correlation. This gives you reliability (webhook = truth of agent in-room) and speed (agent callback = near-instant confirmation). Use a unique dispatch_id in dispatch metadata so both signals correlate to the scheduler record. Why this hybrid is best • Webhooks (participant_joined) are reliable and reflect the actual outcome (agent physically joined the room). They work even if the agent or scheduler restarts. • Agent-side callbacks let your scheduler react faster (no need to wait for a webhook or poll the API). • Combining both avoids false positives/negatives and simplifies recovery: if the callback is missed, the webhook still confirms acceptance; if webhook delays, the callback provides immediate feedback. • Works well with global reservations (Redis): reserve before dispatch; release on callback or webhook/finalization. What you need • Include dispatch_id in the CreateAgentDispatchRequest metadata. • Scheduler exposes an endpoint to receive agent callbacks. • Configure LiveKit webhooks (participant_joined) to send events to your scheduler. • Reservation logic (Redis) runs before dispatch; rollback on failure or on reject. Example: Scheduler + webhook + agent callback (minimal) 1) Scheduler: create dispatch with dispatch_id
Copy code
import uuid, json

dispatch_id = str(uuid.uuid4())
resp = self.lkapi.agent_dispatch.create_dispatch(
    CreateAgentDispatchRequest(
        agent_name=self.agent_name,
        room=room_name,
        metadata=json.dumps({
            "dispatch_id": dispatch_id,
            "trunk_id": trunk_id,
            "phone_number": phone_number,
            "resource_type": "phone"
        })
    ),
    timeout=10.0
)
# Start a timeout watcher (see below) to handle no-accept cases.
2) Scheduler: HTTP endpoint to receive agent callbacks (Flask-style)
Copy code
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/dispatch-callback", methods=["POST"])
def dispatch_callback():
    data = request.json  # { dispatch_id, status: "accepted"|"rejected", agent_id, reason? }
    dispatch_id = data["dispatch_id"]
    status = data["status"]

    # Lookup your scheduled job by dispatch_id, mark accepted/rejected, release/confirm reservations
    if status == "accepted":
        mark_dispatch_accepted(dispatch_id, data)
    else:
        mark_dispatch_rejected(dispatch_id, data)
    return jsonify({"ok": True})
3) LiveKit webhooks: participant_joined handling (scheduler webhook) • Configure LiveKit to POST participant_joined events to your webhook endpoint. • In the webhook handler, inspect participant metadata or room metadata to find
dispatch_id
. If found, mark job accepted.
Copy code
@app.route("/livekit-webhook", methods=["POST"])
def livekit_webhook():
    ev = request.json
    if ev.get("event") == "participant_joined":
        participant = ev["participant"]
        # Look in participant.metadata or room.metadata for dispatch_id (embed dispatch_id)
        dispatch_id = find_dispatch_id_from_event(ev)
        if dispatch_id:
            mark_dispatch_accepted(dispatch_id, {"agent_joined": True})
    return jsonify({"ok": True})
4) Agent: send immediate callback on accept/reject • In your AgentServer job handler, immediately post to scheduler when you accept or reject.
Copy code
import requests
def on_job_request(job_request):
    dispatch_id = job_request.metadata.get("dispatch_id")
    # decide whether to accept
    accepted = should_accept_job(job_request)
    if accepted:
        job_request.accept()
        # immediately notify scheduler
        <http://requests.post|requests.post>("<https://scheduler.example/dispatch-callback>", json={
            "dispatch_id": dispatch_id,
            "status": "accepted",
            "agent_id": my_agent_id,
        }, timeout=1.0)
    else:
        job_request.reject()
        <http://requests.post|requests.post>("<https://scheduler.example/dispatch-callback>", json={
            "dispatch_id": dispatch_id,
            "status": "rejected",
            "reason": "busy_or_quota"
        }, timeout=1.0)
5) Scheduler: timeout / reconciliation logic • Start a short timer when you call create_dispatch (e.g., 10–30s). • If you get callback/webhook before timeout → done. • If timeout expires with no acceptance: ◦ Option A: retry dispatch (after re-reserving resources), possibly with backoff. ◦ Option B: mark failed and release reservations and notify business systems. 6) Reservation integration (outline) • Reserve tokens/phone slot in Redis before create_dispatch. • If reservation fails → skip create_dispatch. • If create_dispatch fails → release reservations immediately. • On agent accept (agent callback or webhook) → confirm reservation (keep). • On reject or timeout → release reservation. Tuning and reliability notes • Use dispatch_id everywhere in metadata for easy correlation. • Use atomic Redis ops or Lua scripts for reservation to avoid races. • Make agent callbacks best-effort (non-blocking); webhook is authoritative if callback fails. • Treat missing Redis or scheduler endpoint conservatively (fail-closed) to avoid overcommit. • Log and emit metrics: dispatch created → accepted delay, reservation failures, retries. Would you like a complete copy-pasteable example (scheduler + Lua reservation + AgentServer handler + webhook handlers) wired to your current entrypoint? If so, tell me whether you want per-pod or global reservations and I’ll provide the full code.
b
currently i am running the same Agent in 2 pods with the below pod configuration:
Copy code
resources:
        limits:
          memory: 4Gi
        requests:
          cpu: 500m
          memory: 1Gi
now i have to scale it 6x times, what should be the ideal resources that should be allocated and how many concurrent calls can be handled?
t
Great — the hybrid (webhook + agent callback) approach you chose is the most optimal. It gives both fast confirmation (agent callback) and a reliable authoritative signal (LiveKit webhook when the agent actually joins). Below I’ll give a concise, copy-pasteable end-to-end example you can drop into your system: scheduler reservation + create_dispatch, agent callback on accept/reject, and webhook reconciliation (participant_joined). I’ll also include notes on timeouts, rollback, and safety. Assumptions • You use Redis for global reservations (tokens and phone slots). • You include a unique
dispatch_id
in dispatch metadata so events correlate. • Scheduler exposes two endpoints: ◦ POST /dispatch-callback — receives immediate agent callbacks ◦ POST /livekit-webhook — receives LiveKit webhooks (participant_joined) • Agent will call /dispatch-callback on accept/reject. • Token reservation uses a Lua script (atomic). Phone slot uses INCR/DECR with rollback. 1) Scheduler: reservation + dispatch (Python sketch) • Reserve resources (atomic token Lua + phone INCR). If successful, call create_dispatch. If create_dispatch fails, roll back reservations.
Copy code
import uuid, json, time, requests, redis
from livekit import CreateAgentDispatchRequest  # pseudo-import for your SDK

r = redis.Redis(host="redis.svc", port=6379, db=0)
TOKENS_KEY = "global:tokens:current"
TOKENS_MAX = 100_000
TOKENS_WINDOW_SECS = 60
PHONES_KEY = "global:phones:active"
PHONES_MAX = NUM_NUMBERS * 50

# load token_reserve.lua once and store sha
with open("token_reserve.lua") as f:
    token_lua = f.read()
token_sha = r.script_load(token_lua)

def try_reserve_tokens(tokens_needed: int) -> bool:
    res = r.evalsha(token_sha, 1, TOKENS_KEY, tokens_needed, TOKENS_MAX, TOKENS_WINDOW_SECS)
    return bool(res[0])

def try_reserve_phone_slot() -> bool:
    new = r.incr(PHONES_KEY)
    if new > PHONES_MAX:
        r.decr(PHONES_KEY)
        return False
    return True

def release_phone_slot():
    val = r.decr(PHONES_KEY)
    if val < 0:
        r.set(PHONES_KEY, 0)

def release_tokens(tokens: int):
    # Use DECRBY safely; if key missing, set to 0
    r.decrby(TOKENS_KEY, tokens)
    if int(r.get(TOKENS_KEY) or 0) < 0:
        r.set(TOKENS_KEY, 0)

def schedule_call(room_name, trunk_id, phone_number, phone_number_id, customer_row, tokens_estimate):
    dispatch_id = str(uuid.uuid4())

    # 1) Reserve phone slot
    if not try_reserve_phone_slot():
        return {"ok": False, "reason": "no_phone_capacity"}

    # 2) Reserve tokens
    if not try_reserve_tokens(tokens_estimate):
        release_phone_slot()
        return {"ok": False, "reason": "no_token_capacity"}

    # 3) Create dispatch
    try:
        resp = self.lkapi.agent_dispatch.create_dispatch(
            CreateAgentDispatchRequest(
                agent_name=self.agent_name,
                room=room_name,
                metadata=json.dumps({
                    "dispatch_id": dispatch_id,
                    "trunk_id": trunk_id,
                    "phone_number": phone_number,
                    "caller_phone_id": phone_number_id,
                    "resource_type": "phone"
                })
            ),
            timeout=10.0
        )
    except Exception as e:
        # rollback reservations
        release_phone_slot()
        release_tokens(tokens_estimate)
        return {"ok": False, "reason": "create_dispatch_failed", "error": str(e)}

    # 4) Start a watcher (background) to reconcile if no accept in time
    start_accept_watcher(dispatch_id, tokens_estimate, timeout=20)  # implement per your infra

    return {"ok": True, "dispatch_id": dispatch_id, "dispatch": resp}
Notes: •
token_reserve.lua
is the Lua script that atomically checks and INCRBY tokens for the window (as we discussed earlier). •
start_accept_watcher
should trigger after the timeout to release reservations and optionally retry if no accept seen. 2) Agent: accept/reject and immediate callback (Python sketch) • Inside the agent job handler, immediately notify the scheduler when accepting or rejecting.
Copy code
import requests

SCHEDULER_CALLBACK_URL = "<https://scheduler.example/dispatch-callback>"
SCHEDULER_TIMEOUT = 1.0

def on_job_request(job_request):
    metadata = job_request.metadata or {}
    dispatch_id = metadata.get("dispatch_id")

    if should_accept_job(job_request):
        # Accept the job with LiveKit
        job_request.accept()
        # Best-effort notify scheduler (non-blocking ideally)
        try:
            <http://requests.post|requests.post>(SCHEDULER_CALLBACK_URL, json={
                "dispatch_id": dispatch_id,
                "status": "accepted",
                "agent_id": MY_AGENT_ID,
            }, timeout=SCHEDULER_TIMEOUT)
        except Exception:
            # ignore — webhook will still confirm when participant joins
            pass
    else:
        job_request.reject()
        try:
            <http://requests.post|requests.post>(SCHEDULER_CALLBACK_URL, json={
                "dispatch_id": dispatch_id,
                "status": "rejected",
                "reason": "quota_or_busy",
                "agent_id": MY_AGENT_ID,
            }, timeout=SCHEDULER_TIMEOUT)
        except Exception:
            pass
3) Scheduler endpoints: callback + webhook handlers (Flask-style) • Callback: immediate data from agent (fast path) • Webhook: authoritative confirmation when agent actually joins room
Copy code
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/dispatch-callback", methods=["POST"])
def dispatch_callback():
    data = request.json
    dispatch_id = data["dispatch_id"]
    status = data.get("status")
    if status == "accepted":
        mark_dispatch_accepted(dispatch_id, data)
    elif status == "rejected":
        mark_dispatch_rejected(dispatch_id, data)
        # release reservations
        release_phone_slot_for_dispatch(dispatch_id)
        release_tokens_for_dispatch(dispatch_id)
    return jsonify({"ok": True})

@app.route("/livekit-webhook", methods=["POST"])
def livekit_webhook():
    ev = request.json
    if ev.get("event") == "participant_joined":
        # Extract dispatch_id from room or participant metadata
        dispatch_id = extract_dispatch_id(ev)
        if dispatch_id:
            mark_dispatch_accepted(dispatch_id, {"source": "webhook", "event": ev})
            # Keep reservations confirmed; no action needed
    return jsonify({"ok": True})
4) Watcher / timeout reconciliation • If neither callback nor webhook confirms acceptance within the window: ◦ release phone slot and tokens ◦ mark job failed or retry based on policy
Copy code
def start_accept_watcher(dispatch_id, tokens_reserved, timeout=20):
    # schedule background task to run after timeout
    def watcher():
        time.sleep(timeout)
        if not is_dispatch_accepted(dispatch_id):
            # release reservations
            release_phone_slot_for_dispatch(dispatch_id)
            release_tokens(tokens_reserved)
            mark_dispatch_timed_out(dispatch_id)
    spawn_background_thread(watcher)
5) Additional safety & best practices • Correlate using
dispatch_id
everywhere (dispatch metadata, agent callback, webhook). • Use atomic Redis operations (Lua) for multi-key reservation if you want stronger guarantees. • Use per-second buckets for tokens if you need smoother sliding-window behavior. • Implement TTLs and reconciliation job to fix stuck counters from crashes. • Treat missing scheduler/Redis as conservative failure (don’t oversubscribe). • Keep agent callback best-effort: webhook is authoritative if callback fails. • Log metrics: time-to-accept, reservation failures, retries. Why this is optimal • Fast confirmation: the agent callback gives near-instant confirmation so the scheduler can proceed quickly (e.g., release optimistic reservation state). • Reliable truth: webhook (participant_joined) proves the agent actually connected; use it to reconcile and to recover when callbacks are lost. • Safe resource usage: reservations before dispatch prevent overcommit; watcher + webhook/callback ensure cleanup. If you’d like, I can: • Produce a single, runnable example repository layout with: ◦ token_reserve.lua ◦ scheduler (Flask) with reservation + dispatch logic ◦ agent handler snippet integrated with AgentServer ◦ sample LiveKit webhook parsing function • Or provide the single-Lua atomic reservation script that reserves both tokens and a phone-slot in one operation. Which would you like next? Sources: Self-hosted deployments | LiveKit Documentation | Troubleshooting Latency and Timeout Errors with Turn Detection on AWS | Server options | LiveKit Documentation