Skip to content

feat(scheduler): agent-owned pre-check hook — let agents veto scheduled invocations deterministically #454

Description

@dolho

Problem

Trinity's scheduler (agent_schedules + scheduler_service.py) unconditionally fires a chat message on every cron tick. Poll-driven agents (PR reviewers, inbox triage, RSS monitors, alert routers, log scanners) burn tokens on empty wakes — a 15-minute cron on a quiet source costs ~96 fires/day × ~$0.02–0.05 per tiny chat turn, all to have the agent respond "nothing to do."

Trinity already has the right cost-observation infra (SUB-004 token/cost tracking, cost alerts) and the right philosophical stance (GUARD-001 guardrails: "deterministic… not relying on model compliance alone"). What's missing is a prevention primitive at the scheduler layer.

What this is NOT

  • Not a replacement for cron schedules — cron stays as-is.
  • Not an operator-facing config field. The operator creates a normal cron schedule like today.
  • Not a new trigger type or schema change.

Proposal — agent-owned pre-check endpoint

The agent decides (via code shipped in its template) whether a scheduled fire should actually propagate. The operator and the scheduler are unchanged; the agent gains an optional hook.

Contract

Trinity's base-image agent-server.py already runs on port 8000 inside every agent container. Add one optional endpoint:

POST  http://agent-<name>:8000/api/pre-check

Agent templates implement it if they want to gate scheduled invocations. If they don't implement it, scheduler behavior is identical to today (backward compatible).

Response shapes:

{ "fire": false, "reason": "no new PRs" }
{ "fire": true, "message": "Review abilityai/trinity#371, abilityai/abilities#42" }

The optional message field lets the agent rewrite the chat payload with real work (e.g. the list of PRs detected by the pre-check). If absent, the scheduler uses schedule.message as today.

Scheduler flow (in services/scheduler_service.py)

def fire_schedule(schedule):
    decision = _run_pre_check(schedule.agent_name)   # new helper
    if decision.skip:
        record_execution(status='skipped', reason=decision.reason)
        return
    message = decision.message or schedule.message
    post_chat(schedule.agent_name, message)

_run_pre_check semantics:

Response Action
200 {fire: true, …} Fire chat (use message if provided, else schedule.message)
200 {fire: false, reason} Record skip with reason, no chat
404 Not Found Agent doesn't implement hook → fire as today (backward compat)
Connection error / timeout / 5xx Fire as today (fail-open: pre-check is an optimisation, not a gate)

Fail-open is deliberate: a broken pre-check must never silently suppress real work. Failures are logged; operators can catch patterns via the skip-count metric.

Why agent-owned, not operator-owned

Operator writes pre_check_command on schedule row Agent implements /api/pre-check
Owner of the logic Whoever types into the Schedules form Agent author, bundled with template
Schedule UI Changes (new trigger-type radio, command, template fields) Unchanged
Reuse across schedules One command per schedule row One endpoint serves every schedule on that agent
Operator has to know agent internals Yes (e.g. that pr-reviewer scan exists) No
Schema change New columns on agent_schedules None
Multi-language support ✓ (any shell command) ✓ (any HTTP responder)
Fail-open semantics Unclear Natural (endpoint absent = fire)

Additional win: the same /api/pre-check hook is available for other trigger sources in the future (events, webhooks, external API calls) without re-specifying the gate each time.

Architectural precedent

  1. EVT-001 (Agent Event Subscriptions) — agent_event_subscriptions already triggers agent execution on events, distinct from cron. The 2026-01-06 roadmap archive entry explicitly frames this as "Event handlers trigger agent execution like schedules but event-driven." This issue adds the deterministic-pre-check sibling to that pattern.
  2. Agent-server extension surface is established — docker/base-image/agent_server/routers/ already has chat.py, credentials.py, files.py, git.py, skills.py, dashboard.py. Adding pre_check.py follows the existing "agent exposes an HTTP contract the backend proxies to" invariant (architecture.md §Architectural Invariant 5).
  3. Fail-open default is consistent with how Trinity handles other optional agent-side features (e.g. dashboard.yaml absence = no custom dashboard, not an error).

Example agent implementations

PR reviewer (dolho/pr-reviewer-agent):

# /home/developer/.trinity/pre-check.py
from pr_reviewer.scan import scan
from pr_reviewer.config import load
def check():
    cfg = load('/home/developer/config/pr-reviewer.yaml')
    work = scan(cfg, ...)
    if not work:
        return {"fire": False, "reason": "no new PRs"}
    return {"fire": True, "message": render_prompt(work)}

Inbox monitor:

def check():
    unread = mail_client.list_unread()
    if not unread:
        return {"fire": False, "reason": "no new mail"}
    return {"fire": True, "message": f"Triage {len(unread)} new emails:\n{format(unread)}"}

Cost alerter:

def check():
    if current_cost < threshold:
        return {"fire": False, "reason": f"cost ok ({current_cost})"}
    return {"fire": True, "message": f"Alert: cost exceeded. Current: {current_cost}"}

Scope

File Change
docker/base-image/agent_server/routers/pre_check.py New router. Dynamically loads /home/developer/.trinity/pre-check.py if present and calls check(). Returns 404 if absent.
docker/base-image/agent_server/main.py Mount the new router.
src/backend/services/scheduler_service.py Call pre-check before post_chat. Record skip on fire:false. Fail-open on error.
src/backend/db/schedules.py + routers/executions.py Accept status='skipped' in schedule_executions. Surface in execution history API.
src/backend/models.py Add 'skipped' to ExecutionStatus enum.
src/frontend/.../Executions.vue Render skipped executions with their reason (small UI polish).
tests/ Fire-on-200, skip-on-fire-false, fail-open-on-404, fail-open-on-timeout, fail-open-on-exception, message-override.
docs/memory/feature-flows/agent-pre-check.md New feature-flow doc.
docs/memory/architecture.md §Agent Containers — document the new endpoint. §Background Services — document the scheduler change.

No database schema change. schedule_executions already has a status column that stores string values.

Estimated effort: 0.5–1 day (smaller than the earlier sketch because there's no migration, no new schedule-level config, no UI form changes).

Security / correctness

  • Pre-check runs in the agent container, same sandbox as chat tool calls. No new privilege.
  • 60 s timeout. Timeout → fire-as-usual, logged.
  • Response size cap (e.g. 32 KB) for the message field — reject oversized payloads and fire with schedule.message instead.
  • Skip records go into schedule_executions so ops can see "this schedule has been skipped 287 times in a row" and catch silent breakage.
  • Because pre-check is fail-open, a malicious/broken agent cannot suppress scheduled invocations of itself — worst case, it wastes tokens (current behavior).

Acceptance criteria

  • POST /api/pre-check endpoint in base image, loads ~/.trinity/pre-check.py if present
  • Scheduler calls pre-check before firing; correctly respects fire:false (skip) and fire:true (fire)
  • fire:true with message → chat fires with that message, overriding schedule.message
  • fire:true without message → chat fires with schedule.message (existing behavior)
  • 404 from agent → fire as usual (backward compat)
  • Timeout / 5xx / exception → fire as usual, log the error
  • Skip recorded in schedule_executions with status='skipped' and reason
  • Feature flow doc + architecture.md updated
  • Unit tests for all five scheduler branches
  • dolho/pr-reviewer-agent template's custom daemon can be retired in favor of a .trinity/pre-check.py

Context / motivation

Discovered while building dolho/pr-reviewer-agent. First workaround was a Python daemon backgrounded from .trinity/setup.sh that polls deterministically and wakes Claude via localhost:8000/api/chat. It works but is a template-level hack: invisible in the Trinity UI, reimplemented per template, no skip metrics. This issue replaces the hack with a first-class agent-server extension point.

Related:

  • docs/planning/PR_REVIEWER_AGENT.md (local, to be upstreamed) — daemon design + why it's a workaround
  • EVT-001 requirements.md §17.2 — precedent for non-cron agent invocation
  • GUARD-001 requirements.md §20.x — philosophical basis for deterministic gates

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions