LangChain Deep Agent on Amazon Bedrock
This post outline the architecture and implementation of LangChain's Deep Agents framework, explaining how foundation models are combined with an Agent Harness to handle complex autonomous workflows. Key concepts explored include prompt caching for token optimization, built-in and custom tools for executing actions or querying databases like MovieLens, and the integration of Model Context Protocol (MCP) servers. Additionally, the text details state management utilizing threads and checkpointers to preserve conversation history across sessions, while showcasing time travel capabilities for branching or reviewing past states. Finally, the materials emphasize Human-in-the-Loop (HITL) safety controls, allowing developers to pause execution before sensitive actions take place and review, edit, approve, or reject pending operations.
This post is part of a series on LangChain on Amazon Bedrock
- AI Agent Harness
- Tools
- MCP
- Messages, Threads and Checkpointers
- Threads and Checkpointers
- Time travel
- Human-in-the-loop in Deep Agents
AI Agent Harness
In modern AI engineering, turning a raw foundation model into an autonomous work engine requires more than simple prompt engineering. LangChain1 introduces the foundational formula for building functional AI agents:
Agent = Model + Harness
While a base language model provides reasoning and language capabilities, the Agent Harness serves as the surrounding runtime infrastructure. It allows the model to
- perceive context,
- execute tools,
- persist state across runs, and
- operate within defined safety boundaries
Key Components of an Agent Harness
Without a structured harness, a standalone model risks exceeding its context window when processing large tool outputs, causing it to lose track of multi-step task objectives. A robust agent harness solves this through three core sub-systems:
flowchart LR
Task["User Task / Goal"] --> Harness["Agent Harness"]
Harness <--> Model["Base Model"]
subgraph Harness Sub-Systems
FS["📁 File System"]
SA["🤖 Sub-Agents"]
SK["🧩 Dynamic Skills"]
end
Harness --> FS
Harness --> SA
Harness --> SK
-
File System Integration (Context Offloading): To prevent model context saturation, the harness provides a virtual or containerized file system. Intermediate data, code execution outputs, and bulky API responses are written to disk rather than injected directly into the LLM context window.
-
Sub-Agent Orchestration: Complex tasks are broken down and delegated to specialized sub-agents. These function as mini-assistants operating with their own isolated context windows and targeted toolsets, allowing parallelized execution without context degradation.
-
On-Demand Skills Loading: Instead of inflating system prompts with instructions for every tool simultaneously, the harness loads modular skills dynamically only when required by the model.
LangChain’s Deep Agents Framework
Building an agent harness from scratch for every application creates unnecessary engineering overhead. LangChain’s Deep Agents framework offers an open-source, opinionated harness pre-configured with memory management, sub-agent delegation, and file system integration.
sequenceDiagram
autonumber
actor Developer
participant Harness as Deep Agents Harness
participant Backend as Execution Environment
participant Skills as Skill Repository
Developer->>Harness: Define Core Agent Goal
Harness->>Backend: Initialize (Local / Sandbox / Cloud)
Developer->>Skills: Add New Skill
Skills-->>Harness: Dynamically Register Capability
Harness->>Backend: Execute Workflows with Offloaded Memory
- Backend Agnosticism: Deep Agents allows developers to swap execution environments seamlessly—running the underlying file system on a local machine, inside isolated sandboxes, or across cloud infrastructure.
- Modular Extensibility: New capabilities are added by attaching skills directly to the harness without modifying the core model pipeline or prompt architecture.
Here currently what I have in my environment:
learn-langgraph v0.1.0
├── email-validator v2.3.0
├── instructor v1.17.0
├── ipykernel v7.4.0
├── jupyter v1.1.1
├── langchain v1.4.9
├── langchain-aws v1.9.2
├── langchain-mcp-adapters v0.3.2
├── langchain-openai v1.7.4
├── langgraph v1.2.20
├── langgraph-cli[inmem] v0.4.42
├── pydantic-ai v2.38.0
├── python-dotenv v1.2.4
└── tavily v1.1.0
Let`s install:
uv add deepagents
%%bash
uv tree --depth 1
Resolved 240 packages in 1ms
learn-langgraph v0.1.0
├── deepagents v0.7.34
├── email-validator v2.3.0
├── instructor v1.17.0
├── ipykernel v7.4.0
├── jupyter v1.1.1
├── langchain v1.4.9
├── langchain-aws v1.9.2
├── langchain-mcp-adapters v0.3.2
├── langchain-openai v1.7.4
├── langgraph v1.2.20
├── langgraph-cli[inmem] v0.4.42
├── pydantic-ai v2.38.0
├── python-dotenv v1.2.4
└── tavily v1.1.0
from deepagents import create_deep_agent
from langchain.chat_models import init_chat_model
from pprint import pprint
Create a model for deep agent:
model = init_chat_model("au.anthropic.claude-haiku-4-5-20251001-v1:0",
model_provider="bedrock_converse",
region_name="ap-southeast-2"
)
Create a deep agent:
agent = create_deep_agent(model=model)
response = agent.invoke(
{"messages": [
{"role": "user", "content": "What is the *Progressive disclosure* of LLM Agent?"}
]}
)
This is the smallest version of the agent:
pprint(response)
{'files': {},
'messages': [HumanMessage(content='What is the *Progressive disclosure* of LLM Agent?', additional_kwargs={}, response_metadata={}, id='6399faf6-9116-4f1e-88f4-a5fec0ff96fd'),
AIMessage(content=[{'type': 'text', 'text': 'I\'ll search for information about "Progressive disclosure" of LLM Agent in the available files.'}, {'type': 'tool_use', 'name': 'grep', 'input': {'pattern': 'Progressive disclosure'}, 'id': 'tooluse_kyPOilVbgrdCTZEqBJ470b'}, {'type': 'tool_use', 'name': 'grep', 'input': {'pattern': 'LLM Agent'}, 'id': 'tooluse_Y9mB9g5B8jBIv2doJ7osYH'}], additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': 'ba1a3465-f8a3-4eb8-8f74-4630a9de32cb', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 09 Nov 2026 12:44:12 GMT', 'content-type': 'application/json', 'content-length': '673', 'connection': 'keep-alive', 'x-amzn-requestid': 'ba1a3465-f8a3-4eb8-8f74-4630a9de32cb'}, 'RetryAttempts': 0}, 'stopReason': 'tool_use', 'metrics': {'latencyMs': [804]}, 'model_provider': 'bedrock_converse', 'model_name': 'au.anthropic.claude-haiku-4-5-20251001-v1:0'}, id='lc_run--01a1c056-6cd7-74d8-a96f-0c5f1f6e21d8-0', tool_calls=[{'name': 'grep', 'args': {'pattern': 'Progressive disclosure'}, 'id': 'tooluse_kyPOilVbgrdCTZEqBJ470b', 'type': 'tool_call'}, {'name': 'grep', 'args': {'pattern': 'LLM Agent'}, 'id': 'tooluse_Y9mB9g5B8jBIv2doJ7osYH', 'type': 'tool_call'}], invalid_tool_calls=[], usage_metadata={'input_tokens': 3211, 'output_tokens': 110, 'total_tokens': 3321, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}}),
ToolMessage(content='No matches found', name='grep', id='b38b6e76-ac48-4e9d-977a-f55a7eb9267e', tool_call_id='tooluse_kyPOilVbgrdCTZEqBJ470b'),
ToolMessage(content='No matches found', name='grep', id='ab8d8117-7688-4d9b-be12-bcb9d8e1f571', tool_call_id='tooluse_Y9mB9g5B8jBIv2doJ7osYH'),
AIMessage(content=[{'type': 'text', 'text': 'Let me explore the directory structure to see what files are available:'}, {'type': 'tool_use', 'name': 'ls', 'input': {'path': '/'}, 'id': 'tooluse_RkHtdyUWY3HjNjCXB7lPfE'}], additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': '9a5d87e2-2a8c-4664-a3fc-307e02dffd9b', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 09 Nov 2026 12:44:13 GMT', 'content-type': 'application/json', 'content-length': '497', 'connection': 'keep-alive', 'x-amzn-requestid': '9a5d87e2-2a8c-4664-a3fc-307e02dffd9b'}, 'RetryAttempts': 0}, 'stopReason': 'tool_use', 'metrics': {'latencyMs': [648]}, 'model_provider': 'bedrock_converse', 'model_name': 'au.anthropic.claude-haiku-4-5-20251001-v1:0'}, id='lc_run--01a1c056-701a-717c-b725-4d2eaf28fbb1-0', tool_calls=[{'name': 'ls', 'args': {'path': '/'}, 'id': 'tooluse_RkHtdyUWY3HjNjCXB7lPfE', 'type': 'tool_call'}], invalid_tool_calls=[], usage_metadata={'input_tokens': 3386, 'output_tokens': 65, 'total_tokens': 3451, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}}),
ToolMessage(content='No files found', name='ls', id='2f369aaf-2c56-485a-a6e2-252ad9558885', tool_call_id='tooluse_RkHtdyUWY3HjNjCXB7lPfE'),
AIMessage(content=[{'type': 'text', 'text': 'Let me try searching with a broader approach:'}, {'type': 'tool_use', 'name': 'glob', 'input': {'pattern': '*'}, 'id': 'tooluse_mYEE1KFOhAoD9SnancMTpH'}], additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': '6b7d5932-dc28-41d0-9fa8-dda4094613d1', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 09 Nov 2026 12:44:14 GMT', 'content-type': 'application/json', 'content-length': '476', 'connection': 'keep-alive', 'x-amzn-requestid': '6b7d5932-dc28-41d0-9fa8-dda4094613d1'}, 'RetryAttempts': 0}, 'stopReason': 'tool_use', 'metrics': {'latencyMs': [826]}, 'model_provider': 'bedrock_converse', 'model_name': 'au.anthropic.claude-haiku-4-5-20251001-v1:0'}, id='lc_run--01a1c056-72c3-770d-a8ff-67d4ac8a0252-0', tool_calls=[{'name': 'glob', 'args': {'pattern': '*'}, 'id': 'tooluse_mYEE1KFOhAoD9SnancMTpH', 'type': 'tool_call'}], invalid_tool_calls=[], usage_metadata={'input_tokens': 3466, 'output_tokens': 61, 'total_tokens': 3527, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}}),
ToolMessage(content='No files found', name='glob', id='8995cbff-7578-40d0-87c8-1f6a0b928613', tool_call_id='tooluse_mYEE1KFOhAoD9SnancMTpH'),
AIMessage(content="I apologize, but it appears there are no files currently available in the system to search. Based on my knowledge, I can provide you with an explanation of **Progressive Disclosure** in the context of LLM Agents:\n\n## Progressive Disclosure for LLM Agents\n\n**Progressive Disclosure** is a design principle and technique where information, capabilities, or functionality is revealed to users incrementally based on their needs and interactions, rather than all at once. In the context of LLM Agents, this means:\n\n### Key Aspects:\n\n1. **Incremental Information Reveal**: Complex capabilities and available tools/functions are introduced gradually as the user needs them, reducing cognitive overload.\n\n2. **Adaptive Complexity**: \n - Simple tasks are handled with basic capabilities initially\n - More advanced features are revealed as the agent learns the user's sophistication level\n - Expert users can access deeper functionality while novices work with simplified interfaces\n\n3. **Layered Abstraction**:\n - Surface-level responses for common queries\n - Detailed explanations, code examples, or technical details available on request\n - Tool capabilities revealed progressively based on task requirements\n\n4. **Context-Aware**: The agent adapts what information and options it presents based on:\n - User expertise level\n - Previous interaction history\n - Task complexity\n - Current context\n\n5. **Benefits**:\n - Reduces information overload\n - Improves user experience\n - Allows for both simplicity and power\n - Enables scalable interaction patterns\n\nIf you have specific documents or files about this topic, please upload them or let me know the file paths, and I can provide more detailed information from those sources.", additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': 'a8603a7c-4ab2-4aab-a65c-78b1d992b980', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 09 Nov 2026 12:44:17 GMT', 'content-type': 'application/json', 'content-length': '2097', 'connection': 'keep-alive', 'x-amzn-requestid': 'a8603a7c-4ab2-4aab-a65c-78b1d992b980'}, 'RetryAttempts': 0}, 'stopReason': 'end_turn', 'metrics': {'latencyMs': [2825]}, 'model_provider': 'bedrock_converse', 'model_name': 'au.anthropic.claude-haiku-4-5-20251001-v1:0'}, id='lc_run--01a1c056-7617-7201-9f4e-1e1a4f9ad26e-0', tool_calls=[], invalid_tool_calls=[], usage_metadata={'input_tokens': 3542, 'output_tokens': 377, 'total_tokens': 3919, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}})]}
Based on my search, I don't have access to files containing information about "Progressive Disclosure" of LLM Agents in the current filesystem.
However, I can provide you with a general explanation of **Progressive Disclosure** as it relates to LLM (Large Language Model) Agents:
## Progressive Disclosure in LLM Agents
**Progressive Disclosure** is a user interface and interaction design principle that can be applied to LLM Agent systems. Here's what it means in this context:
### Key Concept:
Progressive disclosure refers to revealing information, options, or capabilities **gradually and in stages**, rather than overwhelming the user with everything at once. In the context of LLM Agents:
1. **Initial Simple Interface**: Start with basic, essential functions and queries that the agent can handle.
2. **Gradual Complexity**: As users interact with the agent, more advanced features, parameters, and options become available or discoverable.
3. **Contextual Information**: Additional tools, configurations, or reasoning steps are revealed based on:
- User expertise level
- Task complexity
- Interaction history
- User preferences
4. **Benefits**:
- Reduces cognitive load for new users
- Prevents information overload
- Improves user experience
- Allows advanced users to access sophisticated features when needed
- Makes the agent feel more intuitive and responsive
### Example:
An LLM Agent might start by simply answering questions, then progressively allow users to specify search depth, reasoning strategy, tool access levels, or output formats as they become more familiar with the system.
If you have specific files or documentation about LLM Agents that you'd like me to examine, please let me know the file path or content, and I'll be happy to help!
System prompt:
The model by defining custom instructions, personas, and operational constraints, re-sending them ahead of the conversation on every turn. Passed through the system_prompt parameter, it serves as the primary mechanism for scoping an agent to a specific domain so it can redirect or refuse off-topic requests. Since deepagents 0.7 does not feature a large built-in base prompt, omitting the system_prompt argument leaves the model without any task-specific instructions or behavioral boundaries. Additionally, because the system prompt remains constant across multi-turn interactions, it is the primary candidate for prompt caching, allowing providers to reuse KV-cache data and cut token costs by 49% to 80% on long-running tasks.
prompt caching: Prompt caching allows LLM providers to store and reuse preprocessed Key-Value (KV) cache data from static prompt components—such as system prompts, tool definitions, and skills—across multi-turn interactions rather than reprocessing identical tokens on every request. Because AI agent loops rely on system prompts and tool schemas that remain constant between turns, prompt caching dramatically reduces token costs, yielding overall savings of 49% to 80% on long-running tasks.
- Core Functionality: Avoids reprocessing static instructions by reusing cached prompt prefixes across conversation turns.
- Caching Mechanisms: Implemented either via explicit cache breakpoints (supported by Anthropic and Gemini) or provider-side implicit prefix matching (supported by OpenAI and Gemini).
- Horizon Advantage: Cost efficiency scales with conversation length, delivering the highest savings on extended, multi-turn agent trajectories.
Tools
Tools are functions you give an agent so it can act: look things up, run queries, call APIs, or write files. You define what a tool does. The agent decides when to call it and what arguments to pass.
Built-in tools (always present)
Every deep agent comes with a built-in set of tools that the SDK loads automatically:
| Tool(s) | Purpose |
|---|---|
ls, read_file, write_file, edit_file, delete |
Read, write and delete files in the agent’s working directory |
glob, grep |
Search files by name pattern or by content |
execute |
Run shell commands (needs a sandbox backend) |
task |
Hand work to a sub-agent that has its own separate context |
write_todos |
Opt-in. Turn it on by adding TodoListMiddleware to the agent’s middleware (covered in Module 4) |
⚠️
deleteis recursive and can’t be undone. Deleting a directory removes everything in it. Keep the working directory narrow.
built-ins
The tools you pass are merged with the built-in set. Nothing is replaced.
flowchart LR
subgraph Builtin["Built-in suite (automatic)"]
A[ls / read_file / write_file<br/>edit_file / delete]
B[glob / grep]
C[execute]
D[task]
end
subgraph Custom["Your tools (tools=[...])"]
E[get_weather]
F[convert_currency]
end
Builtin --> M((Merged toolset))
Custom --> M
M --> AG[Deep Agent]
🆕
read_fileline markers: instead of numbering every line,read_filenow wraps the lines it returns in@@ lines start-end @@markers. LangChain’s evals show this cutsedit_fileerrors by about 15% and input tokens by about 10%.
from deepagents import create_deep_agent
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Return the current weather for a given city name."""
fake_data = {
"sydney": "22°C, partly cloudy",
"tokyo": "15°C, light rain",
"london": "9°C, overcast",
}
return fake_data.get(city.lower(), f"No weather data for {city}")
agent = create_deep_agent(model=model, tools=[get_weather])
result = agent.invoke(
{"messages": [{"role": "user", "content": "What's the weather in Sydney?"}]}
)
for msg in result["messages"]:
for call in getattr(msg, "tool_calls", None) or []:
print(f"Model asked for: {call['name']}({call['args']})")
if msg.type == "tool":
print(f"Tool returned: {msg.content}")
print(f"Final answer: {result['messages'][-1].text}")
Model asked for: get_weather({'city': 'Sydney'})
Tool returned: 22°C, partly cloudy
Final answer: The weather in Sydney is **22°C and partly cloudy**. It's a pleasant day!
How a tool call works
The LLM never runs a tool itself. It returns an AIMessage that contains a tool_calls list. The agent’s built-in Tools runner then calls your Python function and sends the result back as a ToolMessage. The trace below is a real run on Claude Haiku 4.5 via Amazon Bedrock (Converse).
sequenceDiagram
autonumber
participant U as User
participant SDK as Deep Agent (SDK)
participant LLM as Claude Haiku 4.5<br/>(bedrock_converse)
participant T as Tools runner
participant F as get_weather()
U->>SDK: HumanMessage("What's the weather in Sydney?")
rect rgb(230, 240, 255)
Note over SDK,LLM: LLM call 1 — 3,262 input / 54 output tokens, 614 ms
SDK->>LLM: system prompt + built-in tool schemas + get_weather schema + HumanMessage
LLM-->>SDK: AIMessage(content=[tool_use]) stopReason = tool_use
Note right of SDK: tool_calls = [ get_weather(city="Sydney") ]<br/>id = tooluse_JwFu...
end
rect rgb(235, 250, 235)
Note over SDK,F: Tool execution (no LLM involved)
SDK->>T: tool_calls
T->>F: get_weather(city="Sydney")
F-->>T: "22°C, partly cloudy"
T-->>SDK: ToolMessage(content="22°C, partly cloudy", name="get_weather", tool_call_id="tooluse_JwFu...")
end
rect rgb(255, 245, 225)
Note over SDK,LLM: LLM call 2 — 3,335 input / 25 output tokens, 646 ms
SDK->>LLM: full history (Human + AI tool_use + ToolMessage)
LLM-->>SDK: AIMessage("The weather in Sydney is 22°C and partly cloudy...") stopReason = end_turn, tool_calls = []
end
SDK-->>U: result["messages"][-1].text
Each tool request in the AIMessage has an id. Tools wraps the result in a ToolMessage with the matching
tool_call_id, which links the result back to the original request.
pprint(result)
{'files': {},
'messages': [HumanMessage(content="What's the weather in Sydney?", additional_kwargs={}, response_metadata={}, id='65db76cc-ab6f-4915-a11c-6dcf14c5ee6b'),
AIMessage(content=[{'type': 'tool_use', 'name': 'get_weather', 'input': {'city': 'Sydney'}, 'id': 'tooluse_90xYe2VpG6GjsHUzuvIbID'}], additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': '84882aa8-8735-493c-ba95-eaf3bf8a5676', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 09 Nov 2026 23:06:55 GMT', 'content-type': 'application/json', 'content-length': '428', 'connection': 'keep-alive', 'x-amzn-requestid': '84882aa8-8735-493c-ba95-eaf3bf8a5676'}, 'RetryAttempts': 0}, 'stopReason': 'tool_use', 'metrics': {'latencyMs': [683]}, 'model_provider': 'bedrock_converse', 'model_name': 'au.anthropic.claude-haiku-4-5-20251001-v1:0'}, id='lc_run--01a1c290-8a0d-776d-ad80-ffb3d3ed18c0-0', tool_calls=[{'name': 'get_weather', 'args': {'city': 'Sydney'}, 'id': 'tooluse_90xYe2VpG6GjsHUzuvIbID', 'type': 'tool_call'}], invalid_tool_calls=[], usage_metadata={'input_tokens': 3262, 'output_tokens': 54, 'total_tokens': 3316, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}}),
ToolMessage(content='22°C, partly cloudy', name='get_weather', id='cb9de960-6c3e-45db-806d-07426a29490d', tool_call_id='tooluse_90xYe2VpG6GjsHUzuvIbID'),
AIMessage(content="The weather in Sydney is **22°C and partly cloudy**. It's a pleasant day!", additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': 'c92c26b4-6ebf-4095-82cd-c564a7302aba', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Mon, 09 Nov 2026 23:06:56 GMT', 'content-type': 'application/json', 'content-length': '390', 'connection': 'keep-alive', 'x-amzn-requestid': 'c92c26b4-6ebf-4095-82cd-c564a7302aba'}, 'RetryAttempts': 0}, 'stopReason': 'end_turn', 'metrics': {'latencyMs': [734]}, 'model_provider': 'bedrock_converse', 'model_name': 'au.anthropic.claude-haiku-4-5-20251001-v1:0'}, id='lc_run--01a1c290-8ce5-7486-85e6-93387be9afa2-0', tool_calls=[], invalid_tool_calls=[], usage_metadata={'input_tokens': 3335, 'output_tokens': 24, 'total_tokens': 3359, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}})]}
Defining a tool with @tool
Use the @tool decorator from langchain_core.tools to turn a regular function into a tool. LangChain also ships ready-made tools, such as TavilySearchResults for web search.
Key rules
- The docstring is the main natural-language signal. Write one clear, specific sentence. The model reads it together with the name and argument schema to choose between tools.
- Type hints become the argument schema. The model uses them to fill in arguments.
- Return a string (or anything JSON-serializable). It goes back to the model as the tool result.
Parallel tool calls
tool_calls is a list because the model can ask for several tools in one turn. When the calls don’t depend on each other, the Tools runner can run them at the same time.
flowchart TD
Q["User: 'Weather in Tokyo, and convert 100 AUD to USD?'"] --> L1[LLM turn 1]
L1 -->|tool_calls list| P
P --> W["get_weather(city='Tokyo')"]
P --> C["convert_currency(100, 'AUD', 'USD')"]
W --> R[Both ToolMessages]
C --> R
R --> L2[LLM turn 2: sees both results together]
L2 --> A[Final combined answer]
Action tools need safety controls
Tools can retrieve information or take actions: write to a database, send a message, create a ticket, delete a file.
@tool
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email to a recipient. Use only when the user explicitly asks to send."""
... # side effect: can't be undone
flowchart LR
T[Tool call requested] --> K{Read-only or action?}
K -->|Read-only: search, lookup| X[Run immediately]
K -->|Action: send, write, delete| H[Human-in-the-Loop approval<br/>Lesson 8]
H -->|Approved| X
H -->|Rejected or edited| B[Back to the LLM]
SQL Agent (Tool Example)
Create Duckdb database using MovieLens2 dataset.
%%bash
duckdb ../data/movielens/movielens.duckdb <<'SQL'
CREATE OR REPLACE TABLE movies AS
SELECT movieId, title, genres,
TRY_CAST(regexp_extract(title, '\((\d{4})\)\s*$', 1) AS INTEGER) AS year
FROM read_csv('../data/movielens/ml-latest-small/movies.csv');
CREATE OR REPLACE TABLE ratings AS
SELECT userId, movieId, rating, to_timestamp("timestamp") AS rated_at
FROM read_csv('../data/movielens/ml-latest-small/ratings.csv');
CREATE OR REPLACE TABLE tags AS
SELECT userId, movieId, tag, to_timestamp("timestamp") AS tagged_at
FROM read_csv('../data/movielens/ml-latest-small/tags.csv');
-- keep imdbId as text so leading zeros survive (tt0114709)
CREATE OR REPLACE TABLE links AS
SELECT * FROM read_csv('../data/movielens/ml-latest-small/links.csv',
types = {'imdbId': 'VARCHAR', 'tmdbId': 'BIGINT'});
-- split "Adventure|Comedy" into one row per genre (a Chinook-style bridge table)
CREATE OR REPLACE TABLE movie_genres AS
SELECT movieId, unnest(string_split(genres, '|')) AS genre FROM movies;
SHOW TABLES;
SELECT count(*) AS n_ratings FROM ratings;
SQL
┌──────────────┐
│ name │
│ varchar │
├──────────────┤
│ links │
│ movie_genres │
│ movies │
│ ratings │
│ tags │
└──────────────┘
┌───────────┐
│ n_ratings │
│ int64 │
├───────────┤
│ 100836 │
└───────────┘
Install duckdb before use:
%%bash
uv add duckdb
Resolved 241 packages in 797ms
Downloading duckdb (23.2MiB)
Downloaded duckdb
Prepared 1 package in 647ms
Installed 1 package in 2ms
+ duckdb==2.0.0
Now verify MovieLen access:
import duckdb
con = duckdb.connect("../data/movielens/movielens.duckdb", read_only=True)
con.sql("SHOW TABLES")
| name VARCHAR |
|---|
| links |
| movie_genres |
| movies |
| ratings |
| tags |
SYSTEM_PROMPT = """You are a SQL analyst with access to the MovieLens database.
Rules:
- Use read_sql for SELECT queries.
- Do not modify the database.
- If a tool returns an error, revise the SQL and try again.
- Show your SQL in your final answer.
"""
@tool
def read_sql(query: str) -> str:
"""Run a read-only SELECT query against the Movielens database."""
try:
return str(con.sql(query))
except Exception as e:
return f"Error: {e}"
agent = create_deep_agent(
model=model,
name = "SQLAgent",
tools=[read_sql],
system_prompt=SYSTEM_PROMPT
)
result = agent.invoke(
{"messages": [{"role": "user", "content": "Which five genres have the most movies?"}]}
)
print(result["messages"][-1].text)
**The five genres with the most movies are:**
1. **Drama** - 4,361 movies
2. **Comedy** - 3,756 movies
3. **Thriller** - 1,894 movies
4. **Action** - 1,828 movies
5. **Romance** - 1,596 movies
**SQL Query:**
```sql
SELECT
genre,
COUNT(*) as movie_count
FROM movie_genres
GROUP BY genre
ORDER BY movie_count DESC
LIMIT 5
```
The sequence diagram below is drawn from a LangSmith trace of a single SQLAgent run, answering the question “Which five genres have the most movies?” LangSmith shows the run as a waterfall of nested spans: a model step wrapped by a stack of middleware and ending in a ChatBedrockConverse call, followed by a tools step that runs read_sql against the MovieLens database. The diagram turns that nested, time-based view into a story of who talks to whom. The user, the agent, the middleware chain, the Bedrock model and the database each get their own lifeline, and each model or tool span becomes a request and response between them. Reading it top to bottom, you can see what the waterfall makes hard to spot: the agent guessed a table that didn’t exist, spent three calls finding the real schema, and then ran the correct query. The whole run took 5.61 seconds and used about 25K tokens. The table that follows walks through each step, matching the arrow numbers in the diagram to the spans in the trace.
sequenceDiagram
autonumber
actor U as User
participant A as SQLAgent (deepagents graph)
participant MW as Middleware chain
participant LLM as ChatBedrockConverse
participant DB as read_sql (MovieLens DB)
U->>A: "Which five genres have the most movies?"
A->>A: PatchToolCallsMiddleware.before_agent
Note over MW: Each model call is wrapped by:<br/>Filesystem → SubAgent → _ParentNotifier →<br/>Summarization → AnthropicPromptCaching →<br/>BedrockPromptCaching → UnsupportedContent →<br/>ModelRefusal
rect rgba(120,160,255,0.12)
Note over A,DB: Attempt 1 — guessed schema
A->>MW: model (1.48s)
MW->>LLM: Converse
LLM-->>A: tool_call read_sql(SELECT … FROM genres JOIN movie_genres JOIN movies …)
A->>DB: read_sql
DB-->>A: Error: Table "genres" does not exist
end
rect rgba(255,180,90,0.12)
Note over A,DB: Schema discovery
A->>MW: model (0.82s)
MW->>LLM: Converse
LLM-->>A: tool_call read_sql(information_schema.tables WHERE schema='public')
A->>DB: read_sql
DB-->>A: 0 rows
A->>MW: model (0.76s)
MW->>LLM: Converse
LLM-->>A: tool_call read_sql(SELECT * FROM information_schema.tables LIMIT 10)
A->>DB: read_sql
DB-->>A: table list (incl. movie_genres)
A->>MW: model
MW->>LLM: Converse
LLM-->>A: tool_call read_sql(SELECT * FROM movie_genres LIMIT 5)
A->>DB: read_sql
DB-->>A: columns movieId, genre (e.g. 1 | Adventure)
end
rect rgba(110,200,140,0.12)
Note over A,DB: Final query
A->>MW: model
MW->>LLM: Converse
LLM-->>A: tool_call read_sql(SELECT genre, COUNT(*) … GROUP BY genre ORDER BY … LIMIT 5)
A->>DB: read_sql
DB-->>A: top 5 genres with counts
A->>MW: model
MW->>LLM: Converse
LLM-->>A: Final answer
end
A-->>U: 1. Drama – 4,361 · 2. Comedy – 3,756 · …
Note over U,DB: Total 5.61s · ~25.24K tokens
Here’s the diagram step by step (numbers match the arrows):
| Steps | From → To | What happens | Result |
|---|---|---|---|
| 1 | User → SQLAgent | Asks “Which five genres have the most movies?” | Agent run starts |
| 2 | SQLAgent (self) | PatchToolCallsMiddleware.before_agent cleans up any unfinished tool calls in the message history |
Ready to call the model |
| — | Note | Every model call goes through 8 middleware wrappers: Filesystem, SubAgent, _ParentNotifier, Summarization, AnthropicPromptCaching, BedrockPromptCaching, UnsupportedContent, ModelRefusal | Shown once to keep the diagram readable |
| 3–7 | Attempt 1: guessed schema | ||
| 3–5 | SQLAgent → Middleware → Bedrock | First model call (1.48s) | LLM writes a query that joins genres, movie_genres and movies |
| 6–7 | SQLAgent → read_sql | Runs that query | ❌ Error: table genres doesn’t exist |
| 8–22 | Schema discovery | ||
| 8–12 | SQLAgent → Bedrock → read_sql | Model call 2 (0.82s): list tables in schema public |
0 rows (filter on the wrong schema) |
| 13–17 | SQLAgent → Bedrock → read_sql | Model call 3 (0.76s): SELECT * FROM information_schema.tables LIMIT 10 |
Table list, which includes movie_genres |
| 18–22 | SQLAgent → Bedrock → read_sql | Model call 4: SELECT * FROM movie_genres LIMIT 5 |
Finds columns movieId and genre, so the genre name is stored in this table directly |
| 23–30 | Final query | ||
| 23–27 | SQLAgent → Bedrock → read_sql | Model call 5: SELECT genre, COUNT(*) … GROUP BY genre ORDER BY … LIMIT 5 |
✅ Top 5 genres with counts |
| 28–30 | SQLAgent → Bedrock | Model call 6: turns the result into a readable answer | Final answer text |
| 31 | SQLAgent → User | Returns the answer | Drama 4,361 · Comedy 3,756 · … |
| — | Note | Whole run | 5.61s, about 25.24K tokens |
What this shows: the agent didn’t look up the schema first. It guessed table names, and that cost 4 extra model and tool calls before the correct query ran. If you give it the schema in the system prompt, or have it list tables first, it should need only 2 model calls.
# %%sql
# SELECT
# genre,
# COUNT(*) as movie_count
# FROM movie_genres
# GROUP BY genre
# ORDER BY movie_count DESC
# LIMIT 5;
MCP
MCP lets your agent’s tools live in a separate program (an MCP server). The agent loads them over a connection, and once loaded they are ordinary LangChain tools. The agent can’t tell them apart from tools written with @tool.
| Tools | MCP |
|---|---|
| Tool is a function inside the agent’s code | Tool lives in a separate server process |
| Used by one agent | Reusable by any MCP client (other agents, IDEs, Claude, etc.) |
| Synchronous is fine | Async (async/await), because it talks to another program |
Architecture
Local MCP Server (Example)
Here the local MCP server. Run this server uv run python mcp/tip/tip_server.py in bash prompt:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("tips", log_level="WARNING")
@mcp.tool()
def calc_tip(amount: float, pct: float = 15) -> float:
"""Return the tip for a bill amount at the given percentage."""
return round(amount * pct / 100, 2)
@mcp.tool()
def split_bill(total: float, people: int) -> float:
"""Split a total evenly between people."""
return round(total / people, 2)
if __name__ == "__main__":
mcp.run() # waits for a client on stdio
Here the agent example to access the MCP server:
import asyncio, sys
from pathlib import Path
from deepagents import create_deep_agent
from langchain.chat_models import init_chat_model
from langchain_mcp_adapters.client import MultiServerMCPClient
model = init_chat_model("au.anthropic.claude-haiku-4-5-20251001-v1:0",
model_provider="bedrock_converse",
region_name="ap-southeast-2"
)
SERVER = Path(__file__).parent / "tip_server.py"
async def main():
client = MultiServerMCPClient({
"tips": {
"transport": "stdio",
"command": sys.executable, # the client launches the server itself
"args": [str(SERVER)],
}
})
tools = await client.get_tools()
print([t.name for t in tools]) # ['calc_tip', 'split_bill']
agent = create_deep_agent(model=model, tools=tools)
result = await agent.ainvoke({"messages": [
{"role": "user", "content": "Bill is $64 for 4 people with a 18% tip. How much each?"}
]})
print(result["messages"][-1].text)
asyncio.run(main())
%%bash
uv run python ../mcp/tip/agent.py
['calc_tip', 'split_bill']
Here's the breakdown:
- **Tip (18%):** $11.52
- **Total bill + tip:** $64 + $11.52 = $75.52
- **Per person:** $75.52 ÷ 4 = **$18.88 each**
How a tool call flows:
sequenceDiagram
participant U as User
participant A as Deep Agent (LLM)
participant C as MultiServerMCPClient
participant S as MCP Server
Note over C,S: Startup
C->>S: list tools
S-->>C: tool names + JSON schemas
C-->>A: wrapped as BaseTool objects
Note over U,S: Runtime
U->>A: "What's 18% tip on $64?"
A->>C: tool call: calc_tip(amount=64, pct=18)
C->>S: call_tool (fresh session by default)
S-->>C: 11.52
C-->>A: ToolMessage("11.52")
A-->>U: "The tip is $11.52"
The result is added to the message history as a normal ToolMessage, and the model decides what to do next, exactly as with a local tool.
Security: with
stdio, the client runs whatever command you give it. Only point it at servers you trust.
Remote MCP (Example)
Here the way to access remote AWS documentation:
import json
from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient({
"aws-knowledge": {
"transport": "streamable_http",
"url": "https://knowledge-mcp.global.api.aws",
}
})
tools = await client.get_tools()
for t in tools:
print(t.name, "\n", json.dumps(t.args, indent=2), "\n")
aws___read_documentation
{
"requests": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Doc URL with allow-listed prefix; use exact URL from search."
},
"max_length": {
"type": "integer",
"description": "Chars returned (default 10000)."
},
"start_index": {
"type": "integer",
"description": "Char offset (default 0). Use prior `end_index` to continue, or TOC offset to jump."
}
},
"required": [
"url"
]
},
"description": "List of `{url, max_length?, start_index?}`. Batch 2-5."
}
}
aws___search_documentation
{
"limit": {
"type": "integer",
"description": "Maximum number of results to return (default 4)."
},
"search_phrase": {
"type": "string",
"description": "Keywords; preserve exact error strings and all task terms verbatim."
},
"topics": {
"type": "array",
"items": {
"type": "string",
"description": "topic name"
},
"description": "Up to 3 from: reference_documentation, current_awareness, troubleshooting, amplify_docs, cdk_docs, cdk_constructs, cloudformation, agent_skills, strands_docs, general. Default [\"general\"]."
}
}
aws___list_regions
{}
aws___get_regional_availability
{
"regions": {
"type": "array",
"items": {
"type": "string"
},
"description": "AWS region codes (max 10). Multi-region requires `filters`; single-region supports `next_token`."
},
"resource_type": {
"type": "string",
"description": "Required: 'product' | 'api' | 'cfn'."
},
"filters": {
"type": "array",
"items": {
"type": "string"
},
"description": "Use exact AWS product or sub-feature name.\n\n- product: 'Amazon Bedrock' (service), or sub-features like 'Comprehend Auto Scaling', 'Latency-Based Routing', 'PrivateLink Support'. When the user names a specific sub-feature, filter on the sub-feature -- do NOT generalize to the parent service ('Amazon Comprehend'); that returns availability for the wrong scope.\n- api: 'SdkServiceId+Operation' (e.g. 'CloudFormation+CreateStack', 'IAM+GetSSHPublicKey') or 'SdkServiceId' (e.g. 'EC2'). Use a literal '+' between service and operation -- not space, colon, or hyphen.\n- cfn: 'AWS::EC2::Instance', 'AWS::Lambda::Function'.\n\nInclude every region the user named; don't add filters they didn't request.\n\nValues must EXACTLY match AWS's catalog (e.g. 'AWS Lambda', not 'Lambda' or 'AWS Lambda Service'). If unsure of the exact name, first call once for one region with NO filters to list valid names, then filter on the exact match."
},
"next_token": {
"type": "string",
"description": "Pagination token. Single-region, no filters only."
},
"region": {
"type": "string",
"description": "Unused; use `regions`."
}
}
aws___retrieve_skill
{
"file": {
"type": "string",
"description": "Optional file path within the skill, copied as cited (e.g. `references/architecture.md`). Don't add or strip a `references/` prefix. Omit for SKILL.md."
},
"skill_name": {
"type": "string",
"description": "Required. Exact `skill_name` from a search_documentation result, copied verbatim. Do not invent or modify."
}
}
Let agent to answer the question:
agent = create_deep_agent(
model=model,
tools=tools,
system_prompt="Answer AWS questions using the documentation tools. "
"Cite the documentation URLs you used.",
)
response = await agent.ainvoke({
"messages": [{
"role": "user",
"content": "How do I configure an S3 lifecycle rule to move objects "
"to Glacier after 30 days? Show the JSON."
}]
})
print(response["messages"][-1].content)
Perfect! I have the JSON structure. Let me create a comprehensive response with the JSON example for moving objects to Glacier after 30 days.
Based on the AWS documentation, here's how to configure an S3 lifecycle rule to move objects to Glacier after 30 days:
## JSON Configuration
```json
{
"Rules": [
{
"ID": "MoveToGlacierAfter30Days",
"Filter": {
"Prefix": ""
},
"Status": "Enabled",
"Transitions": [
{
"Days": 30,
"StorageClass": "GLACIER"
}
]
}
]
}
```
## Using with boto3 (Python)
```python
import boto3
s3 = boto3.client('s3')
lifecycle_config = {
'Rules': [
{
'ID': 'MoveToGlacierAfter30Days',
'Filter': {
'Prefix': '' # Apply to all objects
},
'Status': 'Enabled',
'Transitions': [
{
'Days': 30,
'StorageClass': 'GLACIER'
}
]
}
]
}
s3.put_bucket_lifecycle_configuration(
Bucket='your-bucket-name',
LifecycleConfiguration=lifecycle_config
)
```
## Key Configuration Details
- **Days**: 30 — Objects transition after 30 days of creation
- **StorageClass**: `GLACIER` — Transitions to S3 Glacier Flexible Retrieval (also accepts `GLACIER_IR` for Instant Retrieval, `DEEP_ARCHIVE`, etc.)
- **Filter.Prefix**: Empty string `""` means the rule applies to all objects in the bucket
- **Status**: Must be `"Enabled"` for the rule to take effect
## Optional Enhancements
If you want to apply the rule only to objects with a specific prefix:
```json
"Filter": {
"Prefix": "logs/"
}
```
Or if you want multiple transitions (tiering):
```json
"Transitions": [
{
"Days": 30,
"StorageClass": "STANDARD_IA" // Move to infrequent access first
},
{
"Days": 90,
"StorageClass": "GLACIER" // Then to Glacier
}
]
```
**Reference**: [AWS S3 put_bucket_lifecycle_configuration documentation](https://docs.aws.amazon.com/boto3/latest/reference/services/s3/client/put_bucket_lifecycle_configuration.html)
Authorization
Some servers (e.g. GitHub) need sign-in. Add an auth provider next to the transport and URL; it gets and attaches credentials.
client = MultiServerMCPClient({
"github": {
"transport": "streamable_http",
"url": MCP_URL,
"auth": auth_provider, # TypeScript: authProvider
}
})
tools = await client.get_tools() # still loaded the same way
Don’t put tokens in the URL as query parameters. The lesson’s optional OAuth lab walks through the full GitHub flow.
Messages, Threads and Checkpointers
How a deep agent remembers things across separate invoke() calls. It takes three pieces working together:
- messages are the content,
- a thread is the address, and
- the checkpointer is the storage
Messages
An agent takes a list of messages and gives back a longer one. It keeps a growing history instead of a single prompt.
| Message type | Comes from | Contains |
|---|---|---|
| User message | The human | Input text |
AIMessage |
The model | Text and/or tool_calls |
ToolMessage |
A tool that ran | The tool’s result |
The final answer is result["messages"][-1].text.
Threads and Checkpointers
flowchart LR
subgraph CP["Checkpointer (e.g. MemorySaver): the storage"]
direction TB
T1["thread_id = 'alice'<br/>msgs: hi, I'm vegan, ..."]
T2["thread_id = 'bob'<br/>msgs: hi, I like steak, ..."]
end
I1["invoke(config: alice)"] --> T1
I2["invoke(config: bob)"] --> T2
S[("Store<br/>(memory shared across threads)")] -.-> T1
S -.-> T2
The lesson compares this to a detective’s conspiracy board:
| Concept | Analogy | Role |
|---|---|---|
| Checkpointer | The whole board | Stores the state of every thread |
| Thread | One red string | One conversation |
| State | Notes pinned along the string | Messages plus agent state |
thread_id |
The string’s label | Tells the board which string to load or save |
A different example (a recipe assistant):
from langgraph.checkpoint.memory import MemorySaver
agent = create_deep_agent(model=model, checkpointer=MemorySaver())
alice = {"configurable": {"thread_id": "alice"}}
bob = {"configurable": {"thread_id": "bob"}}
agent.invoke({"messages": [{"role": "user", "content": "I'm vegan."}]}, config=alice)
agent.invoke({"messages": [{"role": "user", "content": "Suggest dinner."}]}, config=alice) # vegan dish ✅
agent.invoke({"messages": [{"role": "user", "content": "Suggest dinner."}]}, config=bob) # no dietary info ❌
{'messages': [HumanMessage(content='Suggest dinner.', additional_kwargs={}, response_metadata={}, id='f49d1287-d2ea-4848-b4ff-035777fb3e11'),
AIMessage(content="I'd be happy to help suggest dinner! To give you the best recommendations, it would help to know a bit more:\n\n1. **What type of cuisine** do you're in the mood for? (e.g., Italian, Asian, Mexican, Mediterranean, etc.)\n2. **Any dietary restrictions or preferences?** (vegetarian, vegan, gluten-free, etc.)\n3. **How much time** do you have to prepare? (quick weeknight meal, leisurely dinner, etc.)\n4. **What ingredients** do you have on hand or prefer?\n5. **Are you cooking at home or looking for restaurant suggestions?**\n\nIn the meantime, here are some quick dinner ideas:\n\n- **Pasta with garlic and oil (Aglio e olio)** - simple, elegant, 15 minutes\n- **Stir-fry** - quick, customizable with whatever veggies/protein you have\n- **Tacos** - fun and flexible\n- **Sheet pan salmon with roasted vegetables** - healthy and minimal cleanup\n- **Chicken curry with rice** - flavorful and satisfying\n- **Grilled cheese and tomato soup** - comfort food classic\n\nLet me know more about your preferences and I can give you more tailored suggestions!", additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': 'edcd1596-ff32-4da5-b237-0fb4b52ff059', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Tue, 10 Nov 2026 07:43:09 GMT', 'content-type': 'application/json', 'content-length': '1374', 'connection': 'keep-alive', 'x-amzn-requestid': 'edcd1596-ff32-4da5-b237-0fb4b52ff059'}, 'RetryAttempts': 0}, 'stopReason': 'end_turn', 'metrics': {'latencyMs': [2038]}, 'model_provider': 'bedrock_converse', 'model_name': 'au.anthropic.claude-haiku-4-5-20251001-v1:0'}, id='lc_run--01a1c469-2632-70ca-88aa-a01b82cb88ef-0', tool_calls=[], invalid_tool_calls=[], usage_metadata={'input_tokens': 3202, 'output_tokens': 284, 'total_tokens': 3486, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}})],
'files': {}}
What happens in each setup (this matches the quiz answers):
| Setup | Second call sees |
|---|---|
Same thread_id with a checkpointer |
Both turns |
Same thread_id without a checkpointer |
Only its own message (the id stores nothing by itself) |
New thread_id, same checkpointer |
Only the new message |
New agent with a new MemorySaver, old thread_id |
Nothing (memory lives in the saver instance, not in the id) |
Checkpointer options:
| Checkpointer | Use for | Survives a restart? |
|---|---|---|
MemorySaver / InMemorySaver |
Demos and local development | No |
PostgresSaver, Redis-based savers |
Production | Yes |
| LangGraph Platform | Managed persistence | Yes |
Switching between them only changes the object you pass to checkpointer=.
Time travel
Every checkpoint is kept, not just the latest one, so you can resume from any earlier step:
gitGraph
commit id: "turn 1: I'm vegan"
commit id: "turn 2: suggest dinner"
branch retry
commit id: "Actually, gluten-free too"
checkout main
commit id: "turn 3: dessert?"
history = list(agent.get_state_history(alice))
for i, h in enumerate(reversed(history)):
if len(h.values['messages']) > 0:
print(
f'--- {i = } -----------------------',
f'{h.values['messages'][-1].content}',
f'----------------------------------',
sep="\n"
)
--- i = 1 -----------------------
I'm vegan.
----------------------------------
--- i = 2 -----------------------
I'm vegan.
----------------------------------
--- i = 3 -----------------------
That's great! Is there something specific I can help you with related to being vegan? For example, I could:
- Help you find vegan recipes or meal ideas
- Look up information about vegan nutrition
- Find vegan restaurants or products
- Help you understand vegan lifestyle topics
- Search for vegan-related content in files or documents
- Or something else entirely?
Just let me know what you'd like assistance with!
----------------------------------
--- i = 4 -----------------------
That's great! Is there something specific I can help you with related to being vegan? For example, I could:
- Help you find vegan recipes or meal ideas
- Look up information about vegan nutrition
- Find vegan restaurants or products
- Help you understand vegan lifestyle topics
- Search for vegan-related content in files or documents
- Or something else entirely?
Just let me know what you'd like assistance with!
----------------------------------
--- i = 5 -----------------------
That's great! Is there something specific I can help you with related to being vegan? For example, I could:
- Help you find vegan recipes or meal ideas
- Look up information about vegan nutrition
- Find vegan restaurants or products
- Help you understand vegan lifestyle topics
- Search for vegan-related content in files or documents
- Or something else entirely?
Just let me know what you'd like assistance with!
----------------------------------
--- i = 6 -----------------------
Suggest dinner.
----------------------------------
--- i = 7 -----------------------
Suggest dinner.
----------------------------------
--- i = 8 -----------------------
Here are some great vegan dinner ideas:
**Quick & Easy:**
- **Pasta Aglio e Olio** - Spaghetti with garlic, olive oil, red pepper flakes, and fresh parsley
- **Stir-fry** - Tofu or chickpeas with broccoli, bell peppers, and snap peas in a soy-ginger sauce over rice
- **Tacos** - Seasoned black beans or lentils in tortillas with salsa, guacamole, and vegetables
**Heartier Options:**
- **Lentil curry** - Red lentils with coconut milk, tomatoes, and spices served with rice or naan
- **Buddha bowl** - Roasted vegetables, quinoa, hummus, and tahini dressing
- **Chili** - Bean and veggie chili with cornbread
- **Veggie burger** - Homemade or store-bought with all your favorite toppings
**Comfort Food:**
- **Risotto** - Mushroom or vegetable risotto with nutritional yeast for that cheesy flavor
- **Pizza** - Dairy-free cheese or just loaded with vegetables and fresh herbs
- **Mac and cheese** - Made with cashew cream or store-bought vegan cheese sauce
**Lighter Option:**
- **Buddha salad** - Mixed greens, roasted chickpeas, avocado, sweet potato, and tahini dressing
What sounds good to you? Do you have any dietary preferences, ingredients on hand, or time constraints I should know about?
----------------------------------
--- i = 9 -----------------------
Here are some great vegan dinner ideas:
**Quick & Easy:**
- **Pasta Aglio e Olio** - Spaghetti with garlic, olive oil, red pepper flakes, and fresh parsley
- **Stir-fry** - Tofu or chickpeas with broccoli, bell peppers, and snap peas in a soy-ginger sauce over rice
- **Tacos** - Seasoned black beans or lentils in tortillas with salsa, guacamole, and vegetables
**Heartier Options:**
- **Lentil curry** - Red lentils with coconut milk, tomatoes, and spices served with rice or naan
- **Buddha bowl** - Roasted vegetables, quinoa, hummus, and tahini dressing
- **Chili** - Bean and veggie chili with cornbread
- **Veggie burger** - Homemade or store-bought with all your favorite toppings
**Comfort Food:**
- **Risotto** - Mushroom or vegetable risotto with nutritional yeast for that cheesy flavor
- **Pizza** - Dairy-free cheese or just loaded with vegetables and fresh herbs
- **Mac and cheese** - Made with cashew cream or store-bought vegan cheese sauce
**Lighter Option:**
- **Buddha salad** - Mixed greens, roasted chickpeas, avocado, sweet potato, and tahini dressing
What sounds good to you? Do you have any dietary preferences, ingredients on hand, or time constraints I should know about?
----------------------------------
--- i = 10 -----------------------
Here are some great vegan dinner ideas:
**Quick & Easy:**
- **Pasta Aglio e Olio** - Spaghetti with garlic, olive oil, red pepper flakes, and fresh parsley
- **Stir-fry** - Tofu or chickpeas with broccoli, bell peppers, and snap peas in a soy-ginger sauce over rice
- **Tacos** - Seasoned black beans or lentils in tortillas with salsa, guacamole, and vegetables
**Heartier Options:**
- **Lentil curry** - Red lentils with coconut milk, tomatoes, and spices served with rice or naan
- **Buddha bowl** - Roasted vegetables, quinoa, hummus, and tahini dressing
- **Chili** - Bean and veggie chili with cornbread
- **Veggie burger** - Homemade or store-bought with all your favorite toppings
**Comfort Food:**
- **Risotto** - Mushroom or vegetable risotto with nutritional yeast for that cheesy flavor
- **Pizza** - Dairy-free cheese or just loaded with vegetables and fresh herbs
- **Mac and cheese** - Made with cashew cream or store-bought vegan cheese sauce
**Lighter Option:**
- **Buddha salad** - Mixed greens, roasted chickpeas, avocado, sweet potato, and tahini dressing
What sounds good to you? Do you have any dietary preferences, ingredients on hand, or time constraints I should know about?
----------------------------------
--- i = 11 -----------------------
Actually, gluten-free too.
----------------------------------
--- i = 12 -----------------------
Actually, gluten-free too.
----------------------------------
--- i = 13 -----------------------
Perfect! Here are some great vegan and gluten-free dinner ideas:
**Quick & Easy:**
- **Stir-fry** - Tofu or chickpeas with broccoli, bell peppers, and snap peas in a soy-ginger sauce over rice
- **Tacos** - Seasoned black beans or lentils in corn tortillas with salsa, guacamole, and vegetables
- **Rice bowls** - Cilantro lime rice with roasted vegetables, beans, and avocado
**Heartier Options:**
- **Lentil curry** - Red lentils with coconut milk, tomatoes, and spices served with rice
- **Buddha bowl** - Roasted vegetables, quinoa, hummus, and tahini dressing
- **Chili** - Bean and veggie chili (serve with cornbread made from gluten-free flour, or just enjoy without bread)
- **Veggie burger** - Gluten-free bun or lettuce wrap with all your favorite toppings
**Comfort Food:**
- **Risotto** - Mushroom or vegetable risotto with nutritional yeast
- **Pizza** - Gluten-free crust (store-bought or homemade) with dairy-free cheese or loaded with vegetables
- **Polenta** - Creamy polenta with sautéed mushrooms and vegetables
**Lighter Option:**
- **Buddha salad** - Mixed greens, roasted chickpeas, avocado, sweet potato, and tahini dressing
Most of these are naturally gluten-free or easily adaptable. Do any of these appeal to you, or would you like a specific recipe for something?
----------------------------------
--- i = 14 -----------------------
Perfect! Here are some great vegan and gluten-free dinner ideas:
**Quick & Easy:**
- **Stir-fry** - Tofu or chickpeas with broccoli, bell peppers, and snap peas in a soy-ginger sauce over rice
- **Tacos** - Seasoned black beans or lentils in corn tortillas with salsa, guacamole, and vegetables
- **Rice bowls** - Cilantro lime rice with roasted vegetables, beans, and avocado
**Heartier Options:**
- **Lentil curry** - Red lentils with coconut milk, tomatoes, and spices served with rice
- **Buddha bowl** - Roasted vegetables, quinoa, hummus, and tahini dressing
- **Chili** - Bean and veggie chili (serve with cornbread made from gluten-free flour, or just enjoy without bread)
- **Veggie burger** - Gluten-free bun or lettuce wrap with all your favorite toppings
**Comfort Food:**
- **Risotto** - Mushroom or vegetable risotto with nutritional yeast
- **Pizza** - Gluten-free crust (store-bought or homemade) with dairy-free cheese or loaded with vegetables
- **Polenta** - Creamy polenta with sautéed mushrooms and vegetables
**Lighter Option:**
- **Buddha salad** - Mixed greens, roasted chickpeas, avocado, sweet potato, and tahini dressing
Most of these are naturally gluten-free or easily adaptable. Do any of these appeal to you, or would you like a specific recipe for something?
----------------------------------
print(history[1].values['messages'][-1].content)
Here are some great vegan dinner ideas:
**Quick & Easy:**
- **Pasta Aglio e Olio** - Spaghetti with garlic, olive oil, red pepper flakes, and fresh parsley
- **Stir-fry** - Tofu or chickpeas with broccoli, bell peppers, and snap peas in a soy-ginger sauce over rice
- **Tacos** - Seasoned black beans or lentils in tortillas with salsa, guacamole, and vegetables
**Heartier Options:**
- **Lentil curry** - Red lentils with coconut milk, tomatoes, and spices served with rice or naan
- **Buddha bowl** - Roasted vegetables, quinoa, hummus, and tahini dressing
- **Chili** - Bean and veggie chili with cornbread
- **Veggie burger** - Homemade or store-bought with all your favorite toppings
**Comfort Food:**
- **Risotto** - Mushroom or vegetable risotto with nutritional yeast for that cheesy flavor
- **Pizza** - Dairy-free cheese or just loaded with vegetables and fresh herbs
- **Mac and cheese** - Made with cashew cream or store-bought vegan cheese sauce
**Lighter Option:**
- **Buddha salad** - Mixed greens, roasted chickpeas, avocado, sweet potato, and tahini dressing
What sounds good to you? Do you have any dietary preferences, ingredients on hand, or time constraints I should know about?
older = history[1] # one step back
agent.invoke({"messages": [{"role": "user", "content": "Actually, gluten-free too."}]},
config=older.config) # carries a checkpoint_id, so this branches
{'messages': [HumanMessage(content="I'm vegan.", additional_kwargs={}, response_metadata={}, id='5bc23525-b0d3-4b39-982b-2ada9bc0013f'),
AIMessage(content="That's great! Is there something specific I can help you with related to being vegan? For example, I could:\n\n- Help you find vegan recipes or meal ideas\n- Look up information about vegan nutrition\n- Find vegan restaurants or products\n- Help you understand vegan lifestyle topics\n- Search for vegan-related content in files or documents\n- Or something else entirely?\n\nJust let me know what you'd like assistance with!", additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': 'a98a6107-5cbc-487b-be95-6c1cca52f7f5', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Tue, 10 Nov 2026 07:43:05 GMT', 'content-type': 'application/json', 'content-length': '742', 'connection': 'keep-alive', 'x-amzn-requestid': 'a98a6107-5cbc-487b-be95-6c1cca52f7f5'}, 'RetryAttempts': 0}, 'stopReason': 'end_turn', 'metrics': {'latencyMs': [1039]}, 'model_provider': 'bedrock_converse', 'model_name': 'au.anthropic.claude-haiku-4-5-20251001-v1:0'}, id='lc_run--01a1c469-1782-73e5-8c62-79300b8f1607-0', tool_calls=[], invalid_tool_calls=[], usage_metadata={'input_tokens': 3203, 'output_tokens': 97, 'total_tokens': 3300, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}}),
HumanMessage(content='Suggest dinner.', additional_kwargs={}, response_metadata={}, id='a7e8bad6-a7de-4412-a431-959e804061ff'),
AIMessage(content='Here are some great vegan dinner ideas:\n\n**Quick & Easy:**\n- **Pasta Aglio e Olio** - Spaghetti with garlic, olive oil, red pepper flakes, and fresh parsley\n- **Stir-fry** - Tofu or chickpeas with broccoli, bell peppers, and snap peas in a soy-ginger sauce over rice\n- **Tacos** - Seasoned black beans or lentils in tortillas with salsa, guacamole, and vegetables\n\n**Heartier Options:**\n- **Lentil curry** - Red lentils with coconut milk, tomatoes, and spices served with rice or naan\n- **Buddha bowl** - Roasted vegetables, quinoa, hummus, and tahini dressing\n- **Chili** - Bean and veggie chili with cornbread\n- **Veggie burger** - Homemade or store-bought with all your favorite toppings\n\n**Comfort Food:**\n- **Risotto** - Mushroom or vegetable risotto with nutritional yeast for that cheesy flavor\n- **Pizza** - Dairy-free cheese or just loaded with vegetables and fresh herbs\n- **Mac and cheese** - Made with cashew cream or store-bought vegan cheese sauce\n\n**Lighter Option:**\n- **Buddha salad** - Mixed greens, roasted chickpeas, avocado, sweet potato, and tahini dressing\n\nWhat sounds good to you? Do you have any dietary preferences, ingredients on hand, or time constraints I should know about?', additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': '8912b6b0-fa40-4bd7-b1ea-e4a7b8024042', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Tue, 10 Nov 2026 07:43:07 GMT', 'content-type': 'application/json', 'content-length': '1543', 'connection': 'keep-alive', 'x-amzn-requestid': '8912b6b0-fa40-4bd7-b1ea-e4a7b8024042'}, 'RetryAttempts': 0}, 'stopReason': 'end_turn', 'metrics': {'latencyMs': [2421]}, 'model_provider': 'bedrock_converse', 'model_name': 'au.anthropic.claude-haiku-4-5-20251001-v1:0'}, id='lc_run--01a1c469-1c9f-73ec-9363-d47bfd4531ea-0', tool_calls=[], invalid_tool_calls=[], usage_metadata={'input_tokens': 3307, 'output_tokens': 362, 'total_tokens': 3669, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}}),
HumanMessage(content='Actually, gluten-free too.', additional_kwargs={}, response_metadata={}, id='d86cb580-127c-4da4-8d82-d4984a908242'),
AIMessage(content='Perfect! Here are some great vegan and gluten-free dinner ideas:\n\n**Quick & Easy:**\n- **Stir-fry** - Tofu or chickpeas with broccoli, bell peppers, and snap peas in a soy-ginger sauce over rice\n- **Tacos** - Seasoned black beans or lentils in corn tortillas with salsa, guacamole, and vegetables\n- **Rice bowls** - Cilantro lime rice with roasted vegetables, beans, and avocado\n\n**Heartier Options:**\n- **Lentil curry** - Red lentils with coconut milk, tomatoes, and spices served with rice\n- **Buddha bowl** - Roasted vegetables, quinoa, hummus, and tahini dressing\n- **Chili** - Bean and veggie chili (serve with cornbread made from gluten-free flour, or just enjoy without bread)\n- **Veggie burger** - Gluten-free bun or lettuce wrap with all your favorite toppings\n\n**Comfort Food:**\n- **Risotto** - Mushroom or vegetable risotto with nutritional yeast\n- **Pizza** - Gluten-free crust (store-bought or homemade) with dairy-free cheese or loaded with vegetables\n- **Polenta** - Creamy polenta with sautéed mushrooms and vegetables\n\n**Lighter Option:**\n- **Buddha salad** - Mixed greens, roasted chickpeas, avocado, sweet potato, and tahini dressing\n\nMost of these are naturally gluten-free or easily adaptable. Do any of these appeal to you, or would you like a specific recipe for something?', additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': '9bc4529f-04d3-443f-a88c-fdf68c3b9cd9', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Tue, 10 Nov 2026 07:43:38 GMT', 'content-type': 'application/json', 'content-length': '1635', 'connection': 'keep-alive', 'x-amzn-requestid': '9bc4529f-04d3-443f-a88c-fdf68c3b9cd9'}, 'RetryAttempts': 0}, 'stopReason': 'end_turn', 'metrics': {'latencyMs': [1869]}, 'model_provider': 'bedrock_converse', 'model_name': 'au.anthropic.claude-haiku-4-5-20251001-v1:0'}, id='lc_run--01a1c469-9704-7305-a4af-2221b1005d15-0', tool_calls=[], invalid_tool_calls=[], usage_metadata={'input_tokens': 3680, 'output_tokens': 391, 'total_tokens': 4071, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}})],
'files': {}}
Resuming from an older checkpoint doesn’t delete anything; newer checkpoints stay. It’s useful for fixing a bad turn, trying a different reply, or inspecting state at a past step.
bob_history = list(agent.get_state_history(bob))
for i, h in enumerate(reversed(bob_history)):
if len(h.values['messages']) > 0:
print(
f'--- {i = } -----------------------',
f'{h.values['messages'][-1].content}',
f'----------------------------------',
sep="\n"
)
--- i = 1 -----------------------
Suggest dinner.
----------------------------------
--- i = 2 -----------------------
Suggest dinner.
----------------------------------
--- i = 3 -----------------------
I'd be happy to help suggest dinner! To give you the best recommendations, it would help to know a bit more:
1. **What type of cuisine** do you're in the mood for? (e.g., Italian, Asian, Mexican, Mediterranean, etc.)
2. **Any dietary restrictions or preferences?** (vegetarian, vegan, gluten-free, etc.)
3. **How much time** do you have to prepare? (quick weeknight meal, leisurely dinner, etc.)
4. **What ingredients** do you have on hand or prefer?
5. **Are you cooking at home or looking for restaurant suggestions?**
In the meantime, here are some quick dinner ideas:
- **Pasta with garlic and oil (Aglio e olio)** - simple, elegant, 15 minutes
- **Stir-fry** - quick, customizable with whatever veggies/protein you have
- **Tacos** - fun and flexible
- **Sheet pan salmon with roasted vegetables** - healthy and minimal cleanup
- **Chicken curry with rice** - flavorful and satisfying
- **Grilled cheese and tomato soup** - comfort food classic
Let me know more about your preferences and I can give you more tailored suggestions!
----------------------------------
--- i = 4 -----------------------
I'd be happy to help suggest dinner! To give you the best recommendations, it would help to know a bit more:
1. **What type of cuisine** do you're in the mood for? (e.g., Italian, Asian, Mexican, Mediterranean, etc.)
2. **Any dietary restrictions or preferences?** (vegetarian, vegan, gluten-free, etc.)
3. **How much time** do you have to prepare? (quick weeknight meal, leisurely dinner, etc.)
4. **What ingredients** do you have on hand or prefer?
5. **Are you cooking at home or looking for restaurant suggestions?**
In the meantime, here are some quick dinner ideas:
- **Pasta with garlic and oil (Aglio e olio)** - simple, elegant, 15 minutes
- **Stir-fry** - quick, customizable with whatever veggies/protein you have
- **Tacos** - fun and flexible
- **Sheet pan salmon with roasted vegetables** - healthy and minimal cleanup
- **Chicken curry with rice** - flavorful and satisfying
- **Grilled cheese and tomato soup** - comfort food classic
Let me know more about your preferences and I can give you more tailored suggestions!
----------------------------------
Important beaviour:
- Two calls with the same
thread_idand a checkpointer. The second result holds the messages from both calls, because the saved history carries on. - The same
thread_idwithout a checkpointer. The second call sees only its own message. Thethread_idis just an address and stores nothing by itself. - Calling with an older snapshot’s
config. The thread carries on from that earlier step, and the newer checkpoints are kept, not deleted. - A new
thread_idon the same checkpointer. The agent sees only the new message, because each thread keeps its own separate state.
Human-in-the-loop in Deep Agents
Tools can take actions, and checkpointers can save and resume an agent’s state3. Put together, they let an agent stop before a sensitive tool runs, such as sending an email, updating a database or deleting a file, and wait for a person to decide what happens next. The main reason is safety. The agent can also suspend while it waits, so it doesn’t have to stay active.
The four HITL building blocks
| Piece | Role |
|---|---|
interrupt_on |
Lists the tools that need human review before they run |
checkpointer |
Saves the paused state; the lesson uses MemorySaver() for local development |
thread_id |
Identifies the saved run, so you resume with the same value |
Command(resume=...) |
Continues the paused run with the human’s decision |
Only the tools named in
interrupt_onpause. Any other tool, such asread_file, runs as normal.
Pause, review and resume sequence
There are three phases:
- First, the model proposes a gated call.
- Next, the agent pauses, returns the pending call for review, and the checkpointer saves its state.
- Finally, the human resumes the run with a decision.
sequenceDiagram
participant M as Model
participant A as Agent
participant C as Checkpointer
participant H as Human
participant T as Tool
M->>A: Propose send_email call
A->>C: Save paused state under thread_id
A->>H: Return pending tool call for review
H->>A: Command(resume=decision)
A->>C: Load state for the same thread_id
alt approve or edit
A->>T: Execute with original or edited arguments
else reject
A->>M: Rejection feedback as the tool result
else respond
A->>M: Human message as the tool result
end
Approve, edit, reject and respond decisions
Each tool’s allowed_decisions list controls which choices a reviewer has:
| Decision | Effect | Example |
|---|---|---|
approve |
Runs the tool with the agent’s original arguments | Send an email draft exactly as written |
edit |
Runs the tool with revised arguments | Change the recipient before sending |
reject |
Skips the tool and sends feedback to the agent | Deny a file deletion and explain why |
respond |
Skips the tool and returns the human’s message as the tool result | ask_user("What's your budget?") is answered with “$500” |
Warning: Don’t use
respondto block a side-effecting tool such assend_emailordelete_file. The model reads the reply as a successful result and may think the action already happened. Userejectinstead.
Tip: Keep argument edits small. The lesson warns that large changes can lead the model to rethink its plan and call the tool again.
Key points
- HITL pauses before a gated tool runs, so a person can review the call before any side effect happens.
interrupt_onchooses which tools to gate. A checkpointer and a sharedthread_idletCommand(resume=...)pick up where the run stopped.rejectblocks actions.respondis only for tools where the human’s answer is the result.- Small edits are safer than large ones.
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import Command
def lookup_order(order_id: str) -> str:
"""Look up an order's details."""
return f"Order {order_id}: 2x headphones, $180 total, delivered 3 days agor"
def issue_refund(order_id: str, amount: float, reason: str) -> str:
"""Issue a refund to the customer"""
return f"Refund ${amount} for order{order_id} ({reason})"
agent = create_deep_agent(
model=model,
tools=[lookup_order, issue_refund],
system_prompt="You are a customer support agent. Look up orders before refunding.",
interrupt_on= {
"issue_refund": {"allowed_decisions": ["approve", "edit", "reject"]},
"lookup_order": False, #safe, no approval needed
},
checkpointer=MemorySaver(),
)
if agent try to call issue_refund, pause befoe the tool executes and surface the pending action by human.
import uuid
config = {"configurable": {"thread_id": f"support-{uuid.uuid4()}"}}
# --- Run until it pauses ---
result = agent.invoke(
{"messages": [{"role": "user",
"content": "Order 1042 arrived broken, please refund it in full."}]},
config=config,
)
print(result)
{'messages': [HumanMessage(content='Order 1042 arrived broken, please refund it in full.', additional_kwargs={}, response_metadata={}, id='59a16118-9450-42dd-825f-6533dfb190d1'), AIMessage(content=[{'type': 'text', 'text': "I'll look up the order details first to get the full refund amount."}, {'type': 'tool_use', 'name': 'lookup_order', 'input': {'order_id': '1042'}, 'id': 'tooluse_hd01TgbPEBiiwJMuQUJAQ7'}], additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': '38e06b7e-7964-45ba-84f9-e9fee22437fb', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Wed, 11 Nov 2026 09:36:28 GMT', 'content-type': 'application/json', 'content-length': '510', 'connection': 'keep-alive', 'x-amzn-requestid': '38e06b7e-7964-45ba-84f9-e9fee22437fb'}, 'RetryAttempts': 0}, 'stopReason': 'tool_use', 'metrics': {'latencyMs': [785]}, 'model_provider': 'bedrock_converse', 'model_name': 'au.anthropic.claude-haiku-4-5-20251001-v1:0'}, id='lc_run--01a1c9f7-45e0-7358-8c6a-3e68d8e55776-0', tool_calls=[{'name': 'lookup_order', 'args': {'order_id': '1042'}, 'id': 'tooluse_hd01TgbPEBiiwJMuQUJAQ7', 'type': 'tool_call'}], invalid_tool_calls=[], usage_metadata={'input_tokens': 3370, 'output_tokens': 73, 'total_tokens': 3443, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}}), ToolMessage(content='Order 1042: 2x headphones, $180 total, delivered 3 days agor', name='lookup_order', id='9b28c568-72da-446f-b439-ec7155091fa8', tool_call_id='tooluse_hd01TgbPEBiiwJMuQUJAQ7'), AIMessage(content=[{'type': 'text', 'text': "Now I'll issue the full refund of $180 for the broken order."}, {'type': 'tool_use', 'name': 'issue_refund', 'input': {'order_id': '1042', 'amount': 180, 'reason': 'Item arrived broken'}, 'id': 'tooluse_EDP61lMq6EiCpJSowaiGKJ'}], additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': '725f4f78-8517-4560-a45f-33afd53cc38b', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Wed, 11 Nov 2026 09:36:29 GMT', 'content-type': 'application/json', 'content-length': '548', 'connection': 'keep-alive', 'x-amzn-requestid': '725f4f78-8517-4560-a45f-33afd53cc38b'}, 'RetryAttempts': 0}, 'stopReason': 'tool_use', 'metrics': {'latencyMs': [904]}, 'model_provider': 'bedrock_converse', 'model_name': 'au.anthropic.claude-haiku-4-5-20251001-v1:0'}, id='lc_run--01a1c9f7-490f-705a-a141-a03e84f223df-0', tool_calls=[{'name': 'issue_refund', 'args': {'order_id': '1042', 'amount': 180, 'reason': 'Item arrived broken'}, 'id': 'tooluse_EDP61lMq6EiCpJSowaiGKJ', 'type': 'tool_call'}], invalid_tool_calls=[], usage_metadata={'input_tokens': 3478, 'output_tokens': 111, 'total_tokens': 3589, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}})], 'files': {}, '__interrupt__': [Interrupt(value={'action_requests': [{'name': 'issue_refund', 'args': {'order_id': '1042', 'amount': 180, 'reason': 'Item arrived broken'}, 'description': "Tool execution requires approval\n\nTool: issue_refund\nArgs: {'order_id': '1042', 'amount': 180, 'reason': 'Item arrived broken'}"}], 'review_configs': [{'action_name': 'issue_refund', 'allowed_decisions': ['approve', 'edit', 'reject']}]}, id='04be836d7a01bc7a879ec069c85daf9f', response_schema=None)]}
for i, h in enumerate(result['messages']):
print(
f'--- {i} => {type(h)} ---',
f'{h.content}',
f'----------------------------------',
sep="\n")
--- 0 => <class 'langchain_core.messages.human.HumanMessage'> ---
Order 1042 arrived broken, please refund it in full.
----------------------------------
--- 1 => <class 'langchain_core.messages.ai.AIMessage'> ---
[{'type': 'text', 'text': "I'll look up the order details first to get the full refund amount."}, {'type': 'tool_use', 'name': 'lookup_order', 'input': {'order_id': '1042'}, 'id': 'tooluse_hd01TgbPEBiiwJMuQUJAQ7'}]
----------------------------------
--- 2 => <class 'langchain_core.messages.tool.ToolMessage'> ---
Order 1042: 2x headphones, $180 total, delivered 3 days agor
----------------------------------
--- 3 => <class 'langchain_core.messages.ai.AIMessage'> ---
[{'type': 'text', 'text': "Now I'll issue the full refund of $180 for the broken order."}, {'type': 'tool_use', 'name': 'issue_refund', 'input': {'order_id': '1042', 'amount': 180, 'reason': 'Item arrived broken'}, 'id': 'tooluse_EDP61lMq6EiCpJSowaiGKJ'}]
----------------------------------
Inspect what the agent wants to do:
flatten(result.get("__interrupt__")[-1].value)
{'action_requests.0.name': 'issue_refund',
'action_requests.0.args.order_id': '1042',
'action_requests.0.args.amount': 180,
'action_requests.0.args.reason': 'Item arrived broken',
'action_requests.0.description': "Tool execution requires approval\n\nTool: issue_refund\nArgs: {'order_id': '1042', 'amount': 180, 'reason': 'Item arrived broken'}",
'review_configs.0.action_name': 'issue_refund',
'review_configs.0.allowed_decisions.0': 'approve',
'review_configs.0.allowed_decisions.1': 'edit',
'review_configs.0.allowed_decisions.2': 'reject'}
if result.get("__interrupt__"):
req = result["__interrupt__"][0].value
for action in req["action_requests"]:
print("Agent wants:", action["name"], action["args"])
Agent wants: issue_refund {'order_id': '1042', 'amount': 180, 'reason': 'Item arrived broken'}
Human decides: approve, edit, or reject, to approve:
decision = {"type": "approve"}
Reject with feedback
decision = {"type": "reject",
"message": "Ask for a photo of the damage first."}
is the code.
However, if I want edit the amount (e.g. partial refund):
# 3. Resume with an edit
decision = {"type": "edit",
"edited_action": { # <- not "edit_action"
"name": "issue_refund",
"args": {"order_id": "1042", "amount": 90.0,
"reason": "partial - one item damaged"}
}
}
result = agent.invoke(Command(resume={"decisions": [decision]}), config=config)
print(result["messages"][-1].content)
I've processed a refund for order 1042. However, I notice that a partial refund of $90 was applied by a human reviewer instead of the full $180 refund you requested. This was processed as a partial refund for one damaged item.
Since you mentioned the order arrived broken and you're requesting a full refund, you may want to clarify:
- Did only one of the two headphones arrive broken, or were both items damaged?
- If both items were damaged, you should request the remaining $90 refund to complete the full refund.
Would you like me to help you with anything else regarding this order?
The loop, rearranged with ask_user first
There is one rule for the order: the run expects one decision per pending tool call, in the same order as action_requests. Below, the ask_user branch comes first and the send_email approve/edit/reject branch comes second. Each decision is still added in request order, so the run accepts them.
Define three tools: ask_user pauses to ask you something, lookup_order runs on its own because it’s safe, and issue_refund waits for your approval.
from langgraph.types import interrupt
from langchain_core.tools import tool
@tool
def ask_user(question: str) -> str:
"""Ask the human a clarifying question and wait for their answer."""
# Pauses the graph here. Whatever you pass to Command(resume=...)
# becomes the return value of interrupt(), and goes back to the model.
return interrupt({"type": "ask_user", "question": question})
@tool
def lookup_order(order_id: str) -> str:
"""Look up an order's details."""
return f"Order {order_id}: 2x headphones, $180 total, delivered 3 days ago."
@tool
def issue_refund(order_id: str, amount: float, reason: str) -> str:
"""Issue a refund to the customer."""
return f"Refunded ${amount} for order {order_id} ({reason})."
Build the agent. interrupt_on makes issue_refund wait for your approval, and the checkpointer saves the paused state between cells.
from langgraph.checkpoint.memory import MemorySaver
agent = create_deep_agent(
model=model,
tools=[ask_user, lookup_order, issue_refund],
system_prompt=(
"You are a customer support agent. Always look up the order before refunding. "
"Never ask the user questions in your reply text. "
"If you need any clarification, you MUST call the ask_user tool, "
"one question per call. Only call issue_refund once everything is clear."
),
interrupt_on={
"issue_refund": {"allowed_decisions": ["approve", "edit", "reject"]},
},
checkpointer=MemorySaver(), # keeps state between cells
)
Short functions you call from later cells. Each one runs the agent one step and prints what it’s waiting for next.
from langgraph.types import Command, interrupt
config = None
result = None
def show(res):
"""Print what the agent is waiting for, or its final reply."""
global result
result = res
if res.get("__interrupt__"):
value = res["__interrupt__"][0].value
if value.get("type") == "ask_user":
print("❓ Agent asks:", value["question"])
print(" → run: answer('your answer')")
else:
for a in value["action_requests"]:
print("⚠️ Approval needed:", a["name"], a["args"])
print(" → run: approve() | edit(amount=...) | reject('why')")
else:
print("🤖 Agent:", res["messages"][-1].content)
print(" → done, or run: say('follow-up message')")
def answer(text):
"""Reply to an ask_user question (resume with a plain value)."""
show(agent.invoke(Command(resume=text), config=config))
def approve():
"""Approve the pending issue_refund call as-is."""
show(agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), config=config))
def edit(**changes):
"""Change the pending call's arguments, e.g. edit(amount=90.0)."""
action = result["__interrupt__"][0].value["action_requests"][0]
new_args = {**action["args"], **changes}
decision = {"type": "edit",
"edited_action": {"name": action["name"], "args": new_args}}
show(agent.invoke(Command(resume={"decisions": [decision]}), config=config))
def reject(message):
"""Block the pending call and tell the agent why."""
decision = {"type": "reject", "message": message}
show(agent.invoke(Command(resume={"decisions": [decision]}), config=config))
def say(text):
"""Send a NEW message. Use it when the run has ended, e.g. the agent asked in plain text."""
show(agent.invoke({"messages": [{"role": "user", "content": text}]}, config=config))
Creates a new thread and sends the customer’s request. Re-run this cell any time you want to start over.
import uuid
config = {"configurable": {"thread_id": f"support-{uuid.uuid4()}"}}
say("Order 1042 arrived broken, please refund it.")
❓ Agent asks: Should I refund the full $180 for both items, or just a portion of the order?
→ run: answer('your answer')
Answer the user question:
answer("Only one is broken")
⚠️ Approval needed: issue_refund {'order_id': '1042', 'amount': 90, 'reason': 'Item arrived broken'}
→ run: approve() | edit(amount=...) | reject('why')
Edit the amount to refund for approval:
edit(amount=90.0, reason="Partial refund - one of two headphones damaged")
# or approve() if the amount is already 90
🤖 Agent: Perfect! I've issued a refund of $90 for the broken headphone. The refund has been processed for order 1042.
→ done, or run: say('follow-up message')
The selected run is Turn 5 of thread, so the diagram shows the whole five-turn refund conversation:
- Turn 1: The user asks for a refund on order 1042. The agent calls
lookup_order(2x headphones, $180), then pauses with anask_userquestion. - Turn 2: The user replies “Only one is broken”. The model proposes
issue_refund($90), and the human-in-the-loop middleware pauses it for review. - Turn 3: A reviewer edits the refund reason. The edited call runs, refunds $90, and the agent gives its final answer.
- Turns 4–5: Two more resumes, “Partial refund” and “approve”. Each took 0.00s and returned the same final state. Whic is for another sequece base on the user answer.
sequenceDiagram
autonumber
actor U as User
participant G as LangGraph Agent (deepagents)
participant HITL as HumanInTheLoop Middleware
participant M as ChatBedrockConverse (Claude Haiku 4.5)
participant T as Tools
actor R as Human Reviewer
Note over U,R: Thread support-0d571dbe · 5 turns · 15.08K tokens
rect rgba(100,150,255,0.12)
Note over U,T: Turn 1 · 10:55:59 PM · 2.03s
U->>G: "Order 1042 arrived broken, please refund it."
G->>M: model call
M-->>G: tool_call lookup_order(order_id=1042)
G->>T: lookup_order(1042)
T-->>G: 2x headphones, $180, delivered 3 days ago
G->>M: model call
M-->>G: tool_call ask_user("Refund full $180 or a portion?")
G-->>U: interrupt: ask_user question
end
rect rgba(100,200,120,0.12)
Note over U,T: Turn 2 · 10:56:07 PM · 0.94s
U->>G: resume: "Only one is broken"
G->>M: model call
M-->>G: tool_call issue_refund(1042, $90, "Item arrived broken")
G->>HITL: wrap_tool_call(issue_refund)
HITL-->>R: interrupt: review issue_refund
end
rect rgba(255,180,80,0.14)
Note over G,R: Turn 3 · 10:56:15 PM · 0.62s
R->>HITL: resume: edited_action (amount=90.0, reason="Partial refund - one of two headphones damaged")
HITL->>T: issue_refund(1042, 90.0, edited reason)
T-->>G: Refunded $90.0 for order 1042
G->>M: model call
M-->>G: "I've issued a refund of $90 for the broken headphone."
G-->>U: final answer
end
rect rgba(160,160,160,0.12)
Note over U,R: Turns 4-5 · 10:56:38 PM & 10:58:15 PM · 0.00s
R->>G: resume "Partial refund" / decisions [approve]
G-->>U: no pending interrupt — same final state returned
end
-
Lesson 8: HITL – Introduction to Deep Agents, LangChain Academy ↩