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.

python
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.

Diagram

Scheduled Run Policy

ok

blocked

📅 Scheduled Job

🔧 Toolset Scope

🛡️ Prompt Scan

🤖 Agent Run

❌ Fail Closed

💾 Audit Output

📤 Deliver

📤 Failure Summary

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.

ProtectionDefaultEffect
deliver_on_failureTrueBlocked or failed runs send a failure summary to the delivery target.
scan_assembled_promptTrueThe assembled prompt is scanned for injection before the model turn.
denied_toolsetsDEFAULT_DENIED_TOOLSETScronjob and messaging-interactive are removed from unattended runs.
audit_dirNoneNo 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.

yaml
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
OptionTypeDefaultDescription
deliver_on_failurebooltrueSend a failure summary to the job’s delivery target when the run is blocked or the agent raises.
scan_promptbooltrueRun the injection scan over the assembled prompt (context + user message) before the turn.
denied_toolsetslist[str]DEFAULT_DENIED_TOOLSETSTool categories removed from the tool list for the unattended turn. Provide a list to override; omit to keep defaults.
audit_dirstrnullDirectory 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.

Diagram

Through the gateway

Own ScheduledAgentExecutor
embedded in your app

How do you run
scheduled jobs?

Edit scheduler: in gateway.yaml
applies next tick, no restart

Construct RunPolicy in Python
pass run_policy= to the executor

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:

yaml
scheduler:
  deliver_on_failure: true
  denied_toolsets:
    - cronjob
    - shell
bash
# hot-reload the running gateway, then trigger a due job once
kill -HUP "$(pgrep -f 'praisonai gateway')"
praisonai schedule tick

A 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:

python
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.

Diagram
schedules_runnerWorker BWorker Aschedules_runnerWorker BWorker ALock lets one caller start the ScheduleLoopensure_schedule_runner()ensure_schedule_runner()one loop runningno-opstop_schedule_runner(timeout=5.0)poll thread stopped
python
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)
FunctionBehaviour
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.

ConditionLog levelWhat it meansWhat to do
praisonaiagents.scheduler not installedDEBUGScheduler is optional — nothing to runNo action needed
Executor could not be built (_executor is None)WARNINGJobs stay due; nothing fires until the host restarts with the executor availableInstall / fix the executor dependency, then restart
Store construction, thread, or executor init failureEXCEPTIONReal error with traceback; _runner_started stays FalseRead 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:

python
import logging

logging.getLogger("praisonai.integration.bridges.schedules_runner").setLevel(logging.INFO)
logging.basicConfig(level=logging.INFO)

Quick Start

  1. 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 denies cronjob and messaging-interactive tools and scans every prompt automatically.

  2. 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.

  3. 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 search and summarise tools are available. The custom scanner replaces the built-in heuristic.


How It Works

Diagram
AuditDeliveryAgentPolicyExecutorSchedulerAuditDeliveryAgentPolicyExecutorScheduleralt[scan ok][scan blocked]due jobfilter_tools(agent.tools)scoped toolsscan_prompt(message + skills)chat(message)resultpersist full outputsend resultpersist errorfailure summary (if deliver_on_failure)restore agent.tools
StepWhat happens
filter_toolsRemoves denied/disallowed tools from the agent before the run starts
scan_promptChecks the assembled prompt for injection patterns
chatAgent runs only if the scan passes
persistFull output written to audit_dir regardless of delivery outcome
deliverResult sent to delivery target; failure summary sent if deliver_on_failure=True
restoreAgent tools restored to original state after the run

What Gets Scanned vs What Doesn’t

Diagram

Scanned (untrusted input)

Not scanned (trusted config)

⚙️ system_prompt

📝 instructions

👤 backstory

💬 User message

📚 Loaded skills

📋 Loaded recipes

🛡️ Prompt Scanner

SourceScannedReason
User message✅ YesUntrusted external input
loaded_skills, skills, recipes✅ YesRuntime-loaded, potentially external
system_prompt, instructions, backstory❌ NoTrusted developer-authored config

Choosing Tool-Scope Mode

Diagram

No

Yes, block a few

Yes, only allow specific tools

Need to restrict tools?

RunPolicy default
denies cronjob + messaging-interactive

Set denied_toolsets

Set allowed_toolsets

ModeWhen to useExample
DefaultMost cases — blocks self-scheduling and interactive toolsRunPolicy()
Deny-listBlock specific risky tools while keeping everything elseRunPolicy(denied_toolsets={"shell", "filesystem"})
Allow-listSensitive deployments — only curated tools permittedRunPolicy(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.

Diagram

Yes

No / changed

Yes

No

❌ Failure

🔐 error_signature

Same signature
already alerted?

🤫 Silent

📣 ⚠️ Failure summary

✅ Success

Incident open?

🟢 ✅ recovered.

No-op

Tolerate a single flaky tick — alert only when the same failure repeats twice.

python
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 outcomePrior stateWhat operators see
Failure, first with this signatureno open incident⚠️ Scheduled job '<name>' failed: <error> (one alert)
Failure, same signature repeatsalready alertedSilent — no re-alert
Failure, different signaturealready alerted on signature AFresh ⚠️ alert for signature B
Failure, below alert_after_failures thresholdstill countingSilent — counter advances
Success after an alerted incidentincident alerted✅ Scheduled job '<name>' recovered. (one note)
Success with no open incidentcleanSilent
Failure with deliver_on_failure=FalseanySilent (no-op)
Failure with no state store attachedanyFalls back to per-tick delivery (prior behaviour preserved)

Configuration Options

RunPolicy

OptionTypeDefaultDescription
allowed_toolsetsOptional[Set[str]]NoneIf set, only tools in this set are kept. None means allow all except denied_toolsets.
denied_toolsetsSet[str]{"cronjob", "messaging-interactive"}Tool names removed before the run. Defaults block self-scheduling and interactive tools.
scan_assembled_promptboolTrueScan the assembled prompt for injection patterns before reaching the model.
deliver_on_failureboolTrueOn failure, deliver a compact failure summary to the delivery target.
audit_dirOptional[str]NoneDirectory for durable output audit. None disables the audit.
scannerOptional[Callable]NoneCustom scanner callable. Overrides built-in heuristic. May return PromptScanResult or bool.
alert_after_failuresint1When 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_limitboolTrueWhen 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_secondsfloat60.0Extra 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

FieldTypeDefaultDescription
okboolTrueTrue if the prompt passed the scan.
reasonOptional[str]NoneHuman-readable reason when ok is False.

JobResult Reference

python
from praisonai_bot.scheduler import JobResult
FieldTypeDefaultDescription
jobScheduleJobrequiredThe ScheduleJob that was executed
resultOptional[str]NoneAgent response, or None on failure
statusstr"succeeded"One of "succeeded", "failed", "skipped", "no_change"
errorOptional[str]NoneError message when status == "failed" or skip reason when status == "skipped"
durationfloat0.0Wall-clock seconds for agent execution
deliveredboolFalseWhether 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_errorOptional[str]NoneDelivery 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_pathOptional[str]NoneLocal 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)

python
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

python
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

python
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

python
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.

python
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.

python
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.

python
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:

python
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 populated

A 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

python
# 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 ShellConditionGate

Related