LangChain LangGraph
Module 1 Module 2 Module 3 Module 4 Module 5 Module 6
Module 5 · Long-term Memory
1 5-1 Memory Store
2 5-2 Schema Profile
3 5-3 Schema Coll.
4 5-4 Memory Agent
SUM Module Summary
HomeLangGraphModule 5 · Long-term Memory5-4 Memory Agent
📓 Notebook 📖 Explained
LANGGRAPH MODULE 5 · LESSON 4

Memory Agent:task_mAIstro

The code is explained line by line - from three types of memory Schema definitions, Spy listeners, four node functions, to graph compilation and complete cross-thread memory demonstration.

0Overall architecture overview

task_mAIstro is a ToDo management assistant. The biggest difference from the previous passive memory Chatbot is:It decides when and what kind of memories to save, instead of automatically saving everything after each round of dialogue.

User portrait (Profile)

namespace: ("profile", user_id)
Name, location, occupation, relationships, interests. One per user, Trustcall incremental patch.

To-do list (ToDo)

namespace: ("todo", user_id)
Each task has an exclusive UUID key and supports the coexistence and incremental update of multiple tasks. Useenable_inserts=True

Instructions

namespace: ("instructions", user_id)
The user's preference description for "How to create a ToDo", plain text, overridden directly by LLM.

task_mAIstro graph structure

START
task_mAIstro
↓ route_message (conditional edge)
update_profile
update_type=user
update_todos
update_type=todo
update_instructions
update_type=instructions
END
No tool call
↑ Return task_mAIstro(ToolMessage)
The core difference from Chatbot in the previous sections

The memory robot in the previous sections automatically calls write_memory every round. task_mAIstro passedUpdateMemoryTool proactive decision-making: only when the conversation actually contains information worth saving, the corresponding update node will be triggered.

1Imports and dependencies

📄 memory_agent.py
import uuid
from datetime import datetime

from pydantic           import BaseModel, Field
from trustcall           import create_extractor
from typing              import Literal, Optional, TypedDict

from langchain_core.runnables import RunnableConfig
from langchain_core.messages  import merge_message_runs
from langchain_core.messages  import SystemMessage, HumanMessage

from langchain_deepseek  import ChatDeepSeek

from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph          import StateGraph, MessagesState, START, END
from langgraph.store.base     import BaseStore
from langgraph.store.memory   import InMemoryStore

import configuration
Depend onuse
trustcall.create_extractorBuild a structured extractor to support JSON Patch incremental updates
merge_message_runsMerge consecutive messages with the same role to reduce redundant tokens
MemorySaverIn-thread short-term memory (conversation history)
InMemoryStoreCross-thread long-term memory (Profile/ToDo/Instructions)
configurationlocal module, fromRunnableConfigMedium parsinguser_id

2Spy tool class: monitor the internal calls of Trustcall

Trustcall internally operates memory via utility calls (PatchDocUpdate, schema name is newly created).SpyIs a LangChain Runnable listener, passedwith_listeners(on_end=spy)Mount on the extractor and capture all internal tool call records after the call ends.

class Spy:
    def __init__(self):
        self.called_tools = []

    def __call__(self, run):
        q = [run]
        while q:
            r = q.pop()
            if r.child_runs:
                q.extend(r.child_runs)
            if r.run_type == "chat_model":
                self.called_tools.append(
                    r.outputs["generations"][0][0]["message"]["kwargs"]["tool_calls"]
                )
How Spy works

Triggered after each Trustcall extractor call is completed__call__(run). It traverses the entire run tree breadth-first to find allrun_type == "chat_model"child nodes of , collecting thetool_callslist. In this way, you can know what specific patches or new operations Trustcall has done.

extract_tool_info: Convert Spy data into readable strings

This function parses the tool calls captured by Spy, distinguishes "update existing memory (PatchDoc)" and "new memory (schema name)", formats the result into a string, and finally asToolMessageThe content is returned to the task_mAIstro node.

def extract_tool_info(tool_calls, schema_name="Memory"):
    changes = []

    for call_group in tool_calls:
        for call in call_group:
            if call['name'] == 'PatchDoc':
                changes.append({
                    'type': 'update',
                    'doc_id': call['args']['json_doc_id'],
                    'planned_edits': call['args']['planned_edits'],
                    'value': call['args']['patches'][0]['value']
                })
            elif call['name'] == schema_name:
                changes.append({'type': 'new', 'value': call['args']})

    result_parts = []
    for change in changes:
        if change['type'] == 'update':
            result_parts.append(
                f"Document {change['doc_id']} updated:\n"
                f"Plan: {change['planned_edits']}\n"
                f"Added content: {change['value']}"
            )
        else:
            result_parts.append(
                f"New {schema_name} created:\n"
                f"Content: {change['value']}"
            )

    return "\n\n".join(result_parts)
Why does ToDo need Spy but Profile doesn't?

The Profile node only needs to write the Trustcall update result to the Store.
The ToDo node additionally needs to tell task_mAIstro "what changes have been made" (Which task was created? Which field was updated?). These details are captured by Spy and put into ToolMessage.content for task_mAIstro to generate a user-friendly reply.

3Three types of memory Schema: Profile, ToDo, UpdateMemory

User portrait: Profile

class Profile(BaseModel):
    """This is the profile of the user you are chatting with"""
    name: Optional[str]  = Field(description="The user's name",     default=None)
    location: Optional[str] = Field(description="The user's location", default=None)
    job: Optional[str]   = Field(description="The user's job",       default=None)
    connections: list[str] = Field(
        description="Personal connections: family, friends, coworkers",
        default_factory=list
    )
    interests: list[str] = Field(
        description="Interests that the user has",
        default_factory=list
    )

All fields are optional (Optionalor bringdefault_factory) to avoid errors due to incomplete information when extracting for the first time. Trustcall's JSON Patch only updates the changed fields and retains other known information.

To-do items: ToDo

class ToDo(BaseModel):
    task: str = Field(description="The task to be completed.")
    time_to_complete: Optional[int] = Field(
        description="Estimated time to complete (minutes)."
    )
    deadline: Optional[datetime] = Field(
        description="When the task needs to be completed by", default=None
    )
    solutions: list[str] = Field(
        description="Specific, actionable solutions (ideas / providers / options)",
        min_items=1, default_factory=list
    )
    status: Literal["not started", "in progress", "done", "archived"] = Field(
        description="Current status of the task", default="not started"
    )

solutionsField requires at least one specific course of action (min_items=1). Combined with the following Instructions (users can request "include specific local businesses"), this field will become more and more abundant.

Routing tool: UpdateMemory

class UpdateMemory(TypedDict):
    """Decision on what memory type to update"""
    update_type: Literal['user', 'todo', 'instructions']
UpdateMemory is a routing signal, not a data structure

UpdateMemoryuseTypedDict(not Pydantic) definition, LangGraph will serialize it into tool call format. When the task_mAIstro node calls this tool,update_typeThe value isroute_messageThe function is routed by - it does not store any memory data itself.

Initialize model with profile_extractor

model = ChatDeepSeek(model="deepseek-v4-pro", temperature=0)

# Profile 提取器:全局复用,无 Spy,不需要 enable_inserts(每用户只有一条记录)
profile_extractor = create_extractor(
    model,
    tools=[Profile],
    tool_choice="Profile",
)

profile_extractorIt is a global singleton: because Profile updates do not require Spy monitoring, the same instance can be safely reused. In contrast, ToDo's extractor needs to bind a new one each timeSpy()instance, so created inside the node function.

4Three-segment system prompt word

The behavior of the entire agent is controlled by three prompt words, which are used in three different scenarios.

① MODEL_SYSTEM_MESSAGE — Main agent prompt word

MODEL_SYSTEM_MESSAGE = """You are a helpful chatbot.
You are designed to be a companion to a user, helping them keep track of their ToDo list.

You have a long term memory which keeps track of three things:
1. The user's profile (general information about them)
2. The user's ToDo list
3. General instructions for updating the ToDo list

Here is the current User Profile (may be empty):
<user_profile>
{user_profile}
</user_profile>

Here is the current ToDo List (may be empty):
<todo>
{todo}
</todo>

Here are the current user-specified preferences (may be empty):
<instructions>
{instructions}
</instructions>

Instructions for reasoning:
1. Reason carefully about the user's messages.
2. Decide whether any long-term memory should be updated:
   - Personal info → UpdateMemory(update_type='user')
   - Tasks mentioned → UpdateMemory(update_type='todo')
   - Preferences for ToDo updates → UpdateMemory(update_type='instructions')
3. Do not tell the user when you update the profile or instructions.
   Tell the user when you update the todo list.
4. Err on the side of updating the todo list.
5. Respond naturally after tool calls."""
Injection order of three placeholders

{user_profile}{todo}{instructions}intask_mAIstroThe node is dynamically filled in after reading from Store. At the beginning of each conversation round, the agent can see the latest three-category memory state and make accurate routing decisions.

② TRUSTCALL_INSTRUCTION — extractor instruction

TRUSTCALL_INSTRUCTION = """Reflect on following interaction.
Use the provided tools to retain any necessary memories about the user.
Use parallel tool calling to handle updates and insertions simultaneously.
System Time: {time}"""

This prompt word serves asSystemMessageInserted at the top of the Trustcall's message list to instruct the extractor to focus on the memory value of the conversation.{time}Inject the current time to provide a contextual baseline for the timestamped ToDo deadline.

③ CREATE_INSTRUCTIONS — Instruction update prompt word

CREATE_INSTRUCTIONS = """Reflect on the following interaction.
Based on this interaction, update your instructions for how to update ToDo list items.
Use any feedback from the user to update how they like to have items added, etc.

Your current instructions are:
<current_instructions>
{current_instructions}
</current_instructions>"""

only inupdate_instructionsused in nodes. Instead of using Trustcall, LLM directly generates new natural language instructions and overwrites the old version in the Store.

5Node ①: task_mAIstro — reading memory and routing decisions

def task_mAIstro(state: MessagesState, config: RunnableConfig, store: BaseStore):
    # 从 config 解析 user_id(由 configuration.Configuration 定义)
    configurable = configuration.Configuration.from_runnable_config(config)
    user_id = configurable.user_id

    # 1. 读取用户画像
    namespace = ("profile", user_id)
    memories = store.search(namespace)
    user_profile = memories[0].value if memories else None

    # 2. 读取所有 ToDo,拼接为多行字符串
    namespace = ("todo", user_id)
    memories = store.search(namespace)
    todo = "\n".join(f"{mem.value}" for mem in memories)

    # 3. 读取操作偏好指令
    namespace = ("instructions", user_id)
    memories = store.search(namespace)
    instructions = memories[0].value if memories else ""

    # 4. 构建系统提示,注入三类记忆
    system_msg = MODEL_SYSTEM_MESSAGE.format(
        user_profile=user_profile, todo=todo, instructions=instructions
    )

    # 5. 绑定 UpdateMemory 工具,串行调用(确保路由逻辑简单)
    response = model.bind_tools(
        [UpdateMemory], parallel_tool_calls=False
    ).invoke([SystemMessage(content=system_msg)] + state["messages"])

    return {"messages": [response]}
The meaning of parallel_tool_calls=False

set toFalseForces only one tool call to be triggered at a time.route_messageread onlystate['messages'][-1].tool_calls[0], if multiple calls are triggered in parallel, the routing logic requires more complex processing. In serial mode, only one routing decision is generated in each round, and the logic is clear.

task_mAIstro will be executed twice

First time: Generate UpdateMemory tool call (decision phase).
After the update node is executed, return ToolMessage back to task_mAIstro.
The second time: See the ToolMessage, know that the update is completed, and generate a natural language reply to the user (expression stage).

6Node ②: update_profile — Trustcall incremental update profile

def update_profile(state: MessagesState, config: RunnableConfig, store: BaseStore):
    configurable = configuration.Configuration.from_runnable_config(config)
    user_id = configurable.user_id
    namespace = ("profile", user_id)

    # 从 Store 读取已有 Profile,格式化为 Trustcall 三元组
    existing_items = store.search(namespace)
    tool_name = "Profile"
    existing_memories = (
        [(item.key, tool_name, item.value) for item in existing_items]
        if existing_items else None
    )

    # 注入当前时间,去掉最后一条消息(task_mAIstro 的工具调用消息)
    instr = TRUSTCALL_INSTRUCTION.format(time=datetime.now().isoformat())
    updated_messages = list(merge_message_runs(
        messages=[SystemMessage(content=instr)] + state["messages"][:-1]
    ))

    # Trustcall 提取:有 existing 时做 JSON Patch 增量更新
    result = profile_extractor.invoke({
        "messages": updated_messages,
        "existing": existing_memories
    })

    # 保存到 Store(json_doc_id 存在则更新同一条记录,否则新建)
    for r, rmeta in zip(result["responses"], result["response_metadata"]):
        store.put(namespace, rmeta.get("json_doc_id", str(uuid.uuid4())),
                  r.model_dump(mode="json"))

    # 必须返回 ToolMessage:响应 task_mAIstro 的 UpdateMemory 工具调用
    tool_calls = state['messages'][-1].tool_calls
    return {"messages": [{"role": "tool", "content": "updated profile",
                           "tool_call_id": tool_calls[0]['id']}]}
Why should we remove the last item of state["messages"][:-1]?

state["messages"][-1]Is an AIMessage generated by task_mAIstro that contains the UpdateMemory tool call. This message is an "outstanding tool call request" - if it were passed to Trustcall, the extractor would be confused (it would see an unresponsive tool call). use[:-1]Cut out this message and pass in only the real conversation history.

ToolMessage is a protocol requirement, not optional

The OpenAI compatibility protocol stipulates that each tool call (tool_call in AIMessage) must have a corresponding ToolMessage as a reply. If update_profile does not return ToolMessage, an error will be reported the next time LLM is called because the message sequence is illegal.tool_call_idField associates this ToolMessage with a specific tool call.

7Node ③: update_todos — ToDo update with Spy

The biggest difference between ToDo update and Profile update: Requiredenable_inserts=TrueSupports new tasks and requires Spy to capture change details and put them into ToolMessage.

def update_todos(state: MessagesState, config: RunnableConfig, store: BaseStore):
    configurable = configuration.Configuration.from_runnable_config(config)
    user_id = configurable.user_id
    namespace = ("todo", user_id)

    existing_items = store.search(namespace)
    tool_name = "ToDo"
    existing_memories = (
        [(item.key, tool_name, item.value) for item in existing_items]
        if existing_items else None
    )

    instr = TRUSTCALL_INSTRUCTION.format(time=datetime.now().isoformat())
    updated_messages = list(merge_message_runs(
        messages=[SystemMessage(content=instr)] + state["messages"][:-1]
    ))

    # 每次调用创建新 Spy(防止跨调用数据混用)
    spy = Spy()

    # ToDo 提取器:enable_inserts=True 允许新建任务,挂载 Spy 监听器
    todo_extractor = create_extractor(
        model, tools=[ToDo], tool_choice=tool_name, enable_inserts=True
    ).with_listeners(on_end=spy)

    result = todo_extractor.invoke({
        "messages": updated_messages, "existing": existing_memories
    })

    for r, rmeta in zip(result["responses"], result["response_metadata"]):
        store.put(namespace, rmeta.get("json_doc_id", str(uuid.uuid4())),
                  r.model_dump(mode="json"))

    # 把 Spy 捕获的变更摘要作为 ToolMessage 内容,让 task_mAIstro 能告知用户
    tool_calls = state['messages'][-1].tool_calls
    todo_update_msg = extract_tool_info(spy.called_tools, tool_name)
    return {"messages": [{"role": "tool", "content": todo_update_msg,
                           "tool_call_id": tool_calls[0]['id']}]}
point of comparisonupdate_profileupdate_todos
enable_insertsDefault False (only one Profile)True (can have multiple ToDos)
Spy listenerNo needRequired (inform users of specific changes)
extractor creation methodGlobal singletonCreate new within each function (bind new Spy)
ToolMessage content"updated profile"(fixed string)Summary of changes parsed by Spy
Store key strategyjson_doc_id (usually fixed)json_doc_id (update) or new UUID (insert)

8Node ④: update_instructions — plain text instruction update

Instructions are the user's natural language preference description of "how to manage ToDo". There is no fixed Schema, no Trustcall, and LLM can directly generate the text and then overwrite it.

def update_instructions(state: MessagesState, config: RunnableConfig, store: BaseStore):
    configurable = configuration.Configuration.from_runnable_config(config)
    user_id = configurable.user_id
    namespace = ("instructions", user_id)

    # 用 get(精确 key)而非 search(全量),因为 Instructions 始终只有一条
    existing_memory = store.get(namespace, "user_instructions")

    system_msg = CREATE_INSTRUCTIONS.format(
        current_instructions=existing_memory.value if existing_memory else None
    )

    # 直接调用 LLM 生成新指令文本(不用 Trustcall)
    new_memory = model.invoke(
        [SystemMessage(content=system_msg)] +
        state['messages'][:-1] +
        [HumanMessage(content="Please update the instructions based on the conversation")]
    )

    # 固定 key 覆写:Instructions 只有一条,无需追踪 json_doc_id
    store.put(namespace, "user_instructions", {"memory": new_memory.content})

    tool_calls = state['messages'][-1].tool_calls
    return {"messages": [{"role": "tool", "content": "updated instructions",
                           "tool_call_id": tool_calls[0]['id']}]}
store.get() vs store.search()

store.get(namespace, key): Take a single record according to the precise key, suitable for fixed records with known keys (Profile'sjson_doc_id, Instructions"user_instructions")。
store.search(namespace): Returns all items under the namespace, suitable for an indeterminate number of collections (ToDo list).

Why don't Instructions use Trustcall?

Trustcall fits the structured Pydantic Schema - it forces LLM to output data that conforms to the field definition through tool calls. Instructions are open-domain natural language (users can say "Please list in Chinese" or "Prioritize local businesses when adding"). There are no predefined fields, allowing LLM to directly output a piece of text more flexibly and accurately.

9Conditional edge: route_message

def route_message(
    state: MessagesState, config: RunnableConfig, store: BaseStore
) -> Literal[END, "update_todos", "update_instructions", "update_profile"]:

    message = state['messages'][-1]          # task_mAIstro 的输出

    if len(message.tool_calls) == 0:           # 无工具调用 → 直接结束
        return END

    tool_call = message.tool_calls[0]          # 只取第一个(已强制串行)
    update_type = tool_call['args']['update_type']

    if   update_type == "user":         return "update_profile"
    elif update_type == "todo":         return "update_todos"
    elif update_type == "instructions": return "update_instructions"
    else:
        raise ValueError
Syntax rules for conditional edge functions

Must return a string (node name) orEND. LangGraph will automatically analyze all possible values in the return type annotation, no need to manuallyadd_conditional_edgeslisted in.
conditional edge functionCan only read state, no side effects(The state cannot be modified and the API cannot be called).

task_mAIstro output route_message checks tool_calls update_profile / update_todos / update_instructions / END

10Graph assembly and compilation

# config_schema 将 Configuration(含 user_id)注册为可注入的运行时配置
builder = StateGraph(MessagesState, config_schema=configuration.Configuration)

# add_node(fn) 自动用函数名作为节点名
builder.add_node(task_mAIstro)
builder.add_node(update_todos)
builder.add_node(update_profile)
builder.add_node(update_instructions)

builder.add_edge(START, "task_mAIstro")
builder.add_conditional_edges("task_mAIstro", route_message)

# 更新节点执行完后,返回 task_mAIstro(携带 ToolMessage)
builder.add_edge("update_todos",        "task_mAIstro")
builder.add_edge("update_profile",     "task_mAIstro")
builder.add_edge("update_instructions", "task_mAIstro")

# 双存储模式:线程内短期记忆 + 跨线程长期记忆
across_thread_memory = InMemoryStore()
within_thread_memory = MemorySaver()
graph = builder.compile(
    checkpointer=within_thread_memory,
    store=across_thread_memory
)
Why does the update node need to return to task_mAIstro?

The three update nodes returnToolMessage, rather than giving text directly to the user. When task_mAIstro is triggered for the second time, there is an additional ToolMessage in the message history. After reading it, LLM tells the user "I have added this matter to your ToDo" in natural language. This kind ofAct first, explain laterThe design makes each node have a single responsibility and is easy to test independently.

Complete data flow (one round of ToDo updates)
START
task_mAIstro ← Read type 3 memory, LLM decision
↓ UpdateMemory(update_type='todo')
route_message → "update_todos"
update_todos ← Trustcall + Spy, write to Store
↓ ToolMessage("New ToDo created: ...")
task_mAIstro ← 2nd execution, see ToolMessage
↓ No tool call, generate natural language reply
route_message → END

11Full run demo

config = {"configurable": {"thread_id": "1", "user_id": "Lance"}}
Round 1 — Personal information → trigger update_profile

Human: My name is Lance. I live in SF with my wife. I have a 1 year old daughter.
→ UpdateMemory(update_type='user')
→ update_profile: Profile written to Store (name, location, connections, interests)
AI: Got it! How can I assist you today, Lance?
Round 2 — Task → trigger update_todos

Human: My wife asked me to book swim lessons for the baby.
→ UpdateMemory(update_type='todo')
→ update_todos: New ToDo { task: "Book swim lessons for 1-year-old", solutions: [...], status: "not started" }
AI: I've added "Book swim lessons for your 1-year-old daughter" to your ToDo list!
Round 3 — Action Preferences → Trigger update_instructions

Human: When creating ToDo items, include specific local businesses / vendors.
→ UpdateMemory(update_type='instructions')
→ update_instructions: Write "Include specific local SF businesses and vendors when creating ToDo items."
AI: Got it! I'll make sure to include specific local businesses in San Francisco.
Thread 2 (new session)—all three types of memory are in effect

Human: I have 30 minutes, what tasks can I get done?
AI: You can work on:
1. Book swim lessons for your daughter — ~30 min
→ La Petite Baleen (SF), SF Rec & Parks swim programs
2. Schedule car service — ~10 min
→ City Toyota on Van Ness
Cross-thread memory verification

Thread 2 is a completely new conversation, the agent is never told that the user is Lance or lives in SF.
But it can: ① call the user Lance; ② know that he has a 1-year-old daughter; ③ give specific SF local businesses (because there are requirements in Instructions); ④ list unfinished tasks directly from the ToDo Store.
This is exactly the effect of three types of cross-thread long-term memory (profile + todo + instructions) working together.