Scheduler Monitor Mode

Wake a scheduled job only when a watched source changed — and hand the agent only the diff

Turn any scheduled job into a change-aware monitor — wake only when a watched source changed, and hand the agent only the diff.

Diagram

unchanged

changed / first run

⏰ Tick due

🔎 Probe source

🧮 hash vs stored

🤫 Suppress silently

✂️ Seed diff

🤖 Run + deliver

Quick Start

  1. Agent-centric — watch a page, tell me only what's new

    The agent schedules the monitor for you from a plain request.

    python
    from praisonaiagents import Agent
    from praisonaiagents.tools import schedule_add
    
    agent = Agent(name="watcher", tools=[schedule_add])
    agent.start(
        "Every hour, monitor https://status.example. Only tell me when it changes, "
        "and tell me only what's new."
    )
  2. Direct Python — build the ScheduleJob yourself

    Set monitor to name a cheap source to probe each tick.

    python
    from praisonaiagents.scheduler import ScheduleJob, parse_schedule
    
    job = ScheduleJob(
        name="site-watch",
        message="The watched page changed. Here is the diff; summarise what is new.",
        schedule=parse_schedule("weekdays at 9am"),   # natural language also works
        monitor={"url": "https://status.example"},   # or {"command": "..."}
    )
  3. Shell source — probe a cheap counter

    A bounded command is a better source than a full HTML page.

    python
    job = ScheduleJob(
        name="unread-watch",
        message="Unread count changed — summarise what is new.",
        schedule=parse_schedule("every 1h"),
        monitor={"command": "curl -s https://api.example/unread | jq '.count'"},
    )
  4. YAML — configure via agents.yaml
    yaml
    schedule:
      every: "1h"
      message: "Summarise what is new."
      monitor:
        url: "https://status.example"
      deliver: "telegram:123456"

How It Works

The wrapper hashes the probed source and compares it to the last-seen hash held in per-job state.

Diagram
ChannelAgentStateStoreSourceSchedulerChannelAgentStateStoreSourceSchedulerUnchanged — silentChanged — diff → run → deliverprobe (command / url)outputget_state(job_id) → last hashhash(output) == last hash?record no_change — 0 tokens, no deliveryprobe (command / url)outputget_state(job_id) → last hashhash differs → build bounded diffset_state(job_id, new hash)run(message + diff)deliver result

Caller contract (verbatim from the SDK): “Because a legacy stateless gate accepts only job, callers MUST NOT unconditionally pass state=. Detect capability first — e.g. call should_run(job, state=prior) only when the gate opts into state, otherwise fall back to should_run(job) — so stateless gates keep working unchanged and never raise TypeError.”

An error probe (a transient 500) leaves prior state untouched, so a later recovery to the prior output still suppresses — a transient failure does not count as a change.


Run Status: no_change vs the others

RunRecord.status gains a fourth value so an operator can tell a silent monitor apart from a deliberate skip.

statusMeaningTokensDelivery
succeededThe model turn ran and finishedYesYes
failedThe run erroredMaybeOn-failure only
skippedA go/no-go gate said “don’t run”NoNo
no_changeA watched source was unchanged since the last tickNoNo

What’s Stored in Per-Job State

The monitor keeps a small durable scratchpad keyed by job id — typically the last-seen hash, plus an optional cursor / watermark.

python
from typing import Any, Dict, Protocol, runtime_checkable

@runtime_checkable
class JobStateStoreProtocol(Protocol):
    """Bounded per-job durable scratchpad. All methods OPTIONAL — callers use hasattr()."""

    def get_state(self, job_id: str) -> Dict[str, Any]: ...
    def set_state(self, job_id: str, state: Dict[str, Any]) -> None: ...  # size-capped
    def clear_state(self, job_id: str) -> None: ...  # called when a job is removed

The state is bounded (size-capped by the implementation) and cleared when the job is removed. The core owns only the shape; the wrapper decides where to persist it.


Distinct From Other Gates

Pick the right sibling — monitor is the stateful one that seeds a diff.

Diagram

Cheap go/no-go, seed whole output

Wake only on change, seed a diff

Run a shell command, no LLM

What do you need?

pre_run gate

monitor

command action

FieldStateful?SeedsDistinct outcome
pre_runNoWhole gate outputskipped
monitorYesA bounded diffno_change
commandNoNothing (no LLM)model-free action

Configuration Options

The monitor field is a small mapping naming one cheap source to probe each tick.

KeyTypeDescription
commandstrA shell command whose stdout is hashed and diffed
urlstrA bounded fetch whose body is hashed and diffed
python
monitor={"url": "https://status.example"}
# or
monitor={"command": "curl -s https://api.example/unread | jq '.count'"}

Best Practices

Prefer a bounded, cheap probe

A JSON endpoint returning a single counter beats hashing a full HTML page — it changes only when the thing you care about changes, and it is fast on every tick.

python
# Good — a small, stable signal
monitor={"command": "curl -s https://api.example/unread | jq '.count'"}

# Avoid — a whole page churns on ads, timestamps, and markup noise
monitor={"url": "https://example.com/full-homepage"}
A transient error is not a change

An error probe leaves prior state untouched — a transient 500 does not “count as a change”. When the source recovers to its prior output, the tick still suppresses. Design probes that fail loudly (non-zero exit) rather than emitting an error page as “content”.

Use no_change records to prove a monitor is alive

A silent monitor looks identical to a broken one. Check run history — a stream of no_change records proves the monitor is probing every tick and simply has nothing to report.

bash
praisonai session show   # look for no_change entries
Never pass state= to a stateless gate

Callers MUST detect capability first. Pass state= only when the gate opts into state; otherwise fall back to should_run(job) so legacy stateless gates never raise TypeError.


See Also

Configure a monitor from the CLI with --monitor-url (public HTTP(S)) or --monitor-command (shell stdout) — see CLI: schedule → Wake only when a page changes. Both flags produce byte-identical jobs to the monitor={"url": …} / monitor={"command": …} form here, and the URL path carries the reliability guarantees documented on the core Scheduler Monitor page.


Related