Scheduler Pre-Run Gate

Skip scheduled model turns when a cheap deterministic check says there's nothing to do

Run a cheap shell check before each scheduled tick — skip the model turn (and any delivery ping) when there’s nothing to do.

python
from praisonaiagents import Agent
from praisonaiagents.scheduler import ScheduleJob, Schedule

agent = Agent(
    name="InboxWatcher",
    instructions="Summarise new emails and flag anything important.",
)
job = ScheduleJob(
    name="inbox-watch",
    schedule=Schedule(kind="every", every_seconds=300),
    pre_run="scripts/new_mail.sh",
    message="Summarise these new emails and flag anything important.",
)

The user schedules a tick with a pre_run shell hook; the agent only runs when the gate script reports work to do.

Diagram

exit 0 + output

exit 0 empty

non-zero / timeout

⏰ Tick due

🚦 pre_run?

🤖 Run model + context

🤖 Run model

⏭️ Skip — 0 tokens

Quick Start

  1. YAML (simplest)

    Add pre_run to your agents.yaml schedule block. The agent only runs when the script exits 0 with output.

    yaml
    schedule:
      every: 300
      pre_run: scripts/new_mail.sh
      message: "Summarise these new emails and flag anything important."
  2. Python

    Set pre_run directly on a ScheduleJob.

    python
    from praisonaiagents import Agent
    from praisonaiagents.scheduler import ScheduleJob, Schedule
    
    agent = Agent(
        name="InboxWatcher",
        instructions="Summarise new emails and flag anything important.",
    )
    
    job = ScheduleJob(
        name="inbox-watch",
        schedule=Schedule(kind="every", every_seconds=300),
        pre_run="scripts/new_mail.sh",
        message="Summarise these new emails and flag anything important.",
    )
  3. CLI

    Pass --pre-run when adding a scheduled job.

    bash
    praisonai schedule add "inbox-watch" \
      -s "*/5m" \
      -m "Summarise new emails" \
      --pre-run "scripts/new_mail.sh" \
      --deliver telegram

How It Works

Diagram
AgentShellConditionGateSchedulerAgentShellConditionGateScheduleralt[exit 0 + stdout][exit 0, no stdout][non-zero exit or timeout]loop[Every tick]asyncio.to_thread(run pre_run cmd)run=True, context=stdoutagent.chat(message + context)result → deliverrun=True, context=Noneagent.chat(message)result → deliverrun=False, reason=stderrrecord skipped — 0 tokens spent

The gate runs in asyncio.to_thread so a slow check does not block other scheduled ticks. If the gate itself raises an exception, the executor falls back to running the agent (defensive default).


Configuration Options

ScheduleJob fields

FieldTypeDefaultDescription
pre_runOptional[str]NoneShell command run before each tick. Set via YAML schedule.pre_run or --pre-run
conditionOptional[str]NoneAdvisory natural-language label (round-tripped, not enforced by the default gate)

ScheduledAgentExecutor parameter

ParameterTypeDefaultDescription
condition_resolver`None \False \callable`NoneNone = auto shell gate when pre_run set; False = gating disabled; callable `(job) → gate \None` = custom resolver

ShellConditionGate options

OptionTypeDefaultDescription
timeoutfloat30.0Seconds before the gate is treated as a skip
_MAX_CONTEXT_CHARSint8000Cap on captured stdout appended as context
_MAX_REASON_CHARSint500Cap on stderr surfaced in the skip reason

Gate decision table

Gate command exitstdoutDecisionWhat happens
0non-emptyrun=True, context=stdoutModel runs, stdout appended to message
0emptyrun=True, context=NoneModel runs with original message only
non-zero—run=FalseTick recorded as skipped — no tokens, no delivery
timeout (>timeouts)—run=FalseSame as skipped
gate raises—falls back to run=TrueModel runs (defensive)

Pre-Run Gate vs RunPolicy

Two complementary gates apply on every tick when both are configured:

Diagram

skip

run

blocked

allowed

⏰ Tick

pre_run gate

⏭️ 0 tokens

RunPolicy

🔒 safety block

🤖 Model runs

GatePurposeWhen to configure
Pre-run gate (pre_run)Efficiency — should a run happen at all?When most ticks have nothing to do
RunPolicySafety — what is an unattended run allowed to do?Always in production unattended runs

Common Patterns

Inbox watcher

Only fire when new mail arrives — cuts 288 daily runs to just the count of mail bursts.

yaml
schedule:
  every: 300
  pre_run: scripts/new_mail.sh
  message: "Summarise these new emails and flag anything important."

scripts/new_mail.sh exits 0 and prints new messages when mail is present; exits non-zero when the inbox is quiet.

CI babysitter

Alert a channel only when a build breaks.

bash
praisonai schedule add "ci-watch" \
  -s "*/5m" \
  -m "Summarise the CI failure and suggest a fix" \
  --pre-run "scripts/ci_failed.sh" \
  --deliver telegram

Custom Python gate

Use a callable resolver for richer logic than a shell exit code.

python
from praisonaiagents import Agent
from praisonaiagents.scheduler import ScheduleJob, Schedule, ScheduleRunner, FileScheduleStore
from praisonai_bot.scheduler import ScheduledAgentExecutor

class MyPythonGate:
    def check(self, job):
        import requests
        r = requests.get("https://api.example.com/has-work")
        return r.json().get("pending", 0) > 0

agent = Agent(name="WorkProcessor", instructions="Process pending work items.")

store = FileScheduleStore()
runner = ScheduleRunner(store)

job = ScheduleJob(
    name="work-processor",
    schedule=Schedule(kind="every", every_seconds=60),
    message="Process all pending work items.",
)

executor = ScheduledAgentExecutor(
    runner=runner,
    agent_resolver=lambda _: agent,
    condition_resolver=lambda job: MyPythonGate(),
)

Best Practices

Keep the gate cheap and deterministic

The gate runs on every tick. Aim for under 1 second — a fast file check, an API ping, or a database row count. Avoid heavy computation.

bash
# Good — fast local check
scripts/new_mail.sh

# Avoid — slow network calls without caching
scripts/fetch_all_data_and_analyse.sh
Always set a sensible timeout

The default timeout=30.0 is conservative. A runaway gate stalls that tick’s slot. Set it to match your gate’s expected worst case.

python
from praisonai_bot.scheduler.condition_gate import ShellConditionGate

gate = ShellConditionGate(timeout=5.0)
Use stdout to seed the prompt

The same check that gates the run can feed it the data — the gate’s stdout is automatically appended to the message.

bash
#!/bin/bash
# scripts/new_mail.sh
# Prints new mail subjects if any, exits non-zero if inbox empty
NEW=$(mail -H 2>/dev/null | head -20)
if [ -z "$NEW" ]; then exit 1; fi
echo "$NEW"

The agent then receives: "Summarise these new emails... <new mail subjects>".

Don't put secrets in pre_run

The pre_run string is stored verbatim in the job record. Use environment variables or a secrets manager instead of inline credentials.

bash
# Good — use environment variable
scripts/check_api.sh

# Avoid — secret visible in job record
curl -H "Authorization: Bearer mysecrettoken" https://api.example.com/check
pre_run is CLI/YAML/Python-only — never LLM-callable

The agent-callable schedule_add tool deliberately does not accept pre_run. This prevents a prompt-injected agent from persisting arbitrary shell commands on the host. Always configure pre_run through the CLI, YAML, or Python code.

Understand skipped vs failed

A gate returning non-zero records the tick as skipped (not failed) — this is expected normal operation when there’s nothing to do. Check your run history with praisonai schedule logs to see skip rates.


Skip and Failure Reasons

When a tick is skipped or fails, the JobResult.error field contains one of these reasons:

ReasonSourcestatus
"No message configured"Job has no message fieldskipped
"Agent resolution failed: <exception>"agent_resolver raisedfailed
"No agent found for agent_id=<id>"agent_resolver returned Nonefailed
"pre-run gate: nothing to do"Gate exit non-zero, no stderrskipped
`”pre-run gate: nothing to do (exit

: <stderr>)” | Gate exit non-zero with stderr | skipped | | “pre-run gate timed out (>{timeout:.0f}s)” | Gate exceeded timeout | skipped | | “pre-run gate error: <exception>” | Gate raised exception (defensive: falls back to run) | — | | “Blocked by run policy: <scan.reason>” | RunPolicy scanner blocked | failed` |

When deliver_on_failure is enabled on the RunPolicy, a failure summary is delivered to the channel:

⚠️ Scheduled job '<name>' failed: <error>


JobResult Reference

Every tick yields a JobResult from praisonai_bot.scheduler:

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"
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)

Session Routing

session_target on ScheduleJob controls whether a scheduled tick reuses an existing channel session or gets its own isolated context.

job.session_targetBehaviour
"isolated" (default)Each tick uses session_id=f"cron_{job.id}" — no shared memory across runs
"main"If job.delivery.session_id is set, that session is reused so the scheduled turn joins the channel’s ongoing conversation

session_id is passed only when the resolved agent.chat accepts a session_id keyword argument. The executor inspects the signature before passing it.

yaml
schedule:
  every: 300
  session_target: main   # join the channel's ongoing session

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