Page actions

Memory

Memory in Deep Agents is a file the agent maintains about you. memory=["/memories/AGENTS.md"] installs MemoryMiddleware, which loads that file and appends it to the system message with guidance telling the model to keep it current using edit_file. Nothing in the application parses the conversation for facts; the agent decides what is worth remembering, and the backend decides how long it lasts. Reading it back is the part that takes work, because memory_contents is private state. This page walks the three files of the running example.

What the demo does

The Run tab shows a dispatch desk beside a panel titled Agent Memory, which starts out saying that nothing is remembered yet. The first suggested prompt tells the agent that you fly a Citation CJ3 out of KASE and always want briefings in bullet points, and the panel fills in with the lines the agent writes into /memories/AGENTS.md.

The second suggestion is the one that matters. Reload the page to start a genuinely new thread, then ask what the agent already knows about your operation: the file is still there, because it came from the LangGraph store rather than from the transcript. A small label under the panel heading says which of two sources the panel is reading, and that label turns out to be the interesting part.

How it is built

Three files carry the capability: a Python graph that builds the agent and republishes one private key, an application config, and the Angular component that renders the file. Open the Code tab to read them in place.

The names the graph and the panel agree on

The memory file path, the store namespace, and the name of the custom stream event are module constants. The event name is the contract with the Angular panel, which listens for exactly that string.

graph.py — the shared names
MEMORY_FILE = "/memories/AGENTS.md"
 
#: Fixed namespace: every thread of this demo shares one memory. A real
#: deployment would scope it, e.g. `lambda rt: (rt.server_info.user.identity,)`.
MEMORY_NAMESPACE = ("cockpit", "deep-agents-memory")
 
#: Custom stream event name the Angular panel listens for.
MEMORY_EVENT = "deep_agents.memory"
Warning: Scope the namespace in anything real

The namespace factory ignores its runtime argument, so every thread of this demo shares one memory. That is deliberate here and wrong everywhere else. A real deployment derives the namespace from the caller, for example lambda rt: (rt.server_info.user.identity,).

An agent that owns its memory file

create_deep_agent takes the memory sources and the backend. memory=[...] installs MemoryMiddleware with the agent's own backend, so where the memory lives is decided by the backend argument rather than by the memory argument.

graph.py — building the agent
def build_memory_agent():
    """Build the memory agent.
 
    `store=None` on `StoreBackend` means "resolve the store from the graph
    execution context", which is what the LangGraph server supplies. The
    namespace factory ignores its runtime argument on purpose, so every thread
    of this demo reads and writes the same memory.
    """
    return create_deep_agent(
        model=ChatOpenAI(model="gpt-4.1", temperature=0),
        system_prompt=(PROMPTS_DIR / "memory.md").read_text(),
        backend=StoreBackend(namespace=lambda _runtime: MEMORY_NAMESPACE),
        memory=[MEMORY_FILE],
        middleware=[MemoryVisibilityMiddleware()],
    )

StoreBackend keeps files in LangGraph's BaseStore, which is scoped by namespace and shared across every thread. The default backend is StateBackend, which would put the same file on the thread's own state, where a new conversation would never see it. Leaving store unset means "resolve the store from the graph execution context", which the LangGraph server supplies.

What the prompt allows into memory

What the agent records is a matter of prompt, not code. The system prompt in prompts/memory.md is the whole policy:

`/memories/AGENTS.md` is yours. It is loaded into your context at the start of
every conversation, including conversations you have not had yet, and it is the
only thing about you that survives a new thread.
 
Write to it with `edit_file` whenever the user tells you something durable:
 
- a home base, a fleet type, an operating limitation
- a standing preference about how they want briefings written
- a correction to something you got wrong
 
Keep it as a short markdown list under a `## Crew notes` heading. One line per
fact. Do not record one-off requests, small talk, or anything that will be stale
next week. Never record credentials of any kind.

The last line is not decoration. A memory file is a persistent, model-writable document, so what must never go into it belongs in the prompt explicitly.

Announcing a private state key

MemoryMiddleware annotates memory_contents with PrivateStateAttr, which is OmitFromSchema(input=True, output=True): the key is deliberately absent from the agent's declared input and output. That is right for a transcript, since the memory file is context for the model rather than conversation, and it is why this example does not leave the panel bound to agent.value() alone while a run is in flight. A small middleware announces the key on a channel the client does receive during the run.

graph.py — republishing memory_contents
class MemoryVisibilityMiddleware(AgentMiddleware):
    """Republish `memory_contents` as a `custom` stream event.
 
    `PrivateStateAttr` keeps the key out of the `values` stream, which is right
    for a transcript and wrong for a panel. This is a demo-side shim: the key
    stays private on the state and is simply announced alongside it.
    """
 
    @property
    def name(self) -> str:
        return "MemoryVisibilityMiddleware"
 
    def _emit(self, state: dict[str, Any]) -> None:
        contents = state.get("memory_contents")
        if contents is None:
            return
        try:
            writer = get_stream_writer()
        except (RuntimeError, KeyError):
            # No streaming context. The value is still on the checkpoint, which
            # is what the client's history hydration reads.
            return
        writer({"name": MEMORY_EVENT, "data": {"memory_contents": contents}})
 
    def after_model(self, state: dict[str, Any], runtime: Any) -> None:  # noqa: ANN401, ARG002
        self._emit(state)
        return None
 
    def after_agent(self, state: dict[str, Any], runtime: Any) -> None:  # noqa: ANN401, ARG002
        self._emit(state)
        return None

This is an application-side shim rather than a framework change: the key stays private on the state and is simply announced alongside it. get_stream_writer raises outside a streaming context, which is why the emit is guarded.

Providing the agent

provideAgent() from @threadplane/langgraph registers the agent at the application root, and it is the only provider the <chat> composition requires. The example resolves its connection at runtime because the host that serves the demo decides which runtime is attached; your own application passes apiUrl and assistantId directly.

app.config.ts
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideAgent(() => {
      const connection = injectCockpitRuntimeConnection();
      if (connection.adapter !== 'langgraph') {
        throw new Error('incompatible runtime');
      }
      return {
        apiUrl: connection.apiUrl,
        assistantId: connection.assistantId,
        clientOptions: connection.clientOptions,
      };
    }),
  ],
};

Two sources for one panel

A custom event is a live signal and is not replayed when a thread is reopened. The thread state is durable but arrives only when the client hydrates it, which the adapter does on first connect and again when a run completes. A panel that wants both reads both.

memory.component.ts — the two readers
/** Latest `memory_contents` announced on the custom stream. */
private readonly liveMemory = computed<Record<string, string> | null>(() => {
  for (const event of [...this.agent.customEvents()].reverse()) {
    if (event.name !== MEMORY_EVENT) continue;
    const contents = (event.data as { memory_contents?: unknown } | undefined)?.[
      'memory_contents'
    ];
    if (contents && typeof contents === 'object') return contents as Record<string, string>;
  }
  return null;
});
 
/**
 * `memory_contents` off the hydrated thread state.
 *
 * The key is private to the `values` stream but is written to the
 * checkpoint, so this covers a reopened thread where no custom event has
 * fired in this session.
 */
private readonly settledMemory = computed<Record<string, string> | null>(() => {
  const contents = (this.agent.value() as Record<string, unknown> | undefined)?.[
    'memory_contents'
  ];
  return contents && typeof contents === 'object'
    ? (contents as Record<string, string>)
    : null;
});

agent.customEvents() holds the custom events of the current run — the adapter clears it when a new run starts — so the live reader walks it newest first and takes the most recent payload under the event name the graph emits. agent.value() is the agent state the adapter projects from the latest checkpoint, which carries the key even though the run stream does not.

memory.component.ts — which source is showing
/**
 * Which of the two sources the panel is currently showing.
 *
 * `live` means the graph's visibility middleware announced the key on the
 * custom stream during this run. `checkpoint` means only the settle-time
 * hydration has it — which is what a reopened thread looks like, and also
 * what the panel degrades to if the middleware is removed.
 */
protected readonly memorySource = computed<'live' | 'checkpoint' | 'none'>(() => {
  const live = this.liveMemory();
  if (live && Object.keys(live).length > 0) return 'live';
  return this.settledMemory() ? 'checkpoint' : 'none';
});

Blending the two would hide the distinction. live means the visibility middleware announced the key during this run; checkpoint is what a reopened thread looks like, and also what the panel degrades to if the middleware is removed.

Rendering the file

The panel shows the source label, then one block per remembered file: the path, then one row per non-empty line.

memory.component.ts — the panel
<div sidebar class="panel">
  <h3 class="cap">Agent Memory</h3>
  <p class="source" data-testid="memory-source" [attr.data-source]="memorySource()">
    {{ memorySource() === 'live' ? 'streamed live' : memorySource() === 'checkpoint' ? 'from checkpoint' : 'no source yet' }}
  </p>
  @if (memoryFiles().length === 0) {
    <p class="empty">Nothing remembered yet</p>
  }
  @for (file of memoryFiles(); track file.path) {
    <div class="mem" data-testid="memory-file" [attr.data-path]="file.path">
      <span class="mem__path">{{ file.path }}</span>
      @for (line of file.lines; track $index) {
        <p class="mem__line" data-testid="memory-line">{{ line }}</p>
      }
    </div>
  }
  <p class="hint">
    This file lives in the LangGraph store, not on the thread. Reload the page to start a
    new conversation and it will still be here.
  </p>
</div>

Proving the store rather than the panel

The only assertion that proves cross-thread memory is a genuinely new thread that already knows. Clearing the panel and watching it refill proves that the component works, not that the store is doing the remembering. The example's end-to-end test submits the second prompt after a fresh page load, so the file it asserts on was written during a conversation that is no longer open.

Tip: Assert the label as well as the contents

The panel fills in either way, so contents alone do not tell you whether the live path is working. Asserting that the source label reads live is what keeps the visibility middleware honest.

What's Next

Looking for something specific?