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.
- package/dist/create.js +253 -1
- 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",
|