Module 6.3 — Output Parsers, Memory & Tools

Three concepts that make LangChain apps production-ready


What This Module Covers

Output Parsers → get structured data from LLM instead of raw text
Memory         → give your chatbot the ability to remember conversations
Tools          → let your LLM call functions and external APIs

These three things separate a basic LLM wrapper from a real application.


Part 1 — Output Parsers

The Problem

LLMs return text. But your application needs structured data.


    // What LLM returns (raw text):
    // "The product has pros: fast, reliable, cheap.
    // Cons: no mobile app, limited support."

    // What your app needs (structured data):
    {
        pros: ["fast", "reliable", "cheap"],
        cons: ["no mobile app", "limited support"]
    }

You could parse the text manually — split strings, use regex — but that's fragile and breaks constantly.

Output Parsers tell the LLM exactly what format to use AND automatically parse the response for you.


Create src/04_output_parsers.js:


    import { ChatOpenAI } from "@langchain/openai";
    import { ChatPromptTemplate } from "@langchain/core/prompts";
    import {
        StringOutputParser,
        JsonOutputParser,
        CommaSeparatedListOutputParser,
        StructuredOutputParser
    } from "@langchain/core/output_parsers";
    // StringOutputParser            = AIMessage → plain string
    // JsonOutputParser              = AIMessage → JS object
    // CommaSeparatedListOutputParser = AIMessage → array of strings

    // StructuredOutputParser = most powerful — enforces exact schema

    import { z } from "zod";
    // zod = schema validation library
    // used to define exact shape of output we expect

    import * as dotenv from "dotenv";
    dotenv.config();

    const llm = new ChatOpenAI({ model: "gpt-4o", temperature: 0 });
    // temperature 0 = consistent, deterministic output
    // important for structured data — we want the same format every time


    // ─────────────────────────────────────────
    // PARSER 1 — StringOutputParser
    // Simplest — just extracts text from AIMessage
    // ─────────────────────────────────────────

    async function parser1() {
        console.log("".repeat(50));
        console.log("PARSER 1: StringOutputParser");
        console.log("".repeat(50));

        const chain = ChatPromptTemplate.fromMessages([
            ["human", "What is {topic}?"],
        ]).pipe(llm).pipe(new StringOutputParser());

        const result = await chain.invoke({ topic: "LangChain" });

        console.log("Type:", typeof result);
        // "string"

        console.log("Result:", result);
        // "LangChain is a framework for building AI applications..."
        console.log();
    }


    // ─────────────────────────────────────────
    // PARSER 2 — CommaSeparatedListOutputParser
    // Returns an array of strings
    // ─────────────────────────────────────────

    async function parser2() {
        console.log("".repeat(50));
        console.log("PARSER 2: CommaSeparatedListOutputParser");
        console.log("".repeat(50));

        const parser = new CommaSeparatedListOutputParser();
        // tells LLM to respond with comma-separated values
        // automatically splits into array

        const formatInstructions = parser.getFormatInstructions();
        // getFormatInstructions() = returns text instructions for the LLM
        // tells LLM exactly how to format its response
        // example: "Your response should be a list of comma separated values..."

        console.log("Format instructions sent to LLM:");
        console.log(formatInstructions);
        console.log();

        const prompt = ChatPromptTemplate.fromMessages([
            ["human", `List 5 popular vector databases.
            {format_instructions}`],
            // include format instructions in the prompt
            // tells LLM how to structure its response
        ]);

        const chain = prompt.pipe(llm).pipe(parser);

        const result = await chain.invoke({
            format_instructions: formatInstructions,
            // fills {format_instructions} placeholder
        });

        console.log("Type:", typeof result);
        // "object" (array)

        console.log("Result:", result);
        // ["Pinecone", "Chroma", "Weaviate", "Qdrant", "Milvus"]

        console.log("Length:", result.length);
        // 5

        console.log("First item:", result[0]);
        // "Pinecone"
        console.log();
    }


    // ─────────────────────────────────────────
    // PARSER 3 — StructuredOutputParser with Zod
    // Most powerful — enforces exact data shape
    // Best for production applications
    // ─────────────────────────────────────────

    async function parser3() {
        console.log("".repeat(50));
        console.log("PARSER 3: StructuredOutputParser with Zod schema");
        console.log("".repeat(50));

        // Define the exact shape you want using Zod
        const schema = z.object({
            name: z.string().describe("the name of the technology"),
            // z.string() = must be a string
            // .describe() = tells LLM what this field means

            category: z.string().describe("category like 'database', 'framework', 'library'"),

            yearCreated: z.number().describe("year it was created"),
            // z.number() = must be a number

            keyFeatures: z.array(z.string()).describe("list of 3 key features"),
            // z.array(z.string()) = array of strings

            difficulty: z.enum(["beginner", "intermediate", "advanced"])
                .describe("learning difficulty level"),
            // z.enum() = must be one of these exact values

            isOpenSource: z.boolean().describe("whether it is open source"),
            // z.boolean() = must be true or false

            summary: z.string().describe("one sentence description"),
        });

        const parser = StructuredOutputParser.fromZodSchema(schema);
        // creates parser from Zod schema
        // automatically generates format instructions for LLM
        // automatically validates and parses LLM response

        const formatInstructions = parser.getFormatInstructions();
        // generates detailed instructions telling LLM exactly how to respond
        // includes the JSON structure, field types, descriptions

        const prompt = ChatPromptTemplate.fromMessages([
            ["system", "You extract structured information about technologies."],
            ["human", `Extract information about: {technology}

            {format_instructions}`],
        ]);

        const chain = prompt.pipe(llm).pipe(parser);

        const result = await chain.invoke({
            technology: "Pinecone",
            format_instructions: formatInstructions,
        });

        console.log("Type:", typeof result);
        // "object"

        console.log("Parsed result:");
        console.log(JSON.stringify(result, null, 2));
        // {
        //   "name": "Pinecone",
        //   "category": "database",
        //   "yearCreated": 2019,
        //   "keyFeatures": ["vector search", "scalable", "managed service"],
        //   "difficulty": "beginner",
        //   "isOpenSource": false,
        //   "summary": "Pinecone is a managed vector database..."
        // }

        // Access fields directly
        console.log("\nDirect field access:");
        console.log("Name:", result.name);
        console.log("Open source:", result.isOpenSource);
        console.log("Features:", result.keyFeatures);
        console.log();
    }


    // ─────────────────────────────────────────
    // PARSER 4 — Real world example
    // Customer support ticket classifier
    // ─────────────────────────────────────────

    async function parser4() {
        console.log("".repeat(50));
        console.log("PARSER 4: Real world — ticket classifier");
        console.log("".repeat(50));

        const ticketSchema = z.object({
            category: z.enum(["billing", "technical", "general", "complaint", "feature_request"])
                .describe("ticket category"),

            priority: z.enum(["low", "medium", "high", "urgent"])
                .describe("ticket priority based on content"),

            sentiment: z.enum(["positive", "neutral", "negative", "very_negative"])
                .describe("customer sentiment"),

            summary: z.string().describe("one sentence summary of the issue"),

            requiresHuman: z.boolean()
                .describe("whether this needs a human agent or can be auto-resolved"),

            suggestedResponse: z.string()
                .describe("suggested first response to send to customer"),
        });

        const parser = StructuredOutputParser.fromZodSchema(ticketSchema);

        const prompt = ChatPromptTemplate.fromMessages([
            ["system", `You are a customer support AI that classifies tickets.
            Analyze the ticket and extract structured information.
            {format_instructions}`],
            ["human", "Ticket: {ticket}"],
        ]);

        const chain = prompt.pipe(llm).pipe(parser);

        const tickets = [
            "I've been charged twice for my subscription this month. Please fix this immediately!",
            "How do I export my data to CSV?",
            "Your app keeps crashing whenever I try to upload files. This is unacceptable.",
        ];

        for (const ticket of tickets) {
            const result = await chain.invoke({
                ticket,
                format_instructions: parser.getFormatInstructions(),
            });

            console.log(`\nTicket: "${ticket.substring(0, 50)}..."`);
            console.log(`Category: ${result.category}`);
            console.log(`Priority: ${result.priority}`);
            console.log(`Sentiment: ${result.sentiment}`);
            console.log(`Needs human: ${result.requiresHuman}`);
            console.log(`Suggested response: ${result.suggestedResponse.substring(0, 80)}...`);
        }
        console.log();
    }


    async function main() {
        console.log("\n🔧 OUTPUT PARSERS DEMO\n");
        await parser1();
        await parser2();
        await parser3();
        await parser4();
        console.log("✅ All parser examples complete!");
    }

    main().catch(console.error);

Run:

node src/04_output_parsers.js

Part 2 — Memory

The Problem

By default LLMs have no memory. Every call is independent.


    // Without memory:
    await chain.invoke({ question: "My name is Sofia" });
    // Model: "Nice to meet you Sofia!"

    await chain.invoke({ question: "What is my name?" });
    // Model: "I don't know your name."  ← forgot immediately

Memory fixes this by storing conversation history and injecting it into every new call.


Create src/05_memory.js:


    // ─────────────────────────────────────────
    // IMPORTS
    // ─────────────────────────────────────────

    import { ChatOpenAI } from "@langchain/openai";
    // ChatOpenAI = LangChain's wrapper for OpenAI's chat models
    // Internally it calls: POST https://api.openai.com/v1/chat/completions
    // You don't write the fetch() call manually — LangChain does it for you

    import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
    // ChatPromptTemplate = a reusable template for building chat prompts
    // MessagesPlaceholder = a special slot in the template
    //   → at runtime, this slot gets filled with actual conversation history
    //   → think of it like a {variable} but specifically for a list of messages

    import { StringOutputParser } from "@langchain/core/output_parsers";
    // StringOutputParser = converts LangChain AIMessage object → plain string
    // Without this: response is an AIMessage { content: "...", role: "assistant" }
    // With this:    response is just "..." (the text directly)

    import { RunnableWithMessageHistory } from "@langchain/core/runnables";
    // RunnableWithMessageHistory = a wrapper that adds memory to ANY chain
    // Before each chain call:
    //   → it loads the conversation history from storage
    //   → injects it into the prompt
    // After each chain call:
    //   → it saves the new human message + AI response to storage
    // Without this wrapper: chain has zero memory of past messages

    import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
    // InMemoryChatMessageHistory = stores messages in a JavaScript array in RAM
    // When your Node.js process stops → all history is lost (it's in-memory)
    // For production apps you'd replace this with:
    //   → RedisChatMessageHistory (stores in Redis database)
    //   → MongoDBChatMessageHistory (stores in MongoDB)
    //   → PostgresChatMessageHistory (stores in PostgreSQL)
    // But for learning — in-memory is perfect

    import { HumanMessage, AIMessage } from "@langchain/core/messages";
    // HumanMessage = represents a message from the user
    //   Internal structure: { role: "user", content: "what user typed" }
    // AIMessage = represents a response from the AI model
    //   Internal structure: { role: "assistant", content: "what AI replied" }
    // These are used when manually adding messages to history

    import * as dotenv from "dotenv";
    // dotenv = reads your .env file and loads variables into process.env
    dotenv.config();
    // After this line: process.env.OPENAI_API_KEY = "sk-proj-..."
    // LangChain automatically reads this key when making API calls



    // ─────────────────────────────────────────
    // CREATE THE LLM
    // ─────────────────────────────────────────

    const llm = new ChatOpenAI({
        model: "gpt-4o",
        // which OpenAI model to use
        // "gpt-4o" = GPT-4 Omni — fast and capable

        temperature: 0.7,
        // controls randomness in responses
        // 0.0 = always picks the highest probability token → same answer every time
        // 0.7 = some randomness → natural, varied responses
        // 1.0 = very random → creative but less predictable
        // For a friendly chat assistant, 0.7 is a good balance
    });
    // Internal state of llm object:
    // {
    //   model: "gpt-4o",
    //   temperature: 0.7,
    //   apiKey: "sk-proj-..." (read from process.env automatically)
    // }

    const parser = new StringOutputParser();
    // parser = an object with one job: extract .content from AIMessage
    // When LLM responds, LangChain returns:
    //   AIMessage { content: "Hello! How can I help?", role: "assistant", ... }
    // After parser:
    //   "Hello! How can I help?"  ← just the string




    // ─────────────────────────────────────────
    // BUILD THE PROMPT TEMPLATE
    // ─────────────────────────────────────────

    const prompt = ChatPromptTemplate.fromMessages([
        // fromMessages() takes an array and builds a reusable template
        // Each item becomes one "message" in the conversation

        ["system", `You are a helpful AI assistant.
            You remember everything said in this conversation.
            Be friendly and reference previous messages when relevant.`],
        // ["system", "..."] = creates a SystemMessage
        // This is the hidden instruction that shapes the AI's behavior
        // The user never sees this — it's like a briefing before the conversation
        // Internal: SystemMessage { role: "system", content: "You are a helpful..." }

        new MessagesPlaceholder("history"),
        // This is NOT a regular message — it's a placeholder
        // At runtime, "history" gets replaced with the actual array of past messages
        // Example: if 3 messages happened before:
        //   HumanMessage { content: "Hi, I'm Sofia" }
        //   AIMessage    { content: "Hello Sofia!" }
        //   HumanMessage { content: "I'm learning AI" }
        // → these 3 messages get inserted RIGHT HERE in the prompt
        // "history" = the key name — must match historyMessagesKey below

        ["human", "{question}"],
        // {question} = placeholder for the CURRENT user message
        // Gets filled with whatever the user just typed
        // Internal: HumanMessage { role: "user", content: "actual question" }
    ]);

    // What the full prompt looks like at runtime (simplified):
    // [
    //   SystemMessage: "You are a helpful AI assistant..."
    //   HumanMessage:  "Hi, I'm Sofia"           ← from history
    //   AIMessage:     "Hello Sofia!"             ← from history
    //   HumanMessage:  "What is my name?"         ← current question
    // ]
    // This entire array gets sent to OpenAI in one API call


    // ─────────────────────────────────────────
    // BUILD THE BASE CHAIN (no memory yet)
    // ─────────────────────────────────────────

    const baseChain = prompt.pipe(llm).pipe(parser);
    // .pipe() = connect components — output of left becomes input of right
    // This creates a pipeline:

    // Step 1: prompt
    //   Input:  { question: "What is my name?", history: [array of messages] }
    //   Output: Array of formatted messages (SystemMessage + history + HumanMessage)

    // Step 2: llm
    //   Input:  Array of formatted messages
    //   Internally: calls OpenAI API with all those messages
    //   Output: AIMessage { content: "Your name is Sofia!", role: "assistant" }

    // Step 3: parser
    //   Input:  AIMessage { content: "Your name is Sofia!" }
    //   Output: "Your name is Sofia!"  ← plain string

    // baseChain by itself has NO memory
    // Every .invoke() call is completely independent
    // It doesn't know anything about previous calls


    // ─────────────────────────────────────────
    // MEMORY STORAGE
    // ─────────────────────────────────────────

    const messageHistories = {};
    // messageHistories = plain JavaScript object (acts like a dictionary/HashMap)
    // Key   = session ID string (who is talking)
    // Value = InMemoryChatMessageHistory object (their message history)
    //
    // Current state: {} (empty — no conversations yet)
    //
    // After a few conversations it will look like:
    // {
    //   "demo_session_1": InMemoryChatMessageHistory {
    //     messages: [
    //       HumanMessage { content: "Hi, I'm Sofia" },
    //       AIMessage    { content: "Hello Sofia!" },
    //       HumanMessage { content: "I'm learning AI" },
    //       AIMessage    { content: "That's great!" }
    //     ]
    //   },
    //   "user_alice": InMemoryChatMessageHistory {
    //     messages: [
    //       HumanMessage { content: "My favorite color is blue" },
    //       AIMessage    { content: "Blue is a great color!" }
    //     ]
    //   }
    // }

    function getSessionHistory(sessionId) {
        // This function is called AUTOMATICALLY by RunnableWithMessageHistory
        // before every chain invocation
        // sessionId = the ID passed in the config object
        // example: "demo_session_1", "user_alice", "user_bob"

        if (!messageHistories[sessionId]) {
            // First time we see this sessionId → create empty history for them
            messageHistories[sessionId] = new InMemoryChatMessageHistory();
            // InMemoryChatMessageHistory starts with:
            // { messages: [] }  ← empty array
        }

        return messageHistories[sessionId];
        // Returns the history object for this specific session
        // RunnableWithMessageHistory uses this to:
        //   1. LOAD history → inject into prompt BEFORE calling the chain
        //   2. SAVE new messages → add to history AFTER chain responds
    }


    // ─────────────────────────────────────────
    // WRAP BASE CHAIN WITH MEMORY
    // ─────────────────────────────────────────

    const chainWithMemory = new RunnableWithMessageHistory({
        runnable: baseChain,
        // the chain we want to add memory to
        // This is the prompt | llm | parser pipeline we built above

        getMessageHistory: getSessionHistory,
        // function to call to get/create history for a session
        // RunnableWithMessageHistory calls this automatically

        inputMessagesKey: "question",
        // tells the wrapper: "the user's current message is in the 'question' key"
        // must match {question} in the prompt template
        // when you call .invoke({ question: "..." }) → this is the current message

        historyMessagesKey: "history",
        // tells the wrapper: "inject the history into the 'history' key"
        // must match new MessagesPlaceholder("history") in the prompt template
        // the wrapper automatically fills this before calling baseChain
    });

    // chainWithMemory is now a STATEFUL chain
    // Every .invoke() call automatically:
    //   BEFORE: loads history → adds to prompt
    //   DURING: runs baseChain (prompt → llm → parser)
    //   AFTER:  saves [HumanMessage, AIMessage] to history for next time


    // ─────────────────────────────────────────
    // EXAMPLE 1 — Conversation with memory
    // ─────────────────────────────────────────

    async function example1() {
        console.log("".repeat(50));
        console.log("EXAMPLE 1: Conversation with memory");
        console.log("".repeat(50));

        const sessionId = "demo_session_1";
        // Unique identifier for this conversation
        // Think of it like a chat room ID or user ID
        // All messages with this ID share the same history
        // Different IDs = different conversations = different memories

        const config = { configurable: { sessionId } };
        // config object that gets passed to .invoke()
        // RunnableWithMessageHistory reads sessionId from here
        // to know which history to load and save to
        // configurable is LangChain's standard way to pass runtime config

        const questions = [
            "Hi! My name is Sofia and I'm a MERN developer.",
            "I'm learning AI engineering right now.",
            "What is my name?",
            "What am I learning?",
            "Give me a learning tip based on what you know about me.",
        ];
        // 5 questions that build on each other
        // Questions 3, 4, 5 test if the AI remembers questions 1 and 2

        for (const question of questions) {
            console.log(`\nYou: ${question}`);

            const response = await chainWithMemory.invoke(
                { question },
                // { question: "Hi! My name is Sofia..." }
                // This is the CURRENT user message
                // "question" key matches inputMessagesKey in RunnableWithMessageHistory

                config,
                // { configurable: { sessionId: "demo_session_1" } }
                // Tells the wrapper which session history to use
            );

            console.log(`AI: ${response}`);
            // response = plain string from StringOutputParser
            // example: "Nice to meet you Sofia! How can I help you today?"
        }

        // What happens internally for each .invoke() call:

        // ── TURN 1: "Hi! My name is Sofia and I'm a MERN developer." ──
        //
        // 1. RunnableWithMessageHistory calls getSessionHistory("demo_session_1")
        //    → History is empty: { messages: [] }
        //
        // 2. Builds prompt with empty history:
        //    [SystemMessage: "You are a helpful...",
        //     HumanMessage: "Hi! My name is Sofia..."]
        //
        // 3. Sends to OpenAI API → gets response:
        //    "Nice to meet you Sofia! As a MERN developer, you must be..."
        //
        // 4. Saves to history:
        //    messages: [
        //      HumanMessage { content: "Hi! My name is Sofia..." },
        //      AIMessage    { content: "Nice to meet you Sofia!..." }
        //    ]
        //
        // 5. Returns: "Nice to meet you Sofia!..."

        // ── TURN 2: "I'm learning AI engineering right now." ──
        //
        // 1. getSessionHistory("demo_session_1") → history now has 2 messages
        //
        // 2. Builds prompt WITH history:
        //    [SystemMessage: "You are a helpful...",
        //     HumanMessage: "Hi! My name is Sofia..."  ← from history
        //     AIMessage:    "Nice to meet you Sofia!..." ← from history
        //     HumanMessage: "I'm learning AI engineering right now."] ← current
        //
        // 3. OpenAI sees the FULL conversation → responds with context
        //    "That's exciting! AI engineering is a fascinating field..."
        //
        // 4. Saves to history: now 4 messages total
        //
        // 5. Returns: "That's exciting!..."

        // ── TURN 3: "What is my name?" ──
        //
        // 1. getSessionHistory → history has 4 messages
        //
        // 2. Full prompt sent to OpenAI:
        //    [System, Human("Hi I'm Sofia"), AI("Nice to meet you"),
        //     Human("I'm learning AI"), AI("That's exciting"),
        //     Human("What is my name?")]
        //
        // 3. OpenAI has seen "Sofia" mentioned in turn 1
        //    Response: "Your name is Sofia!"
        //
        // Without memory, OpenAI would say "I don't know your name"
        // With memory, it can see the entire conversation → "Sofia"

        console.log();
    }


    // ─────────────────────────────────────────
    // EXAMPLE 2 — Multiple separate sessions
    // Different users have different memories
    // ─────────────────────────────────────────

    async function example2() {
        console.log("".repeat(50));
        console.log("EXAMPLE 2: Multiple separate sessions");
        console.log("".repeat(50));

        // User 1 conversation
        await chainWithMemory.invoke(
            { question: "My favorite color is blue and I love pizza." },
            { configurable: { sessionId: "user_alice" } }
            // session "user_alice"
        );

        // User 2 conversation
        await chainWithMemory.invoke(
            { question: "I'm a doctor and I specialize in cardiology." },
            { configurable: { sessionId: "user_bob" } }
            // session "user_bob" — completely separate memory
        );

        // Now ask both users about themselves
        const aliceAnswer = await chainWithMemory.invoke(
            { question: "What do you know about me?" },
            { configurable: { sessionId: "user_alice" } }
            // uses alice's history — knows about blue and pizza
        );

        const bobAnswer = await chainWithMemory.invoke(
            { question: "What do you know about me?" },
            { configurable: { sessionId: "user_bob" } }
            // uses bob's history — knows about cardiology
        );

        console.log("Alice session response:", aliceAnswer);
        console.log("\nBob session response:", bobAnswer);
        // Different answers — different memories — same model
        console.log();
    }


    // ─────────────────────────────────────────
    // EXAMPLE 3 — Inspect stored memory
    // See what's actually stored
    // ─────────────────────────────────────────

    async function example3() {
        console.log("".repeat(50));
        console.log("EXAMPLE 3: Inspecting stored memory");
        console.log("".repeat(50));

        // Run a quick conversation
        const sessionId = "inspect_demo";
        const config = { configurable: { sessionId } };

        await chainWithMemory.invoke(
            { question: "My name is Arjun" },
            config
        );

        await chainWithMemory.invoke(
            { question: "I work at a startup building AI products" },
            config
        );

        // Now inspect what's stored
        const history = getSessionHistory(sessionId);
        const messages = await history.getMessages();
        // getMessages() = returns all stored messages

        console.log(`\nStored messages for session "${sessionId}":`);
        messages.forEach((msg, index) => {
            const role = msg.constructor.name;
            // "HumanMessage" or "AIMessage"
            const preview = msg.content.substring(0, 60);
            console.log(`  ${index + 1}. [${role}]: ${preview}...`);
        });

        console.log(`\nTotal messages stored: ${messages.length}`);
        // 4 messages: 2 human + 2 AI responses
        console.log();
    }


    // ─────────────────────────────────────────
    // EXAMPLE 4 — Memory with context window limit
    // Real apps need to limit memory size
    // ─────────────────────────────────────────

    async function example4() {
        console.log("".repeat(50));
        console.log("EXAMPLE 4: Memory with window limit");
        console.log("".repeat(50));
        console.log("(Showing concept — keeps last N messages only)\n");

        // In production you'd use a windowed memory
        // that only keeps the last N messages
        // to prevent context window overflow

        // Simple manual implementation:
        const MAX_HISTORY = 6;
        // keep only last 6 messages (3 exchanges)

        const sessionId = "windowed_demo";

        // Simulate a long conversation
        const turns = [
            "Message 1: I like cats",
            "Message 2: My favorite food is sushi",
            "Message 3: I work as a developer",
            "Message 4: I live in India",
            "Message 5: I enjoy hiking on weekends",
        ];

        for (const message of turns) {
            await chainWithMemory.invoke(
                { question: message },
                { configurable: { sessionId } }
            );

            // After each turn — check and trim history if needed
            const history = getSessionHistory(sessionId);
            const messages = await history.getMessages();

            if (messages.length > MAX_HISTORY) {
                // Too many messages — trim to last MAX_HISTORY
                const trimmed = messages.slice(-MAX_HISTORY);
                // slice(-6) = take last 6 messages

                // Clear and refill with trimmed history
                await history.clear();
                for (const msg of trimmed) {
                    await history.addMessage(msg);
                }
                console.log(`   Trimmed to last ${MAX_HISTORY} messages`);
            }
        }

        // Final check
        const finalHistory = getSessionHistory(sessionId);
        const finalMessages = await finalHistory.getMessages();
        console.log(`\nFinal message count: ${finalMessages.length} (max: ${MAX_HISTORY})`);
        console.log("Memory windowing working correctly ✅");
        console.log();
    }


    async function main() {
        console.log("\n🧠 MEMORY DEMO\n");
        // await example1();
        // await example2();
        // await example3();
        await example4();
        console.log("✅ All memory examples complete!");
    }

    main().catch(console.error);

Run:

node src/05_memory.js

Part 3 — Tools

The Problem

LLMs can only generate text. But real applications need to DO things:

Calculate the current date
Search the web
Call an API
Run a database query
Send an email

Tools give the LLM the ability to call functions — and use the results in its response.


Create src/06_tools.js:


    // ─────────────────────────────────────────
    // IMPORTS
    // ─────────────────────────────────────────

    // ChatOpenAI = wrapper class that lets us talk to OpenAI's GPT models
    import { ChatOpenAI } from "@langchain/openai";

    // tool() = helper function that converts a normal JS function into a
    // "tool" that the LLM (the AI model) can call on its own when needed
    import { tool } from "@langchain/core/tools";

    // createAgent = the modern way (2026) to build an agent in LangChain.
    // It replaces the old AgentExecutor + createToolCallingAgent combo.
    // Internally it runs on LangGraph, which manages the think -> call tool -> think -> answer loop.
    import { createAgent } from "langchain";

    // zod = a library used to define "schemas" (rules for what data should look like).
    // The LLM uses these schemas to know exactly what inputs a tool expects.
    import { z } from "zod";

    // dotenv = loads variables (like API keys) from a .env file into process.env
    import * as dotenv from "dotenv";
    dotenv.config(); // Actually reads the .env file and loads it into memory

    // ─────────────────────────────────────────
    // CREATE THE LLM (the "brain" of the agent)
    // ─────────────────────────────────────────

    const llm = new ChatOpenAI({
        model: "gpt-4o",   // which OpenAI model to use
        temperature: 0,    // 0 = very consistent/predictable answers, no randomness
    });

    // ─────────────────────────────────────────
    // TOOL 1 — CALCULATOR
    // Purpose: Do math instead of letting the LLM "guess" numbers in its head
    // ─────────────────────────────────────────

    const calculatorTool = tool(
        // First argument: the actual function that runs when this tool is called.
        // Whatever the LLM decides to pass in (operation, a, b) lands here automatically.
        async ({ operation, a, b }) => {
            switch (operation) {
                case "add": return `${a} + ${b} = ${a + b}`;
                case "subtract": return `${a} - ${b} = ${a - b}`;
                case "multiply": return `${a} × ${b} = ${a * b}`;
                case "divide":
                    if (b === 0) return "Error: Cannot divide by zero"; // safety check
                    return `${a} ÷ ${b} = ${a / b}`;
                default:
                    return "Unknown operation"; // fallback if something unexpected is passed
            }
        },

        // Second argument: metadata that tells the LLM how/when to use this tool
        {
            name: "calculator", // this exact name is how the LLM "calls" the tool internally

            description: "Performs basic math operations. Use this for any calculations.",
            // The LLM reads this description to decide WHEN this tool is relevant.
            // Better description = better decision-making by the LLM.

            schema: z.object({
                operation: z.enum(["add", "subtract", "multiply", "divide"])
                    .describe("the math operation to perform"),
                a: z.number().describe("first number"),
                b: z.number().describe("second number"),
            }),
            // schema = strict contract. The LLM MUST send exactly these fields
            // (operation, a, b) with these exact types, or the call will fail validation.
        }
    );

    // ─────────────────────────────────────────
    // TOOL 2 — WEATHER (simulated / fake data)
    // Purpose: Demonstrate how a tool can return "external" data
    // In a real app, this function would call a real weather API (like OpenWeatherMap)
    // ─────────────────────────────────────────

    const weatherTool = tool(
        async ({ city }) => {
            // Hardcoded fake weather database (stand-in for a real API call)
            const weatherData = {
                "Delhi": { temp: 38, condition: "Hot and sunny", humidity: 45 },
                "Mumbai": { temp: 29, condition: "Humid and cloudy", humidity: 85 },
                "Bangalore": { temp: 24, condition: "Pleasant and breezy", humidity: 65 },
                "London": { temp: 15, condition: "Cloudy with light rain", humidity: 78 },
            };

            const data = weatherData[city]; // look up the city in our fake database
            if (!data) return `Weather data not available for ${city}`; // handle unknown city

            // Build a readable sentence using the found data
            return `Weather in ${city}: ${data.condition}, ${data.temp}°C, Humidity: ${data.humidity}%`;
        },
        {
            name: "get_weather",
            description: "Gets current weather for a city. Use when asked about weather.",
            schema: z.object({
                city: z.string().describe("the city name to get weather for"),
            }),
        }
    );

    // ─────────────────────────────────────────
    // TOOL 3 — PRODUCT SEARCH (simulated store database)
    // Purpose: Show how a tool can filter/search data based on multiple inputs
    // ─────────────────────────────────────────

    const productSearchTool = tool(
        async ({ query, maxPrice }) => {
            // Fake product catalog (stand-in for a real database query)
            const products = [
                { name: "Laptop Pro X1", price: 85000, category: "laptop", rating: 4.5 },
                { name: "Budget Laptop Z", price: 35000, category: "laptop", rating: 4.0 },
                { name: "Wireless Mouse", price: 1500, category: "mouse", rating: 4.3 },
                { name: "Mechanical Keyboard", price: 5000, category: "keyboard", rating: 4.7 },
                { name: "4K Monitor", price: 45000, category: "monitor", rating: 4.6 },
                { name: "Budget Monitor", price: 15000, category: "monitor", rating: 3.9 },
            ];

            // Filter the products array based on the search query and optional max price
            const results = products.filter(p => {
                const matchesQuery =
                    p.name.toLowerCase().includes(query.toLowerCase()) ||     // match by name
                    p.category.toLowerCase().includes(query.toLowerCase());   // OR match by category
                const withinPrice = maxPrice ? p.price <= maxPrice : true;    // price filter (optional)
                return matchesQuery && withinPrice; // must satisfy BOTH conditions
            });

            if (results.length === 0) return "No products found matching your criteria";

            // Turn the filtered array into a readable multi-line string
            return results.map(p =>
                `${p.name} — ₹${p.price.toLocaleString()} (Rating: ${p.rating}/5)`
            ).join("\n"); // join each product on a new line
        },
        {
            name: "search_products",
            description: "Search for products in our store. Use when customer asks about products or prices.",
            schema: z.object({
                query: z.string().describe("product name or category to search for"),
                maxPrice: z.number().optional().describe("maximum price in rupees (optional)"),
                // .optional() = the LLM does NOT have to provide this field
            }),
        }
    );

    // ─────────────────────────────────────────
    // TOOL 4 — DATE AND TIME
    // Purpose: Give the LLM access to the real current date/time
    // (LLMs don't inherently "know" the current real-world time on their own)
    // ─────────────────────────────────────────

    const dateTimeTool = tool(
        async ({ timezone }) => {
            const now = new Date(); // grabs the real current date/time from the system

            const options = {
                timeZone: timezone || "Asia/Kolkata", // default to India time if none given
                dateStyle: "full",   // e.g. "Wednesday, 15 July 2026"
                timeStyle: "long",   // e.g. "5:45:12 pm GMT+5:30"
            };

            // Format the date/time nicely in Indian English style
            return `Current date/time: ${now.toLocaleString("en-IN", options)}`;
        },
        {
            name: "get_datetime",
            description: "Gets the current date and time. Use when asked about today's date or current time.",
            schema: z.object({
                timezone: z.string().optional()
                    .describe("timezone like 'Asia/Kolkata' or 'America/New_York' (optional)"),
            }),
        }
    );

    // ─────────────────────────────────────────
    // COLLECT ALL TOOLS INTO ONE ARRAY
    // The agent will pick whichever tool fits the question, on its own
    // ─────────────────────────────────────────

    const tools = [calculatorTool, weatherTool, productSearchTool, dateTimeTool];

    // ─────────────────────────────────────────
    // SYSTEM PROMPT
    // This is like a "job description" given to the LLM before any conversation starts.
    // It sets rules/behavior the LLM should always follow.
    // ─────────────────────────────────────────

    const systemPrompt = `You are a helpful assistant with access to tools.
    Use tools whenever they would help answer the question accurately.
    Always use the calculator tool for any math — don't calculate in your head.
    Always use get_datetime for current date/time questions.`;

    // ─────────────────────────────────────────
    // CREATE THE AGENT
    // createAgent() bundles together: the LLM (model) + the tools + the system prompt
    // and internally builds a LangGraph "graph" that manages the full reasoning loop:
    // user asks -> LLM thinks -> LLM may call a tool -> tool runs -> LLM sees result -> LLM answers
    // ─────────────────────────────────────────

    const agent = createAgent({
        model: llm,        // the brain that decides what to do
        tools,              // the list of actions it's allowed to take
        systemPrompt,        // the behavior rules it must follow
    });

    // ─────────────────────────────────────────
    // FUNCTION TO RUN THE AGENT FOR A SINGLE QUESTION
    // ─────────────────────────────────────────

    async function runAgent(question) {
        console.log("".repeat(50)); // prints a line of 50 dashes, just for visual separation
        console.log(`Question: ${question}`);
        console.log("".repeat(50));

        // agent.invoke() actually RUNS the agent on this input and waits for the final result.
        // The input MUST be in { messages: [...] } format because createAgent (LangGraph-based)
        // works with a "conversation state" made of messages, not a plain string.
        const result = await agent.invoke({
            messages: [
                { role: "user", content: question }, // this is the user's question, as a chat message
            ],
        });

        // result.messages = the FULL conversation history including:
        // the user's question, any tool calls, any tool results, and the final AI reply.
        // We only care about the LAST message, which is the agent's final answer.
        const finalMessage = result.messages[result.messages.length - 1];

        console.log("\nFinal Answer:", finalMessage.content);
        console.log();
    }

    // ─────────────────────────────────────────
    // MAIN FUNCTION — entry point of the script
    // ─────────────────────────────────────────

    async function main() {
        console.log("\n🔨 TOOLS AND AGENTS DEMO\n");

        // This will trigger the agent to internally call the calculatorTool
        // because the system prompt told it: "Always use the calculator tool for any math"
        await runAgent("What is 1847 multiplied by 23?");

        // Will trigger weatherTool since the system prompt covers weather questions
        await runAgent("What is the weather like in Bangalore right now?");

        // Will trigger productSearchTool with query="laptops" and maxPrice=50000
        await runAgent("Show me laptops under ₹50,000");

        // Will trigger dateTimeTool
        await runAgent("What is today's date and time?");

        // This will trigger MULTIPLE tools in one go: weatherTool AND productSearchTool
        await runAgent(
            "What's the weather in Delhi? And what laptops can I buy for under ₹40,000?"
        );

        console.log("✅ All tool examples complete!");
    }

    // Actually starts everything. .catch(console.error) makes sure that if
    // anything crashes/fails, the error gets printed instead of silently failing.
    main().catch(console.error);

Run:

node src/06_tools.js

How Tools Work Under the Hood

This is important to understand — it's not magic:

User: "What is 1847 × 23?"
        ↓
LLM thinks: "I need to calculate this.
             I have a calculator tool.
             I should use it."
        ↓
LLM outputs a tool call (not text):

    {
        "tool": "calculator",
        "inputs": {
            "operation": "multiply",
            "a": 1847,
            "b": 23
        }
    }

↓ AgentExecutor intercepts this Runs the actual calculator function Gets result: "1847 × 23 = 42481" ↓ Result fed back to LLM: "Tool result: 1847 × 23 = 42481" ↓ LLM generates final response: "1847 multiplied by 23 equals 42,481."

The LLM doesn't calculate — it decides to use a tool, the tool calculates, the result comes back to the LLM, and the LLM formats the final answer.


The Agent Loop

User question
      ↓
LLM thinks: do I need a tool?
      ↓
YES → pick the right tool
      ↓
Call tool with correct inputs
      ↓
Get tool result
      ↓
LLM thinks: do I have enough info now?
      ↓
YES → generate final answer
NO  → call another tool
      ↓
Final answer to user

This loop runs until the LLM decides it has enough information.


3-Line Summary

  1. Output Parsers tell the LLM exactly what format to return and automatically convert the response — StructuredOutputParser with Zod gives you typed JavaScript objects with validated fields instead of raw strings.
  2. Memory stores conversation history per session using a session ID — RunnableWithMessageHistory wraps any chain to automatically inject past messages into every new call so the LLM can reference what was said before.
  3. Tools are JavaScript functions wrapped with a name, description, and input schema — the LLM reads the descriptions to decide which tool to call, passes the right inputs, gets the result back, and uses it to form its final answer.

Module 6.3 — Complete ✅

Coming up — Module 6.4 — Document Loaders, Text Splitters & Vector Stores in LangChain

We rebuild the PDF chatbot from Phase 5 — but this time using LangChain components. You'll see exactly how much simpler the code becomes, and understand the LangChain versions of every piece you built manually.

Module 6.4 — Document Loaders, Text Splitters & Vector Stores

Rebuilding the PDF chatbot in LangChain — see how much simpler it gets What We're Doing In Phase 5 you built a PDF chatbot manually — 7 ...