Skip to content
460 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 AbilityLevel,
7 AbilitySetting,
8 AgentAbilities,
9 AgentAutonomy,
10 AgentBudget,
11 AgentLook,
12 AgentRouting,
13 McpServer,
14 NewWorkspaceAgent,
15 PersonalityPreset,
16 AgentFaces,
17 SubagentDef,
18} from "@g1t/contracts";
19import { ABILITY_LEVELS, EMPTY_ABILITIES, MAX_MCP_SERVERS, integrationAbilities, maxLevel, mcpAbility, withinLevel } from "../../../packages/contracts/src/abilities.ts";
20import { readLook } from "../../../packages/contracts/src/agent-look.ts";
21import { FOUNDATIONAL_SKILL_IDS } from "../../../packages/contracts/src/skills.ts";
22
23import { checkHandle } from "./handle.ts";
24import { isEffort, isTier, limitsAgree } from "./routing.ts";
25
26/** A library skill's id (skill-library.ts), as `skills_off` may name it. */
27const LIBRARY_SKILL_ID = /^skl_[0-9a-z]{26}$/;
28
29export const PRESETS: PersonalityPreset[] = ["crisp", "friendly", "socratic", "terse"];
30
31export const DEFAULT_ROUTING: AgentRouting = { floor: null, ceiling: null, providers: [], pinned: null, effort: "auto" };
32export const DEFAULT_BUDGET: AgentBudget = { monthly_micros: null, daily_micros: null, task_micros: null };
33/** What an agent may do alone until someone says otherwise: open pull requests; the rest asks. */
34export const DEFAULT_AUTONOMY: AgentAutonomy = {
35 open_pull_requests: "alone",
36 merge: "approval",
37 deploy_production: "approval",
38 edit_docs: "suggest",
39};
40/** Tasks at once. */
41export const DEFAULT_CAPACITY = 3;
42export const MAX_CAPACITY = 10;
43
44const LIMITS = { displayName: 64, role: 120, title: 60, duty: 160, instructions: 8000, personality: 1000, providers: 10, pinned: 200 };
45/** $100,000 in millionths: a cap above this is a typo. */
46const MAX_MICROS = 100_000_000_000;
47
48/** Everything a definition holds, complete: what is stored and versioned. */
49export type Definition = {
50 handle: string;
51 display_name: string;
52 role: string;
53 instructions: string;
54 personality_preset: PersonalityPreset;
55 personality: string;
56 routing: AgentRouting;
57 budget: AgentBudget;
58 autonomy: AgentAutonomy;
59 capacity: number;
60 template: string | null;
61 /** What its generated face is drawn from, when it has chosen none. */
62 avatar_seed: string;
63 /** Its face as chosen (@g1t/contracts agent-look.ts), or null for the seed's. */
64 look: AgentLook | null;
65 title: string;
66 responsibilities: string[];
67 subagents: SubagentDef[];
68 faces: AgentFaces;
69 /** Spaces whose artifacts it reads first. */
70 reading: string[];
71 /** Foundational skills turned off for it, by id (@g1t/contracts skills.ts). */
72 skills_off: string[];
73 /** Its abilities' levels and credentials, and its MCP servers (@g1t/contracts abilities.ts). */
74 abilities: AgentAbilities;
75};
76
77/**
78 * The one-line role its title makes: "QA Engineer". Which teams it is on
79 * are team memberships (identity), never part of the agent.
80 */
81export function roleOf(d: Pick<Definition, "title">): string {
82 return d.title.trim();
83}
84
85/**
86 * The role an agent was given when it carried its own team or department,
87 * made from them: "QA Engineer on the qa team", "QA Engineer, QA". A
88 * stored role that is exactly this follows the title (store.ts).
89 */
90export function legacyRoleOf(title: string, team: string | null, department: string | null): string {
91 const t = title.trim();
92 if (!t) return "";
93 if (team) return `${t} on the ${team} team`;
94 return department?.trim() ? `${t}, ${department.trim()}` : t;
95}
96
97export const MAX_SUBAGENTS = 8;
98
99export type Checked<T> = { ok: true; value: T } | { ok: false; message: string };
100
101const bad = (message: string): { ok: false; message: string } => ({ ok: false, message });
102
103function text(value: unknown, what: string, max: number, required: boolean): Checked<string> {
104 if (value === undefined || value === null) return required ? bad(`${what} is required.`) : { ok: true, value: "" };
105 if (typeof value !== "string") return bad(`${what} is text.`);
106 const trimmed = value.trim();
107 if (required && !trimmed) return bad(`${what} is required.`);
108 if (trimmed.length > max) return bad(`${what} is at most ${max} characters.`);
109 return { ok: true, value: trimmed };
110}
111
112function routingOf(base: AgentRouting, given: unknown): Checked<AgentRouting> {
113 if (given === undefined || given === null) return { ok: true, value: base };
114 if (typeof given !== "object") return bad("Routing is an object.");
115 const g = given as Partial<AgentRouting>;
116 const next: AgentRouting = { ...base };
117 for (const key of ["floor", "ceiling"] as const) {
118 if (g[key] === undefined) continue;
119 if (g[key] !== null && !isTier(g[key])) return bad(`The ${key} is small, large, frontier or none.`);
120 next[key] = g[key] ?? null;
121 }
122 if (!limitsAgree(next.floor, next.ceiling)) return bad("The floor is above the ceiling: lower the floor or raise the ceiling.");
123 if (g.providers !== undefined) {
124 if (!Array.isArray(g.providers) || g.providers.some((p) => typeof p !== "string" || !p.trim())) return bad("Providers is a list of names.");
125 const providers = [...new Set(g.providers.map((p) => p.trim()))];
126 if (providers.length > LIMITS.providers) return bad(`An agent names at most ${LIMITS.providers} providers.`);
127 next.providers = providers;
128 }
129 if (g.effort !== undefined) {
130 if (g.effort !== null && !isEffort(g.effort)) return bad("Effort is auto, low, medium, high or max.");
131 next.effort = g.effort ?? "auto";
132 }
133 if (g.pinned !== undefined) {
134 if (g.pinned === null || g.pinned === "") next.pinned = null;
135 else if (typeof g.pinned !== "string" || g.pinned.trim().length > LIMITS.pinned || !/^[^/\s]+\/\S+$/.test(g.pinned.trim())) {
136 return bad("A pinned model is written provider/model.");
137 } else next.pinned = g.pinned.trim();
138 }
139 return { ok: true, value: next };
140}
141
142function budgetOf(base: AgentBudget, given: unknown): Checked<AgentBudget> {
143 if (given === undefined || given === null) return { ok: true, value: base };
144 if (typeof given !== "object") return bad("Budget is an object.");
145 const g = given as Partial<AgentBudget>;
146 const next: AgentBudget = { ...base };
147 for (const key of ["monthly_micros", "daily_micros", "task_micros"] as const) {
148 const value = g[key];
149 if (value === undefined) continue;
150 if (value === null) next[key] = null;
151 else if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_MICROS) {
152 return bad("A budget is a whole number of millionths of a dollar, or none.");
153 } else next[key] = value;
154 }
155 return { ok: true, value: next };
156}
157
158const AUTONOMY_CHOICES: { [K in keyof AgentAutonomy]: AgentAutonomy[K][] } = {
159 open_pull_requests: ["alone", "approval"],
160 merge: ["alone", "approval", "never"],
161 deploy_production: ["approval", "never"],
162 edit_docs: ["alone", "suggest"],
163};
164
165function autonomyOf(base: AgentAutonomy, given: unknown): Checked<AgentAutonomy> {
166 if (given === undefined || given === null) return { ok: true, value: base };
167 if (typeof given !== "object") return bad("Autonomy is an object.");
168 const next = { ...base } as Record<string, string>;
169 for (const [key, choices] of Object.entries(AUTONOMY_CHOICES) as [string, string[]][]) {
170 const value = (given as Record<string, unknown>)[key];
171 if (value === undefined) continue;
172 if (typeof value !== "string" || !choices.includes(value)) return bad(`${key} is one of ${choices.join(", ")}.`);
173 next[key] = value;
174 }
175 return { ok: true, value: next as AgentAutonomy };
176}
177
178/** The most abilities with a choice of their own one agent keeps. */
179const MAX_ABILITY_SETTINGS = 400;
180
181/**
182 * The abilities' settings, checked against what the agent can have: an
183 * integration ability the catalog knows, or a tool of one of its MCP
184 * servers, each at a level its kind allows. g1t's own abilities keep their
185 * choices in `autonomy`, so nothing is kept for them here.
186 */
187function abilitiesOf(base: AgentAbilities, given: unknown, servers: McpServer[]): Checked<AgentAbilities> {
188 if (given === undefined || given === null) return { ok: true, value: { ...base, mcp_servers: servers } };
189 if (typeof given !== "object" || Array.isArray(given)) return bad("Abilities are an object.");
190 const settings = (given as { settings?: unknown }).settings;
191 if (settings === undefined) return { ok: true, value: { ...base, mcp_servers: servers } };
192 if (!settings || typeof settings !== "object" || Array.isArray(settings)) return bad("Abilities' settings are an object by ability id.");
193 const entries = Object.entries(settings as Record<string, unknown>);
194 if (entries.length > MAX_ABILITY_SETTINGS) return bad(`At most ${MAX_ABILITY_SETTINGS} abilities keep a setting.`);
195 const next: Record<string, AbilitySetting> = {};
196 for (const [id, raw] of entries) {
197 const def = abilityDef(id, servers);
198 if (!def) return bad(`There is no ability called ${id.slice(0, 60)} for this agent.`);
199 if (!raw || typeof raw !== "object") return bad(`${id}'s setting is an object.`);
200 const setting = raw as Record<string, unknown>;
201 const kept: AbilitySetting = {};
202 if (setting.level !== undefined && setting.level !== null) {
203 if (typeof setting.level !== "string" || !ABILITY_LEVELS.includes(setting.level as AbilityLevel)) return bad(`${id}'s level is alone, asked, ask or never.`);
204 const level = setting.level as AbilityLevel;
205 const max = maxLevel(def.kind);
206 if (!withinLevel(level, max)) return bad(`${def.label} can't be set above ${max === "ask" ? "Ask first" : max}: it ${def.kind === "restricted" ? "is a purchase, a credential or a permission change" : "isn't allowed that freely"}.`);
207 kept.level = level;
208 }
209 if (setting.credentials !== undefined && setting.credentials !== null) {
210 if (def.group !== "integration") return bad(`Only an integration's abilities say whose connection they run on.`);
211 if (setting.credentials !== "workspace" && setting.credentials !== "asker") return bad(`${id}'s credentials are workspace or asker.`);
212 kept.credentials = setting.credentials;
213 }
214 if (Object.keys(kept).length) next[id] = kept;
215 }
216 // In id order, so the same choices always read the same.
217 return { ok: true, value: { settings: Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b))), mcp_servers: servers } };
218}
219
220/** The ability `id` names, among the catalog's integration abilities and the agent's MCP servers' tools; null when there is none. */
221function abilityDef(id: string, servers: McpServer[]) {
222 const parts = id.split(":");
223 if (parts[0] === "integration" && parts.length === 3) return integrationAbilities(parts[1]!).find((def) => def.id === id) ?? null;
224 if (parts[0] === "mcp" && parts.length >= 3) {
225 const server = servers.find((s) => s.id === parts[1]);
226 const tool = server?.tools.find((t) => `mcp:${server.id}:${t.name}` === id);
227 return server && tool ? mcpAbility(server, tool) : null;
228 }
229 return null;
230}
231
232/** The MCP servers as kept: at most `MAX_MCP_SERVERS`, names unique. Only the service's own MCP calls change them. */
233function mcpServersOf(given: unknown): Checked<McpServer[]> {
234 if (!Array.isArray(given)) return bad("MCP servers are a list.");
235 if (given.length > MAX_MCP_SERVERS) return bad(`An agent has at most ${MAX_MCP_SERVERS} MCP servers.`);
236 const names = new Set<string>();
237 for (const server of given as McpServer[]) {
238 if (!server || typeof server !== "object" || typeof server.id !== "string" || typeof server.name !== "string" || typeof server.url !== "string" || !Array.isArray(server.tools)) return bad("An MCP server has an id, a name, a url and tools.");
239 if (names.has(server.name)) return bad(`Two servers are called ${server.name}.`);
240 names.add(server.name);
241 }
242 return { ok: true, value: given as McpServer[] };
243}
244
245function responsibilitiesOf(given: unknown): Checked<string[]> {
246 if (!Array.isArray(given) || given.some((d) => typeof d !== "string")) return bad("Responsibilities are a list of short duties.");
247 const duties = [...new Set((given as string[]).map((d) => d.trim()).filter(Boolean))];
248 if (duties.length && (duties.length < 2 || duties.length > 8)) return bad("Give 2 to 8 responsibilities, or none yet.");
249 if (duties.some((d) => d.length > LIMITS.duty)) return bad(`Each responsibility is at most ${LIMITS.duty} characters.`);
250 return { ok: true, value: duties };
251}
252
253const ORDER = ["small", "large", "frontier"] as const;
254
255/** A subagent's limits held within its agent's: never a lower floor, never a higher ceiling. */
256export function withinParent(
257 sub: { floor: AgentRouting["floor"]; ceiling: AgentRouting["ceiling"] },
258 parent: Pick<AgentRouting, "floor" | "ceiling">,
259): { floor: AgentRouting["floor"]; ceiling: AgentRouting["ceiling"] } {
260 const at = (tier: AgentRouting["floor"]) => (tier ? ORDER.indexOf(tier) : -1);
261 let floor = sub.floor;
262 let ceiling = sub.ceiling;
263 if (parent.floor && at(floor) < at(parent.floor)) floor = parent.floor;
264 if (parent.ceiling && (!ceiling || at(ceiling) > at(parent.ceiling))) ceiling = parent.ceiling;
265 // Held inside, a floor can end above the ceiling: the ceiling, the spending rail, wins.
266 if (floor && ceiling && at(floor) > at(ceiling)) floor = ceiling;
267 return { floor, ceiling };
268}
269
270function subagentsOf(given: unknown): Checked<SubagentDef[]> {
271 if (!Array.isArray(given)) return bad("Subagents are a list.");
272 if (given.length > MAX_SUBAGENTS) return bad(`An agent keeps at most ${MAX_SUBAGENTS} subagents.`);
273 const out: SubagentDef[] = [];
274 for (const raw of given as Partial<SubagentDef>[]) {
275 if (!raw || typeof raw !== "object") return bad("Each subagent has a name, a description and instructions.");
276 const name = typeof raw.name === "string" ? raw.name.trim().toLowerCase() : "";
277 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.");
278 if (out.some((sub) => sub.name === name)) return bad(`Two subagents are called ${name}.`);
279 const description = text(raw.description, `${name}'s description`, 200, true);
280 if (!description.ok) return description;
281 const instructions = text(raw.instructions, `${name}'s instructions`, 4000, true);
282 if (!instructions.ok) return instructions;
283 const routing = raw.routing ?? { floor: null, ceiling: null };
284 for (const tier of [routing.floor, routing.ceiling]) {
285 if (tier !== null && tier !== undefined && !isTier(tier)) return bad(`${name}'s limits are small, large, frontier or none.`);
286 }
287 if (!limitsAgree(routing.floor ?? null, routing.ceiling ?? null)) return bad(`${name}'s floor is above its ceiling.`);
288 const parallel = raw.max_parallel ?? 2;
289 if (typeof parallel !== "number" || !Number.isInteger(parallel) || parallel < 1 || parallel > 8) return bad(`${name} runs 1 to 8 at once.`);
290 out.push({
291 name,
292 description: description.value,
293 instructions: instructions.value,
294 routing: { floor: routing.floor ?? null, ceiling: routing.ceiling ?? null },
295 max_parallel: parallel,
296 });
297 }
298 return { ok: true, value: out };
299}
300
301/**
302 * `changes` applied to `base` (a new agent's defaults, or its current
303 * definition), checked. `templates` are the template ids an agent may name.
304 */
305export function applyChanges(
306 base: Definition | null,
307 changes: Partial<NewWorkspaceAgent>,
308 templates: string[],
309 options: { builtin?: boolean; mcp_servers?: McpServer[] } = {},
310): Checked<Definition> {
311 if (!changes || typeof changes !== "object") return bad("Send the agent's fields.");
312 const creating = base === null;
313 const from: Definition = base ?? {
314 handle: "",
315 display_name: "",
316 role: "",
317 instructions: "",
318 personality_preset: "crisp",
319 personality: "",
320 routing: DEFAULT_ROUTING,
321 budget: DEFAULT_BUDGET,
322 autonomy: DEFAULT_AUTONOMY,
323 capacity: DEFAULT_CAPACITY,
324 template: null,
325 avatar_seed: "",
326 look: null,
327 title: "",
328 responsibilities: [],
329 subagents: [],
330 faces: "internal",
331 reading: [],
332 skills_off: [],
333 abilities: EMPTY_ABILITIES,
334 };
335 const next: Definition = { ...from, look: from.look ?? null, skills_off: from.skills_off ?? [], abilities: from.abilities ?? EMPTY_ABILITIES };
336 // Whether the role was made from the title, so it follows it.
337 const roleDerived = !from.role || from.role === roleOf(from);
338 if (creating || changes.handle !== undefined) {
339 const handle = checkHandle(changes.handle);
340 if (!handle.ok) return bad(handle.message);
341 next.handle = handle.handle;
342 }
343 const fields = [
344 ["display_name", "A display name", LIMITS.displayName, true],
345 ["role", "The role", LIMITS.role, false],
346 ["title", "The title", LIMITS.title, false],
347 // The built-in agent's instructions are added to its fixed job, and may be empty.
348 ["instructions", "The instructions", LIMITS.instructions, !options.builtin],
349 ["personality", "The personality", LIMITS.personality, false],
350 ] as const;
351 for (const [key, what, max, required] of fields) {
352 if (!creating && changes[key] === undefined) continue;
353 const value = text(changes[key], what, max, required);
354 if (!value.ok) return value;
355 next[key] = value.value;
356 }
357 if (changes.responsibilities !== undefined) {
358 const duties = responsibilitiesOf(changes.responsibilities);
359 if (!duties.ok) return duties;
360 next.responsibilities = duties.value;
361 }
362 // A role left empty, or made from the title before, follows it.
363 if (!next.role || (changes.role === undefined && roleDerived)) next.role = roleOf(next);
364 if (!next.role) return bad("Give the agent a title or a one-line role.");
365 if (changes.avatar_seed !== undefined) {
366 const seed = text(changes.avatar_seed, "The avatar seed", 64, false);
367 if (!seed.ok) return seed;
368 next.avatar_seed = seed.value;
369 }
370 // A new face from the handle, unless one was chosen; renaming keeps the face.
371 if (!next.avatar_seed) next.avatar_seed = next.handle;
372 // A chosen face, part by part; null goes back to the seed's.
373 if (changes.look !== undefined) {
374 if (changes.look === null) next.look = null;
375 else {
376 const look = readLook(changes.look);
377 if (!look) return bad("A look names each part of the face (shape, color, eyes, mouth, antenna, accessory, pattern) with one of its choices.");
378 next.look = look;
379 }
380 }
381 if (changes.personality_preset !== undefined) {
382 if (!PRESETS.includes(changes.personality_preset)) return bad(`The personality preset is one of ${PRESETS.join(", ")}.`);
383 next.personality_preset = changes.personality_preset;
384 }
385 const routing = routingOf(next.routing, changes.routing);
386 if (!routing.ok) return routing;
387 next.routing = routing.value;
388 const budget = budgetOf(next.budget, changes.budget);
389 if (!budget.ok) return budget;
390 next.budget = budget.value;
391 const autonomy = autonomyOf(next.autonomy, changes.autonomy);
392 if (!autonomy.ok) return autonomy;
393 next.autonomy = autonomy.value;
394 if (changes.capacity !== undefined) {
395 const capacity = changes.capacity;
396 if (typeof capacity !== "number" || !Number.isInteger(capacity) || capacity < 1 || capacity > MAX_CAPACITY) {
397 return bad(`Capacity is 1 to ${MAX_CAPACITY} tasks at once.`);
398 }
399 next.capacity = capacity;
400 }
401 if (changes.template !== undefined) {
402 if (changes.template !== null && !templates.includes(changes.template)) return bad("There is no such template.");
403 next.template = changes.template;
404 }
405 if (changes.subagents !== undefined) {
406 const subagents = subagentsOf(changes.subagents);
407 if (!subagents.ok) return subagents;
408 next.subagents = subagents.value;
409 }
410 if (changes.reading !== undefined) {
411 if (!Array.isArray(changes.reading)) return bad("Required reading is a list of spaces.");
412 const ids = [...new Set(changes.reading.filter((id): id is string => typeof id === "string").map((id) => id.trim()).filter(Boolean))];
413 if (ids.length > 10) return bad("An agent has at most 10 spaces of required reading.");
414 if (ids.some((id) => !/^[A-Za-z0-9_-]{1,80}$/.test(id))) return bad("That isn't a space.");
415 next.reading = ids;
416 }
417 if (changes.skills_off !== undefined) {
418 if (!Array.isArray(changes.skills_off)) return bad("Skills turned off are a list of skills.");
419 const ids = new Set(changes.skills_off.filter((id): id is string => typeof id === "string").map((id) => id.trim()).filter(Boolean));
420 // Foundational skills by id, and the library's by theirs (skl_…), which
421 // may be off before or after they are attached.
422 const library = [...ids].filter((id) => LIBRARY_SKILL_ID.test(id));
423 const unknown = [...ids].find((id) => !FOUNDATIONAL_SKILL_IDS.includes(id) && !LIBRARY_SKILL_ID.test(id));
424 if (unknown) return bad(`There is no skill called ${unknown.slice(0, 40)}.`);
425 if (library.length > 200) return bad("At most 200 library skills can be off for one agent.");
426 // In the skills' own order, so the same choice always reads the same.
427 next.skills_off = [...FOUNDATIONAL_SKILL_IDS.filter((id) => ids.has(id)), ...library.sort()];
428 }
429 // MCP servers change only through the service's own calls (index.ts), which pass them here; a change never carries them.
430 let servers = next.abilities.mcp_servers ?? [];
431 if (options.mcp_servers) {
432 const checked = mcpServersOf(options.mcp_servers);
433 if (!checked.ok) return checked;
434 servers = checked.value;
435 }
436 const abilities = abilitiesOf(next.abilities, changes.abilities, servers);
437 if (!abilities.ok) return abilities;
438 // A setting for a tool of a server that is gone goes with it.
439 abilities.value.settings = Object.fromEntries(Object.entries(abilities.value.settings).filter(([id]) => abilityDef(id, servers)));
440 next.abilities = abilities.value;
441 if (changes.faces !== undefined) {
442 if (changes.faces === "customers") return bad("Customer-facing agents aren't available yet.");
443 if (changes.faces !== "internal") return bad("An agent faces internal: the workspace's own people.");
444 next.faces = "internal";
445 }
446 // Never wider than their agent, whichever of the two changed.
447 next.subagents = next.subagents.map((sub) => ({ ...sub, routing: withinParent(sub.routing, next.routing) }));
448 return { ok: true, value: next };
449}
450
451/** A stored JSON column, read defensively: a bad value is the default. */
452export function readJson<T extends object>(raw: unknown, fallback: T): T {
453 if (typeof raw !== "string") return fallback;
454 try {
455 const parsed = JSON.parse(raw) as unknown;
456 return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...fallback, ...(parsed as T) } : fallback;
457 } catch {
458 return fallback;
459 }
460}