Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

325 linesCodeBlameRaw
1import { ArrowLeft, Lock } from "lucide-react";
2import { useEffect, useRef, useState } from "react";
3import { Link, data, redirect, useFetcher, useSearchParams } from "react-router";
4
5import type { AgentProposal, AgentTemplate, DraftTurn, PluginAgentTemplate } from "@g1t/contracts";
6
7import type { Route } from "./+types/new";
8import { AgentForm, TemplateGallery } from "../../../components/agents-mode";
9import { type BuilderAnswer, CreateDraft, DescribeBox, ProposalCard, TryChat } from "../../../components/agents/builder";
10import { type BuilderDefinition, readDefinition } from "../../../lib/agent-builder";
11import { type AgentDraft, BLANK_DRAFT, cleanHandle, readAgentForm, readTeams } from "../../../lib/agent-form";
12import { channelPath } from "../../../lib/chat";
13import { pluginAgentDraft } from "../../../lib/customize";
14import { page } from "../../../lib/meta";
15import { chat, docs, identity, workspaceAgents } from "../../../lib/services.server";
16import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
17import { Alert } from "../../../components/ui/alert";
18import { Button } from "../../../components/ui/button";
19import { Card } from "../../../components/ui/card";
20
21export function meta({ params, ...args }: Route.MetaArgs) {
22 return page(args, { title: `New agent · ${params.owner} · g1t` });
23}
24
25export async function loader({ params, context, request }: Route.LoaderArgs) {
26 const viewer = requireUser(context, request);
27 const role = roleIn(viewer, params.owner);
28 if (!role) throw data(null, { status: 404 });
29 const slug = params.owner.toLowerCase();
30 // `?plugin=<ref>&agent=<name>`: a plugin's subagent to start from (Customize, Templates).
31 const search = new URL(request.url).searchParams;
32 const wanted = search.get("plugin") && search.get("agent") ? { plugin: search.get("plugin")!, agent: search.get("agent")! } : null;
33 const [templates, teams, spaces, policy, fromPlugins] = await Promise.all([
34 workspaceAgents.templates().catch(() => null),
35 identity.listTeams(viewer, params.owner).catch(() => null),
36 readingSpaces(slug, viewer),
37 workspaceAgents.policy(slug, viewer).catch(() => null),
38 wanted ? workspaceAgents.pluginAgents(slug, viewer).catch(() => null) : Promise.resolve(null),
39 ]);
40 const pluginAgent: PluginAgentTemplate | null = fromPlugins?.ok ? (fromPlugins.value.find((a) => `${a.plugin}@${a.marketplace}` === wanted!.plugin && a.name === wanted!.agent) ?? null) : null;
41 const owner = role === "owner";
42 // Members make personal agents unless an owner turned that off; owners always may.
43 const membersMay = policy?.ok ? policy.value.members_create_agents : true;
44 return {
45 templates,
46 pluginAgent,
47 owner,
48 mayCreate: owner || membersMay,
49 // The teams the viewer may add a new agent to: owners any, a maintainer theirs.
50 joinable: teams?.ok ? teams.value.filter((team) => team.can_manage).map((team) => ({ slug: team.slug, name: team.name })) : [],
51 spaces,
52 };
53}
54
55/** The Docs spaces the viewer can read, for an agent's required reading; none when Docs can't say. */
56async function readingSpaces(slug: string, viewer: Parameters<typeof docs.sidebar>[1]): Promise<{ id: string; name: string; kind: string }[]> {
57 const sidebar = await docs.sidebar(slug, viewer).catch(() => null);
58 return sidebar?.ok ? sidebar.value.spaces.filter((space) => !space.archived_at).map((space) => ({ id: space.id, name: space.name, kind: space.kind })) : [];
59}
60
61const NO_ANSWER = "The agents service didn't answer. Try again in a moment.";
62
63/**
64 * Drafting from a description and trying the draft answer here; creating
65 * makes the agent, then opens a direct message with it: talking to it is
66 * how work starts. If chat does not answer, its page instead.
67 */
68export async function action({ params, context, request }: Route.ActionArgs) {
69 assertSameOrigin(request);
70 const viewer = requireUser(context, request);
71 const slug = params.owner.toLowerCase();
72 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
73 const form = await request.formData();
74 const intent = String(form.get("intent") ?? "");
75 if (intent === "draft") {
76 const scope = form.get("scope") === "personal" ? "personal" : form.get("scope") === "workspace" ? "workspace" : null;
77 const drafted = await workspaceAgents.draft(slug, viewer, { description: String(form.get("description") ?? ""), scope }).catch(() => null);
78 if (!drafted) return { intent, ok: false, error: NO_ANSWER } satisfies BuilderAnswer;
79 return (drafted.ok ? { intent, ok: true, proposal: drafted.value } : { intent, ok: false, error: drafted.error.message }) satisfies BuilderAnswer;
80 }
81 if (intent === "try") {
82 const definition = readDefinition(form.get("definition"));
83 let messages: DraftTurn[] = [];
84 try {
85 messages = JSON.parse(String(form.get("messages") ?? "[]")) as DraftTurn[];
86 } catch {
87 messages = [];
88 }
89 if (!definition) return { intent, ok: false, error: "The draft couldn't be read. Reload and try again." } satisfies BuilderAnswer;
90 const answered = await workspaceAgents.tryDraft(slug, viewer, { definition, messages }).catch(() => null);
91 if (!answered) return { intent, ok: false, error: NO_ANSWER } satisfies BuilderAnswer;
92 return (answered.ok ? { intent, ok: true, ...answered.value } : { intent, ok: false, error: answered.error.message }) satisfies BuilderAnswer;
93 }
94 let input;
95 if (intent === "create_draft") {
96 const definition = readDefinition(form.get("definition"));
97 if (!definition) return { errors: { form: "The draft couldn't be read. Reload and try again." } };
98 input = { ...definition, handle: cleanHandle(definition.handle || definition.display_name) };
99 } else {
100 const read = readAgentForm(form);
101 if (!read.ok) return { errors: read.errors };
102 input = read.input;
103 }
104 // Its teams: memberships, added as it is made; a personal agent joins none.
105 const made = await workspaceAgents.create(slug, viewer, input, { teams: input.scope === "personal" ? [] : readTeams(form) }).catch(() => null);
106 if (!made) return { errors: { form: NO_ANSWER } };
107 if (!made.ok) {
108 const field = made.error.code === "conflict" ? "handle" : "form";
109 return { errors: { [field]: made.error.message } };
110 }
111 const dm = await chat.openDm(slug, viewer, [{ kind: "agent", id: made.value.id }]).catch(() => null);
112 throw redirect(dm?.ok ? channelPath(slug, dm.value) : `/${slug}/-/agents/${made.value.handle}`);
113}
114
115/** A template's suggested names (`name_ideas`), when the agents service sends them. */
116function ideasOf(template: AgentTemplate | undefined): string[] {
117 if (!template) return [];
118 return [template.display_name, ...(template.name_ideas ?? []).filter((name) => name !== template.display_name)];
119}
120
121function draftFrom(template: AgentTemplate): AgentDraft {
122 // A name to enjoy first, its handle after it; the template's own name when it has none.
123 const name = ideasOf(template)[0] ?? template.display_name;
124 return {
125 ...BLANK_DRAFT,
126 handle: cleanHandle(name),
127 display_name: name,
128 title: template.title,
129 responsibilities: template.responsibilities,
130 subagents: template.subagents,
131 role: template.role,
132 instructions: template.instructions,
133 personality_preset: template.personality_preset,
134 routing: template.routing,
135 template: template.id,
136 };
137}
138
139/** The drafted agent as the full form takes it. */
140function formDraft(d: BuilderDefinition): AgentDraft {
141 return {
142 ...BLANK_DRAFT,
143 handle: d.handle,
144 display_name: d.display_name,
145 title: d.title ?? "",
146 responsibilities: d.responsibilities ?? [],
147 instructions: d.instructions,
148 personality_preset: d.personality_preset ?? "crisp",
149 personality: d.personality ?? "",
150 routing: { floor: d.routing?.floor ?? null, ceiling: d.routing?.ceiling ?? null, providers: [], pinned: null },
151 budget: { monthly_micros: d.budget?.monthly_micros ?? null, daily_micros: d.budget?.daily_micros ?? null, task_micros: d.budget?.task_micros ?? null },
152 look: d.look ?? null,
153 };
154}
155
156export default function NewAgent({ loaderData, actionData, params }: Route.ComponentProps) {
157 const [search, setSearch] = useSearchParams();
158 const chosen = search.get("template");
159 const { owner, mayCreate } = loaderData;
160 const templates = loaderData.templates ?? [];
161 const template = templates.find((t) => t.id === chosen);
162 const errors = actionData && "errors" in actionData ? actionData.errors : undefined;
163 const drafter = useFetcher<BuilderAnswer>({ key: "agent-draft" });
164 const [proposal, setProposal] = useState<AgentProposal | null>(null);
165 const [definition, setDefinition] = useState<BuilderDefinition | null>(null);
166 const [full, setFull] = useState(false);
167 // "Add to teams" on the drafted card: memberships, posted with Create.
168 const [joining, setJoining] = useState<string[]>([]);
169 const handled = useRef<unknown>(null);
170 useEffect(() => {
171 if (drafter.state !== "idle" || !drafter.data || handled.current === drafter.data) return;
172 handled.current = drafter.data;
173 if (drafter.data.ok && drafter.data.intent === "draft") {
174 setProposal(drafter.data.proposal);
175 setDefinition({ ...drafter.data.proposal.definition, avatar_seed: drafter.data.proposal.definition.handle });
176 setFull(false);
177 }
178 }, [drafter.state, drafter.data]);
179
180 // A role, a plugin's subagent, or every field from nothing: the form, as before.
181 const pluginAgent = search.get("plugin") ? loaderData.pluginAgent : null;
182 const formFrom = template ? draftFrom(template) : pluginAgent ? pluginAgentDraft(pluginAgent) : chosen === "blank" ? BLANK_DRAFT : full && definition ? formDraft(definition) : null;
183 const personal = full && definition ? definition.scope === "personal" : !owner;
184 const back = (
185 <Link to={`/${params.owner}/-/agents`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
186 <ArrowLeft size={14} />
187 Agents
188 </Link>
189 );
190
191 if (formFrom) {
192 return (
193 <div className="pb-4">
194 {back}
195 <header className="mt-4 mb-8">
196 <h1 className="text-2xl font-semibold tracking-tight">New agent</h1>
197 <p className="mt-1.5 max-w-2xl text-sm text-muted">
198 {personal ? "Your personal agent: only you can talk to it, and it spends from your budget. " : ""}
199 {pluginAgent ? `Started from ${pluginAgent.name}, which the plugin ${pluginAgent.plugin} brings: its description and system prompt are filled in. ` : ""}Every field, as you'll find it on its profile later.{" "}
200 <Button
201 type="button"
202 onClick={() => {
203 setFull(false);
204 setSearch({}, { preventScrollReset: true, replace: true });
205 }}
206 variant="link"
207 size="inline"
208 className="text-fg underline-offset-2 font-normal"
209 >
210 {definition ? "Back to the draft" : "Describe it instead"}
211 </Button>
212 .
213 </p>
214 </header>
215 {!full && <TemplateGallery templates={templates} chosen={chosen} onChoose={(id) => setSearch(id ? { template: id } : {}, { preventScrollReset: true, replace: true })} />}
216 <div className={full ? "" : "mt-10 border-t border-line pt-10"}>
217 {errors?.form && (
218 <Alert asChild className="mb-6 px-4 py-3">
219 <p>{errors.form}</p>
220 </Alert>
221 )}
222 <AgentForm
223 draft={formFrom}
224 errors={errors}
225 submit="Create agent"
226 intent="create"
227 formKey={full ? `draft:${definition?.handle}` : pluginAgent ? `plugin:${pluginAgent.ref}` : (chosen ?? "blank")}
228 nameIdeas={full && proposal ? [proposal.definition.display_name, ...proposal.name_ideas] : ideasOf(template)}
229 joinable={loaderData.joinable}
230 personal={personal}
231 spaces={loaderData.spaces}
232 seed={full ? definition?.avatar_seed : undefined}
233 hidden={{
234 scope: full && definition ? definition.scope : owner ? "workspace" : "personal",
235 ...(full && definition ? { skills_off: (definition.skills_off ?? []).join(","), avatar_seed: definition.avatar_seed ?? "" } : {}),
236 }}
237 />
238 </div>
239 </div>
240 );
241 }
242
243 if (proposal && definition) {
244 return (
245 <div className="pb-4">
246 {back}
247 <header className="mt-4 mb-6 flex flex-wrap items-end justify-between gap-3">
248 <div className="min-w-0">
249 <h1 className="text-2xl font-semibold tracking-tight">Meet {definition.display_name || "your agent"}</h1>
250 <p className="mt-1.5 max-w-2xl text-sm text-muted">
251 Drafted from what you described. Change anything, try it in the test chat, and create it when it's right.{" "}
252 {definition.scope === "personal" && (
253 <span className="inline-flex items-center gap-1">
254 <Lock size={12} />
255 Only you will be able to talk to it.
256 </span>
257 )}
258 </p>
259 </div>
260 <Button
261 type="button"
262 onClick={() => {
263 setProposal(null);
264 setDefinition(null);
265 }}
266 variant="link"
267 size="inline"
268 className="text-sm text-muted hover:text-fg font-normal"
269 >
270 Describe it again
271 </Button>
272 </header>
273 {errors?.form && (
274 <Alert asChild className="mb-6 px-4 py-3">
275 <p>{errors.form}</p>
276 </Alert>
277 )}
278 {errors?.handle && (
279 <Alert asChild className="mb-6 px-4 py-3">
280 <p>{errors.handle}</p>
281 </Alert>
282 )}
283 <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,26rem)]">
284 <div className="min-w-0 space-y-4">
285 <ProposalCard
286 slug={params.owner.toLowerCase()}
287 owner={owner}
288 proposal={proposal}
289 value={definition}
290 onChange={setDefinition}
291 joinable={loaderData.joinable}
292 teams={joining}
293 onTeams={setJoining}
294 />
295 <CreateDraft definition={definition} teams={definition.scope === "personal" ? [] : joining} onEditAll={() => setFull(true)} />
296 </div>
297 <div className="lg:sticky lg:top-4 lg:h-[calc(100dvh-13rem)] lg:self-start">
298 <TryChat definition={definition} />
299 </div>
300 </div>
301 </div>
302 );
303 }
304
305 return (
306 <div className="pb-4">
307 {back}
308 <div className="mx-auto mt-8 max-w-2xl sm:mt-14">
309 <h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">What should this agent do?</h1>
310 <p className="mt-2 text-sm text-muted">Describe the job in a sentence or a paragraph. g1t drafts the whole agent for you to change, try and create.</p>
311 {!mayCreate && (
312 <Card asChild radius="lg" className="mt-5 px-4 py-3 text-sm text-muted">
313 <p>
314 This workspace's owners have turned off personal agents, so only owners create agents here. Ask an owner in chat if you'd like one.
315 </p>
316 </Card>
317 )}
318 {loaderData.templates == null && <p className="mt-5 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-muted">Roles to start from aren't available right now; the agents service didn't answer.</p>}
319 <div className="mt-6">
320 <DescribeBox slug={params.owner.toLowerCase()} owner={owner} mayCreate={mayCreate} templates={templates} fetcher={drafter} />
321 </div>
322 </div>
323 </div>
324 );
325}