@agentproto/routine
v0.2.3
Published
@agentproto/routine — AIP-41 ROUTINE.md reference implementation. A markdown + frontmatter format for declaring a recurring or event-driven invocation of an action, workflow, or tool. Decouples "when" (the schedule) from "what" (the target). Supports cron
Maintainers
Readme
@agentproto/routine
AIP-41 ROUTINE.md reference implementation. A markdown + frontmatter format for declaring a recurring or event-driven invocation of an action, workflow, or tool. Decouples "when" (the schedule) from "what" (the target). Supports cron / interval / calendar / manual / event-driven schedules, with retry, jitter, catchup policy, identity attribution, and failure routing.
Status: 0.1.0-alpha. Generated by
scripts/scaffold-aip.mjs—build()andvalidate()bodies are TODOs.
Spec: https://agentproto.sh/docs/aip-41
Usage
import { defineRoutine } from "@agentproto/routine"
const x = defineRoutine({
id: "my-routine",
description: "Short purpose.",
// ...
})Runtime bridge (SPEC)
defineRoutine/parseRoutineManifest above only validate a ROUTINE.md.
Until this section, nothing in @agentproto/runtime read .routines/*/
ROUTINE.md and turned it into a live scheduled job — packages/worktree/
routines/worktree-gc/ROUTINE.md (shipped enabled:false in PR #626) was
inert. This section is the design for the bridge that makes it real, scouted
from source before writing any code:
routineFrontmatterSchema.targetand.schedulewerez.any()(packages/routine/src/schema.ts:16) — no validation at all.- The daemon's only live scheduling primitive is
CronScheduler(packages/runtime/src/cron-scheduler.ts): a single 20s tick loop over jobs persisted at~/.agentproto/cron-jobs.json, with action kindscommand | agent | prompt-session(cron-scheduler.ts:50-72) and a provenagent-kind path (mirrorsworktree-investigator-weeklyincron_list).cron_run(orchestration-tools.ts:1750) fires a job immediately, bypassing its schedule — the patternroutine_triggermirrors. - There is no persistent, addressable "call any daemon tool by name"
registry.
McpServer(@modelcontextprotocol/sdk) is not a gateway singleton here —mcpServerFactoryinpackages/runtime/src/index.tsconstructs a freshMcpServerand re-runs everyregisterXTools(...)pass per/mcpconnection (index.ts:1211), so no long-lived server reference survives across cron ticks. Its registered tools live on the underscore-prefixed, undeclared_registeredToolsmap — reaching into it is already precedented in-tree, not invented here:packages/runtime/src/__tests__/tool-subset.test.ts:20-26castsserver as unknown as { _registeredTools?: ... }to read tool names back out for a unit test. The bridge reuses the exact same cast to call a handler, not just list one.
Decisions
- Declarative surface stays AIP-41 — no new manifest primitive.
- Target kinds —
target.toolis the universal path (registrar → daemon tool by name, ANY tool).target.agentandtarget.workfloware sugar that lower to a tool call:target.tool = { tool, inputs? }→ dispatched as-is.target.agent = { adapter, prompt, model?, cwd? }→ dispatched astool:"agent_start"with those fields asinputs. New target kind —TargetAgentdoes not exist in the current AIP-41 draft'starget: TargetAction | TargetWorkflow | TargetToolunion (packages/routine/src/types.ts:46). Flagged as a specs-repo follow-up below; this repo's schema is intentionally ahead of the draft.target.workflow = { workflow: string | { file, ref, inline }, inputs? }(the existing AIP-41 shape) →workflow.file(or a bare string, treated as a file path) dispatches astool:"workflow_run_file"with{ path, input }.ref/inlinesub-forms are not implemented — there is no "run a saved workflow by id" registry in this runtime (workflow_starttakes an inlinestagesarray, it doesn't resolve a saved definition) —reconcile()/trigger()report a clear unsupported-shape error for those rather than silently no-op'ing.target.action(AIP-39 ACTION ref) is validated but not dispatched — no action resolver exists in this runtime. Same "clear error, not silent" treatment.- All three dispatched kinds fold to the same
{ tool, inputs }pair, so the registrar has exactly one execution path (routineTargetToToolCall), not three.
- Schema tightening —
targetmoves fromz.any()to az.unionof four.strict()object schemas (tool/agent/workflow/action — seeschema.ts).z.unionrather thanz.discriminatedUnionbecause the variants are told apart by which key is present (toolvsagentvsworkflowvsaction), not a shared literal discriminator field — adding one would itself be a spec change.scheduleis deliberately leftz.any(): onlytargetwas in-scope for tightening per the brief, and aScheduleCron-only zod schema would reject the four other schedule kinds the type already documents. Instead,routine-registrar.tsruntime-guardsschedule.kind === "cron"at the point of use and reports the other four kinds as a clear "not yet supported" skip, not a validation failure at parse time. - Dispatch mechanism — one shared
dispatchTool, not four bespoke wrappers.createGateway(index.ts) builds ONE lazily-constructed, cached internalMcpServervia the existingmcpServerFactory()(same registration pass a real/mcpconnection gets —worktree_gc,agent_start,workflow_start,workflow_run_file, and every other daemon tool land on it), and adispatchTool(name, inputs)closure that reaches into its_registeredTools[name].handler(inputs, {})— same cast astool-subset.test.ts, now load-bearing rather than test-only. Built lazily (first"tool"-kind cron/trigger fire, not at boot) so it adds no boot latency when no routine ever fires. This single function is handed to bothCronScheduler(newkind:"tool"action, for real scheduled fires) andRoutineRegistrar.trigger()(for on-demand fires) — one dispatch implementation, two callers. - Registrar (
packages/runtime/src/routine-registrar.ts) —reconcile()scans<workspace>/.routines/*/ROUTINE.md, validates each viaparseRoutineManifest, and for everyenabled:true+schedule.kind:"cron"routine creates aCronSchedulerjob taggedlabel:"routine:<id>"; a routine that disappears or turnsenabled:falsehas its tagged job deleted; a routine whose resolved{schedule, action}changed gets its old job deleted and a new one created (delete+recreate —CronSchedulerhas noupdate()). Called once at gateway boot; re-callable on demand (no file-watcher in this pass — noted as a follow-up, not silently dropped). Per-file parse/target errors are collected and skip only that file, not the whole scan. routine_trigger(MCP tool, mirrorscron_run) +POST /routine-defs/:id/trigger(HTTP, mirrorsPOST /cron/:id/run) — re-parses the routine fresh, resolves its target to a tool call, and callsdispatchTooldirectly (not via a registered cron job): works even for a disabled routine or onereconcile()hasn't seen yet, and bypasses both the schedule AND theenabledflag, same ascron_runbypasses schedule for an existing job (cron-scheduler.ts:514-519has noactivecheck either).
/routines/* naming collision (resolved)
This bridge's manual-fire route lives at /routine-defs/:id/trigger,
not /routines/:id/trigger — originally to avoid confusing an AIP-41
routine id with a RoutineRunner run id on the same /routines/* prefix.
That collision is now moot: routine_start/routine_status/
routine_cancel/routine_escalation_resolve and their /routines/* run
routes (the unrelated ad-hoc RoutineRunner primitive) were removed
entirely (PLAN.md Phase B3). GET /routines now serves only this
registrar's list(), and /routine-defs/* keeps its own prefix regardless.
Specs-repo follow-up (not applied here — separate repo, per brief)
- Document
target.agent({ adapter, prompt, model?, cwd? }) as a first-class AIP-41 target kind inresources/aip-41/draft/ROUTINE.schema.jsonupstream — this repo'stypes.ts/schema.tsare ahead of the generated draft for this one shape. - Consider documenting
schedule.kindvalues beyondcronas MAY-be-partial in v1 runtimes, since this bridge only implementscron(interval / calendar / manual / event are parsed-but-unscheduled here).
License
MIT — see LICENSE.
