Skip to content
359 linesCodeBlameRaw
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 */
5import type {
6 AgentAutonomy,
7 AgentBudget,
8 AgentRouting,
9 NewWorkspaceAgent,
10 PersonalityPreset,
11 AgentFaces,
12 SubagentDef,
13} from "@g1t/contracts";
14import { FOUNDATIONAL_SKILL_IDS } from "../../../packages/contracts/src/skills.ts";
15
16import { checkHandle } from "./handle.ts";
17import { isEffort, isTier, limitsAgree } from "./routing.ts";
18
19/** A library skill's id (skill-library.ts), as `skills_off` may name it. */
20const LIBRARY_SKILL_ID = /^skl_[0-9a-z]{26}$/;
21
22export const PRESETS: PersonalityPreset[] = ["crisp", "friendly", "socratic", "terse"];
23
24export const DEFAULT_ROUTING: AgentRouting = { floor: null, ceiling: null, providers: [], pinned: null, effort: "auto" };
25export const DEFAULT_BUDGET: AgentBudget = { monthly_micros: null, daily_micros: null, task_micros: null };
26/** What an agent may do alone until someone says otherwise: open pull requests; the rest asks. */
27export const DEFAULT_AUTONOMY: AgentAutonomy = {
28 open_pull_requests: "alone",
29 merge: "approval",
30 deploy_production: "approval",
31 edit_docs: "suggest",
32};
33/** Tasks at once. */
34export const DEFAULT_CAPACITY = 3;
35export const MAX_CAPACITY = 10;
36
37const LIMITS = { displayName: 64, role: 120, title: 60, duty: 160, instructions: 8000, personality: 1000, providers: 10, pinned: 200 };
38/** $100,000 in millionths: a cap above this is a typo. */
39const MAX_MICROS = 100_000_000_000;
40
41/** Everything a definition holds, complete: what is stored and versioned. */
42export type Definition = {
43 handle: string;
44 display_name: string;
45 role: string;
46 instructions: string;
47 personality_preset: PersonalityPreset;
48 personality: string;
49 routing: AgentRouting;
50 budget: AgentBudget;
51 autonomy: AgentAutonomy;
52 capacity: number;
53 template: string | null;
54 /** What its generated avatar is drawn from. */
55 avatar_seed: string;
56 title: string;
57 responsibilities: string[];
58 subagents: SubagentDef[];
59 faces: AgentFaces;
60 /** Spaces whose artifacts it reads first. */
61 reading: string[];
62 /** Foundational skills turned off for it, by id (@g1t/contracts skills.ts). */
63 skills_off: string[];
64};
65
66/**
67 * The one-line role its title makes: "QA Engineer". Which teams it is on
68 * are team memberships (identity), never part of the agent.
69 */
70export function roleOf(d: Pick<Definition, "title">): string {
71 return d.title.trim();
72}
73
74/**
75 * The role an agent was given when it carried its own team or department,
76 * made from them: "QA Engineer on the qa team", "QA Engineer, QA". A
77 * stored role that is exactly this follows the title (store.ts).
78 */
79export function legacyRoleOf(title: string, team: string | null, department: string | null): string {
80 const t = title.trim();
81 if (!t) return "";
82 if (team) return `${t} on the ${team} team`;
83 return department?.trim() ? `${t}, ${department.trim()}` : t;
84}
85
86export const MAX_SUBAGENTS = 8;
87
88export type Checked<T> = { ok: true; value: T } | { ok: false; message: string };
89
90const bad = (message: string): { ok: false; message: string } => ({ ok: false, message });
91
92function text(value: unknown, what: string, max: number, required: boolean): Checked<string> {
93 if (value === undefined || value === null) return required ? bad(`${what} is required.`) : { ok: true, value: "" };
94 if (typeof value !== "string") return bad(`${what} is text.`);
95 const trimmed = value.trim();
96 if (required && !trimmed) return bad(`${what} is required.`);
97 if (trimmed.length > max) return bad(`${what} is at most ${max} characters.`);
98 return { ok: true, value: trimmed };
99}
100
101function routingOf(base: AgentRouting, given: unknown): Checked<AgentRouting> {
102 if (given === undefined || given === null) return { ok: true, value: base };
103 if (typeof given !== "object") return bad("Routing is an object.");
104 const g = given as Partial<AgentRouting>;
105 const next: AgentRouting = { ...base };
106 for (const key of ["floor", "ceiling"] as const) {
107 if (g[key] === undefined) continue;
108 if (g[key] !== null && !isTier(g[key])) return bad(`The ${key} is small, large, frontier or none.`);
109 next[key] = g[key] ?? null;
110 }
111 if (!limitsAgree(next.floor, next.ceiling)) return bad("The floor is above the ceiling: lower the floor or raise the ceiling.");
112 if (g.providers !== undefined) {
113 if (!Array.isArray(g.providers) || g.providers.some((p) => typeof p !== "string" || !p.trim())) return bad("Providers is a list of names.");
114 const providers = [...new Set(g.providers.map((p) => p.trim()))];
115 if (providers.length > LIMITS.providers) return bad(`An agent names at most ${LIMITS.providers} providers.`);
116 next.providers = providers;
117 }
118 if (g.effort !== undefined) {
119 if (g.effort !== null && !isEffort(g.effort)) return bad("Effort is auto, low, medium, high or max.");
120 next.effort = g.effort ?? "auto";
121 }
122 if (g.pinned !== undefined) {
123 if (g.pinned === null || g.pinned === "") next.pinned = null;
124 else if (typeof g.pinned !== "string" || g.pinned.trim().length > LIMITS.pinned || !/^[^/\s]+\/\S+$/.test(g.pinned.trim())) {
125 return bad("A pinned model is written provider/model.");
126 } else next.pinned = g.pinned.trim();
127 }
128 return { ok: true, value: next };
129}
130
131function budgetOf(base: AgentBudget, given: unknown): Checked<AgentBudget> {
132 if (given === undefined || given === null) return { ok: true, value: base };
133 if (typeof given !== "object") return bad("Budget is an object.");
134 const g = given as Partial<AgentBudget>;
135 const next: AgentBudget = { ...base };
136 for (const key of ["monthly_micros", "daily_micros", "task_micros"] as const) {
137 const value = g[key];
138 if (value === undefined) continue;
139 if (value === null) next[key] = null;
140 else if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_MICROS) {
141 return bad("A budget is a whole number of millionths of a dollar, or none.");
142 } else next[key] = value;
143 }
144 return { ok: true, value: next };
145}
146
147const AUTONOMY_CHOICES: { [K in keyof AgentAutonomy]: AgentAutonomy[K][] } = {
148 open_pull_requests: ["alone", "approval"],
149 merge: ["alone", "approval", "never"],
150 deploy_production: ["approval", "never"],
151 edit_docs: ["alone", "suggest"],
152};
153
154function autonomyOf(base: AgentAutonomy, given: unknown): Checked<AgentAutonomy> {
155 if (given === undefined || given === null) return { ok: true, value: base };
156 if (typeof given !== "object") return bad("Autonomy is an object.");
157 const next = { ...base } as Record<string, string>;
158 for (const [key, choices] of Object.entries(AUTONOMY_CHOICES) as [string, string[]][]) {
159 const value = (given as Record<string, unknown>)[key];
160 if (value === undefined) continue;
161 if (typeof value !== "string" || !choices.includes(value)) return bad(`${key} is one of ${choices.join(", ")}.`);
162 next[key] = value;
163 }
164 return { ok: true, value: next as AgentAutonomy };
165}
166
167function responsibilitiesOf(given: unknown): Checked<string[]> {
168 if (!Array.isArray(given) || given.some((d) => typeof d !== "string")) return bad("Responsibilities are a list of short duties.");
169 const duties = [...new Set((given as string[]).map((d) => d.trim()).filter(Boolean))];
170 if (duties.length && (duties.length < 2 || duties.length > 8)) return bad("Give 2 to 8 responsibilities, or none yet.");
171 if (duties.some((d) => d.length > LIMITS.duty)) return bad(`Each responsibility is at most ${LIMITS.duty} characters.`);
172 return { ok: true, value: duties };
173}
174
175const ORDER = ["small", "large", "frontier"] as const;
176
177/** A subagent's limits held within its agent's: never a lower floor, never a higher ceiling. */
178export function withinParent(
179 sub: { floor: AgentRouting["floor"]; ceiling: AgentRouting["ceiling"] },
180 parent: Pick<AgentRouting, "floor" | "ceiling">,
181): { floor: AgentRouting["floor"]; ceiling: AgentRouting["ceiling"] } {
182 const at = (tier: AgentRouting["floor"]) => (tier ? ORDER.indexOf(tier) : -1);
183 let floor = sub.floor;
184 let ceiling = sub.ceiling;
185 if (parent.floor && at(floor) < at(parent.floor)) floor = parent.floor;
186 if (parent.ceiling && (!ceiling || at(ceiling) > at(parent.ceiling))) ceiling = parent.ceiling;
187 // Held inside, a floor can end above the ceiling: the ceiling, the spending rail, wins.
188 if (floor && ceiling && at(floor) > at(ceiling)) floor = ceiling;
189 return { floor, ceiling };
190}
191
192function subagentsOf(given: unknown): Checked<SubagentDef[]> {
193 if (!Array.isArray(given)) return bad("Subagents are a list.");
194 if (given.length > MAX_SUBAGENTS) return bad(`An agent keeps at most ${MAX_SUBAGENTS} subagents.`);
195 const out: SubagentDef[] = [];
196 for (const raw of given as Partial<SubagentDef>[]) {
197 if (!raw || typeof raw !== "object") return bad("Each subagent has a name, a description and instructions.");
198 const name = typeof raw.name === "string" ? raw.name.trim().toLowerCase() : "";
199 if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,31}$/.test(name)) return bad("A subagent's name is 2 to 32 lowercase letters, digits and single hyphens.");
200 if (out.some((sub) => sub.name === name)) return bad(`Two subagents are called ${name}.`);
201 const description = text(raw.description, `${name}'s description`, 200, true);
202 if (!description.ok) return description;
203 const instructions = text(raw.instructions, `${name}'s instructions`, 4000, true);
204 if (!instructions.ok) return instructions;
205 const routing = raw.routing ?? { floor: null, ceiling: null };
206 for (const tier of [routing.floor, routing.ceiling]) {
207 if (tier !== null && tier !== undefined && !isTier(tier)) return bad(`${name}'s limits are small, large, frontier or none.`);
208 }
209 if (!limitsAgree(routing.floor ?? null, routing.ceiling ?? null)) return bad(`${name}'s floor is above its ceiling.`);
210 const parallel = raw.max_parallel ?? 2;
211 if (typeof parallel !== "number" || !Number.isInteger(parallel) || parallel < 1 || parallel > 8) return bad(`${name} runs 1 to 8 at once.`);
212 out.push({
213 name,
214 description: description.value,
215 instructions: instructions.value,
216 routing: { floor: routing.floor ?? null, ceiling: routing.ceiling ?? null },
217 max_parallel: parallel,
218 });
219 }
220 return { ok: true, value: out };
221}
222
223/**
224 * `changes` applied to `base` (a new agent's defaults, or its current
225 * definition), checked. `templates` are the template ids an agent may name.
226 */
227export function applyChanges(
228 base: Definition | null,
229 changes: Partial<NewWorkspaceAgent>,
230 templates: string[],
231 options: { builtin?: boolean } = {},
232): Checked<Definition> {
233 if (!changes || typeof changes !== "object") return bad("Send the agent's fields.");
234 const creating = base === null;
235 const from: Definition = base ?? {
236 handle: "",
237 display_name: "",
238 role: "",
239 instructions: "",
240 personality_preset: "crisp",
241 personality: "",
242 routing: DEFAULT_ROUTING,
243 budget: DEFAULT_BUDGET,
244 autonomy: DEFAULT_AUTONOMY,
245 capacity: DEFAULT_CAPACITY,
246 template: null,
247 avatar_seed: "",
248 title: "",
249 responsibilities: [],
250 subagents: [],
251 faces: "internal",
252 reading: [],
253 skills_off: [],
254 };
255 const next: Definition = { ...from, skills_off: from.skills_off ?? [] };
256 // Whether the role was made from the title, so it follows it.
257 const roleDerived = !from.role || from.role === roleOf(from);
258 if (creating || changes.handle !== undefined) {
259 const handle = checkHandle(changes.handle);
260 if (!handle.ok) return bad(handle.message);
261 next.handle = handle.handle;
262 }
263 const fields = [
264 ["display_name", "A display name", LIMITS.displayName, true],
265 ["role", "The role", LIMITS.role, false],
266 ["title", "The title", LIMITS.title, false],
267 // The built-in agent's instructions are added to its fixed job, and may be empty.
268 ["instructions", "The instructions", LIMITS.instructions, !options.builtin],
269 ["personality", "The personality", LIMITS.personality, false],
270 ] as const;
271 for (const [key, what, max, required] of fields) {
272 if (!creating && changes[key] === undefined) continue;
273 const value = text(changes[key], what, max, required);
274 if (!value.ok) return value;
275 next[key] = value.value;
276 }
277 if (changes.responsibilities !== undefined) {
278 const duties = responsibilitiesOf(changes.responsibilities);
279 if (!duties.ok) return duties;
280 next.responsibilities = duties.value;
281 }
282 // A role left empty, or made from the title before, follows it.
283 if (!next.role || (changes.role === undefined && roleDerived)) next.role = roleOf(next);
284 if (!next.role) return bad("Give the agent a title or a one-line role.");
285 if (changes.avatar_seed !== undefined) {
286 const seed = text(changes.avatar_seed, "The avatar seed", 64, false);
287 if (!seed.ok) return seed;
288 next.avatar_seed = seed.value;
289 }
290 // A new face from the handle, unless one was chosen; renaming keeps the face.
291 if (!next.avatar_seed) next.avatar_seed = next.handle;
292 if (changes.personality_preset !== undefined) {
293 if (!PRESETS.includes(changes.personality_preset)) return bad(`The personality preset is one of ${PRESETS.join(", ")}.`);
294 next.personality_preset = changes.personality_preset;
295 }
296 const routing = routingOf(next.routing, changes.routing);
297 if (!routing.ok) return routing;
298 next.routing = routing.value;
299 const budget = budgetOf(next.budget, changes.budget);
300 if (!budget.ok) return budget;
301 next.budget = budget.value;
302 const autonomy = autonomyOf(next.autonomy, changes.autonomy);
303 if (!autonomy.ok) return autonomy;
304 next.autonomy = autonomy.value;
305 if (changes.capacity !== undefined) {
306 const capacity = changes.capacity;
307 if (typeof capacity !== "number" || !Number.isInteger(capacity) || capacity < 1 || capacity > MAX_CAPACITY) {
308 return bad(`Capacity is 1 to ${MAX_CAPACITY} tasks at once.`);
309 }
310 next.capacity = capacity;
311 }
312 if (changes.template !== undefined) {
313 if (changes.template !== null && !templates.includes(changes.template)) return bad("There is no such template.");
314 next.template = changes.template;
315 }
316 if (changes.subagents !== undefined) {
317 const subagents = subagentsOf(changes.subagents);
318 if (!subagents.ok) return subagents;
319 next.subagents = subagents.value;
320 }
321 if (changes.reading !== undefined) {
322 if (!Array.isArray(changes.reading)) return bad("Required reading is a list of spaces.");
323 const ids = [...new Set(changes.reading.filter((id): id is string => typeof id === "string").map((id) => id.trim()).filter(Boolean))];
324 if (ids.length > 10) return bad("An agent has at most 10 spaces of required reading.");
325 if (ids.some((id) => !/^[A-Za-z0-9_-]{1,80}$/.test(id))) return bad("That isn't a space.");
326 next.reading = ids;
327 }
328 if (changes.skills_off !== undefined) {
329 if (!Array.isArray(changes.skills_off)) return bad("Skills turned off are a list of skills.");
330 const ids = new Set(changes.skills_off.filter((id): id is string => typeof id === "string").map((id) => id.trim()).filter(Boolean));
331 // Foundational skills by id, and the library's by theirs (skl_…), which
332 // may be off before or after they are attached.
333 const library = [...ids].filter((id) => LIBRARY_SKILL_ID.test(id));
334 const unknown = [...ids].find((id) => !FOUNDATIONAL_SKILL_IDS.includes(id) && !LIBRARY_SKILL_ID.test(id));
335 if (unknown) return bad(`There is no skill called ${unknown.slice(0, 40)}.`);
336 if (library.length > 200) return bad("At most 200 library skills can be off for one agent.");
337 // In the skills' own order, so the same choice always reads the same.
338 next.skills_off = [...FOUNDATIONAL_SKILL_IDS.filter((id) => ids.has(id)), ...library.sort()];
339 }
340 if (changes.faces !== undefined) {
341 if (changes.faces === "customers") return bad("Customer-facing agents aren't available yet.");
342 if (changes.faces !== "internal") return bad("An agent faces internal: the workspace's own people.");
343 next.faces = "internal";
344 }
345 // Never wider than their agent, whichever of the two changed.
346 next.subagents = next.subagents.map((sub) => ({ ...sub, routing: withinParent(sub.routing, next.routing) }));
347 return { ok: true, value: next };
348}
349
350/** A stored JSON column, read defensively: a bad value is the default. */
351export function readJson<T extends object>(raw: unknown, fallback: T): T {
352 if (typeof raw !== "string") return fallback;
353 try {
354 const parsed = JSON.parse(raw) as unknown;
355 return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...fallback, ...(parsed as T) } : fallback;
356 } catch {
357 return fallback;
358 }
359}