Skip to content
266 linesCodeBlameRaw
1/**
2 * The agent form (components/agents-mode.tsx) as the agents service takes
3 * it: what each field means, and the checks made before it is sent. Pure,
4 * so it is tested on its own.
5 */
6import type { AgentEffort, AgentLook, ModelTier, NewWorkspaceAgent, PersonalityPreset, SubagentDef, WorkspaceAgent } from "@g1t/contracts";
7import { readLook } from "@g1t/contracts/agent-look";
8
9import { plainDollars } from "./money.ts";
10
11/** An effort setting from a form: one of the five, or null. */
12export function readEffort(value: FormDataEntryValue | null): AgentEffort | null {
13 const text = String(value ?? "");
14 return text === "auto" || text === "low" || text === "medium" || text === "high" || text === "max" ? text : null;
15}
16
17/** The tiers, cheapest first: `MODEL_TIERS` in the contracts. */
18const MODEL_TIERS: ModelTier[] = ["small", "large", "frontier"];
19
20export const PRESETS: { value: PersonalityPreset; label: string; about: string }[] = [
21 { value: "crisp", label: "Crisp", about: "Clear and to the point, with enough context to act on." },
22 { value: "friendly", label: "Friendly", about: "Warm and encouraging; explains as it goes." },
23 { value: "socratic", label: "Socratic", about: "Asks before it assumes; draws out what you mean." },
24 { value: "terse", label: "Terse operator", about: "As few words as will do. Status, then done." },
25];
26
27export const TIER_LABELS: Record<ModelTier, string> = {
28 small: "Fast: replies and triage",
29 large: "Standard: making changes, most reviews",
30 frontier: "Most capable: planning and hard work",
31};
32
33export const AUTONOMY = {
34 open_pull_requests: { label: "Open pull requests", options: [["alone", "On its own"], ["approval", "After approval"]] },
35 merge: { label: "Merge", options: [["alone", "On its own"], ["approval", "After approval"], ["never", "Never"]] },
36 deploy_production: { label: "Deploy to production", options: [["approval", "After approval"], ["never", "Never"]] },
37 edit_docs: { label: "Edit docs", options: [["alone", "Directly"], ["suggest", "As suggestions"]] },
38} as const;
39
40/** The handle as people type it: lowercase letters, digits and dashes. */
41export function cleanHandle(typed: string): string {
42 return typed
43 .trim()
44 .toLowerCase()
45 .replace(/^@/, "")
46 .replace(/[\s_]+/g, "-")
47 .replace(/[^a-z0-9-]/g, "")
48 .replace(/-{2,}/g, "-")
49 .replace(/^-|-$/g, "")
50 .slice(0, 39);
51}
52
53/** Handles the site's own addresses use, and g1t's: never an agent's. */
54const RESERVED_HANDLES = new Set(["g1t", "new", "runs", "fleet"]);
55
56function tier(value: FormDataEntryValue | null): ModelTier | null {
57 const text = String(value ?? "");
58 return (MODEL_TIERS as string[]).includes(text) ? (text as ModelTier) : null;
59}
60
61function pick<T extends string>(value: FormDataEntryValue | null, allowed: readonly (readonly [T, string])[], fallback: T): T {
62 const text = String(value ?? "");
63 return (allowed.find(([key]) => key === text)?.[0] ?? fallback) as T;
64}
65
66export type AgentFormResult = { ok: true; input: NewWorkspaceAgent } | { ok: false; errors: Record<string, string> };
67
68/** The form, checked: the agent to make or the changes to save, or what to fix, by field. */
69export function readAgentForm(form: FormData, options: { orchestrator?: boolean } = {}): AgentFormResult {
70 const errors: Record<string, string> = {};
71 const display_name = String(form.get("display_name") ?? "").trim();
72 const handle = cleanHandle(String(form.get("handle") ?? "") || display_name);
73 const title = String(form.get("title") ?? "").trim();
74 const responsibilities = form
75 .getAll("responsibility")
76 .map((value) => String(value).trim())
77 .filter(Boolean)
78 .slice(0, MAX_RESPONSIBILITIES);
79 const subagents = readSubagents(form.get("subagents"));
80 // Docs spaces it reads first; the agents service checks them.
81 const reading = [...new Set(form.getAll("reading").map((value) => String(value).trim()).filter(Boolean))].slice(0, 10);
82 const instructions = String(form.get("instructions") ?? "").trim();
83 if (!display_name) errors.display_name = "Give it a name.";
84 if (!handle) errors.handle = "Give it a handle, like @ship.";
85 else if (RESERVED_HANDLES.has(handle) && !(options.orchestrator && handle === "g1t")) errors.handle = `@${handle} is taken by g1t.`;
86 if (!title && !options.orchestrator) errors.title = "Give it a title, like QA Engineer.";
87 if (subagents == null) errors.subagents = "The subagents couldn't be read. Edit one and save again.";
88 // g1t's job is fixed; what is written here is added to it, and may be nothing.
89 if (!instructions && !options.orchestrator) errors.instructions = "Tell it what it's responsible for.";
90 const floor = tier(form.get("floor"));
91 const ceiling = tier(form.get("ceiling"));
92 if (floor && ceiling && MODEL_TIERS.indexOf(floor) > MODEL_TIERS.indexOf(ceiling)) errors.ceiling = "The ceiling can't be below the floor.";
93 const budget = {
94 monthly_micros: microsFromDollars(form.get("monthly")),
95 daily_micros: microsFromDollars(form.get("daily")),
96 task_micros: microsFromDollars(form.get("task")),
97 };
98 for (const [field, value] of [["monthly", budget.monthly_micros], ["daily", budget.daily_micros], ["task", budget.task_micros]] as const) {
99 if (value != null && Number.isNaN(value)) errors[field] = "A sum in dollars, like 25.";
100 }
101 const capacity = Number(form.get("capacity") ?? 3);
102 if (!Number.isInteger(capacity) || capacity < 1 || capacity > 20) errors.capacity = "Between 1 and 20.";
103 const providers = String(form.get("providers") ?? "any");
104 const pinned = String(form.get("pinned") ?? "").trim();
105 // How hard it works; a form without the control leaves it as it is.
106 const effort = readEffort(form.get("effort"));
107 if (pinned && !/^[\w.-]+\/[\w.:@-]+$/.test(pinned)) errors.pinned = "As provider/model.";
108 if (Object.keys(errors).length > 0) return { ok: false, errors };
109 return {
110 ok: true,
111 input: {
112 handle,
113 display_name,
114 // Its title, made by the agents service.
115 role: "",
116 title,
117 responsibilities,
118 subagents: subagents ?? [],
119 reading,
120 instructions,
121 personality_preset: pick(form.get("personality_preset"), PRESETS.map((p) => [p.value, p.label] as const), "crisp"),
122 personality: String(form.get("personality") ?? "").trim(),
123 routing: {
124 floor,
125 ceiling,
126 providers: providers === "any" ? [] : [providers],
127 pinned: pinned || null,
128 ...(effort ? { effort } : {}),
129 },
130 budget,
131 autonomy: {
132 open_pull_requests: pick(form.get("open_pull_requests"), AUTONOMY.open_pull_requests.options, "alone"),
133 merge: pick(form.get("merge"), AUTONOMY.merge.options, "approval"),
134 deploy_production: pick(form.get("deploy_production"), AUTONOMY.deploy_production.options, "approval"),
135 edit_docs: pick(form.get("edit_docs"), AUTONOMY.edit_docs.options, "suggest"),
136 },
137 capacity,
138 template: String(form.get("template") ?? "") || null,
139 // A drafted agent carries its scope, the skills it keeps off and its face.
140 ...(form.get("scope") === "personal" || form.get("scope") === "workspace" ? { scope: form.get("scope") as "personal" | "workspace" } : {}),
141 ...(form.has("skills_off") ? { skills_off: String(form.get("skills_off") ?? "").split(",").map((id) => id.trim()).filter(Boolean) } : {}),
142 ...(String(form.get("avatar_seed") ?? "").trim() ? { avatar_seed: String(form.get("avatar_seed")).trim().slice(0, 64) } : {}),
143 // Its face: a chosen look, or `null` for the seed's; a form without the field leaves it as it is.
144 ...readLookField(form.get("look")),
145 },
146 };
147}
148
149/** Most responsibilities an agent lists. */
150export const MAX_RESPONSIBILITIES = 8;
151
152/**
153 * The Face section's field: `null` (the word) gives the agent back its
154 * seed's face, a look as JSON sets one, and nothing or something that is
155 * not a look changes nothing.
156 */
157export function readLookField(value: FormDataEntryValue | null): { look?: AgentLook | null } {
158 const text = String(value ?? "").trim();
159 if (!text) return {};
160 if (text === "null") return { look: null };
161 const look = readLook(text);
162 return look ? { look } : {};
163}
164
165/** The subagents, as the form sends them (JSON); null when they are not valid. */
166function readSubagents(value: FormDataEntryValue | null): SubagentDef[] | null {
167 const text = String(value ?? "").trim();
168 if (!text) return [];
169 try {
170 const parsed = JSON.parse(text) as unknown;
171 if (!Array.isArray(parsed)) return null;
172 return parsed.filter(
173 (item): item is SubagentDef =>
174 item != null && typeof item === "object" && typeof (item as SubagentDef).name === "string" && typeof (item as SubagentDef).description === "string",
175 );
176 } catch {
177 return null;
178 }
179}
180
181/** A subagent's name: lowercase letters, digits and hyphens. */
182export function cleanSubagentName(typed: string): string {
183 return cleanHandle(typed).slice(0, 40);
184}
185
186/**
187 * A subagent's routing limits, kept within its agent's: a floor below the
188 * agent's is raised to it, a ceiling above the agent's is lowered to it,
189 * and a floor above the ceiling comes down to it.
190 */
191export function clampRouting(
192 sub: { floor: ModelTier | null; ceiling: ModelTier | null },
193 agent: { floor: ModelTier | null; ceiling: ModelTier | null },
194): { floor: ModelTier | null; ceiling: ModelTier | null } {
195 const rank = (tier: ModelTier) => MODEL_TIERS.indexOf(tier);
196 let floor = sub.floor;
197 let ceiling = sub.ceiling;
198 if (agent.floor && (!floor || rank(floor) < rank(agent.floor))) floor = agent.floor;
199 if (agent.ceiling && (!ceiling || rank(ceiling) > rank(agent.ceiling))) ceiling = agent.ceiling;
200 if (floor && ceiling && rank(floor) > rank(ceiling)) floor = ceiling;
201 return { floor, ceiling };
202}
203
204/**
205 * The teams a new agent joins as it is made (the form's "Add to teams"),
206 * by slug: membership is the team's, never part of the agent.
207 */
208export function readTeams(form: FormData): string[] {
209 return [...new Set(form.getAll("teams").map((value) => String(value).trim().toLowerCase()).filter((slug) => /^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)))].slice(0, 20);
210}
211
212/** What the form starts from: an agent being edited, a template, or nothing. */
213export type AgentDraft = Pick<
214 WorkspaceAgent,
215 | "handle"
216 | "display_name"
217 | "role"
218 | "title"
219 | "responsibilities"
220 | "subagents"
221 | "reading"
222 | "instructions"
223 | "personality_preset"
224 | "personality"
225 | "routing"
226 | "budget"
227 | "autonomy"
228 | "capacity"
229 | "template"
230 | "look"
231>;
232
233export const BLANK_DRAFT: AgentDraft = {
234 handle: "",
235 display_name: "",
236 role: "",
237 title: "",
238 responsibilities: [],
239 subagents: [],
240 reading: [],
241 instructions: "",
242 personality_preset: "crisp",
243 personality: "",
244 routing: { floor: null, ceiling: null, providers: [], pinned: null, effort: "auto" },
245 budget: { monthly_micros: 50_000_000, daily_micros: null, task_micros: 5_000_000 },
246 autonomy: { open_pull_requests: "alone", merge: "approval", deploy_production: "approval", edit_docs: "suggest" },
247 capacity: 3,
248 template: null,
249 look: null,
250};
251
252/** Dollars from micro-dollars, for a form field: empty for none, `20` for a whole sum, else to the cent. */
253export function dollarsField(micros: number | null | undefined): string {
254 if (micros == null) return "";
255 return plainDollars(micros, { whole: true });
256}
257
258/** Micro-dollars from a form field in dollars: null when empty, NaN when not a sum. */
259export function microsFromDollars(value: FormDataEntryValue | null): number | null {
260 const text = String(value ?? "").trim().replace(/^\$/, "").replaceAll(",", "");
261 if (!text) return null;
262 const dollars = Number(text);
263 if (!Number.isFinite(dollars) || dollars < 0) return Number.NaN;
264 return Math.round(dollars * 1_000_000);
265}
266