Skip to content
171 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 department: text("department", 40),
42 team: null,
43 role: "",
44 instructions: text("instructions", 8000),
45 responsibilities: list("responsibilities", 160, 8),
46 personality_preset: PRESETS.includes(d.personality_preset as PersonalityPreset) ? (d.personality_preset as PersonalityPreset) : "crisp",
47 personality: text("personality", 1000),
48 routing: { floor: tier(routing.floor), ceiling: tier(routing.ceiling), providers: [], pinned: null },
49 budget: { monthly_micros: micros(budget.monthly_micros), daily_micros: micros(budget.daily_micros), task_micros: micros(budget.task_micros) },
50 skills_off: list("skills_off", 40, 20),
51 template: null,
52 avatar_seed: text("avatar_seed", 64) || undefined,
53 scope: d.scope === "personal" ? "personal" : "workspace",
54 };
55}
56
57/** What the person is asked to do about an integration the agent needs. */
58export type IntegrationHint = {
59 id: string;
60 name: string;
61 why: string;
62 /** `connect` (an owner sets it up), `ask` (anyone else asks an owner), `soon` (nobody can yet). */
63 action: "connect" | "ask" | "soon";
64 /** Its page in the Marketplace, which connects it or asks for it. */
65 href: string;
66};
67
68export function integrationHint(slug: string, suggestion: { id: string; why: string }, owner: boolean): IntegrationHint | null {
69 const connector = connectorById(suggestion.id);
70 if (!connector) return null;
71 const href = `/${slug}/-/marketplace/integrations/${encodeURIComponent(connector.id)}`;
72 const action = connector.status === "soon" ? "soon" : owner ? "connect" : "ask";
73 return { id: connector.id, name: connector.name, why: suggestion.why, action, href: action === "connect" && connector.href?.workspace ? connectorPath(connector.href.workspace, slug) : href };
74}
75
76// ── A change in words, before and after ───────────────────────────────────
77
78export type DiffLine = { kind: "same" | "added" | "removed"; text: string };
79
80/** Lines of `before` and `after`, as kept, removed and added (longest common subsequence). */
81export function lineDiff(before: string, after: string): DiffLine[] {
82 const a = before.split("\n");
83 const b = after.split("\n");
84 // Long jobs are compared in full; past this, as removed and added wholesale.
85 if (a.length * b.length > 250_000) return [...a.map((text) => ({ kind: "removed" as const, text })), ...b.map((text) => ({ kind: "added" as const, text }))];
86 const table: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
87 for (let i = a.length - 1; i >= 0; i--) {
88 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]!);
89 }
90 const out: DiffLine[] = [];
91 let i = 0;
92 let j = 0;
93 while (i < a.length && j < b.length) {
94 if (a[i] === b[j]) {
95 out.push({ kind: "same", text: a[i]! });
96 i++;
97 j++;
98 } else if (table[i + 1]![j]! >= table[i]![j + 1]!) out.push({ kind: "removed", text: a[i++]! });
99 else out.push({ kind: "added", text: b[j++]! });
100 }
101 while (i < a.length) out.push({ kind: "removed", text: a[i++]! });
102 while (j < b.length) out.push({ kind: "added", text: b[j++]! });
103 return out;
104}
105
106/** One field a drafted change touches: its name, and the before and after as people read them. */
107export type ChangeRow = { field: string; label: string; before: string; after: string; lines?: DiffLine[] };
108
109const dollars = (micros: number | null | undefined) => (micros == null ? "No cap" : `$${(micros / 1_000_000).toLocaleString("en-US", { maximumFractionDigits: 2 })}`);
110const tierWord = (tier: string | null | undefined) => (tier === "small" ? "Fast" : tier === "large" ? "Standard" : tier === "frontier" ? "Most capable" : "None");
111
112/**
113 * A drafted change as rows to review: each field it sets, before and
114 * after; the job as a line diff. `skillName` names a skill by id.
115 */
116export function changeRows(agent: Pick<WorkspaceAgent, keyof NewWorkspaceAgent & keyof WorkspaceAgent>, changes: Partial<NewWorkspaceAgent>, skillName: (id: string) => string = (id) => id): ChangeRow[] {
117 const rows: ChangeRow[] = [];
118 const plain = (field: keyof NewWorkspaceAgent, label: string) => {
119 const after = changes[field];
120 if (after === undefined) return;
121 rows.push({ field, label, before: String(agent[field as keyof typeof agent] ?? "") || "None", after: String(after ?? "") || "None" });
122 };
123 plain("display_name", "Name");
124 if (changes.handle !== undefined) rows.push({ field: "handle", label: "Handle", before: `@${agent.handle}`, after: `@${changes.handle}` });
125 plain("title", "Title");
126 plain("department", "Department");
127 if (changes.instructions !== undefined) {
128 rows.push({ field: "instructions", label: "Job", before: agent.instructions, after: changes.instructions, lines: lineDiff(agent.instructions, changes.instructions) });
129 }
130 if (changes.responsibilities !== undefined) {
131 const before = agent.responsibilities ?? [];
132 rows.push({
133 field: "responsibilities",
134 label: "Responsibilities",
135 before: before.join("\n"),
136 after: changes.responsibilities.join("\n"),
137 lines: lineDiff(before.join("\n"), changes.responsibilities.join("\n")),
138 });
139 }
140 plain("personality_preset", "Voice");
141 plain("personality", "Personality");
142 if (changes.skills_off !== undefined) {
143 const off = new Set(changes.skills_off);
144 const was = new Set(agent.skills_off ?? []);
145 const on = [...was].filter((id) => !off.has(id)).map(skillName);
146 const offNow = [...off].filter((id) => !was.has(id)).map(skillName);
147 rows.push({
148 field: "skills_off",
149 label: "Skills",
150 before: [...was].length ? `Off: ${[...was].map(skillName).join(", ")}` : "All on",
151 after: [on.length ? `Turned on: ${on.join(", ")}` : "", offNow.length ? `Turned off: ${offNow.join(", ")}` : ""].filter(Boolean).join(". "),
152 });
153 }
154 if (changes.routing) {
155 for (const key of ["floor", "ceiling"] as const) {
156 if (changes.routing[key] === undefined) continue;
157 rows.push({ field: key, label: key === "floor" ? "Model floor" : "Model ceiling", before: tierWord(agent.routing[key]), after: tierWord(changes.routing[key]) });
158 }
159 }
160 if (changes.budget) {
161 for (const [key, label] of [
162 ["monthly_micros", "Monthly budget"],
163 ["daily_micros", "Daily budget"],
164 ["task_micros", "Per session"],
165 ] as const) {
166 if (changes.budget[key] === undefined) continue;
167 rows.push({ field: key, label, before: dollars(agent.budget[key]), after: dollars(changes.budget[key]) });
168 }
169 }
170 return rows;
171}