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