Skip to main content

Advanced: multi-step research agent (LangGraph)

A react agent is one loop: think, call a tool, think again. That is the right shape for "answer this with tools" and the wrong shape for research, where the useful structure is a plan the model commits to before it starts gathering.

By the end of this guide you will have an agent that:

  • plans first, in its own graph node, and does not re-plan every turn
  • remembers across sessions, so the second conversation starts from the first
  • calls MCP tools your project has attached
  • refreshes itself on a schedule, so the memory is warm before anyone asks

Source: examples/agents/langgraph-research.

What you need

  • A project, and platformctl login done once.
  • About 15 minutes.
  • Costs while idle: nothing. The agent scales to zero and a scaled-to-zero workload is not billed for compute. See Teardown for what does cost while it exists.

The shape

plan decide what to look up, ONCE
|
v
+-> gather work the list, with tools
| |
| +-- tool call --> tools --+
| |
+-------------------------------+
|
| no more tool calls
v
write answer, with NO tools bound

Three graph nodes rather than one loop, and the split earns its keep in one specific place: write has no tools. A model that can still call a search tool while writing will keep searching rather than commit. Removing the tools is what makes it answer.

The code

graph.py
import asyncio
from typing import Annotated, List, TypedDict

from langchain_core.messages import AnyMessage, HumanMessage, SystemMessage
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition

import crusoe_langchain as crusoe

_MCP = asyncio.run(crusoe.mcp_tools())
TOOLS = [crusoe.search_memory, crusoe.run_python, *_MCP]

model = crusoe.foundry_model()
gatherer = model.bind_tools(TOOLS)


class State(TypedDict):
messages: Annotated[List[AnyMessage], add_messages]
plan: str

Two things in those few lines are worth stopping on.

mcp_tools() is awaited at import, on purpose

It is asyncio.run at module scope because tools must be resolved before the graph is compiled, and there is no running event loop yet - uvicorn starts after your module is imported.

This is also fail-closed. If an attached MCP server is unreachable, the error propagates and the agent fails to start. The alternative - catching it and carrying on

  • serves an agent whose tools silently vanished, which looks like a model that suddenly got worse.
The plan lives in state, not in messages

Appending the plan to messages puts it in the model's context twice: once as its own output, once in the gather prompt. In practice the model then re-plans when it sees the old plan. Keeping it in State is what makes "plan once" true.

The three graph nodes, and the edges between them:

graph.py (continued)
async def plan(state: State) -> dict:
out = await model.ainvoke([SystemMessage(PLAN_PROMPT), *state["messages"]])
return {"plan": out.content}


async def gather(state: State) -> dict:
out = await gatherer.ainvoke(
[SystemMessage(GATHER_PROMPT.format(plan=state["plan"])), *state["messages"]]
)
return {"messages": [out]}


async def write(state: State) -> dict:
# `model`, not `gatherer` - no tools bound.
out = await model.ainvoke([*state["messages"], HumanMessage(WRITE_PROMPT)])
return {"messages": [out]}

And the wiring:

graph.py (continued)
builder = StateGraph(State)
builder.add_node("plan", plan)
builder.add_node("gather", gather)
builder.add_node("tools", ToolNode(TOOLS))
builder.add_node("write", write)

builder.set_entry_point("plan")
builder.add_edge("plan", "gather")
builder.add_conditional_edges("gather", route_after_gather,
{"tools": "tools", "write": "write"})
builder.add_edge("tools", "gather")
builder.add_edge("write", END)

graph = builder.compile()
The stock tools_condition does not terminate

The obvious wiring is LangGraph's own tools_condition, which asks one question: did the model request a tool? If yes, go to tools; the edge from tools goes back to gather.

Nothing in that loop ends it. A model that keeps deciding to search keeps searching, and LangGraph stops it only at its own recursion limit:

langgraph.errors.GraphRecursionError: Recursion limit of 25 reached
without hitting a stop condition.

which reaches your caller as a 500, not as an answer. This example hit it on its second real question.

So gathering gets a budget, and the router asks two questions:

MAX_TOOL_ROUNDS = 6

def route_after_gather(state: State) -> str:
last = state["messages"][-1] if state["messages"] else None
wants_tool = bool(getattr(last, "tool_calls", None))
if wants_tool and state.get("rounds", 0) < MAX_TOOL_ROUNDS:
return "tools"
return "write" # out of budget, or nothing left to look up

The counter lives in State, not in a closure: a graph can be resumed, and a counter that resets on resume is not a budget. The budget belongs in the graph rather than in the prompt, because a prompt is a request and this is a guarantee.

Deploy it

platformctl deploy ./examples/agents/langgraph-research \
--name researcher --framework langgraph

A new agent gets the platform's default size, medium - 1 vCPU and 1 GiB, which is plenty for a planning agent. To change it afterwards:

platformctl agents config set researcher --size large

platformctl platform limits prints the four sizes and what each resolves to.

Watch it plan

platformctl invoke researcher 'What changed in our retention policy this year, and who approved it?'

The first thing in the trace is the plan, then a search_memory call before any other tool. That order is in the gather prompt on purpose: previous sessions may already have established some of this, and a lookup is cheaper than a search.

The numbers to compare against

Measured on a warm instance:

WhatNumber
A four-lookup question, plan through answer12-15 s
Cold start, first invoke after idle20 s+, and it may time out
The plan appears in the answer

The harness streams every graph node's model output, and the plan is model output. So a reader sees the numbered plan and then the answer, in that order.

That is usually welcome for a research agent — it shows the work — but it is not optional, and it is worth knowing before you put this in front of someone expecting a bare reply.

If a run takes 40 seconds or dies, the agent is gathering more than it planned to. Check MAX_TOOL_ROUNDS, and check that write invokes model and not gatherer.

Give it memory that outlives the session

Memory is written when you ask for it:

platformctl invoke researcher \
'Log retention here is 90 days, approved by Dana in March 2026.' --memorize
Read this before you try to read it back

Memory is private to the caller who created it. MEMORY_SCOPE defaults to user, and a memory written by one caller is invisible to another.

platformctl invoke mints a fresh caller on every call — it has no --user flag. So memorizing with the CLI and then reading back with the CLI cannot work: the second call is a different person as far as the platform is concerned, and it will find nothing.

This is the platform behaving correctly. It is also the single easiest way to conclude that memory is broken when it is not.

You have two honest ways to demonstrate it.

Either share the memory across callers, which suits a documentation assistant where every fact is for everyone:

platformctl agents env set researcher MEMORY_SCOPE=shared

Then memorize, and ask again in a new session:

platformctl invoke researcher 'How long do we keep logs, and who approved it?'
I searched the long-term memory... The only mention that surfaced was a
user-provided statement claiming "logs are kept for 90 days, approved by
Dana in March 2026."

Note what the agent did with it: it reported the memory as a claim, and kept it separate from what the documentation says. That is the behaviour you want from a memory that anyone can write to.

Or keep memory per-person and pass a stable caller, which is what a real assistant does — the invoke API takes a user_id, and the same user_id across two calls is the same person:

{"message": "How long do we keep logs?", "user_id": "u-dana"}
--memorize is a durable write

Under MEMORY_SCOPE=shared, everything memorized is readable by every later caller of this agent. Do not memorize one person's private details on a shared agent.

Refresh it on a schedule

The point of durable memory is that something can fill it before anyone asks. A scheduled trigger runs the agent on a cron and memorizes the result:

platformctl serverless triggers create researcher-refresh \
--type schedule \
--target researcher \
--target-path /invoke \
--cron '0 6 * * *' \
--payload '{"message":"Summarise anything that changed in the policy docs since yesterday.","memorize":true}'

--target-path /invoke matters: without it the trigger calls the service root, which is not the invoke endpoint.

At 06:00 the agent researches, memorizes what it found, and scales back to zero. The first person to ask that morning gets an answer out of memory in about a second instead of waiting fifteen.

Traps, at the point you hit them

TrapWhat you seeWhy
write bound to toolsRuns are 40 s+ and the answer arrives late or not at allThe model keeps searching because it still can. Use model, not gatherer.
Plan appended to messagesThe agent re-plans mid-run and changes directionIt sees its own plan as context and revises it. Keep it in State.
mcp_tools() awaited inside a graph nodeRuntimeError: this event loop is already runningIt must be resolved at import, before compile.
tools_condition with no budgetGraphRecursionError, surfacing as a 500The gather/tools loop has no stop condition of its own.
Memorize with the CLI, read back with the CLIThe agent finds nothingMemory is per-caller and the CLI mints a caller per call.
An attached MCP server is downThe agent fails to start, in a restart loopDeliberate. Detach the server, or fix it - an agent with silently missing tools is worse.
No --memorizeThe next session knows nothingMemory is written on request, not automatically. Nothing is stored by accident.

Teardown

In dependency order - the trigger first, because it references the agent:

platformctl serverless triggers delete researcher-refresh
platformctl delete researcher

What it costs to leave running. The agent scales to zero when idle, so an idle agent costs no compute. What persists is its memory bank in VectorDB, which holds storage until the agent is deleted, and its revisions, which each hold two entries against the project's Services quota. Deleting the agent releases both. A daily refresh trigger is one invocation per day - the cost is the model tokens for one research run.

Where next