| 1 | /** |
| 2 | * An agent's definition: what a new one gets by default, what a change may |
| 3 | * set, and how a stored row reads. Pure, so it is tested on its own. |
| 4 | */ |
| 5 | import type { |
| 6 | AgentAutonomy, |
| 7 | AgentBudget, |
| 8 | AgentRouting, |
| 9 | NewWorkspaceAgent, |
| 10 | PersonalityPreset, |
| 11 | } from "@g1t/contracts"; |
| 12 | |
| 13 | import { checkHandle } from "./handle.ts"; |
| 14 | import { isTier, limitsAgree } from "./routing.ts"; |
| 15 | |
| 16 | export const PRESETS: PersonalityPreset[] = ["crisp", "friendly", "socratic", "terse"]; |
| 17 | |
| 18 | export const DEFAULT_ROUTING: AgentRouting = { floor: null, ceiling: null, providers: [], pinned: null }; |
| 19 | export const DEFAULT_BUDGET: AgentBudget = { monthly_micros: null, daily_micros: null, task_micros: null }; |
| 20 | /** What an agent may do alone until someone says otherwise: open pull requests; the rest asks. */ |
| 21 | export const DEFAULT_AUTONOMY: AgentAutonomy = { |
| 22 | open_pull_requests: "alone", |
| 23 | merge: "approval", |
| 24 | deploy_production: "approval", |
| 25 | edit_docs: "suggest", |
| 26 | }; |
| 27 | /** Tasks at once (docs/WORKSPACE.md, "Decisions to confirm"). */ |
| 28 | export const DEFAULT_CAPACITY = 3; |
| 29 | export const MAX_CAPACITY = 10; |
| 30 | |
| 31 | const LIMITS = { displayName: 64, role: 120, instructions: 8000, personality: 1000, providers: 10, pinned: 200 }; |
| 32 | /** $100,000 in millionths: a cap above this is a typo. */ |
| 33 | const MAX_MICROS = 100_000_000_000; |
| 34 | |
| 35 | /** Everything a definition holds, complete: what is stored and versioned. */ |
| 36 | export type Definition = { |
| 37 | handle: string; |
| 38 | display_name: string; |
| 39 | role: string; |
| 40 | instructions: string; |
| 41 | personality_preset: PersonalityPreset; |
| 42 | personality: string; |
| 43 | routing: AgentRouting; |
| 44 | budget: AgentBudget; |
| 45 | autonomy: AgentAutonomy; |
| 46 | capacity: number; |
| 47 | template: string | null; |
| 48 | }; |
| 49 | |
| 50 | export type Checked<T> = { ok: true; value: T } | { ok: false; message: string }; |
| 51 | |
| 52 | const bad = (message: string): { ok: false; message: string } => ({ ok: false, message }); |
| 53 | |
| 54 | function text(value: unknown, what: string, max: number, required: boolean): Checked<string> { |
| 55 | if (value === undefined || value === null) return required ? bad(`${what} is required.`) : { ok: true, value: "" }; |
| 56 | if (typeof value !== "string") return bad(`${what} is text.`); |
| 57 | const trimmed = value.trim(); |
| 58 | if (required && !trimmed) return bad(`${what} is required.`); |
| 59 | if (trimmed.length > max) return bad(`${what} is at most ${max} characters.`); |
| 60 | return { ok: true, value: trimmed }; |
| 61 | } |
| 62 | |
| 63 | function routingOf(base: AgentRouting, given: unknown): Checked<AgentRouting> { |
| 64 | if (given === undefined || given === null) return { ok: true, value: base }; |
| 65 | if (typeof given !== "object") return bad("Routing is an object."); |
| 66 | const g = given as Partial<AgentRouting>; |
| 67 | const next: AgentRouting = { ...base }; |
| 68 | for (const key of ["floor", "ceiling"] as const) { |
| 69 | if (g[key] === undefined) continue; |
| 70 | if (g[key] !== null && !isTier(g[key])) return bad(`The ${key} is small, large, frontier or none.`); |
| 71 | next[key] = g[key] ?? null; |
| 72 | } |
| 73 | if (!limitsAgree(next.floor, next.ceiling)) return bad("The floor is above the ceiling: lower the floor or raise the ceiling."); |
| 74 | if (g.providers !== undefined) { |
| 75 | if (!Array.isArray(g.providers) || g.providers.some((p) => typeof p !== "string" || !p.trim())) return bad("Providers is a list of names."); |
| 76 | const providers = [...new Set(g.providers.map((p) => p.trim()))]; |
| 77 | if (providers.length > LIMITS.providers) return bad(`An agent names at most ${LIMITS.providers} providers.`); |
| 78 | next.providers = providers; |
| 79 | } |
| 80 | if (g.pinned !== undefined) { |
| 81 | if (g.pinned === null || g.pinned === "") next.pinned = null; |
| 82 | else if (typeof g.pinned !== "string" || g.pinned.trim().length > LIMITS.pinned || !/^[^/\s]+\/\S+$/.test(g.pinned.trim())) { |
| 83 | return bad("A pinned model is written provider/model."); |
| 84 | } else next.pinned = g.pinned.trim(); |
| 85 | } |
| 86 | return { ok: true, value: next }; |
| 87 | } |
| 88 | |
| 89 | function budgetOf(base: AgentBudget, given: unknown): Checked<AgentBudget> { |
| 90 | if (given === undefined || given === null) return { ok: true, value: base }; |
| 91 | if (typeof given !== "object") return bad("Budget is an object."); |
| 92 | const g = given as Partial<AgentBudget>; |
| 93 | const next: AgentBudget = { ...base }; |
| 94 | for (const key of ["monthly_micros", "daily_micros", "task_micros"] as const) { |
| 95 | const value = g[key]; |
| 96 | if (value === undefined) continue; |
| 97 | if (value === null) next[key] = null; |
| 98 | else if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_MICROS) { |
| 99 | return bad("A budget is a whole number of millionths of a dollar, or none."); |
| 100 | } else next[key] = value; |
| 101 | } |
| 102 | return { ok: true, value: next }; |
| 103 | } |
| 104 | |
| 105 | const AUTONOMY_CHOICES: { [K in keyof AgentAutonomy]: AgentAutonomy[K][] } = { |
| 106 | open_pull_requests: ["alone", "approval"], |
| 107 | merge: ["alone", "approval", "never"], |
| 108 | deploy_production: ["approval", "never"], |
| 109 | edit_docs: ["alone", "suggest"], |
| 110 | }; |
| 111 | |
| 112 | function autonomyOf(base: AgentAutonomy, given: unknown): Checked<AgentAutonomy> { |
| 113 | if (given === undefined || given === null) return { ok: true, value: base }; |
| 114 | if (typeof given !== "object") return bad("Autonomy is an object."); |
| 115 | const next = { ...base } as Record<string, string>; |
| 116 | for (const [key, choices] of Object.entries(AUTONOMY_CHOICES) as [string, string[]][]) { |
| 117 | const value = (given as Record<string, unknown>)[key]; |
| 118 | if (value === undefined) continue; |
| 119 | if (typeof value !== "string" || !choices.includes(value)) return bad(`${key} is one of ${choices.join(", ")}.`); |
| 120 | next[key] = value; |
| 121 | } |
| 122 | return { ok: true, value: next as AgentAutonomy }; |
| 123 | } |
| 124 | |
| 125 | /** |
| 126 | * `changes` applied to `base` (a new agent's defaults, or its current |
| 127 | * definition), checked. `templates` are the template ids an agent may name. |
| 128 | */ |
| 129 | export function applyChanges(base: Definition | null, changes: Partial<NewWorkspaceAgent>, templates: string[]): Checked<Definition> { |
| 130 | if (!changes || typeof changes !== "object") return bad("Send the agent's fields."); |
| 131 | const creating = base === null; |
| 132 | const from: Definition = base ?? { |
| 133 | handle: "", |
| 134 | display_name: "", |
| 135 | role: "", |
| 136 | instructions: "", |
| 137 | personality_preset: "crisp", |
| 138 | personality: "", |
| 139 | routing: DEFAULT_ROUTING, |
| 140 | budget: DEFAULT_BUDGET, |
| 141 | autonomy: DEFAULT_AUTONOMY, |
| 142 | capacity: DEFAULT_CAPACITY, |
| 143 | template: null, |
| 144 | }; |
| 145 | const next: Definition = { ...from }; |
| 146 | if (creating || changes.handle !== undefined) { |
| 147 | const handle = checkHandle(changes.handle); |
| 148 | if (!handle.ok) return bad(handle.message); |
| 149 | next.handle = handle.handle; |
| 150 | } |
| 151 | const fields = [ |
| 152 | ["display_name", "A display name", LIMITS.displayName, true], |
| 153 | ["role", "The role", LIMITS.role, true], |
| 154 | ["instructions", "The instructions", LIMITS.instructions, true], |
| 155 | ["personality", "The personality", LIMITS.personality, false], |
| 156 | ] as const; |
| 157 | for (const [key, what, max, required] of fields) { |
| 158 | if (!creating && changes[key] === undefined) continue; |
| 159 | const value = text(changes[key], what, max, required); |
| 160 | if (!value.ok) return value; |
| 161 | next[key] = value.value; |
| 162 | } |
| 163 | if (changes.personality_preset !== undefined) { |
| 164 | if (!PRESETS.includes(changes.personality_preset)) return bad(`The personality preset is one of ${PRESETS.join(", ")}.`); |
| 165 | next.personality_preset = changes.personality_preset; |
| 166 | } |
| 167 | const routing = routingOf(next.routing, changes.routing); |
| 168 | if (!routing.ok) return routing; |
| 169 | next.routing = routing.value; |
| 170 | const budget = budgetOf(next.budget, changes.budget); |
| 171 | if (!budget.ok) return budget; |
| 172 | next.budget = budget.value; |
| 173 | const autonomy = autonomyOf(next.autonomy, changes.autonomy); |
| 174 | if (!autonomy.ok) return autonomy; |
| 175 | next.autonomy = autonomy.value; |
| 176 | if (changes.capacity !== undefined) { |
| 177 | const capacity = changes.capacity; |
| 178 | if (typeof capacity !== "number" || !Number.isInteger(capacity) || capacity < 1 || capacity > MAX_CAPACITY) { |
| 179 | return bad(`Capacity is 1 to ${MAX_CAPACITY} tasks at once.`); |
| 180 | } |
| 181 | next.capacity = capacity; |
| 182 | } |
| 183 | if (changes.template !== undefined) { |
| 184 | if (changes.template !== null && !templates.includes(changes.template)) return bad("There is no such template."); |
| 185 | next.template = changes.template; |
| 186 | } |
| 187 | return { ok: true, value: next }; |
| 188 | } |
| 189 | |
| 190 | /** A stored JSON column, read defensively: a bad value is the default. */ |
| 191 | export function readJson<T extends object>(raw: unknown, fallback: T): T { |
| 192 | if (typeof raw !== "string") return fallback; |
| 193 | try { |
| 194 | const parsed = JSON.parse(raw) as unknown; |
| 195 | return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...fallback, ...(parsed as T) } : fallback; |
| 196 | } catch { |
| 197 | return fallback; |
| 198 | } |
| 199 | } |