Skip to content
203 linesCodeBlameRaw

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 asked1/**
2 * Routines (docs/WORKSPACE.md, "Routines"): work an agent does on a
The Support Specialist agent is Sam now, not Izzy, in its template, the home page tour, the marketing page and the docs, and the tour's Chat sidebar matches the app's3 * schedule, such as Sam's Monday digest of support themes or Bruno's
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 asked4 * 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 opened12import { 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 asked13
14import { type SessionEnv, startSession } from "./sessions.ts";
15import { 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 opened16import { 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 asked17
18export { checkRoutine, checkSchedule, describeSchedule, nextRun } from "./schedule.ts";
19import type { Row } from "./store.ts";
20
21/** Routines one agent may keep. */
22export const MAX_ROUTINES = 25;
23
24export 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 opened32 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 asked35 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 opened49function 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
58export 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
68export 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 asked72export 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 opened73 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 asked74 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 opened79 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 asked82 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. */
98async 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 opened107export 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
116export 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 asked124 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 opened142 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 asked145 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 opened149 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 asked150 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 opened153 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 asked161 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. */
179export 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
201export function newRoutineId(): string {
202 return newId("rtn");
203}