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
- 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.
- 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).
- 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
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
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
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.pyalready runs on port 8000 inside every agent container. Add one optional endpoint: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
messagefield 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 usesschedule.messageas today.Scheduler flow (in
services/scheduler_service.py)_run_pre_checksemantics:200 {fire: true, …}messageif provided, elseschedule.message)200 {fire: false, reason}404 Not FoundFail-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
pre_check_commandon schedule row/api/pre-checkpr-reviewer scanexists)agent_schedulesAdditional win: the same
/api/pre-checkhook is available for other trigger sources in the future (events, webhooks, external API calls) without re-specifying the gate each time.Architectural precedent
agent_event_subscriptionsalready 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.docker/base-image/agent_server/routers/already haschat.py,credentials.py,files.py,git.py,skills.py,dashboard.py. Addingpre_check.pyfollows the existing "agent exposes an HTTP contract the backend proxies to" invariant (architecture.md §Architectural Invariant 5).dashboard.yamlabsence = no custom dashboard, not an error).Example agent implementations
PR reviewer (
dolho/pr-reviewer-agent):Inbox monitor:
Cost alerter:
Scope
docker/base-image/agent_server/routers/pre_check.py/home/developer/.trinity/pre-check.pyif present and callscheck(). Returns 404 if absent.docker/base-image/agent_server/main.pysrc/backend/services/scheduler_service.pypost_chat. Record skip onfire:false. Fail-open on error.src/backend/db/schedules.py+routers/executions.pystatus='skipped'inschedule_executions. Surface in execution history API.src/backend/models.py'skipped'toExecutionStatusenum.src/frontend/.../Executions.vuetests/docs/memory/feature-flows/agent-pre-check.mddocs/memory/architecture.mdNo database schema change.
schedule_executionsalready has astatuscolumn 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
messagefield — reject oversized payloads and fire withschedule.messageinstead.schedule_executionsso ops can see "this schedule has been skipped 287 times in a row" and catch silent breakage.Acceptance criteria
POST /api/pre-checkendpoint in base image, loads~/.trinity/pre-check.pyif presentfire:false(skip) andfire:true(fire)fire:truewithmessage→ chat fires with that message, overridingschedule.messagefire:truewithoutmessage→ chat fires withschedule.message(existing behavior)schedule_executionswithstatus='skipped'and reasondolho/pr-reviewer-agenttemplate's custom daemon can be retired in favor of a.trinity/pre-check.pyContext / motivation
Discovered while building
dolho/pr-reviewer-agent. First workaround was a Python daemon backgrounded from.trinity/setup.shthat polls deterministically and wakes Claude vialocalhost: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 workaroundrequirements.md§17.2 — precedent for non-cron agent invocationrequirements.md§20.x — philosophical basis for deterministic gates