create-langgraph-app 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/create.js +253 -1
  2. package/package.json +1 -1
package/dist/create.js CHANGED
@@ -101,6 +101,7 @@ function generatePackageJson(config) {
101
101
  dev: "tsx src/index.ts",
102
102
  "dev:http": "tsx src/server.ts",
103
103
  test: "vitest run",
104
+ "test:watch": "vitest",
104
105
  typecheck: "tsc --noEmit",
105
106
  },
106
107
  dependencies: Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b))),
@@ -126,7 +127,7 @@ function generateTsConfig() {
126
127
  types: ["node"],
127
128
  outDir: "dist",
128
129
  },
129
- include: ["src"],
130
+ include: ["src", "tests"],
130
131
  }, null, 2) + "\n";
131
132
  }
132
133
  function generateEnvConfig(config) {
@@ -335,6 +336,252 @@ export function makeSupervisor({
335
336
  }
336
337
  `;
337
338
  }
339
+ // Maps a selected pattern to the app it generates, so the server and demos
340
+ // only reference files that actually exist.
341
+ const PATTERN_APPS = {
342
+ supervisor: { route: "supervisor", fn: "createSupervisorApp", path: "./apps/supervisor" },
343
+ swarm: { route: "swarm", fn: "createSwarmApp", path: "./apps/swarm" },
344
+ hitl: { route: "interrupt", fn: "createInterruptApp", path: "./apps/interrupt" },
345
+ structured: { route: "analyst", fn: "createAnalystApp", path: "./apps/analyst" },
346
+ rag: { route: "rag", fn: "createRagApp", path: "./apps/rag" },
347
+ };
348
+ function selectedApps(config) {
349
+ return config.patterns.map((p) => PATTERN_APPS[p]).filter(Boolean);
350
+ }
351
+ function generateServer(config) {
352
+ const apps = selectedApps(config);
353
+ const imports = apps.map((a) => `import { ${a.fn} } from "${a.path}";`).join("\n");
354
+ const entries = apps.map((a) => ` "${a.route}": asApp(await ${a.fn}()),`).join("\n");
355
+ return `import "./config/env";
356
+ import { fastify, type FastifyError, type FastifyReply, type FastifyRequest } from "fastify";
357
+ import { Command } from "@langchain/langgraph";
358
+ import { AIMessageChunk, type BaseMessage } from "@langchain/core/messages";
359
+ import { PORT } from "./config/env";
360
+ ${imports}
361
+
362
+ // The apps are a mix of agents and graphs whose generic types don't unify;
363
+ // this is the structural slice the routes actually need.
364
+ interface AppGraph {
365
+ invoke(input: unknown, config?: unknown): Promise<{ messages: BaseMessage[] } & Record<string, unknown>>;
366
+ stream(input: unknown, config?: unknown): Promise<AsyncIterable<[unknown, { langgraph_node?: string } | undefined]>>;
367
+ getState(config: unknown): Promise<{ values?: unknown; next?: string[]; tasks?: unknown[] } | undefined>;
368
+ }
369
+ const asApp = (g: unknown) => g as AppGraph;
370
+
371
+ function httpError(status: number, message: string) {
372
+ return Object.assign(new Error(message), { statusCode: status });
373
+ }
374
+
375
+ /** Reject malformed bodies before they reach the agent. */
376
+ function validateMessages(raw: unknown): { role: string; content: string }[] {
377
+ if (raw === undefined) return [];
378
+ if (!Array.isArray(raw)) throw httpError(400, '"messages" must be an array');
379
+ return raw.map((m, i) => {
380
+ const { role, content } = (m ?? {}) as Record<string, unknown>;
381
+ if (typeof role !== "string" || typeof content !== "string") {
382
+ throw httpError(400, \`messages[\${i}] needs string "role" and "content"\`);
383
+ }
384
+ return { role, content };
385
+ });
386
+ }
387
+
388
+ const server = fastify({ logger: false });
389
+
390
+ server.setErrorHandler(async (error: FastifyError, _req: FastifyRequest, reply: FastifyReply) => {
391
+ const status = error.statusCode ?? 500;
392
+ return reply.status(status).send({ error: error.message });
393
+ });
394
+
395
+ async function start() {
396
+ const apps: Record<string, AppGraph> = {
397
+ ${entries}
398
+ };
399
+
400
+ const getApp = (name: string) => {
401
+ const app = apps[name];
402
+ if (!app) throw httpError(404, \`Unknown app: "\${name}". Available: \${Object.keys(apps).join(", ")}\`);
403
+ return app;
404
+ };
405
+
406
+ server.post<{ Params: { app: string } }>("/:app/invoke", async (req, reply) => {
407
+ const app = getApp(req.params.app);
408
+ const body = (req.body ?? {}) as { messages?: unknown; thread_id?: string };
409
+ const messages = validateMessages(body.messages);
410
+ const thread_id = body.thread_id ?? "default";
411
+
412
+ const result = await app.invoke({ messages }, { configurable: { thread_id } });
413
+ const last = result.messages.at(-1);
414
+ return reply.send({
415
+ messages: result.messages,
416
+ structuredResponse: (result as Record<string, unknown>).structuredResponse ?? null,
417
+ lastMessage: typeof last?.content === "string" ? last.content : JSON.stringify(last?.content),
418
+ });
419
+ });
420
+
421
+ server.post<{ Params: { app: string } }>("/:app/stream", async (req, reply) => {
422
+ const app = getApp(req.params.app);
423
+ const body = (req.body ?? {}) as { messages?: unknown; thread_id?: string };
424
+ const messages = validateMessages(body.messages);
425
+ const thread_id = body.thread_id ?? "default";
426
+
427
+ reply.raw.writeHead(200, {
428
+ "Content-Type": "text/event-stream",
429
+ "Cache-Control": "no-cache",
430
+ Connection: "keep-alive",
431
+ });
432
+ const send = (e: unknown) => reply.raw.write(\`data: \${JSON.stringify(e)}\\n\\n\`);
433
+
434
+ try {
435
+ const stream = await app.stream({ messages }, { configurable: { thread_id }, streamMode: "messages" });
436
+ for await (const [chunk, metadata] of stream) {
437
+ if (chunk instanceof AIMessageChunk) {
438
+ send({ type: "token", content: chunk.content, node: metadata?.langgraph_node ?? "unknown" });
439
+ }
440
+ }
441
+ send({ type: "done" });
442
+ } catch (err) {
443
+ send({ type: "error", content: err instanceof Error ? err.message : "Stream failed" });
444
+ } finally {
445
+ reply.raw.end();
446
+ }
447
+ });
448
+
449
+ server.post<{ Params: { app: string } }>("/:app/resume", async (req, reply) => {
450
+ const app = getApp(req.params.app);
451
+ const body = (req.body ?? {}) as { thread_id?: string; decision?: string };
452
+ if (body.decision === undefined) {
453
+ return reply.status(400).send({ error: '"decision" field is required' });
454
+ }
455
+ const result = await app.invoke(new Command({ resume: body.decision }), {
456
+ configurable: { thread_id: body.thread_id ?? "default" },
457
+ });
458
+ const last = result.messages.at(-1);
459
+ return reply.send({
460
+ messages: result.messages,
461
+ lastMessage: typeof last?.content === "string" ? last.content : JSON.stringify(last?.content),
462
+ });
463
+ });
464
+
465
+ server.get<{ Params: { app: string; threadId: string } }>("/:app/threads/:threadId", async (req, reply) => {
466
+ const app = getApp(req.params.app);
467
+ const state = await app.getState({ configurable: { thread_id: req.params.threadId } });
468
+ if (!state?.values) return reply.status(404).send({ error: "Thread not found" });
469
+ return reply.send({ values: state.values, next: state.next ?? [], tasks: state.tasks ?? [] });
470
+ });
471
+
472
+ server.get("/health", async () => ({ status: "ok", apps: Object.keys(apps) }));
473
+
474
+ // Container runtimes send SIGTERM on stop — drain instead of dying mid-request.
475
+ let shuttingDown = false;
476
+ for (const signal of ["SIGTERM", "SIGINT"] as const) {
477
+ process.on(signal, () => {
478
+ if (shuttingDown) return;
479
+ shuttingDown = true;
480
+ server.close().then(() => process.exit(0)).catch(() => process.exit(1));
481
+ });
482
+ }
483
+
484
+ await server.listen({ port: PORT, host: "0.0.0.0" });
485
+ console.log(\`Server running at http://localhost:\${PORT}\`);
486
+ console.log(\`Apps: \${Object.keys(apps).join(", ")}\`);
487
+ }
488
+
489
+ start().catch((err) => {
490
+ console.error("Failed to start server:", err);
491
+ process.exit(1);
492
+ });
493
+ `;
494
+ }
495
+ function generateScriptedModel() {
496
+ return `import { BaseChatModel } from "@langchain/core/language_models/chat_models";
497
+ import { AIMessage, type BaseMessage } from "@langchain/core/messages";
498
+ import type { ChatResult } from "@langchain/core/outputs";
499
+
500
+ /**
501
+ * A model that replays a queued list of responses, so tests can drive real
502
+ * graphs offline — no API key, no network, fully deterministic.
503
+ */
504
+ export class ScriptedToolCallingModel extends BaseChatModel {
505
+ private queue: AIMessage[];
506
+
507
+ constructor(queue: AIMessage[]) {
508
+ super({});
509
+ this.queue = [...queue];
510
+ }
511
+
512
+ _llmType(): string {
513
+ return "scripted-tool-calling";
514
+ }
515
+
516
+ override bindTools(): this {
517
+ return this;
518
+ }
519
+
520
+ async _generate(_messages: BaseMessage[]): Promise<ChatResult> {
521
+ const message = this.queue.shift();
522
+ if (!message) throw new Error("Scripted model ran out of responses");
523
+ return { generations: [{ message, text: "" }] };
524
+ }
525
+ }
526
+ `;
527
+ }
528
+ function generateAgentTest(config) {
529
+ const hasSupervisor = config.patterns.includes("supervisor");
530
+ const supervisorTest = hasSupervisor
531
+ ? `
532
+ it("delegates to a subagent and returns its answer", async () => {
533
+ const llm = new ScriptedToolCallingModel([
534
+ // supervisor delegates
535
+ new AIMessage({
536
+ content: "",
537
+ tool_calls: [{ id: "c1", name: "worker", args: { task: "do the thing" } }],
538
+ }),
539
+ // subagent answers
540
+ new AIMessage("worker done"),
541
+ // supervisor wraps up
542
+ new AIMessage("All finished."),
543
+ ]);
544
+
545
+ const worker = makeAgent({ name: "worker", llm, tools: [] });
546
+ const app = await makeSupervisor({
547
+ subagents: [{ name: "worker", description: "Does the thing.", agent: worker }],
548
+ llm,
549
+ checkpointer: new MemorySaver(),
550
+ });
551
+
552
+ const result = await app.invoke(
553
+ { messages: [{ role: "user", content: "do the thing" }] },
554
+ { configurable: { thread_id: "t1" } }
555
+ );
556
+
557
+ expect(result.messages.at(-1)?.content).toBe("All finished.");
558
+ // the supervisor sees the subagent's final answer, not its internals
559
+ const toolMessages = result.messages.filter((m) => m.getType() === "tool");
560
+ expect(toolMessages).toHaveLength(1);
561
+ expect(toolMessages[0].content).toBe("worker done");
562
+ });
563
+ `
564
+ : "";
565
+ const supervisorImports = hasSupervisor
566
+ ? `import { makeSupervisor } from "../src/agents/supervisor";\nimport { MemorySaver } from "@langchain/langgraph";\n`
567
+ : "";
568
+ return `import { describe, expect, it } from "vitest";
569
+ import { AIMessage } from "@langchain/core/messages";
570
+ import { makeAgent } from "../src/agents/factory";
571
+ ${supervisorImports}import { ScriptedToolCallingModel } from "./helpers/scripted-model";
572
+
573
+ describe("agents", () => {
574
+ it("runs a single agent to a final answer", async () => {
575
+ const llm = new ScriptedToolCallingModel([new AIMessage("Hello there.")]);
576
+ const agent = makeAgent({ name: "assistant", llm, tools: [], system: "Be brief." });
577
+
578
+ const result = await agent.invoke({ messages: [{ role: "user", content: "hi" }] });
579
+
580
+ expect(result.messages.at(-1)?.content).toBe("Hello there.");
581
+ });
582
+ ${supervisorTest}});
583
+ `;
584
+ }
338
585
  // Pattern-specific: only files for selected patterns are generated
339
586
  function getPatternFiles(config) {
340
587
  const files = [];
@@ -706,6 +953,11 @@ export async function createRagApp() {
706
953
  );
707
954
  console.log("Result:", rag.messages.at(-1)?.content);`);
708
955
  }
956
+ // HTTP server — routes only the apps that were generated
957
+ files.push({ path: "src/server.ts", content: generateServer(config) });
958
+ // Tests — offline, no API key needed
959
+ files.push({ path: "tests/helpers/scripted-model.ts", content: generateScriptedModel() });
960
+ files.push({ path: "tests/agents.test.ts", content: generateAgentTest(config) });
709
961
  // Generate index.ts
710
962
  files.push({
711
963
  path: "src/index.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-langgraph-app",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Scaffold a new LangGraph multi-agent project in seconds",
5
5
  "type": "module",
6
6
  "bin": {