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.
| Chat and workspace agents: channels, DMs and named agents you talk to | 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 | } |