| 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 | 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"; |
| 19 | import { ABILITY_LEVELS, EMPTY_ABILITIES, MAX_MCP_SERVERS, integrationAbilities, maxLevel, mcpAbility, withinLevel } from "../../../packages/contracts/src/abilities.ts"; |
| 20 | import { readLook } from "../../../packages/contracts/src/agent-look.ts"; |
| 21 | import { FOUNDATIONAL_SKILL_IDS } from "../../../packages/contracts/src/skills.ts"; |
| 22 | |
| 23 | import { checkHandle } from "./handle.ts"; |
| 24 | import { isEffort, isTier, limitsAgree } from "./routing.ts"; |
| 25 | |
| 26 | /** A library skill's id (skill-library.ts), as `skills_off` may name it. */ |
| 27 | const LIBRARY_SKILL_ID = /^skl_[0-9a-z]{26}$/; |
| 28 | |
| 29 | export const PRESETS: PersonalityPreset[] = ["crisp", "friendly", "socratic", "terse"]; |
| 30 | |
| 31 | export const DEFAULT_ROUTING: AgentRouting = { floor: null, ceiling: null, providers: [], pinned: null, effort: "auto" }; |
| 32 | export 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. */ |
| 34 | export 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. */ |
| 41 | export const DEFAULT_CAPACITY = 3; |
| 42 | export const MAX_CAPACITY = 10; |
| 43 | |
| 44 | const 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. */ |
| 46 | const MAX_MICROS = 100_000_000_000; |
| 47 | |
| 48 | /** Everything a definition holds, complete: what is stored and versioned. */ |
| 49 | export 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 | */ |
| 81 | export 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 | */ |
| 90 | export 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 | |
| 97 | export const MAX_SUBAGENTS = 8; |
| 98 | |
| 99 | export type Checked<T> = { ok: true; value: T } | { ok: false; message: string }; |
| 100 | |
| 101 | const bad = (message: string): { ok: false; message: string } => ({ ok: false, message }); |
| 102 | |
| 103 | function 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 | |
| 112 | function 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 | |
| 142 | function 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 | |
| 158 | const 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 | |
| 165 | function 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. */ |
| 179 | const 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 | */ |
| 187 | function 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. */ |
| 221 | function 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. */ |
| 233 | function 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 | |
| 245 | function 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 | |
| 253 | const 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. */ |
| 256 | export 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 | |
| 270 | function 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 | */ |
| 305 | export 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. */ |
| 452 | export 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 | } |