| 1 | /** |
| 2 | * The agent builder's page logic (components/agents/builder.tsx): a drafted |
| 3 | * definition read back from the page, what an integration it needs asks of |
| 4 | * the person, and a change drafted in words as a before-and-after. Pure, |
| 5 | * with type-only contract imports and the connector catalog, so it is |
| 6 | * tested under Node. |
| 7 | */ |
| 8 | import type { AgentBudget, AgentRouting, NewWorkspaceAgent, PersonalityPreset, WorkspaceAgent, WorkspaceAgentScope } from "@g1t/contracts"; |
| 9 | import { readLook } from "@g1t/contracts/agent-look"; |
| 10 | import { connectorById, connectorPath } from "@g1t/contracts/connectors"; |
| 11 | |
| 12 | import { wholeDollars } from "./money.ts"; |
| 13 | |
| 14 | /** The draft as the page keeps it while it is edited: everything `create` takes, and its scope. */ |
| 15 | export type BuilderDefinition = NewWorkspaceAgent & { scope: WorkspaceAgentScope; avatar_seed?: string }; |
| 16 | |
| 17 | const PRESETS: PersonalityPreset[] = ["crisp", "friendly", "socratic", "terse"]; |
| 18 | const TIERS = ["small", "large", "frontier"] as const; |
| 19 | |
| 20 | /** |
| 21 | * A definition the page sent back as JSON, checked for shape: the agents |
| 22 | * service checks every rule again. Null when it isn't one. |
| 23 | */ |
| 24 | export function readDefinition(raw: FormDataEntryValue | null): BuilderDefinition | null { |
| 25 | let parsed: unknown; |
| 26 | try { |
| 27 | parsed = JSON.parse(String(raw ?? "")); |
| 28 | } catch { |
| 29 | return null; |
| 30 | } |
| 31 | if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; |
| 32 | const d = parsed as Record<string, unknown>; |
| 33 | const text = (key: string, max: number) => (typeof d[key] === "string" ? (d[key] as string).trim().slice(0, max) : ""); |
| 34 | const list = (key: string, max: number, count: number) => |
| 35 | Array.isArray(d[key]) ? (d[key] as unknown[]).filter((v): v is string => typeof v === "string").map((v) => v.trim().slice(0, max)).filter(Boolean).slice(0, count) : []; |
| 36 | const tier = (value: unknown) => ((TIERS as readonly unknown[]).includes(value) ? (value as AgentRouting["floor"]) : null); |
| 37 | const routing = (d.routing && typeof d.routing === "object" ? d.routing : {}) as Partial<AgentRouting>; |
| 38 | const budget = (d.budget && typeof d.budget === "object" ? d.budget : {}) as Partial<AgentBudget>; |
| 39 | const micros = (value: unknown) => (typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.round(value) : null); |
| 40 | return { |
| 41 | handle: text("handle", 40), |
| 42 | display_name: text("display_name", 64), |
| 43 | title: text("title", 60), |
| 44 | role: "", |
| 45 | instructions: text("instructions", 8000), |
| 46 | responsibilities: list("responsibilities", 160, 8), |
| 47 | personality_preset: PRESETS.includes(d.personality_preset as PersonalityPreset) ? (d.personality_preset as PersonalityPreset) : "crisp", |
| 48 | personality: text("personality", 1000), |
| 49 | routing: { floor: tier(routing.floor), ceiling: tier(routing.ceiling), providers: [], pinned: null }, |
| 50 | budget: { monthly_micros: micros(budget.monthly_micros), daily_micros: micros(budget.daily_micros), task_micros: micros(budget.task_micros) }, |
| 51 | skills_off: list("skills_off", 40, 20), |
| 52 | template: null, |
| 53 | avatar_seed: text("avatar_seed", 64) || undefined, |
| 54 | look: readLook(d.look), |
| 55 | scope: d.scope === "personal" ? "personal" : "workspace", |
| 56 | }; |
| 57 | } |
| 58 | |
| 59 | /** What the person is asked to do about an integration the agent needs. */ |
| 60 | export type IntegrationHint = { |
| 61 | id: string; |
| 62 | name: string; |
| 63 | why: string; |
| 64 | /** `connect` (an owner sets it up), `ask` (anyone else asks an owner), `soon` (nobody can yet). */ |
| 65 | action: "connect" | "ask" | "soon"; |
| 66 | /** Its page in the Marketplace, which connects it or asks for it. */ |
| 67 | href: string; |
| 68 | }; |
| 69 | |
| 70 | export function integrationHint(slug: string, suggestion: { id: string; why: string }, owner: boolean): IntegrationHint | null { |
| 71 | const connector = connectorById(suggestion.id); |
| 72 | if (!connector) return null; |
| 73 | const href = `/${slug}/-/marketplace/integrations/${encodeURIComponent(connector.id)}`; |
| 74 | const action = connector.status === "soon" ? "soon" : owner ? "connect" : "ask"; |
| 75 | return { id: connector.id, name: connector.name, why: suggestion.why, action, href: action === "connect" && connector.href?.workspace ? connectorPath(connector.href.workspace, slug) : href }; |
| 76 | } |
| 77 | |
| 78 | // ── A change in words, before and after ─────────────────────────────────── |
| 79 | |
| 80 | export type DiffLine = { kind: "same" | "added" | "removed"; text: string }; |
| 81 | |
| 82 | /** Lines of `before` and `after`, as kept, removed and added (longest common subsequence). */ |
| 83 | export function lineDiff(before: string, after: string): DiffLine[] { |
| 84 | const a = before.split("\n"); |
| 85 | const b = after.split("\n"); |
| 86 | // Long jobs are compared in full; past this, as removed and added wholesale. |
| 87 | if (a.length * b.length > 250_000) return [...a.map((text) => ({ kind: "removed" as const, text })), ...b.map((text) => ({ kind: "added" as const, text }))]; |
| 88 | const table: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0)); |
| 89 | for (let i = a.length - 1; i >= 0; i--) { |
| 90 | for (let j = b.length - 1; j >= 0; j--) table[i]![j] = a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!); |
| 91 | } |
| 92 | const out: DiffLine[] = []; |
| 93 | let i = 0; |
| 94 | let j = 0; |
| 95 | while (i < a.length && j < b.length) { |
| 96 | if (a[i] === b[j]) { |
| 97 | out.push({ kind: "same", text: a[i]! }); |
| 98 | i++; |
| 99 | j++; |
| 100 | } else if (table[i + 1]![j]! >= table[i]![j + 1]!) out.push({ kind: "removed", text: a[i++]! }); |
| 101 | else out.push({ kind: "added", text: b[j++]! }); |
| 102 | } |
| 103 | while (i < a.length) out.push({ kind: "removed", text: a[i++]! }); |
| 104 | while (j < b.length) out.push({ kind: "added", text: b[j++]! }); |
| 105 | return out; |
| 106 | } |
| 107 | |
| 108 | /** One field a drafted change touches: its name, and the before and after as people read them. */ |
| 109 | export type ChangeRow = { field: string; label: string; before: string; after: string; lines?: DiffLine[] }; |
| 110 | |
| 111 | const dollars = (micros: number | null | undefined) => (micros == null ? "No cap" : wholeDollars(micros)); |
| 112 | const tierWord = (tier: string | null | undefined) => (tier === "small" ? "Fast" : tier === "large" ? "Standard" : tier === "frontier" ? "Most capable" : "None"); |
| 113 | |
| 114 | /** |
| 115 | * A drafted change as rows to review: each field it sets, before and |
| 116 | * after; the job as a line diff. `skillName` names a skill by id. |
| 117 | */ |
| 118 | export function changeRows(agent: Pick<WorkspaceAgent, keyof NewWorkspaceAgent & keyof WorkspaceAgent>, changes: Partial<NewWorkspaceAgent>, skillName: (id: string) => string = (id) => id): ChangeRow[] { |
| 119 | const rows: ChangeRow[] = []; |
| 120 | const plain = (field: keyof NewWorkspaceAgent, label: string) => { |
| 121 | const after = changes[field]; |
| 122 | if (after === undefined) return; |
| 123 | rows.push({ field, label, before: String(agent[field as keyof typeof agent] ?? "") || "None", after: String(after ?? "") || "None" }); |
| 124 | }; |
| 125 | plain("display_name", "Name"); |
| 126 | if (changes.handle !== undefined) rows.push({ field: "handle", label: "Handle", before: `@${agent.handle}`, after: `@${changes.handle}` }); |
| 127 | plain("title", "Title"); |
| 128 | if (changes.instructions !== undefined) { |
| 129 | rows.push({ field: "instructions", label: "Job", before: agent.instructions, after: changes.instructions, lines: lineDiff(agent.instructions, changes.instructions) }); |
| 130 | } |
| 131 | if (changes.responsibilities !== undefined) { |
| 132 | const before = agent.responsibilities ?? []; |
| 133 | rows.push({ |
| 134 | field: "responsibilities", |
| 135 | label: "Responsibilities", |
| 136 | before: before.join("\n"), |
| 137 | after: changes.responsibilities.join("\n"), |
| 138 | lines: lineDiff(before.join("\n"), changes.responsibilities.join("\n")), |
| 139 | }); |
| 140 | } |
| 141 | plain("personality_preset", "Voice"); |
| 142 | plain("personality", "Personality"); |
| 143 | if (changes.look !== undefined) { |
| 144 | rows.push({ field: "look", label: "Face", before: agent.look ? "A chosen face" : "Its own face", after: changes.look ? "A chosen face" : "Its own face" }); |
| 145 | } |
| 146 | if (changes.skills_off !== undefined) { |
| 147 | const off = new Set(changes.skills_off); |
| 148 | const was = new Set(agent.skills_off ?? []); |
| 149 | const on = [...was].filter((id) => !off.has(id)).map(skillName); |
| 150 | const offNow = [...off].filter((id) => !was.has(id)).map(skillName); |
| 151 | rows.push({ |
| 152 | field: "skills_off", |
| 153 | label: "Skills", |
| 154 | before: [...was].length ? `Off: ${[...was].map(skillName).join(", ")}` : "All on", |
| 155 | after: [on.length ? `Turned on: ${on.join(", ")}` : "", offNow.length ? `Turned off: ${offNow.join(", ")}` : ""].filter(Boolean).join(". "), |
| 156 | }); |
| 157 | } |
| 158 | if (changes.routing) { |
| 159 | for (const key of ["floor", "ceiling"] as const) { |
| 160 | if (changes.routing[key] === undefined) continue; |
| 161 | rows.push({ field: key, label: key === "floor" ? "Model floor" : "Model ceiling", before: tierWord(agent.routing[key]), after: tierWord(changes.routing[key]) }); |
| 162 | } |
| 163 | } |
| 164 | if (changes.budget) { |
| 165 | for (const [key, label] of [ |
| 166 | ["monthly_micros", "Monthly budget"], |
| 167 | ["daily_micros", "Daily budget"], |
| 168 | ["task_micros", "Per session"], |
| 169 | ] as const) { |
| 170 | if (changes.budget[key] === undefined) continue; |
| 171 | rows.push({ field: key, label, before: dollars(agent.budget[key]), after: dollars(changes.budget[key]) }); |
| 172 | } |
| 173 | } |
| 174 | return rows; |
| 175 | } |