Phase 3: User Profile & Session Management

Right now TaskFlow logs in and out correctly, but it has a real usability problem: Okta's access and ID tokens are short-lived (an hour, by default), and once one expires, our Middleware from Phase 2 just kicks the user back to /login — even though they never actually "logged out." This phase fixes that properly, and also lets users see and update their own profile.

What Each Token Is Actually For — A Precise Recap

We touched on this back in Lecture 2, but now that you've handled these tokens in real code, the distinctions matter more:

  • ID Token — A signed statement of identity, valid for a short window (default: 1 hour on Okta). Meant to be read by your app, never sent to an external API. We use it to know who's logged in.
  • Access Token — A signed permission slip, also short-lived (default: 1 hour). Meant to be sent to APIs (Okta's own APIs, or your own protected Next.js Route Handlers, which we'll build in Phase 5) as proof of what the bearer can do.
  • Refresh Token — A separate, much longer-lived credential (Okta's default for confidential web apps like ours is persistent, not single-use, unlike Single-Page Apps which get rotating short-lived refresh tokens). Its only job is to be exchanged for a fresh Access Token and ID Token, without forcing the user to log in again.

Short-lived access/ID tokens exist for a good reason: if one ever leaks, the damage window is small. The Refresh Token compensates for that short lifespan by quietly getting new tokens in the background — this is called silent renewal, and it's what we build next.

Step 1: Actually Requesting a Refresh Token

Two things must both be true for Okta to hand out a refresh token, and we haven't done either yet:

  1. Your Okta Application must have Refresh Token enabled as an allowed grant type.
  2. Your /authorize request must include the offline_access scope — this is the specific scope that tells Okta "I want a refresh token too," and per Okta's rules it must be requested here, at authorization time, not later at token exchange time.

Go back to the Okta Admin Console → Applications → Applications → your TaskFlow app → General tab → Edit (in the Grant type section) and check Refresh Token alongside the existing Authorization Code grant. Save.

Now update src/app/api/auth/login/route.ts from Phase 2 — change one line:


    authorizeUrl.searchParams.set("scope", "openid profile email offline_access");

And update the callback route's cookie-setting section in src/app/api/auth/callback/route.ts to also store the refresh token:


    if (tokens.refresh_token) {
        response.cookies.set("refresh_token", tokens.refresh_token, {
            httpOnly: true,
            secure: process.env.NODE_ENV === "production",
            sameSite: "lax",
            maxAge: 60 * 60 * 24 * 30, // Okta's default persistent refresh token lifetime is generous; 30 days is a reasonable cookie ceiling
            path: "/",
        });
    }

Step 2: A Refresh Helper

Create src/lib/refreshTokens.ts:


    interface RefreshedTokens {
        access_token: string;
        id_token: string;
        refresh_token?: string;
        expires_in: number;
    }

    export async function refreshTokens(refreshToken: string): Promise<RefreshedTokens | null> {
        const response = await fetch(`${process.env.OKTA_ORG_URL}/oauth2/default/v1/token`, {
            method: "POST",
            headers: { "Content-Type": "application/x-www-form-urlencoded" },
            body: new URLSearchParams({
                grant_type: "refresh_token",
                client_id: process.env.OKTA_CLIENT_ID!,
                client_secret: process.env.OKTA_CLIENT_SECRET!,
                refresh_token: refreshToken,
                scope: "openid profile email offline_access",
            }),
        });

        if (!response.ok) return null;
        return response.json();
    }

This does exactly what its name says: takes a refresh token, sends the refresh_token grant to Okta's /token endpoint (the same endpoint we used for the initial exchange in Phase 2, just a different grant_type), and returns a fresh set of tokens.

Step 3: Wiring Silent Renewal Into Middleware

Now update src/middleware.ts from Phase 2 so that an expired ID token triggers a refresh attempt before giving up:


    import { NextRequest, NextResponse } from "next/server";
    import { verifyIdToken } from "@/lib/verifyToken";
    import { refreshTokens } from "@/lib/refreshTokens";

    export async function middleware(request: NextRequest) {
        const idToken = request.cookies.get("id_token")?.value;
        const refreshToken = request.cookies.get("refresh_token")?.value;

        if (idToken) {
            try {
                await verifyIdToken(idToken);
                return NextResponse.next(); // Still valid, nothing to do.
            } catch {
                // Fall through to the refresh attempt below.
            }
        }

        if (refreshToken) {
            const refreshed = await refreshTokens(refreshToken);
            if (refreshed) {
                const response = NextResponse.next();
                response.cookies.set("id_token", refreshed.id_token, {
                    httpOnly: true,
                    secure: process.env.NODE_ENV === "production",
                    sameSite: "lax",
                    maxAge: refreshed.expires_in,
                    path: "/",
                });
                response.cookies.set("access_token", refreshed.access_token, {
                    httpOnly: true,
                    secure: process.env.NODE_ENV === "production",
                    sameSite: "lax",
                    maxAge: refreshed.expires_in,
                    path: "/",
                });
                if (refreshed.refresh_token) {
                    response.cookies.set("refresh_token", refreshed.refresh_token, {
                        httpOnly: true,
                        secure: process.env.NODE_ENV === "production",
                        sameSite: "lax",
                        maxAge: 60 * 60 * 24 * 30,
                        path: "/",
                    });
                }
                return response;
            }
        }

        // No valid session and no working refresh token — genuinely logged out.
        const response = NextResponse.redirect(new URL("/login", request.url));
        response.cookies.delete("id_token");
        response.cookies.delete("access_token");
        response.cookies.delete("refresh_token");
        return response;
    }

    export const config = {
        matcher: ["/dashboard/:path*"],
    };

The logic now reads cleanly against what we just learned: try the existing ID token first; if it's expired or invalid, attempt a silent refresh using the refresh token; only redirect to /login if that refresh also fails (meaning the refresh token itself expired or was revoked — a genuine logout condition). Notice we conditionally update the refresh_token cookie too — this matters because if Okta ever rotates it, using the old one again would fail.

Step 4: Displaying the User's Profile

You already display name and email on the dashboard, pulled from ID token claims. For a fuller profile view, the standard OIDC way is the /userinfo endpoint, which returns whatever claims your granted scopes allow, keyed off the access token — useful because it reflects Okta's current stored profile, not a snapshot frozen at login time.

Create src/app/profile/page.tsx:


    import { cookies } from "next/headers";
    import { redirect } from "next/navigation";

    async function getUserInfo(accessToken: string) {
        const response = await fetch(`${process.env.OKTA_ORG_URL}/oauth2/default/v1/userinfo`, {
            headers: { Authorization: `Bearer ${accessToken}` },
            cache: "no-store",
        });
        if (!response.ok) return null;
        return response.json();
    }

    export default async function ProfilePage() {
        const cookieStore = await cookies();
        const accessToken = cookieStore.get("access_token")?.value;

        if (!accessToken) redirect("/login");

        const userInfo = await getUserInfo(accessToken);

        return (
            <div className="p-8 max-w-md">
                <h1 className="text-2xl font-semibold text-slate-800 mb-4">Your Profile</h1>
                <div className="rounded-lg border border-slate-200 p-4 space-y-2">
                    <p><span className="text-slate-500">Name:</span> {userInfo?.name}</p>
                    <p><span className="text-slate-500">Email:</span> {userInfo?.email}</p>
                    <p><span className="text-slate-500">Locale:</span> {userInfo?.locale ?? "Not set"}</p>
                </div>
            </div>
        );
    }

Add /profile to the Middleware matcher array from Step 3 so this page is protected too.

Step 5: Letting Users Update Their Own Profile

Older Okta tutorials handle this by calling the full Users Management API with an admin API token — that approach requires a privileged secret your app really shouldn't hold just to let a user edit their own name. Okta's current, correct answer for this exact case is the MyAccount API: end-user-scoped endpoints that work directly off the user's own access token, no admin credentials involved.

To enable it: in the Admin Console, go to your default Authorization Server → API Scopes tab, and grant the okta.myAccount.profile.manage scope (alongside the standard okta.myAccount.profile.read for reading). Then request that scope in your login route's scope parameter alongside the others.

A minimal update handler, src/app/api/profile/update/route.ts:


    import { NextRequest, NextResponse } from "next/server";

    export async function PATCH(request: NextRequest) {
        const accessToken = request.cookies.get("access_token")?.value;
        if (!accessToken) {
            return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
        }

        const body = await request.json(); // e.g. { firstName: "Aditi" }

        const response = await fetch(`${process.env.OKTA_ORG_URL}/idp/myaccount/profile`, {
            method: "PATCH",
            headers: {
                Authorization: `Bearer ${accessToken}`,
                "Content-Type": "application/json",
                Accept: "application/json; okta-version=1.0.0",
            },
            body: JSON.stringify({ profile: body }),
        });

        if (!response.ok) {
            return NextResponse.json({ error: "Update failed" }, { status: response.status });
        }

        return NextResponse.json(await response.json());
    }

Notice the Accept header includes okta-version=1.0.0 — the MyAccount API requires an explicit API version in that header, which is a detail specific to this API and easy to miss.

A Note on Custom Attributes

Everything above works for Okta's built-in profile fields (firstName, lastName, email, and so on). If TaskFlow needs its own fields — say, a jobTitle or teamSize — you add them as custom attributes on the User profile schema, under Directory → Profile Editor → User (default) in the Admin Console. Once added there, they behave exactly like built-in fields for both /userinfo (if you map them to a claim — covered properly in Phase 5) and the MyAccount API.


Where TaskFlow Stands Now

Sessions survive past the one-hour token expiry through silent, automatic renewal. Users can see their live profile data and update it themselves through Okta's current, properly-scoped end-user API — no admin secrets involved anywhere in this flow.

That completes Phase 3.

Module 8.3 — Agent Memory & Planning

How agents remember things and plan multi-step tasks


The Two Memory Systems in LangGraph

LangGraph has two completely separate memory systems. Most developers only know about one of them.

CHECKPOINTER (short-term memory)
→ Stores conversation history per thread_id
→ Like the chat history in ChatGPT
→ Lost when you clear the conversation
→ Scope: one conversation thread

STORE (long-term memory)
→ Stores facts across ALL conversations
→ User preferences, important facts, past decisions
→ Persists even when conversations change
→ Scope: cross-thread, user-level

Real world comparison:
Checkpointer = what you said in today's meeting
Store        = what you know about this person from all past meetings

Project Setup


    mkdir agent-memory-demo
    cd agent-memory-demo
    npm init -y

Update package.json:


  {
    "name": "agent-memory-demo",
    "version": "1.0.0",
    "type": "module",
    "scripts": {
      "test": "echo \"Error: no test specified\" && exit 1"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "description": "",
    "dependencies": {
      "@langchain/langgraph": "^1.4.8",
      "@langchain/openai": "^1.5.5",
      "dotenv": "^17.4.2",
      "langchain": "^1.5.4",
      "zod": "^4.4.3"
    }
  }


    npm install langchain @langchain/openai @langchain/langgraph zod dotenv

Create .env:


    OPENAI_API_KEY=sk-proj-your-key-here


Part 1 — Checkpointer (Short-Term Memory)

Create src/01_checkpointer.js:


  import { createAgent, tool } from "langchain";
  import { MemorySaver } from "@langchain/langgraph";
  // MemorySaver = in-memory checkpointer
  // stores conversation snapshots in a JavaScript Map
  // data is lost when Node.js process stops
  // fine for learning — use SqliteSaver for production

  import { ChatOpenAI } from "@langchain/openai";
  import { z } from "zod";
  import * as dotenv from "dotenv";
  dotenv.config();


  // ─────────────────────────────────────────
  // SETUP
  // ─────────────────────────────────────────

  const checkpointer = new MemorySaver();
  // checkpointer = stores conversation state snapshots
  // internally it is a Map: thread_id → serialized state
  // example internal state after a few messages:
  // {
  //   "thread_abc": {
  //     v: 1,
  //     channel_values: {
  //       messages: [
  //         HumanMessage { content: "My name is Sofia" },
  //         AIMessage    { content: "Hello Sofia!" },
  //         HumanMessage { content: "I am a MERN developer" },
  //         AIMessage    { content: "Great! MERN is a popular stack." }
  //       ]
  //     }
  //   }
  // }

  const simpleInfoTool = tool(
    async ({ topic }) => {
      const info = {
        "MERN":    "MERN = MongoDB, Express, React, Node.js. A full-stack JavaScript framework.",
        "Next.js": "Next.js is a React framework for building full-stack web applications.",
        "AI":      "AI = Artificial Intelligence. Machines that simulate human intelligence.",
      };
      return info[topic] || `No info found for: ${topic}`;
    },
    {
      name: "get_info",
      description: "Gets information about a tech topic.",
      schema: z.object({ topic: z.string() }),
    }
  );

  const agent = createAgent({
    model: new ChatOpenAI({ model: "gpt-4o", temperature: 0.1 }),
    tools: [simpleInfoTool],
    checkpointer,
    // passing checkpointer enables memory
    // without this → every .invoke() call is independent
    // with this    → messages accumulate per thread_id
    systemPrompt: "You are a helpful assistant. Remember what the user tells you about themselves.",
  });


  // ─────────────────────────────────────────
  // HELPER — send one message and get response
  // ─────────────────────────────────────────

  async function chat(threadId, message) {
    // threadId = which conversation to continue
    // message  = what the user is saying now

    const result = await agent.invoke(
      { messages: [{ role: "user", content: message }] },
      { configurable: { thread_id: threadId } }
      // thread_id = tells checkpointer which state to load/save
      // same thread_id = same conversation continues
      // different thread_id = fresh new conversation
    );

    const lastMsg = result.messages[result.messages.length - 1];
    // last message = AI's final response in this turn
    return lastMsg.content;
  }


  // ─────────────────────────────────────────
  // EXAMPLE 1 — Memory within a conversation
  // ─────────────────────────────────────────

  async function example1() {
    console.log("".repeat(55));
    console.log("EXAMPLE 1: Memory within one conversation");
    console.log("".repeat(55));

    const threadId = "conversation_001";
    // unique ID for this conversation
    // you choose the ID — could be user ID, session ID, UUID

    // Turn 1 — introduce ourselves
    const r1 = await chat(threadId, "Hi! My name is Sofia and I am a MERN developer.");
    console.log("Turn 1:");
    console.log("User: Hi! My name is Sofia and I am a MERN developer.");
    console.log("AI:  ", r1);

    // Turn 2 — follow up question
    const r2 = await chat(threadId, "What frameworks do you think I should learn next?");
    console.log("\nTurn 2:");
    console.log("User: What frameworks do you think I should learn next?");
    console.log("AI:  ", r2);
    // AI should mention MERN background because it remembers Turn 1

    // Turn 3 — test memory
    const r3 = await chat(threadId, "What is my name and what do I do?");
    console.log("\nTurn 3:");
    console.log("User: What is my name and what do I do?");
    console.log("AI:  ", r3);
    // AI should say "Your name is Sofia and you are a MERN developer"
    // It remembers from Turn 1 — not from context window tricks

    console.log();
  }


  // ─────────────────────────────────────────
  // EXAMPLE 2 — Different threads = different memories
  // ─────────────────────────────────────────

  async function example2() {
    console.log("".repeat(55));
    console.log("EXAMPLE 2: Two separate users, separate memories");
    console.log("".repeat(55));

    // User A's conversation
    await chat("user_alice", "My name is Alice and I love Python.");
    await chat("user_alice", "I work at Google.");

    // User B's conversation — completely separate
    await chat("user_bob", "My name is Bob and I am a data scientist.");
    await chat("user_bob", "I live in Mumbai.");

    // Now ask both about themselves
    const aliceAnswer = await chat("user_alice", "What do you know about me?");
    const bobAnswer   = await chat("user_bob",   "What do you know about me?");

    console.log("Alice's thread response:", aliceAnswer);
    console.log("Bob's thread response:  ", bobAnswer);
    // Alice's thread: knows about Python, Google
    // Bob's thread:   knows about data science, Mumbai
    // No mixing between threads ✅

    console.log();
  }


  // ─────────────────────────────────────────
  // EXAMPLE 3 — Inspect what is stored in checkpointer
  // ─────────────────────────────────────────

  async function example3() {
    console.log("".repeat(55));
    console.log("EXAMPLE 3: Inspecting checkpointer state");
    console.log("".repeat(55));

    const threadId = "inspect_demo";

    await chat(threadId, "My favorite color is blue.");
    await chat(threadId, "I enjoy hiking on weekends.");

    // Get the stored state for this thread
    const state = await agent.getState(
      { configurable: { thread_id: threadId } }
      // same config format as invoke()
    );
    // state.values = the current state of the agent for this thread
    // state.values.messages = array of all messages stored

    const messages = state.values.messages;
    // example messages array:
    // [
    //   HumanMessage { content: "My favorite color is blue." },
    //   AIMessage    { content: "That's a nice color!" },
    //   HumanMessage { content: "I enjoy hiking on weekends." },
    //   AIMessage    { content: "Hiking is great exercise!" }
    // ]

    console.log(`\nStored messages for thread "${threadId}":`);
    messages.forEach((msg, i) => {
      const role = msg.getType();
      // getType() = "human", "ai", or "tool"
      const preview = msg.content.substring(0, 60);
      console.log(`  ${i + 1}. [${role.padEnd(5)}] ${preview}`);
    });

    console.log(`\nTotal messages stored: ${messages.length}`);
    console.log();
  }


  async function main() {
    console.log("🧠 CHECKPOINTER MEMORY DEMO\n");
    await example1();
    await example2();
    await example3();
    console.log("✅ Checkpointer demo complete!");
  }

  main().catch(console.error);

Run:

node src/01_checkpointer.js

Part 2 — Store (Long-Term Memory)

The Store persists facts across different conversations — this is what MemorySaver cannot do.

Create src/02_store.js:


    import { createAgent } from "langchain";
    import { MemorySaver, InMemoryStore } from "@langchain/langgraph";
    // MemorySaver  = short-term (conversation history per thread)
    // InMemoryStore = long-term (facts that survive across threads)
    //
    // In production replace InMemoryStore with:
    // → SqliteStore  for local persistent storage
    // → PostgresStore for production database

    import { ChatOpenAI } from "@langchain/openai";
    import { tool } from "langchain";
    import { z } from "zod";
    import * as dotenv from "dotenv";
    dotenv.config();


    // ─────────────────────────────────────────
    // SETUP BOTH MEMORY SYSTEMS
    // ─────────────────────────────────────────

    const checkpointer = new MemorySaver();
    // short-term: conversation history per thread_id

    const store = new InMemoryStore();
    // long-term: facts that persist across ALL threads
    // InMemoryStore works like a key-value store
    // store.put(namespace, key, value) → save
    // store.get(namespace, key)        → retrieve
    // store.search(namespace, query)   → search


    // ─────────────────────────────────────────
    // TOOLS THAT USE THE STORE
    // These tools let the agent read/write long-term memory
    // ─────────────────────────────────────────

    const saveUserFactTool = tool(
        async ({ userId, fact, category }) => {
            // This tool saves an important fact about a user
            // to the long-term store — persists across conversations

            const namespace = ["user_facts", userId];
            // namespace = array of strings that acts like a folder path
            // example: ["user_facts", "sofia_123"]
            // all facts for this user go in this namespace

            const key = `${category}_${Date.now()}`;
            // unique key for this fact
            // example: "preference_1752672000000"

            await store.put(namespace, key, {
                fact,
                // the actual fact string
                // example: "prefers dark mode"

                category,
                // what type of fact this is
                // example: "preference", "background", "goal"

                savedAt: new Date().toISOString(),
                // when this was saved
            });

            return `Saved fact for user ${userId}: "${fact}" (category: ${category})`;
            // confirmation string back to the agent
        },
        {
            name: "save_user_fact",
            description: `Saves an important fact about the user to long-term memory.
    Use this when the user shares something important about themselves:
    preferences, background, goals, or any fact worth remembering across conversations.`,
            schema: z.object({
                userId: z.string().describe("the user's ID"),
                fact: z.string().describe("the fact to remember"),
                category: z.enum(["preference", "background", "goal", "general"])
                    .describe("what type of fact this is"),
            }),
        }
    );


    const getUserFactsTool = tool(
        async ({ userId }) => {
            // Retrieves all saved facts for a user from long-term store

            const namespace = ["user_facts", userId];
            // same namespace as saveUserFactTool
            // example: ["user_facts", "sofia_123"]

            const results = await store.search(namespace, { query: "" });
            // store.search() = search within this namespace
            // query: "" = return all items (empty query = no filter)
            //
            // example results:
            // [
            //   { key: "preference_123", value: { fact: "prefers dark mode", category: "preference", savedAt: "..." } },
            //   { key: "background_456", value: { fact: "MERN developer", category: "background", savedAt: "..." } },
            // ]

            if (!results || results.length === 0) {
                return `No stored facts found for user ${userId}`;
            }

            const facts = results.map(item =>
                `[${item.value.category}] ${item.value.fact}`
            ).join("\n");
            // format each fact as "[category] fact text"
            // example: "[preference] prefers dark mode\n[background] MERN developer"

            return `Known facts about user ${userId}:\n${facts}`;
        },
        {
            name: "get_user_facts",
            description: `Retrieves all known facts about a user from long-term memory.
    Use this at the start of a conversation to personalize responses
    based on what you know about this user from previous conversations.`,
            schema: z.object({
                userId: z.string().describe("the user's ID to look up"),
            }),
        }
    );


    // ─────────────────────────────────────────
    // CREATE AGENT WITH BOTH MEMORY SYSTEMS
    // ─────────────────────────────────────────

    const agent = createAgent({
        model: new ChatOpenAI({ model: "gpt-4o", temperature: 0.1 }),
        tools: [saveUserFactTool, getUserFactsTool],
        checkpointer,
        // short-term: remembers this conversation
        store,
        // long-term: reads/writes cross-conversation facts
        systemPrompt: `You are a personalized assistant that remembers users.

    At the start of each conversation:
    1. Call get_user_facts to load what you know about this user
    2. Use that context to personalize your responses

    During conversation:
    3. When user shares something important → call save_user_fact
    4. Categories: preference (what they like), background (who they are),
                goal (what they want to achieve), general (other facts)

    Be natural about using memory — don't announce every save.`,
    });


    // ─────────────────────────────────────────
    // HELPER
    // ─────────────────────────────────────────

    async function chat(threadId, message) {
        const result = await agent.invoke(
            { messages: [{ role: "user", content: message }] },
            { configurable: { thread_id: threadId } }
        );
        return result.messages[result.messages.length - 1].content;
    }


    // ─────────────────────────────────────────
    // DEMO — Shows memory persisting across separate conversations
    // ─────────────────────────────────────────

    async function demo() {
        console.log("".repeat(55));
        console.log("DEMO: Long-term memory across conversations");
        console.log("".repeat(55));

        const userId = "sofia_123";

        // ── CONVERSATION 1 ──────────────────────────────────────
        console.log("\n📅 CONVERSATION 1 (thread: conv_001)");
        console.log("".repeat(40));

        const c1r1 = await chat("conv_001",
            `Hello! I am ${userId}. I am a MERN stack developer learning AI engineering.`
        );
        console.log("User: Hello! I am sofia_123. I am a MERN developer learning AI.");
        console.log("AI:  ", c1r1);

        const c1r2 = await chat("conv_001",
            "I prefer learning through hands-on projects rather than theory."
        );
        console.log("\nUser: I prefer learning through hands-on projects.");
        console.log("AI:  ", c1r2);
        // Agent should save these facts to long-term store


        // ── CONVERSATION 2 (new thread — different session) ─────
        console.log("\n📅 CONVERSATION 2 (thread: conv_002 — new session)");
        console.log("".repeat(40));
        console.log("(This is a completely new conversation — checkpointer has no history here)");

        const c2r1 = await chat("conv_002",
            `Hi, I am ${userId} again. What do you know about me?`
        );
        console.log(`\nUser: Hi, I am ${userId} again. What do you know about me?`);
        console.log("AI:  ", c2r1);
        // Agent loads facts from store even though this is a new thread
        // Should mention MERN background and learning preference

        const c2r2 = await chat("conv_002",
            "Can you suggest what I should focus on next in my AI learning journey?"
        );
        console.log("\nUser: Can you suggest what to focus on next in AI learning?");
        console.log("AI:  ", c2r2);
        // Agent uses stored facts to give personalized recommendation

        console.log();
    }


    async function main() {
        console.log("💾 LONG-TERM STORE MEMORY DEMO\n");
        await demo();
        console.log("✅ Store memory demo complete!");
    }

    main().catch(console.error);

Run:

node src/02_store.js

Part 3 — Agent Planning

Planning = agent breaks a complex task into steps before acting.

Create src/03_planning.js:


    import { createAgent, tool } from "langchain";
    import { MemorySaver } from "@langchain/langgraph";
    import { ChatOpenAI } from "@langchain/openai";
    import { z } from "zod";
    import * as dotenv from "dotenv";
    dotenv.config();


    // ─────────────────────────────────────────
    // TOOLS FOR A RESEARCH PLANNING AGENT
    // ─────────────────────────────────────────

    const searchTool = tool(
        async ({ query }) => {
            // Simulated web search
            console.log(`   🔍 Searching: "${query}"`);
            await new Promise(r => setTimeout(r, 200));
            // simulate network delay

            const results = {
                "LangChain architecture 2025": "LangChain 1.x uses LangGraph as the agent backend. Key components: createAgent, tool(), MemorySaver, InMemoryStore.",
                "RAG best practices": "RAG best practices: chunk size 500-1000 chars, overlap 10-20%, MMR retrieval, reranking for quality, score thresholds.",
                "vector database comparison": "Pinecone: managed, easy setup. Chroma: open source, local. Qdrant: high performance. pgvector: PostgreSQL extension.",
                "AI agent patterns": "Common patterns: ReAct (reason+act), Plan-and-Execute, Reflexion, MRKL, self-ask with search.",
            };

            return results[query] || `Search results for "${query}": [simulated research findings about this topic]`;
        },
        {
            name: "search",
            description: "Searches the web for information on a topic.",
            schema: z.object({ query: z.string().describe("search query") }),
        }
    );

    const summarizeTool = tool(
        async ({ text, maxSentences }) => {
            // Simulated summarizer
            console.log(`   📝 Summarizing text (${text.length} chars → ${maxSentences} sentences)`);
            return `Summary (${maxSentences} sentences): This is a condensed version of the provided text focusing on the key points.`;
        },
        {
            name: "summarize",
            description: "Summarizes a piece of text into a specified number of sentences.",
            schema: z.object({
                text: z.string().describe("text to summarize"),
                maxSentences: z.number().describe("maximum sentences in summary"),
            }),
        }
    );

    const writeReportTool = tool(
        async ({ title, sections }) => {
            // Compiles collected information into a report
            console.log(`   📄 Writing report: "${title}" with ${sections.length} sections`);

            const report = `
    # ${title}

    ${sections.map((s, i) => `## ${i + 1}. ${s.heading}\n${s.content}`).join("\n\n")}

    ---
    Report generated by AI Research Agent
        `.trim();

            return report;
        },
        {
            name: "write_report",
            description: "Compiles research findings into a formatted report.",
            schema: z.object({
                title: z.string().describe("report title"),
                sections: z.array(z.object({
                    heading: z.string().describe("section heading"),
                    content: z.string().describe("section content"),
                })).describe("report sections"),
            }),
        }
    );


    // ─────────────────────────────────────────
    // PLANNING AGENT
    // System prompt teaches it to plan first then act
    // ─────────────────────────────────────────

    const planningAgent = createAgent({
        model: new ChatOpenAI({ model: "gpt-4o", temperature: 0.1 }),
        tools: [searchTool, summarizeTool, writeReportTool],
        checkpointer: new MemorySaver(),
        systemPrompt: `You are a research agent that plans before acting.

    When given a research task:
    STEP 1 — PLAN: First explain your plan in 3-5 steps
    STEP 2 — SEARCH: Gather information using the search tool
    STEP 3 — PROCESS: Summarize and organize findings
    STEP 4 — REPORT: Write a final report using write_report

    Always follow this order. Do not skip planning.
    Be systematic and thorough.`,
    });


    // ─────────────────────────────────────────
    // RUN THE PLANNING AGENT
    // ─────────────────────────────────────────

    async function runResearchTask(task) {
        console.log("\n" + "=".repeat(55));
        console.log("Research Task:", task);
        console.log("=".repeat(55));

        const result = await planningAgent.invoke(
            { messages: [{ role: "user", content: task }] },
            { configurable: { thread_id: `research_${Date.now()}` } }
        );

        console.log("\n📋 FINAL OUTPUT:");
        console.log("".repeat(55));
        console.log(result.messages[result.messages.length - 1].content);
    }


    async function main() {
        console.log("📊 AGENT PLANNING DEMO\n");

        await runResearchTask(
            "Research the topic of RAG systems and create a short report covering: what RAG is, best practices, and which vector database to choose."
        );

        console.log("\n✅ Planning demo complete!");
    }

    main().catch(console.error);

Run:

node src/03_planning.js

Memory Types — Summary Table

┌──────────────────┬────────────────────┬─────────────────────┐
│ Type             │ LangGraph Object   │ Use Case            │
├──────────────────┼────────────────────┼─────────────────────┤
│ Short-term       │ MemorySaver        │ Conversation        │
│ (in-memory)      │                    │ history in one chat │
├──────────────────┼────────────────────┼─────────────────────┤
│ Short-term       │ SqliteSaver        │ Conversation        │
│ (persistent)     │                    │ history + restarts  │
├──────────────────┼────────────────────┼─────────────────────┤
│ Long-term        │ InMemoryStore      │ User facts across   │
│ (in-memory)      │                    │ all conversations   │
├──────────────────┼────────────────────┼─────────────────────┤
│ Long-term        │ SqliteStore /      │ Production user     │
│ (persistent)     │ PostgresStore      │ preferences, facts  │
├──────────────────┼────────────────────┼─────────────────────┤
│ Semantic         │ Vector Store       │ Similarity-based    │
│ (search-based)   │ (Pinecone etc.)    │ document retrieval  │
└──────────────────┴────────────────────┴─────────────────────┘

3-Line Summary

  1. LangGraph has two separate memory systems — the Checkpointer stores conversation history per thread (short-term, like chat history) and the Store persists facts across all threads (long-term, like user preferences) — use both together for fully personalized agents.
  2. MemorySaver and InMemoryStore are for development only — data is lost when the process stops — for production use SqliteSaver (local file) or PostgresSaver (database) for conversations and SqliteStore / PostgresStore for long-term facts.
  3. Agent planning means giving the agent a system prompt that instructs it to outline its steps before acting — this dramatically improves quality on complex multi-step tasks because the agent reasons about the full approach before calling any tools.

Module 8.3 — Complete ✅

Coming up — Module 8.4 — Multi-Agent Systems with LangGraph

Building systems where multiple specialized agents collaborate — one agent researches, another writes, another reviews. This is how production AI systems handle complex tasks that are too big for one agent.

Phase 4: Security Features — Part 1 (MFA & Adaptive Policies)

Here's the genuinely good news for this entire phase: because TaskFlow uses the redirect model we built in Phase 2 — where the actual l...