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