Gateway Admission Control

Bound the number of concurrent inbound agent runs with a fair queue and explicit overflow policy

Admission control caps the number of concurrent inbound agent runs across all users, queues the overflow fairly, and explicitly sheds load when the queue is full.

python
from praisonaiagents import Agent
from praisonai.bots import BotOS

agent = Agent(name="Support", instructions="Help users")

bot = BotOS(
    agent=agent,
    platforms=["telegram"],
    max_concurrent_runs=32,
)
bot.start()

The user sends a chat message on a channel; admission control admits, queues, or rejects the run before the agent replies.

Diagram

Gateway Admission Control

ADMIT

QUEUE

REJECT

πŸ“₯ Inbound Turn

🚦 Decide

πŸ€– Run Agent

⏳ Fair Queue

πŸ›‘ Busy Ack

βœ… Reply

Quick Start

  1. Simple Usage

    Cap aggregate concurrent runs with a single parameter:

    python
    from praisonaiagents import Agent
    from praisonai.bots import BotOS
    
    agent = Agent(name="Support", instructions="Help users")
    
    bot = BotOS(
        agent=agent,
        platforms=["telegram"],
        max_concurrent_runs=32,
    )
    bot.start()
  2. With Configuration

    Add a wait queue and choose what happens when the queue is full:

    python
    from praisonaiagents import Agent
    from praisonai.bots import BotOS
    
    agent = Agent(name="Support", instructions="Help users")
    
    bot = BotOS(
        agent=agent,
        platforms=["telegram", "discord"],
        max_concurrent_runs=32,
        queue_depth=128,
        overflow_policy="reject",   # reject | queue | shed_oldest
    )
    bot.start()

How It Works

Diagram
AgentAdmissionGatewayUserAgentAdmissionGatewayUseralt[Capacity available][At ceiling, queue has room][Queue full]Inbound messagedecide(in_flight, queued, session_id)ADMITRun turnReplyReplyQUEUE(waits fairly)Run when slot freesReplyREJECT⏳ Busy right now β€” please resend in a moment.
DecisionWhenWhat the user sees
ADMITin_flight < max_concurrent_runsImmediate response
QUEUEAt ceiling, queue has roomBrief wait, then response
REJECTQueue full and policy is reject (or shed_oldest can’t evict)Friendly busy acknowledgement

Configuration Options

These fields live on GatewayConfig (read from praisonaiagents/gateway/config.py):

OptionTypeDefaultDescription
max_concurrent_runsint0Max concurrent inbound agent runs across all users. 0 disables the gate (legacy behaviour).
max_concurrent_runs_per_scopeint0Optional per-tenant/per-scope sub-limit within the global ceiling. A tenant already at its sub-limit is queued/shed against its own slice even when a global slot is free, so a noisy tenant cannot starve quiet ones. 0 disables it (concurrency-only global behaviour). See Per-tenant fairness.
queue_depthint128Max number of inbound turns that can wait when at the ceiling.
overflow_policystr"reject"Behaviour when at the ceiling and the queue is full: reject, queue, or shed_oldest.
max_rss_mbfloat0.0Hard RSS ceiling in MiB for memory-aware admission. 0 disables it (concurrency-only, legacy behaviour).
per_user_per_minuteint0Max turns one identity may start per minute. 0 disables (default). Over the budget, the gate raises AdmissionRejected("Rate limit reached β€” please try again in Ns.", retry_after=N) before any work starts. Composes with max_concurrent_runs; a bad value on reload rejects the reload. See Gateway Rate Limit.

Precedence: CLI flags β†’ YAML β†’ Python defaults.

Python

python
from praisonai.bots import BotOS
from praisonaiagents import Agent

agent = Agent(name="Support", instructions="Help users")

bot = BotOS(
    agent=agent,
    platforms=["telegram"],
    max_concurrent_runs=32,
    max_concurrent_runs_per_scope=4,   # per-tenant cap within the ceiling
    queue_depth=128,
    overflow_policy="reject",
    per_user_per_minute=20,            # optional per-user turn budget
)

YAML (gateway.yaml)

yaml
gateway:
  host: "127.0.0.1"
  port: 8765
  max_concurrent_runs: 32
  max_concurrent_runs_per_scope: 4   # per-tenant cap within the ceiling
  queue_depth: 128
  overflow_policy: reject   # reject | queue | shed_oldest
  per_user_per_minute: 20   # optional; 0 = unlimited

CLI

bash
praisonai gateway start \
  --max-concurrent-runs 32 \
  --queue-depth 128 \
  --overflow-policy reject

CLI flags override YAML, which overrides Python defaults. Set max_concurrent_runs_per_scope via YAML or the Python surface.


Common Patterns

Production multi-tenant bot β€” explicit busy ack under load; no OOM risk; no provider 429 storm:

python
bot = BotOS(
    agent=agent,
    platforms=["telegram", "discord", "slack"],
    max_concurrent_runs=32,
    queue_depth=128,
    overflow_policy="reject",
)

Burst-tolerant single-channel β€” lower aggregate concurrency, deeper queue, no rejections under modest bursts:

python
bot = BotOS(
    agent=agent,
    platforms=["telegram"],
    max_concurrent_runs=8,
    queue_depth=64,
    overflow_policy="queue",
)

Newest-message-wins β€” useful when conversation freshness matters more than fairness. When shed_oldest can’t evict a live waiter, the newcomer is rejected rather than overfilling the queue:

python
bot = BotOS(
    agent=agent,
    platforms=["telegram"],
    max_concurrent_runs=16,
    queue_depth=32,
    overflow_policy="shed_oldest",
)

Multi-tenant fair-share β€” a global ceiling of 32 with a per-tenant cap of 4, so no single tenant monopolises the gateway even under a burst:

python
bot = BotOS(
    agent=agent,
    platforms=["telegram", "discord", "slack"],
    max_concurrent_runs=32,
    queue_depth=128,
    overflow_policy="reject",
    max_concurrent_runs_per_scope=4,
)

Observability

BotOS.admission_stats exposes live counters without any extra setup:

python
print(bot.admission_stats)
# {
#   "max_concurrent_runs": 32, "queue_depth": 128,
#   "max_concurrent_runs_per_scope": 4,   # ← per-tenant sub-limit (0 when disabled)
#   "in_flight": 12, "queued": 3,
#   "awaiting_human": 2,   # ← admitted turns parked on a human approval, not holding a slot
#   "admitted": 1247, "rejected": 0, "shed": 0,
#   "max_rss_mb": 0.0,   # ← memory-aware ceiling (0.0 when disabled)
#   "per_user_per_minute": 20,   # ← configured budget (0 when disabled)
#   "rate_limited": 0,           # ← refusals due to the per-user budget
# }

Watch rejected alongside your LLM provider’s 429 rate. When both rise together, raise max_concurrent_runs. When only rejected rises, deepen queue_depth or switch to overflow_policy="queue".

  • Watch rate_limited to spot a chatty client; a steady rise with rejected flat means raising max_concurrent_runs won’t help that user β€” raise per_user_per_minute instead.

Memory-aware admission

Set one number β€” max_rss_mb β€” and the gateway queues turns under soft RSS pressure and sheds them under hard pressure, before the OOM killer fires. Zero deps, no new subsystem β€” the same admission gate that enforces the concurrency ceiling folds a memory decision into every admit().

Diagram

Memory-aware admission

rss below soft

soft ≀ rss below hard

rss β‰₯ hard

πŸ“Š RSS sample

🚦 Pressure?

βœ… ADMIT

⏳ QUEUE

β›” REJECT
busy ack

Quick start

Python (single knob)

python
from praisonaiagents import Agent
from praisonai.bots import BotOS

agent = Agent(name="Support", instructions="Help users")

# 1 GiB hard ceiling; soft (queue) threshold auto-derived at 90% (~921 MiB).
bot = BotOS(agent=agent, platforms=["telegram"], max_rss_mb=1024)

print(bot.admission_stats["max_rss_mb"])
# 1024.0

YAML

yaml
# Top-level (canonical praisonai.yaml or gateway.yaml)
max_rss_mb: 1024

# Or nested under gateway:
gateway:
  max_rss_mb: 1024

Explicit ladder

python
from praisonaiagents.gateway import MemoryPressurePolicy
from praisonai_bot.bots._admission import build_admission_gate

# Explicit two-threshold ladder instead of the single-knob 90% default.
policy = MemoryPressurePolicy(soft_rss_mb=800, hard_rss_mb=1024)
gate = build_admission_gate(
    max_concurrent_runs=32,   # concurrency ceiling (optional)
    resource_policy=policy,   # memory-aware layer
)

The pressure ladder

Given a sample’s resident set size (rss_mb), the policy decides:

RSSDecisionWhat the gateway does
rss < soft_rss_mbADMITRun the turn immediately.
soft_rss_mb ≀ rss < hard_rss_mbQUEUEApply backpressure via the existing bounded wait queue.<sup>*</sup>
rss β‰₯ hard_rss_mbREJECTShed with a polite busy ack (honours the existing reject / shed_oldest overflow policy).
rss is None (platform can’t report)ADMITNever block on a missing signal β€” sampler self-disables with a single warning.
* **Memory-only mode caveat.** With no concurrency ceiling (`max_concurrent_runs=0`), there is no slot to wait on, so a soft-pressure QUEUE degrades to ADMIT β€” real wait-queue backpressure needs a concurrency ceiling *alongside* `max_rss_mb`. **The hard threshold always REJECTs regardless**, which is what prevents the OOM kill.

Memory-aware configuration

OptionTypeDefaultDescription
max_rss_mbfloat0.0Hard RSS ceiling in MiB. 0 disables memory-aware admission (concurrency-only, byte-for-byte legacy behaviour).
MemoryPressurePolicy.soft_rss_mbfloat0.0Soft (QUEUE) threshold in MiB. When you set only max_rss_mb, this is auto-derived as 0.9 Γ— max_rss_mb.
MemoryPressurePolicy.hard_rss_mbfloat0.0Hard (REJECT) threshold in MiB.
soft_ratio (in build_memory_pressure_policy)float0.9Fraction of the hard ceiling that becomes the soft threshold when a single-knob max_rss_mb is used. Clamped to [0.0, 1.0].

Choosing values

Small always-on host (a $5 VPS)

Set max_rss_mb at ~70–80% of the box’s RAM ceiling minus other resident daemons. Example: on a 1 GiB VPS running only the gateway, max_rss_mb: 700 gives a ~630 MiB soft threshold β€” bursts queue, sustained pressure sheds, and the OOM killer stays quiet.

Containerised deployment with a memory limit

Set max_rss_mb to ~80% of the container’s memory limit. The pressure ladder then queues before the kernel starts reclaiming pages aggressively, preserving observability and giving the graceful-drain window a chance to complete in-flight turns.

Large host combining concurrency + memory

Combine max_concurrent_runs (concurrency ceiling) with max_rss_mb (memory ceiling). Concurrency handles CPU-scaled bursts; memory handles per-turn resident growth (large tool outputs, long transcripts). Either dimension can shed independently.

Development / test (off)

Default max_rss_mb=0 disables memory-aware admission entirely. admit() is bit-for-bit as before β€” the exact behaviour of every release prior to 2026-07-27.

Sampler behaviour

The wrapper samples process RSS on a lightweight cadence:

  • Preferred: psutil.Process().memory_info().rss β€” a live, monotonic reading. Enabled automatically if psutil is installed.
  • Fallback: stdlib resource.getrusage(RUSAGE_SELF).ru_maxrss β€” a peak (not live) reading, good enough to catch a climbing leak. Kilobytes on Linux, bytes on macOS/BSD; the sampler normalises to MiB.
  • Self-disable: if the platform can report neither (Windows without psutil, exotic runtimes), the sampler emits a single AdmissionGate: resource sampling unavailable on this platform; memory-pressure admission disabled. warning and thereafter returns a rss_mb=None sample. The policy admits on None β€” the monitor never crashes the gateway it protects.

Memory observability

admission_stats gains a max_rss_mb field so operators can confirm the ceiling is wired end-to-end:

python
bot.admission_stats
# {
#   "max_concurrent_runs": 32, "queue_depth": 128,
#   "in_flight": 4, "queued": 0,
#   "awaiting_human": 0,
#   "admitted": 1247, "rejected": 3, "shed": 0,
#   "max_rss_mb": 1024.0,     # ← the configured hard ceiling (0.0 when disabled)
# }

Per-tenant fairness

Cap concurrency per tenant so a single noisy tenant can’t monopolise every run slot.

Diagram

Per-tenant fairness

A at sub-limit

B under sub-limit

slot free

πŸ“₯ Tenant A
4 in-flight

🚦 Per-scope
decide

πŸ“₯ Tenant B
1 in-flight

⏳ Queue A

🚦 Global
decide

πŸ€– Run B

The per-scope gate runs first and is the tighter, per-tenant ceiling; the global ceiling still bounds aggregate concurrency second. A tenant already at max_concurrent_runs_per_scope is queued (or shed by the declared overflow_policy) against its own slice β€” even when the global ceiling has free slots β€” so its burst can never consume another tenant’s capacity.

Python

python
from praisonaiagents import Agent
from praisonai.bots import BotOS

agent = Agent(name="Support", instructions="Help users")

bot = BotOS(
    agent=agent,
    platforms=["telegram", "discord"],
    max_concurrent_runs=32,             # global ceiling
    max_concurrent_runs_per_scope=4,    # per-tenant cap within the ceiling
    queue_depth=128,
    overflow_policy="reject",
)
bot.start()

YAML

yaml
gateway:
  host: "127.0.0.1"
  port: 8765
  max_concurrent_runs: 32
  max_concurrent_runs_per_scope: 4
  queue_depth: 128
  overflow_policy: reject

CLI

bash
# Set the per-scope sub-limit via gateway.yaml, then start:
praisonai gateway start --config gateway.yaml

How per-scope admission decides

Diagram
AgentAdmissionGatewayTenant A userAgentAdmissionGatewayTenant A userTenant B’s slots areunaffected β€” fairness preservedalt[scope_in_flight >= max_concurrent_runs_per_scope][scope_in_flight < sub-limit and global slot free]Inbound turn (session=A)decide(scope=”A”, scope_in_flight=4, …)QUEUE (against tenant A’s slice)ADMITRun turnReply

With max_concurrent_runs=32 and max_concurrent_runs_per_scope=4:

  1. Quiet tenant B sends a turn β€” its scope has 0 in-flight, well under 4, and the global count is under 32, so the turn is admitted immediately.
  2. Noisy tenant A bursts 10 concurrent turns: the first 4 are admitted (they fill A’s per-scope slice); turns 5–10 are queued against A’s slice β€” even though the global ceiling still has 28 free slots.
  3. Tenant B sends a second turn while A’s burst is queued: it is admitted immediately, because B’s per-scope count is 1 and the global ceiling has room. No starvation.
  4. As A’s in-flight turns complete, its queued turns dispatch one at a time, keeping A at most 4 concurrent.

Admission during human approval

A turn parked on a human approval does not hold its slot β€” other turns can run while the approver decides.

Diagram

Admission during human approval

πŸ“₯ Turn admitted

πŸ€– Agent runs

πŸ”§ Tool needs approval

⏸️ Slot released
awaiting_human +1

πŸ™‹ Human decides

▢️ Resume
free slot or borrow

βœ… Turn finishes
debt repaid first

No configuration is needed. With max_concurrent_runs=1, a single pending approval no longer blocks every other turn:

Diagram
HumanAgentAdmissionGatewayUserHumanAgentAdmissionGatewayUserawaiting_human += 1slot releasedslot taken or borrowedawaiting_human -= 1Inbound turn (A)admit()ADMIT (slot held)Run turn Arequest_approvalInbound turn (B)admit()ADMIT (slot now free)Run turn BAllowReply AReply A
  • Every approval backend β€” the slot is released while ApprovalRegistry.approve_sync / approve_async waits on the backend, so presentation (gateway), Slack, Telegram, Discord, HTTP and webhook approvals all behave the same.
  • Sync and async approvals β€” async approvals flip the slot on the gate’s event loop directly; sync approvals running in a worker thread hop onto that loop without blocking it.
  • Non-blocking resume β€” when the human decides (or the approval times out), the turn takes a free slot if one exists; otherwise it borrows one that the next released slot repays. A sync approval parked in a thread-pool worker therefore cannot deadlock against a turn waiting for that worker.
  • Parallel approvals β€” every branch of a turn that requests several approvals at once (e.g. asyncio.gather) resumes already counted in in_flight.
  • Cancellation-safe β€” cancelling a parked turn (/stop, turn timeout) never double-releases or leaks a slot. See Approval Turn Liveness.

Reading the counters

PatternMeaning
awaiting_human high, in_flight lowMany turns are waiting on approvers β€” check approver responsiveness
in_flight > max_concurrent_runsBorrows outstanding after resume β€” normal, transient
in_flight + awaiting_human ≫ max_concurrent_runsExpected β€” parked turns have released their slots

Best Practices

Start with max_concurrent_runs β‰ˆ 2Γ— expected steady-state

Set max_concurrent_runs to roughly twice your expected concurrent-user baseline. Watch admission_stats.rejected β€” if rejections are non-zero under normal load, raise the ceiling.

Size max_concurrent_runs_per_scope for your tenant count

On a multi-tenant hosted gateway, set max_concurrent_runs_per_scope to roughly max_concurrent_runs / expected_active_tenants. Rate-limit and spend-budget admission are already per-tenant; this closes the last global-only axis of the admission triad.

Default to overflow_policy='reject' for multi-user deployments

Silent unbounded queueing under load is harder to debug than an explicit busy ack. reject surfaces pressure immediately and lets users retry on their own schedule.

Pair with flow control for full gateway protection

Admission control bounds inbound concurrent runs; flow control bounds outbound send throughput and per-session inbox depth. Production gateways usually want both.

Leave the gate off only for single-user or local dev

max_concurrent_runs=0 (the default) disables the gate entirely β€” every inbound turn runs immediately. Suitable for local development or single-operator deployments where there is no shared provider quota to protect.


Related