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