Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 1 | /** |
| 2 | * Routines (docs/WORKSPACE.md, "Routines"): work an agent does on a | |
| 3 | * schedule, such as Izzy's Monday digest of support themes or Bruno's | |
| 4 | * morning look at failed deploys. Each run is a session in the routine's | |
| 5 | * channel, paid from the agent's budget, with the access of the person who | |
| 6 | * set it up (its sponsor), never more: if the sponsor leaves the workspace | |
| 7 | * or can no longer read the channel, the routine pauses and says why. | |
| 8 | * | |
| 9 | * The schedule is in UTC, every hour, day, weekday or week, at a minute | |
| 10 | * (and hour, and day). `nextRun` is pure, so it is tested on its own. | |
| 11 | */ | |
| Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened | 12 | import { type AgentRoutine, type RoutineEvent, type RoutineSchedule, askerAccess, chatClient, identityClient, newId } from "@g1t/contracts"; |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 13 | |
| 14 | import { type SessionEnv, startSession } from "./sessions.ts"; | |
| 15 | import { checkSchedule, describeSchedule, nextRun } from "./schedule.ts"; | |
| Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened | 16 | import { EVENT_KEYS, describeEvents } from "./suggest.ts"; |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 17 | |
| 18 | export { checkRoutine, checkSchedule, describeSchedule, nextRun } from "./schedule.ts"; | |
| 19 | import type { Row } from "./store.ts"; | |
| 20 | ||
| 21 | /** Routines one agent may keep. */ | |
| 22 | export const MAX_ROUTINES = 25; | |
| 23 | ||
| 24 | export type RoutineRow = { | |
| 25 | id: string; | |
| 26 | agent_id: string; | |
| 27 | workspace_id: string; | |
| 28 | /** The workspace's slug, for posting and billing. */ | |
| 29 | workspace: string; | |
| 30 | name: string; | |
| 31 | instructions: string; | |
| Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened | 32 | schedule: string | null; |
| 33 | events: string; | |
| 34 | repos: string; | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 35 | channel_id: string; |
| 36 | channel_name: string | null; | |
| 37 | sponsor: string; | |
| 38 | sponsor_username: string | null; | |
| 39 | enabled: number; | |
| 40 | paused_note: string | null; | |
| 41 | next_run_at: string | null; | |
| 42 | last_run_at: string | null; | |
| 43 | last_session_id: string | null; | |
| 44 | runs: number; | |
| 45 | created_at: string; | |
| 46 | updated_at: string; | |
| 47 | }; | |
| 48 | ||
| Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened | 49 | function list<T>(raw: string | null): T[] { |
| 50 | try { | |
| 51 | const value = JSON.parse(raw || "[]") as unknown; | |
| 52 | return Array.isArray(value) ? (value as T[]) : []; | |
| 53 | } catch { | |
| 54 | return []; | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | export function scheduleOf(row: Pick<RoutineRow, "schedule">): RoutineSchedule | null { | |
| 59 | if (!row.schedule) return null; | |
| 60 | try { | |
| 61 | const checked = checkSchedule(JSON.parse(row.schedule)); | |
| 62 | return checked.ok ? checked.value : null; | |
| 63 | } catch { | |
| 64 | return null; | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | export function eventsOf(row: Pick<RoutineRow, "events">): RoutineEvent[] { | |
| 69 | return list<RoutineEvent>(row.events).filter((e) => EVENT_KEYS.includes(e)); | |
| 70 | } | |
| 71 | ||
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 72 | export function toRoutine(row: RoutineRow): AgentRoutine { |
| Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened | 73 | const schedule = scheduleOf(row); |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 74 | return { |
| 75 | id: row.id, | |
| 76 | agent_id: row.agent_id, | |
| 77 | name: row.name, | |
| 78 | instructions: row.instructions, | |
| Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened | 79 | schedule, |
| 80 | events: eventsOf(row), | |
| 81 | repos: list<string>(row.repos), | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 82 | channel_id: row.channel_id, |
| 83 | channel_name: row.channel_name, | |
| 84 | sponsor: row.sponsor, | |
| 85 | sponsor_username: row.sponsor_username, | |
| 86 | enabled: !!row.enabled, | |
| 87 | paused_note: row.paused_note, | |
| 88 | next_run_at: row.enabled ? row.next_run_at : null, | |
| 89 | last_run_at: row.last_run_at, | |
| 90 | last_session_id: row.last_session_id, | |
| 91 | runs: row.runs, | |
| 92 | created_at: row.created_at, | |
| 93 | updated_at: row.updated_at, | |
| 94 | }; | |
| 95 | } | |
| 96 | ||
| 97 | /** Pauses a routine and says why, on its page. */ | |
| 98 | async function pause(db: D1Database, id: string, note: string): Promise<void> { | |
| 99 | await db.prepare("UPDATE agent_routines SET enabled = 0, paused_note = ?, updated_at = ? WHERE id = ?").bind(note, new Date().toISOString(), id).run(); | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Runs one routine now, as a session: checks its sponsor may still read its | |
| 104 | * channel and that the agent is still in it, then starts the session and | |
| 105 | * moves its next run on. Returns the session's id, or why it couldn't. | |
| 106 | */ | |
| Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened | 107 | export type RoutineOccasion = { |
| 108 | /** What happened, in a line, and where: "Pull request acme/web#12 is ready for review: Fix CSV export". */ | |
| 109 | what: string; | |
| 110 | /** Where to read more, relative to the site. */ | |
| 111 | href: string | null; | |
| 112 | /** The repository it happened in, by id, which the sponsor must be able to read. */ | |
| 113 | repo_id: string; | |
| 114 | }; | |
| 115 | ||
| 116 | export async function runRoutine( | |
| 117 | env: SessionEnv, | |
| 118 | routine: RoutineRow, | |
| 119 | agent: Row, | |
| 120 | workspace: string, | |
| 121 | now = new Date(), | |
| 122 | occasion: RoutineOccasion | null = null, | |
| 123 | ): Promise<{ ok: true; session: string } | { ok: false; message: string }> { | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 124 | const db = env.DB; |
| 125 | const [sponsor] = await identityClient(env.IDENTITY) | |
| 126 | .usersForAudience([routine.sponsor]) | |
| 127 | .catch(() => []); | |
| 128 | const membership = sponsor?.workspaces?.find((m) => m.slug.toLowerCase() === workspace.toLowerCase()); | |
| 129 | if (!sponsor || !membership) { | |
| 130 | await pause(db, routine.id, "Paused: the person who set it up is no longer in the workspace. Anyone who can manage the agent can take it over by saving it."); | |
| 131 | return { ok: false, message: "Its sponsor is no longer in the workspace." }; | |
| 132 | } | |
| 133 | const audience = await chatClient(env.CHAT).audience(workspace, routine.channel_id); | |
| 134 | if (!audience.ok) { | |
| 135 | await pause(db, routine.id, "Paused: its channel is gone, or the agent is no longer in it."); | |
| 136 | return { ok: false, message: "Its channel can't be read." }; | |
| 137 | } | |
| 138 | if (audience.value.kind !== "public" && !audience.value.member_user_ids.includes(sponsor.id)) { | |
| 139 | await pause(db, routine.id, `Paused: @${sponsor.username} is no longer in its channel.`); | |
| 140 | return { ok: false, message: "Its sponsor is no longer in its channel." }; | |
| 141 | } | |
| Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened | 142 | const schedule = scheduleOf(routine); |
| 143 | // A scheduled run moves the clock on first, so a slow start never runs it twice; an event's run leaves it. | |
| 144 | const next = occasion ? routine.next_run_at : schedule ? nextRun(schedule, now).toISOString() : null; | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 145 | await db |
| 146 | .prepare("UPDATE agent_routines SET next_run_at = ?, last_run_at = ?, runs = runs + 1, updated_at = ? WHERE id = ?") | |
| 147 | .bind(next, now.toISOString(), now.toISOString(), routine.id) | |
| 148 | .run(); | |
| Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened | 149 | const when = [schedule ? describeSchedule(schedule) : null, describeEvents(eventsOf(routine)) || null].filter(Boolean).join("; "); |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 150 | const session = await startSession(env, { |
| 151 | agent, | |
| 152 | kind: "routine", | |
| Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened | 153 | title: occasion ? `${routine.name}: ${occasion.what}`.slice(0, 120) : routine.name, |
| 154 | goal: [ | |
| 155 | `This is your routine "${routine.name}" (${when || "run by hand"}), set up by @${sponsor.username}. Post its report for #${routine.channel_name ?? "the channel"}.`, | |
| 156 | occasion ? `It runs now because: ${occasion.what}${occasion.href ? ` (${occasion.href})` : ""}. Work on that one thing.` : "", | |
| 157 | routine.instructions, | |
| 158 | ] | |
| 159 | .filter(Boolean) | |
| 160 | .join("\n\n"), | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 161 | workspace, |
| 162 | channel_id: routine.channel_id, | |
| 163 | channel_kind: audience.value.kind === "dm" ? "dm" : "channel", | |
| 164 | channel_name: routine.channel_name, | |
| 165 | thread_root: null, | |
| 166 | message_id: null, | |
| 167 | asked_by: sponsor.id, | |
| 168 | asked_by_username: sponsor.username, | |
| 169 | asker: askerAccess(sponsor, workspace), | |
| 170 | routine_id: routine.id, | |
| 171 | chain: [], | |
| 172 | hops: 0, | |
| 173 | }); | |
| 174 | await db.prepare("UPDATE agent_routines SET last_session_id = ? WHERE id = ?").bind(session.id, routine.id).run(); | |
| 175 | return { ok: true, session: session.id }; | |
| 176 | } | |
| 177 | ||
| 178 | /** Every routine due now, run. For the cron trigger, every few minutes. */ | |
| 179 | export async function runDue(env: SessionEnv, now = new Date()): Promise<number> { | |
| 180 | const db = env.DB; | |
| 181 | const due = await db | |
| 182 | .prepare("SELECT * FROM agent_routines WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ? ORDER BY next_run_at LIMIT 25") | |
| 183 | .bind(now.toISOString()) | |
| 184 | .all<RoutineRow>(); | |
| 185 | let ran = 0; | |
| 186 | for (const routine of due.results) { | |
| 187 | const agent = await db.prepare("SELECT * FROM agents WHERE id = ?").bind(routine.agent_id).first<Row>(); | |
| 188 | if (!agent || agent.archived_at) { | |
| 189 | await pause(db, routine.id, "Paused: its agent was archived."); | |
| 190 | continue; | |
| 191 | } | |
| 192 | const slug = routine.workspace; | |
| 193 | if (!slug) continue; | |
| 194 | const result = await runRoutine(env, routine, agent, slug, now).catch((error: unknown) => ({ ok: false as const, message: String(error) })); | |
| 195 | if (result.ok) ran++; | |
| 196 | else console.error("agents: a routine did not run", routine.id, result.message); | |
| 197 | } | |
| 198 | return ran; | |
| 199 | } | |
| 200 | ||
| 201 | export function newRoutineId(): string { | |
| 202 | return newId("rtn"); | |
| 203 | } |