Gateway Hot-Reload
Edit gateway.yaml live — restart only what changed
from praisonaiagents import Agent
agent = Agent(name="reload-agent", instructions="Hot-reload gateway configuration without downtime.")
agent.start("Reload the gateway configuration without restarting the service.")The gateway diffs gateway.yaml against the running config and restarts only affected agents or channels. The WebSocket server keeps running.
# gateway.yaml
agents:
assistant:
instructions: "You are a helpful assistant."praisonai gateway run gateway.yaml
# Edit agents.assistant.instructions and save — only agents reload (~5s)The user edits gateway.yaml on disk; the watcher diffs changes and reloads only affected agents or channels while the WebSocket server stays up.
How It Works
Quick Start
- Run the gateway
bash praisonai gateway run gateway.yaml - Edit live
Change agent instructions or a single channel token in
gateway.yamland save. The watcher applies a selective reload within ~5 seconds (1s debounce). Credential fields can also be secret references ({source, id}) instead of plaintext. - Trigger reload manually with SIGHUP
Send
SIGHUPto reload without editing a file — useful for orchestration scripts and systemd:bash # Find the gateway pid, then: kill -HUP $(pgrep -f "praisonai gateway") # Or under systemd: systemctl reload praisonai-gateway
Event-driven vs Polling
The watcher prefers event-driven file notifications via the optional watchdog package and falls back gracefully to mtime polling when watchdog is unavailable or an observer cannot start.
| Mode | How it works | When used |
|---|---|---|
| Event-driven | OS file-system events via watchdog | watchdog>=3.0.0 installed |
| mtime polling | Periodic stat check every 5s | watchdog not installed or observer fails |
Both modes apply the same 1s debounce to coalesce rapid saves.
Install watchdog for faster reload detection:
pip install "praisonai[gateway]"
# or
pip install "praisonai[all]"Operator-triggered Reload via SIGHUP
start_with_config installs a SIGHUP handler that runs the same reload_config path as a file-change reload — no shutdown, no dropped connections.
Reload via SIGHUP is best-effort — it is silently skipped on platforms without SIGHUP support (e.g. Windows).
Drain-coordinated Channel Restart
When a reload triggers a channel restart, the gateway drains in-flight turns before bouncing the channel — no mid-conversation cuts.
The drain window for reload-triggered restarts is controlled by a new YAML key:
gateway:
reload_drain_timeout: 10 # seconds; falls back to drain_timeout if unsetHot-Apply (no restart)
Some config paths are safe to apply in place — the gateway mutates the live subsystem without restarting agents or channels. When the diff only touches these paths, no drain runs, no client sees a blip.
An operator raising gateway.reload_drain_timeout at 3am does not disconnect a live conversation:
from praisonaiagents import Agent
# The agent whose transport is served by the gateway
agent = Agent(
name="ops-bot",
instructions="Answer ops questions. Stay connected — I'll be tuning drain timings live.",
)
agent.start("Under the hood: operators can now raise gateway.reload_drain_timeout "
"from 10s to 30s in gateway.yaml without dropping this connection.")Editing the key in gateway.yaml triggers the hot-apply on the next SIGHUP / file save — no restart, no client disconnect, no drain:
# gateway.yaml — before
gateway:
reload_drain_timeout: 10
# gateway.yaml — after (hot-applied on the next SIGHUP / file save)
gateway:
reload_drain_timeout: 30The health check confirms the change landed without a restart — the only changed path is hot-applied:
payload = gateway.health()
print(payload["reload"]["changed_paths"])
# ('gateway.reload_drain_timeout',) ← the only changed path, hot-appliedThe full registry of hot-appliable paths:
| Path | Live effect | What restarts |
|---|---|---|
gateway.logging.level | Root/gateway logger level applied immediately | Nothing |
gateway.drain_timeout | New drain window used on the next graceful drain | Nothing |
gateway.reload_drain_timeout | New reload-drain window used on the next reload-triggered channel restart | Nothing |
Anything not in this registry falls through to the existing restart-based plans — restart is always the safe default for unknown or structural changes. Invalid values (e.g. a non-numeric timeout) are ignored so one bad key never aborts an otherwise-good reload; the applied paths appear in the reload summary line as hot[...] and in health().reload.changed_paths.
Restart Scope
Each Effect is prefixed with the canonical ReloadScope value that classify_reload(path) returns for that section.
| Changed section | Effect |
|---|---|
gateway.logging.level, gateway.drain_timeout, gateway.reload_drain_timeout | HOT — hot-apply in place, no channel restart, no agent recreate (see Hot-Apply) |
agents.* | AGENTS — recreate agents only; channels keep running |
provider., guardrails. | AGENTS — recreate agents |
channels.<name>.* | CHANNEL — restart only that channel |
channels.<name>.unknown_user_policy | CHANNEL — re-read policy and rebuild BotConfig — re-fires the startup warning |
channels.<name>.owner_user_id | CHANNEL — re-read owner and rebuild BotConfig |
scheduler., routes., routing.* | FULL — full channel restart; the candidate is validated first and the previous config is restored if channels fail to start |
lifecycle.* | Rebuild scale-to-zero / drain-marker policies; cancel or relaunch only the loops whose enablement changed (_reconcile_lifecycle) — no channel or process restart |
Entire channels section (bare) | FULL — full channel restart; the candidate is validated first and the previous config is restored if channels fail to start |
| Invalid YAML on save | Keep last-known-good config |
Full restart stops and starts all channels but does not restart the WebSocket server — connected clients stay connected.
Full-restart safety: validated cutover & rollback
A structural edit to gateway.yaml is now an atomic, validated cutover — a schema-valid but runtime-invalid edit is a rejected reload with the old gateway still up, not an outage.
from praisonaiagents import Agent
agent = Agent(
name="ops-bot",
instructions=(
"Answer ops questions about safe gateway edits. Stay connected — "
"even a bad structural edit to gateway.yaml no longer takes me down."
),
)
agent.start(
"Under the hood: on a full-restart edit (e.g. gateway.port or channels "
"added/removed), the gateway validates the new config first. If it fails, "
"the previous config keeps serving. If channels fail to start after the "
"cutover, the previous config is automatically restored."
)The candidate config is built and proven before any live channel is drained; only a passing candidate is swapped in.
How it works
The candidate agents stay off the live registry until after the drain, so in-flight turns keep resolving the previous agents right up to the cutover.
Failure modes and outcomes
Every outcome below is visible through the existing health().reload payload — grep for your scenario.
| Scenario | Live channels drained? | Live agents mutated? | Final state | health().reload.last_result | health().reload.error |
|---|---|---|---|---|---|
| Agent build fails (unreachable model, adapter throws on load, bad wiring) | ❌ No | ❌ No | Previous config still serving | "failed" | agent build failed: <exception> |
| Agent build succeeds, all channels start OK | ✅ Yes (proven candidate first) | ✅ Yes (atomically, after drain) | New config live | "ok" | None |
Agent build succeeds, start_channels raises | ✅ Yes | Restored to previous | Previous config brought back up | "failed" | <start_channels exception> |
| Agent build succeeds, every new channel is degraded (no live bot) | ✅ Yes | Restored to previous | Previous config brought back up | "failed" | all channels failed to start (<name>: <reason>; …) |
Selective (non-FULL) reload | Unchanged | Unchanged | Unchanged | Unchanged | Unchanged |
The selective-reload paths (AGENTS, CHANNEL, HOT) are untouched — the safety guarantees add to FULL only.
Observability of a rejected or rolled-back reload
A rejected candidate and a rolled-back cutover both surface as last_result="failed" with a specific error.
# After a rejected candidate:
print(gateway.health()["reload"])
# {'watcher': 'active', 'last_result': 'failed', 'last_at': 1731801234.5,
# 'changed_paths': ['gateway.port', 'channels.discord.token'],
# 'error': 'agent build failed: unreachable model'}
# After a rolled-back cutover:
print(gateway.health()["reload"])
# {'watcher': 'active', 'last_result': 'failed', 'last_at': 1731801240.1,
# 'changed_paths': ['gateway.port', 'channels.discord.token'],
# 'error': 'all channels failed to start (discord: credential unavailable)'}See ReloadStatus fields for the full schema.
Programmatic pre-flight
Any orchestrator can await validation before writing a file — validate_candidate does not touch the live runtime, so it is safe to call from a canary or deploy script before saving gateway.yaml.
from praisonaiagents.gateway import ReloadValidationProtocol, CandidateReport
assert isinstance(gateway, ReloadValidationProtocol)
report: CandidateReport = await gateway.validate_candidate(candidate_cfg)
if not report.ok:
raise RuntimeError(f"Candidate rejected: {'; '.join(report.failures)}")
# Only reached when the candidate is safe to apply.CandidateReport carries the three fields the wrapper returns:
| Field | Type | Default | Meaning |
|---|---|---|---|
ok | bool | (required) | Whether the candidate built and passed pre-flight — only then may the live runtime be drained and the candidate swapped in. |
failures | list[str] | [] | Human-readable reasons the candidate was rejected (empty when ok); joined into health().reload.error. |
candidate | Optional[Any] | None | Opaque handle to the already-built candidate runtime activated on success; None when the runtime validates in place. |
ReloadValidationProtocol is runtime_checkable, so isinstance(gateway, ReloadValidationProtocol) holds for every gateway build — the “no cutover to an unvalidated candidate” guarantee is canonical, not per-entry-point.
Observability
The gateway records every reload outcome so operators can confirm the last edit took effect without scraping logs.
Reloads still log a concise summary line on completion:
reload applied: agents; restart[telegram]The format is reload applied: <changed-sections>; restart[<channels>]. Grep for reload applied to trace all reloads in your log stream.
Reload status in health()
health() surfaces the reload outcome and config revisions when the gateway runs from a config file:
from praisonaiagents import Agent
# Operator polls health from a running gateway
payload = gateway.health()
print(payload["reload"])
# {'watcher': 'active', 'last_result': 'ok', 'last_at': 1731801234.5,
# 'changed_paths': ['agents.assistant.instructions'], 'error': None}
print(payload["applied_config_revision"], payload["on_disk_config_revision"])
print(payload["config_drift"]) # True if a restart is still owedThese four keys are additive — they only appear when the gateway runs from a config file, so existing health() consumers see no change:
| Key | Type | Description |
|---|---|---|
reload | dict | Full reload status from ReloadStatus.to_dict() — outcome + watcher liveness. |
applied_config_revision | str (12-char) | Revision id of the config the gateway is actually running. |
on_disk_config_revision | str (12-char) | Revision id of the current gateway.yaml on disk. |
config_drift | bool | True when applied_config_revision != on_disk_config_revision. |
The pressure block is a sibling additive health() key that follows this exact to_dict() pattern — present only when the back-pressure machinery is wired.
ReloadStatus fields
ReloadStatus is a frozen dataclass describing the most recent reload attempt:
| Field | Type | Default | Meaning | |||
|---|---|---|---|---|---|---|
watcher | "active" \ | "disabled" | "disabled" | "active" while the watcher runs; "disabled" once it has genuinely given up. Detects silent degradation. | ||
last_result | "ok" \ | "failed" \ | "no_changes" \ | "never" | "never" | Outcome of the most recent reload attempt. |
last_at | float \ | None | None | Unix timestamp of the last reload attempt. | ||
changed_paths | tuple[str, ...] | () | Config paths that changed on the last successful reload. | |||
error | str \ | None | None | Human-readable reason on "failed"; None otherwise. |
Compare configs offline
compute_config_revision returns the same 12-character revision id used by health(), so you can check an on-disk config before deploying it:
from praisonaiagents.gateway import compute_config_revision
import yaml
with open("gateway.yaml") as f:
revision = compute_config_revision(yaml.safe_load(f))
print(revision) # e.g. 'a1b2c3d4e5f6'Identical logical configs produce identical revisions regardless of key order or whitespace; an empty or None config returns the sentinel "000000000000".
Inspect the hot-apply registry
HOT_APPLIABLE_KEYS and is_hot_appliable are public exports from praisonaiagents.gateway, so you can query which paths are zero-restart before an edit:
from praisonaiagents.gateway import HOT_APPLIABLE_KEYS, is_hot_appliable
# The whole registry (frozenset[str], safe to iterate)
print(sorted(HOT_APPLIABLE_KEYS))
# ['gateway.drain_timeout', 'gateway.logging.level', 'gateway.reload_drain_timeout']
# Ask about a specific path — matches exact paths AND leaves under a registered key
is_hot_appliable("gateway.logging.level") # True
is_hot_appliable("gateway.logging.level.extra") # True (leaf under a registered key)
is_hot_appliable("agents.assistant.instructions") # False → falls through to restart planThe SupportsHotReload protocol is runtime_checkable — a gateway implementation opts in by defining an apply_hot_reload(paths, new_config) -> None method. Third-party gateway subclasses that want the wrapper’s hot-apply behaviour implement this method; core owns the classification so every runtime reloads identically.
Programmatic reload-scope classification
ReloadScope and classify_reload are public exports from praisonaiagents.gateway that tell you exactly what an edit to gateway.yaml will do — before you save it.
from praisonaiagents import Agent
from praisonaiagents.gateway import classify_reload, ReloadScope
agent = Agent(
name="ops-bot",
instructions="Answer ops questions about safe gateway reloads.",
)
# Before saving gateway.yaml, ask what the edit will actually do:
scope = classify_reload("gateway.logging.level")
if scope == ReloadScope.HOT:
agent.start("The change is hot-apply — no channel restart, no client disconnect.")
elif scope == ReloadScope.CHANNEL:
agent.start("Only the affected channel will restart. Other channels stay connected.")
elif scope == ReloadScope.AGENTS:
agent.start("Agents recreate in place. Channels keep running.")
else: # ReloadScope.FULL
agent.start("Full channel restart — expect a brief drain across all channels.")The four canonical scopes are plain strings, so wrapper and runtime code can compare against them without importing the class:
from praisonaiagents.gateway import ReloadScope, classify_reload
ReloadScope.HOT # "hot" — apply in place, no restart
ReloadScope.CHANNEL # "channel" — restart only channels.<name>
ReloadScope.AGENTS # "agents" — recreate agents only, channels keep running
ReloadScope.FULL # "full" — full restart (fail-safe default)
# Ask what a specific path would do before saving:
classify_reload("gateway.logging.level") # 'hot'
classify_reload("channels.telegram.token") # 'channel'
classify_reload("agents.assistant.instructions") # 'agents'
classify_reload("guardrails.jailbreak.enabled") # 'agents'
classify_reload("routes.discord") # 'full' — structural fallback
classify_reload("channels") # 'full' — bare section, fail-safeclassify_reload is canonical and side-effect-free — every runtime (core, wrapper, third-party gateway subclasses) uses the same classifier, so a pre-flight check reflects exactly what happens at reload time. Anything not explicitly recognised falls through to ReloadScope.FULL, keeping full restart the fail-safe default.
Reload observability flow
Inspect reload from the CLI
praisonai gateway status prints the reload result, watcher state, and config drift alongside the existing status:
praisonai gateway statusReload: OK 2m ago changed=agents.assistant.instructions
Watcher: active
Config: a1b2c3d4e5f6 (up to date)When the running config no longer matches disk, the drift is shown with both revisions:
Reload: FAILED 30s ago invalid YAML at agents.assistant
Watcher: active
Config: on-disk 9f8e7d6c5b4a != applied a1b2c3d4e5f6 (change not in effect)When config_drift is true
A drift means the config on disk has not taken effect — walk this path to recover:
Tuning
| Setting | Default | Description |
|---|---|---|
| Poll interval | 5.0s | How often the mtime watcher checks the file |
| Debounce | 1.0s | Wait after last write before applying |
gateway.reload_drain_timeout | falls back to gateway.drain_timeout | Bounded drain window before a reload-triggered channel restart. Hot-appliable — edits take effect on the next reload-triggered drain without restarting anything (see Hot-Apply). |
Best Practices
Prefer agent-only edits for prompt tweaks
Changing agents.* avoids dropping live Telegram/Discord sessions.
Scope channel edits narrowly
Edit one channel block to restart only that platform.
Validate YAML before saving
Invalid saves are ignored — the previous config keeps running.
Use SIGHUP in systemd for zero-downtime config pushes
Add ExecReload=kill -HUP $MAINPID to your systemd unit so systemctl reload triggers a drain-coordinated reload without stopping the process.