Scheduled Run Policy
Scope tools, scan prompts, and audit output for unattended scheduled agent runs
RunPolicy is a run-scoped guardrail that limits what an unattended scheduled agent run is allowed to do — before the agent is handed the toolset or the prompt.
from praisonaiagents import Agent
from praisonai_bot.scheduler import ScheduledAgentExecutor # Tier 2b — bot package
from praisonai.scheduler import RunPolicy # Tier 3 — wrapper (safety gate stays here)
agent = Agent(
name="NewsBot",
instructions="Summarise today's AI news in 3 bullets.",
)
# executor = ScheduledAgentExecutor(runner=runner, agent_resolver=lambda _: agent, run_policy=RunPolicy())The user schedules an unattended run; RunPolicy scopes tools, scans the prompt, and audits output before delivery.
Defaults (on by default)
The gateway constructs a default RunPolicy at boot with all protections on — you no longer build one by hand to get the safety net.
| Protection | Default | Effect |
|---|---|---|
deliver_on_failure | True | Blocked or failed runs send a failure summary to the delivery target. |
scan_assembled_prompt | True | The assembled prompt is scanned for injection before the model turn. |
denied_toolsets | DEFAULT_DENIED_TOOLSETS | cronjob and messaging-interactive are removed from unattended runs. |
audit_dir | None | No durable audit unless you set a directory. |
The gateway rebuilds the policy from gateway.yaml on every tick, so a live edit plus hot-reload applies on the next tick — no process restart.
Configuring via gateway.yaml
Override the defaults with an optional scheduler: block. Every field is optional; omitted fields keep the fail-closed defaults.
scheduler:
deliver_on_failure: true # send a failure summary to the job's delivery target
scan_prompt: true # scan the assembled prompt before the turn
denied_toolsets: # tool categories removed from unattended runs
- cronjob
- shell
audit_dir: /var/log/praisonai/scheduler # durable full-output audit trail| Option | Type | Default | Description |
|---|---|---|---|
deliver_on_failure | bool | true | Send a failure summary to the job’s delivery target when the run is blocked or the agent raises. |
scan_prompt | bool | true | Run the injection scan over the assembled prompt (context + user message) before the turn. |
denied_toolsets | list[str] | DEFAULT_DENIED_TOOLSETS | Tool categories removed from the tool list for the unattended turn. Provide a list to override; omit to keep defaults. |
audit_dir | str | null | Directory for the durable full-output audit trail. When set, every scheduled run writes an audit record. |
gateway.yaml or Python RunPolicy?
Gateway deployments tune the policy in gateway.yaml; embedders that run their own executor construct RunPolicy in Python.
Gateway user — tune in gateway.yaml
Add a scheduler: block, hot-reload, and the next tick uses the new policy. A failing job now delivers a summary to its target:
scheduler:
deliver_on_failure: true
denied_toolsets:
- cronjob
- shell# hot-reload the running gateway, then trigger a due job once
kill -HUP "$(pgrep -f 'praisonai gateway')"
praisonai schedule tickA blocked or failing run lands as a failure summary on the job’s delivery target — no silent drop.
Embedder — construct RunPolicy in Python
Import RunPolicy and hand it to your own executor. This is the right path outside the gateway:
from praisonaiagents import Agent
from praisonaiagents.scheduler import ScheduleRunner, FileScheduleStore
from praisonai_bot.scheduler import ScheduledAgentExecutor
from praisonai.scheduler.run_policy import RunPolicy, DEFAULT_DENIED_TOOLSETS
agent = Agent(name="NewsBot", instructions="Summarise today's AI news in 3 bullets.")
runner = ScheduleRunner(FileScheduleStore())
executor = ScheduledAgentExecutor(
runner=runner,
agent_resolver=lambda _id: agent,
run_policy=RunPolicy(denied_toolsets=DEFAULT_DENIED_TOOLSETS | {"shell"}),
)Host-app embedding
praisonai.integration.bridges.schedules_runner also builds its executor with a default RunPolicy, so due jobs in an embedded host app are guarded the same way as the gateway.
Start and stop
Call ensure_schedule_runner() once at startup and stop_schedule_runner() on shutdown.
from fastapi import FastAPI
from praisonai.integration.bridges.schedules_runner import (
ensure_schedule_runner,
stop_schedule_runner,
)
app = FastAPI()
app.add_event_handler("startup", ensure_schedule_runner)
app.add_event_handler("shutdown", stop_schedule_runner)| Function | Behaviour |
|---|---|
ensure_schedule_runner() | Thread-safe and idempotent — concurrent callers never start a second ScheduleLoop. |
stop_schedule_runner(timeout=5.0) | Stops the poll loop (waiting up to timeout seconds). Idempotent; a later ensure_schedule_runner() starts a fresh loop. |
Diagnostics
ensure_schedule_runner() reports startup outcomes at three log levels, so operators see when scheduled jobs will not fire.
| Condition | Log level | What it means | What to do |
|---|---|---|---|
praisonaiagents.scheduler not installed | DEBUG | Scheduler is optional — nothing to run | No action needed |
Executor could not be built (_executor is None) | WARNING | Jobs stay due; nothing fires until the host restarts with the executor available | Install / fix the executor dependency, then restart |
| Store construction, thread, or executor init failure | EXCEPTION | Real error with traceback; _runner_started stays False | Read the traceback and fix the root cause |
On a real failure _runner_started remains False, so a subsequent ensure_schedule_runner() call retries automatically.
Enable INFO-level logging to surface the WARNING / EXCEPTION lines:
import logging
logging.getLogger("praisonai.integration.bridges.schedules_runner").setLevel(logging.INFO)
logging.basicConfig(level=logging.INFO)Quick Start
- Defaults (safe for unattended runs)
python from praisonaiagents import Agent from praisonaiagents.scheduler import ScheduleRunner, FileScheduleStore from praisonai_bot.scheduler import ScheduledAgentExecutor from praisonai.scheduler import RunPolicy agent = Agent( name="NewsBot", instructions="Summarise today's AI news in 3 bullets.", ) store = FileScheduleStore() runner = ScheduleRunner(store) executor = ScheduledAgentExecutor( runner=runner, agent_resolver=lambda _id: agent, run_policy=RunPolicy(), )RunPolicy()with no arguments deniescronjobandmessaging-interactivetools and scans every prompt automatically. - With audit and fail-closed delivery
python from praisonaiagents import Agent from praisonaiagents.scheduler import ScheduleRunner, FileScheduleStore from praisonai_bot.scheduler import ScheduledAgentExecutor from praisonai.scheduler import RunPolicy agent = Agent( name="ReportBot", instructions="Generate a daily summary report.", ) store = FileScheduleStore() runner = ScheduleRunner(store) executor = ScheduledAgentExecutor( runner=runner, agent_resolver=lambda _id: agent, run_policy=RunPolicy( audit_dir="/var/log/praisonai/runs", deliver_on_failure=True, ), )Every run writes full output to
/var/log/praisonai/runs. On failure, a compact summary is sent to the delivery target. - Strict allow-list with custom scanner
python from praisonaiagents import Agent from praisonaiagents.scheduler import ScheduleRunner, FileScheduleStore from praisonai_bot.scheduler import ScheduledAgentExecutor from praisonai.scheduler import RunPolicy def my_scanner(prompt): return "secret" not in prompt.lower() agent = Agent( name="SecureBot", instructions="Process financial data safely.", ) store = FileScheduleStore() runner = ScheduleRunner(store) executor = ScheduledAgentExecutor( runner=runner, agent_resolver=lambda _id: agent, run_policy=RunPolicy( allowed_toolsets={"search", "summarise"}, denied_toolsets=set(), scanner=my_scanner, ), )Only
searchandsummarisetools are available. The custom scanner replaces the built-in heuristic.
How It Works
| Step | What happens |
|---|---|
filter_tools | Removes denied/disallowed tools from the agent before the run starts |
scan_prompt | Checks the assembled prompt for injection patterns |
chat | Agent runs only if the scan passes |
persist | Full output written to audit_dir regardless of delivery outcome |
deliver | Result sent to delivery target; failure summary sent if deliver_on_failure=True |
restore | Agent tools restored to original state after the run |
What Gets Scanned vs What Doesn’t
| Source | Scanned | Reason |
|---|---|---|
| User message | ✅ Yes | Untrusted external input |
loaded_skills, skills, recipes | ✅ Yes | Runtime-loaded, potentially external |
system_prompt, instructions, backstory | ❌ No | Trusted developer-authored config |
Choosing Tool-Scope Mode
| Mode | When to use | Example |
|---|---|---|
| Default | Most cases — blocks self-scheduling and interactive tools | RunPolicy() |
| Deny-list | Block specific risky tools while keeping everything else | RunPolicy(denied_toolsets={"shell", "filesystem"}) |
| Allow-list | Sensitive deployments — only curated tools permitted | RunPolicy(allowed_toolsets={"search", "summarise"}) |
Failure Alerting — Once Per Incident
Failure summaries are de-duplicated per incident by default: a broken job alerts once, stays silent on identical repeats, and sends a single recovery note on the next success.
Tolerate a single flaky tick — alert only when the same failure repeats twice.
from praisonaiagents import Agent
from praisonai.scheduler import RunPolicy
agent = Agent(
name="nightly-brief",
instructions="Summarise overnight repo activity.",
)
# Tolerate a single flaky tick — alert only when the same failure repeats twice.
# The wrapper folds each failure through the built-in IncidentTracker for you.
policy = RunPolicy(alert_after_failures=2)
agent.start("Every day at 02:00, brief last-night activity to slack:#eng.")| Run outcome | Prior state | What operators see |
|---|---|---|
| Failure, first with this signature | no open incident | ⚠️ Scheduled job '<name>' failed: <error> (one alert) |
| Failure, same signature repeats | already alerted | Silent — no re-alert |
| Failure, different signature | already alerted on signature A | Fresh ⚠️ alert for signature B |
Failure, below alert_after_failures threshold | still counting | Silent — counter advances |
| Success after an alerted incident | incident alerted | ✅ Scheduled job '<name>' recovered. (one note) |
| Success with no open incident | clean | Silent |
Failure with deliver_on_failure=False | any | Silent (no-op) |
| Failure with no state store attached | any | Falls back to per-tick delivery (prior behaviour preserved) |
Configuration Options
RunPolicy
| Option | Type | Default | Description |
|---|---|---|---|
allowed_toolsets | Optional[Set[str]] | None | If set, only tools in this set are kept. None means allow all except denied_toolsets. |
denied_toolsets | Set[str] | {"cronjob", "messaging-interactive"} | Tool names removed before the run. Defaults block self-scheduling and interactive tools. |
scan_assembled_prompt | bool | True | Scan the assembled prompt for injection patterns before reaching the model. |
deliver_on_failure | bool | True | On failure, deliver a compact failure summary to the delivery target. |
audit_dir | Optional[str] | None | Directory for durable output audit. None disables the audit. |
scanner | Optional[Callable] | None | Custom scanner callable. Overrides built-in heuristic. May return PromptScanResult or bool. |
alert_after_failures | int | 1 | When deliver_on_failure is on, alert only after this many consecutive failures of the same error signature. Latches failure summaries through the core IncidentTracker so a broken job alerts once per distinct error and sends a single ✅ recovered. note on the next success. Values below 1 are treated as 1. Falls back to per-tick delivery when no state store is available. |
hold_on_rate_limit | bool | True | When a run fails with a provider rate-limit / quota signal, park the job past the provider’s own reset window (its Retry-After / usage-reset plus hold_slack_seconds) instead of re-firing every tick against a benched provider. Intervening fires coalesce; the executor emits one “held until N” notice via the incident path and clears the hold on the first run that reaches the model. Set False to keep the prior fire-every-tick behaviour. See Provider-Quota Hold. |
hold_slack_seconds | float | 60.0 | Extra seconds added past the provider’s reset window when parking, so the job does not re-fire the instant the window reopens (clock skew / provider rounding). |
PromptScanResult
| Field | Type | Default | Description |
|---|---|---|---|
ok | bool | True | True if the prompt passed the scan. |
reason | Optional[str] | None | Human-readable reason when ok is False. |
JobResult Reference
from praisonai_bot.scheduler import JobResult| Field | Type | Default | Description |
|---|---|---|---|
job | ScheduleJob | required | The ScheduleJob that was executed |
result | Optional[str] | None | Agent response, or None on failure |
status | str | "succeeded" | One of "succeeded", "failed", "skipped", "no_change" |
error | Optional[str] | None | Error message when status == "failed" or skip reason when status == "skipped" |
duration | float | 0.0 | Wall-clock seconds for agent execution |
delivered | bool | False | Whether the payload actually reached the target channel. As of PraisonAI #4198 this is the real delivery signal — a gateway routing failure, a missing channel bot, or a delivery_handler that returns False records delivered=False and populates delivery_error, even when the send did not raise. On a deliver_on_failure=True run whose failure summary reached the channel, delivered is True — the summary is the delivered payload. |
delivery_error | Optional[str] | None | Delivery failure message, separate from execution error. For a non-raising failure (gateway routing could not resolve, live handler returned False, missing target metadata) this records a synthetic "delivery to <channel>:<id> did not complete" string. For a raised exception, records str(exception). |
audit_path | Optional[str] | None | Local path where full output was persisted (if any) |
Built-in Injection Heuristics
The built-in scanner flags these patterns (case-insensitive):
| Pattern matched | ||
|---|---|---|
| `ignore (all )?(previous\ | prior\ | above) instructions` |
| `disregard (all )?(previous\ | prior\ | above) instructions` |
| `forget (everything\ | all previous\ | your instructions)` |
| `reveal (your )?(system prompt\ | instructions)` | |
| `you are now (a )?(different\ | new)\b` | |
<\s/?\ssystem\s*> |
Common Patterns
Deny-list pattern (default behaviour)
from praisonai.scheduler import RunPolicy
policy = RunPolicy()Blocks cronjob (prevents self-scheduling) and messaging-interactive (no human is present to respond).
Allow-list for sensitive deployments
from praisonai.scheduler import RunPolicy
policy = RunPolicy(
allowed_toolsets={"search", "summarise"},
)Only search and summarise are available — everything else is silently removed before the run.
Custom scanner plugin
from praisonai.scheduler import RunPolicy, PromptScanResult
def presidio_scanner(prompt: str) -> PromptScanResult:
# Delegate to Microsoft Presidio or any guardrail library
result = presidio_analyzer.analyze(text=prompt, language="en")
if result:
return PromptScanResult(ok=False, reason=f"PII detected: {result[0].entity_type}")
return PromptScanResult(ok=True)
policy = RunPolicy(scanner=presidio_scanner)Extending the default deny-list
from praisonai.scheduler.run_policy import DEFAULT_DENIED_TOOLSETS, RunPolicy
policy = RunPolicy(
denied_toolsets=DEFAULT_DENIED_TOOLSETS | {"shell", "filesystem"}
)Best Practices
Keep audit_dir on persistent storage
audit_dir is the only record of a run when delivery fails. Use a path on durable storage (mounted volume, S3-backed FUSE, etc.) — not a temp directory.
RunPolicy(audit_dir="/mnt/persistent/praisonai/runs")Override denied_toolsets explicitly when extending
Adding tools to the deny-list requires merging with DEFAULT_DENIED_TOOLSETS. A fresh set assignment silently drops the built-in protections.
from praisonai.scheduler.run_policy import DEFAULT_DENIED_TOOLSETS
RunPolicy(denied_toolsets=DEFAULT_DENIED_TOOLSETS | {"shell"})Set a bot token so failure summaries reach an operator
Failure delivery follows the same rules as success delivery: with a wired delivery_handler it goes through the live gateway; without one, it uses the per-platform standalone sender for Telegram / Slack / Discord and records delivery_error on the run if neither is available. Set the appropriate {PLATFORM}_BOT_TOKEN env var so failure summaries reach an operator even when the gateway is down. See Out-of-process delivery.
executor = ScheduledAgentExecutor(
runner=runner,
agent_resolver=lambda _id: agent,
delivery_handler=my_notify_function,
run_policy=RunPolicy(deliver_on_failure=True),
)Custom scanners must be fast and side-effect-free
The scanner runs on every scheduled job, synchronously. Avoid network calls, file I/O, or any operation that can raise unexpectedly — exceptions in the scanner fail the job closed.
Distinguish result.status from result.delivery_error
A job can complete successfully but fail to reach the delivery target. Check both fields:
result = executor.run_job(job)
if result.status == "succeeded" and result.delivery_error:
print(f"Job ran OK but delivery failed: {result.delivery_error}")
print(f"Full output saved at: {result.audit_path}")
# As of PraisonAI #4198 the invariant holds both ways:
# result.delivered is True ⇔ the payload reached the target
# result.delivered is False ⇔ it did not, and delivery_error is populatedA failed send does not consume the alert. When a failure summary or recovery note does not reach the channel, the incident state is left un-committed, so the next tick re-observes and retries the alert and the next success retries the recovery note (see test_incident_wiring.py::TestDeliveryFailureRetry).
Imports: Bot-First and Wrapper Shims
# Canonical (bot-tier)
from praisonai_bot.scheduler import ScheduledAgentExecutor, JobResult
from praisonai_bot.scheduler.condition_gate import ShellConditionGate
# Wrapper shims (require pip install praisonai)
from praisonai.scheduler.executor import ScheduledAgentExecutor
from praisonai.scheduler.condition_gate import ShellConditionGateRelated
Schedule agents with cost limits and retries
Scheduler Pre-Run GateCost-efficiency gate for scheduled ticks
Schedule CLICommand-line scheduling
praisonai-bot SDKFull bot-tier SDK reference
Multi-Tenant SchedulerIsolate each gateway user’s jobs with a principal owner key
Scheduler Model PinPin a job to a model — drift fails closed and delivers a summary