Skip to content
229 linesCodeBlameRaw
1/**
2 * Agents as stored in D1, and their spend. Shared by the RPC methods and
3 * the desk.
4 */
5import type { SubagentDef, WorkspaceAgent } from "@g1t/contracts";
6
7import { agentStatus, budgetBlock, dayKey, monthKey } from "./budget.ts";
8import { type Definition, DEFAULT_AUTONOMY, DEFAULT_BUDGET, DEFAULT_ROUTING, legacyRoleOf, PRESETS, readJson } from "./definition.ts";
9
10export type Row = {
11 id: string;
12 workspace_id: string;
13 handle: string;
14 display_name: string;
15 avatar: string | null;
16 role: string;
17 instructions: string;
18 personality_preset: string;
19 personality: string;
20 routing: string;
21 budget: string;
22 autonomy: string;
23 capacity: number;
24 template: string | null;
25 avatar_seed: string | null;
26 title: string | null;
27 /**
28 * Unused columns from when an agent carried its own team and department.
29 * Teams are memberships in identity now; `team` is read once, by the move
30 * to them (team-move.ts), and `role` made from them follows the title.
31 */
32 team?: string | null;
33 department?: string | null;
34 responsibilities: string | null;
35 subagents: string | null;
36 faces: string | null;
37 reading?: string | null;
38 skills_off?: string | null;
39 version: number;
40 /** 1 for the workspace's built-in @g1t. */
41 builtin: number;
42 /** `workspace`, or `personal` (a member's own); missing before personal agents. */
43 scope?: string | null;
44 /** A personal agent's member: user id and username. */
45 owner_id?: string | null;
46 owner_username?: string | null;
47 busy_until: string | null;
48 created_by: string;
49 created_at: string;
50 updated_at: string;
51 archived_at: string | null;
52 /** Joined from agent_spend: this month and today. */
53 spent_month?: number | null;
54 spent_day?: number | null;
55};
56
57/**
58 * Agents with this month's and today's spend: `where` filters `agents`
59 * (aliased `a`); its parameters follow the two periods.
60 */
61export function selectAgents(where: string): string {
62 return `SELECT a.*, m.micros AS spent_month, d.micros AS spent_day FROM agents a
63 LEFT JOIN agent_spend m ON m.agent_id = a.id AND m.period = ?1
64 LEFT JOIN agent_spend d ON d.agent_id = a.id AND d.period = ?2
65 WHERE ${where}`;
66}
67
68export function periods(now: Date): [string, string] {
69 return [monthKey(now), dayKey(now)];
70}
71
72export function definitionOf(row: Row): Definition {
73 const title = row.title ?? "";
74 // A role made from the title and its old team or department is the title alone.
75 const made = (row.team || row.department?.trim()) && row.role === legacyRoleOf(title, row.team ?? null, row.department ?? null);
76 return {
77 handle: row.handle,
78 display_name: row.display_name,
79 role: made && title.trim() ? title.trim() : row.role,
80 instructions: row.instructions,
81 personality_preset: PRESETS.includes(row.personality_preset as Definition["personality_preset"])
82 ? (row.personality_preset as Definition["personality_preset"])
83 : "crisp",
84 personality: row.personality,
85 routing: readJson(row.routing, DEFAULT_ROUTING),
86 budget: readJson(row.budget, DEFAULT_BUDGET),
87 autonomy: readJson(row.autonomy, DEFAULT_AUTONOMY),
88 capacity: row.capacity,
89 template: row.template,
90 avatar_seed: row.avatar_seed || row.handle,
91 title,
92 responsibilities: readList<string>(row.responsibilities),
93 subagents: readList<SubagentDef>(row.subagents),
94 faces: "internal",
95 reading: readList<string>(row.reading ?? null),
96 skills_off: readList<string>(row.skills_off ?? null),
97 };
98}
99
100export function toAgent(row: Row, now: Date): WorkspaceAgent {
101 const definition = definitionOf(row);
102 const spent = { month: row.spent_month ?? 0, day: row.spent_day ?? 0 };
103 return {
104 id: row.id,
105 workspace_id: row.workspace_id,
106 avatar: row.avatar,
107 ...definition,
108 builtin: !!row.builtin,
109 scope: isPersonal(row) ? "personal" : "workspace",
110 personal_owner_id: isPersonal(row) ? (row.owner_id ?? null) : null,
111 personal_owner: isPersonal(row) ? (row.owner_username ?? null) : null,
112 version: row.version,
113 status: row.archived_at ? "paused" : agentStatus({ busyUntil: row.busy_until, now, blocked: budgetBlock(definition.budget, spent, now) !== null }),
114 spent_month_micros: spent.month,
115 created_by: row.created_by,
116 created_at: row.created_at,
117 updated_at: row.updated_at,
118 archived_at: row.archived_at,
119 };
120}
121
122/** Whether a row is a member's personal agent. */
123export function isPersonal(row: Pick<Row, "scope">): boolean {
124 return row.scope === "personal";
125}
126
127/**
128 * Adds a charge to the paying agent's month and day: a reply's, or a
129 * session step's (`task`), counted as one of each.
130 */
131export function spendStatements(db: D1Database, agentId: string, micros: number, now: Date, task: "reply" | "session" = "reply"): D1PreparedStatement[] {
132 const [replies, sessions] = task === "reply" ? [1, 0] : [0, 1];
133 return periods(now).map((period) =>
134 db
135 .prepare(
136 `INSERT INTO agent_spend (agent_id, period, micros, replies, sessions) VALUES (?1, ?2, ?3, ?4, ?5)
137 ON CONFLICT (agent_id, period) DO UPDATE SET micros = micros + ?3, replies = replies + ?4, sessions = sessions + ?5`,
138 )
139 .bind(agentId, period, Math.max(0, Math.ceil(micros)), replies, sessions),
140 );
141}
142
143/** A stored JSON list, read defensively: anything else is empty. */
144function readList<T>(raw: string | null): T[] {
145 if (!raw) return [];
146 try {
147 const parsed = JSON.parse(raw) as unknown;
148 return Array.isArray(parsed) ? (parsed as T[]) : [];
149 } catch {
150 return [];
151 }
152}
153
154/**
155 * The agents table's definition columns, in the order `definitionColumns`
156 * gives their values. Statements are built from this one list, so a new
157 * field is added in one place.
158 */
159export const DEFINITION_COLUMNS = [
160 "handle",
161 "display_name",
162 "role",
163 "instructions",
164 "personality_preset",
165 "personality",
166 "routing",
167 "budget",
168 "autonomy",
169 "capacity",
170 "template",
171 "avatar_seed",
172 "title",
173 "responsibilities",
174 "subagents",
175 "faces",
176 "reading",
177 "skills_off",
178] as const;
179
180/** A definition's values, in `DEFINITION_COLUMNS` order. */
181export function definitionColumns(d: Definition): (string | number | null)[] {
182 return [
183 d.handle,
184 d.display_name,
185 d.role,
186 d.instructions,
187 d.personality_preset,
188 d.personality,
189 JSON.stringify(d.routing),
190 JSON.stringify(d.budget),
191 JSON.stringify(d.autonomy),
192 d.capacity,
193 d.template,
194 d.avatar_seed,
195 d.title,
196 JSON.stringify(d.responsibilities),
197 JSON.stringify(d.subagents),
198 d.faces,
199 JSON.stringify(d.reading ?? []),
200 JSON.stringify(d.skills_off ?? []),
201 ];
202}
203
204/**
205 * A new agent row: `id`, `workspace_id`, the definition, then `extra`
206 * columns (version, builtin, who made it, when), all bound in order.
207 */
208export function insertAgent(db: D1Database, id: string, workspaceId: string, d: Definition, extra: Record<string, string | number>, orIgnore = false): D1PreparedStatement {
209 const names = ["id", "workspace_id", ...DEFINITION_COLUMNS, ...Object.keys(extra)];
210 const values = [id, workspaceId, ...definitionColumns(d), ...Object.values(extra)];
211 return db
212 .prepare(`INSERT ${orIgnore ? "OR IGNORE " : ""}INTO agents (${names.join(", ")}) VALUES (${names.map(() => "?").join(", ")})`)
213 .bind(...values);
214}
215
216/** Sets a row's definition, from the version read only, with `extra` columns. */
217export function updateAgent(db: D1Database, id: string, readVersion: number, d: Definition, extra: Record<string, string | number>): D1PreparedStatement {
218 const names = [...DEFINITION_COLUMNS, ...Object.keys(extra)];
219 return db
220 .prepare(`UPDATE agents SET ${names.map((name) => `${name} = ?`).join(", ")} WHERE id = ? AND version = ?`)
221 .bind(...definitionColumns(d), ...Object.values(extra), id, readVersion);
222}
223
224/** A version of a definition, as saved. */
225export function versionStatement(db: D1Database, agentId: string, version: number, d: Definition, by: string, at: string): D1PreparedStatement {
226 return db
227 .prepare("INSERT INTO agent_versions (agent_id, version, definition, changed_by, created_at) VALUES (?, ?, ?, ?, ?)")
228 .bind(agentId, version, JSON.stringify(d), by, at);
229}