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