mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-03 15:05:06 +02:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd4e07af83 | ||
|
|
63aac78338 | ||
|
|
677b176bf7 | ||
|
|
9dff8c23d3 | ||
|
|
120c12a9f1 | ||
|
|
515e010f79 | ||
|
|
61192cb883 | ||
|
|
18633bc99e | ||
|
|
2558f81889 | ||
|
|
b832fefc58 | ||
|
|
444d699fe8 | ||
|
|
e315fb7397 | ||
|
|
2c2ace2a40 | ||
|
|
74218fadad | ||
|
|
7a39e5fc6e | ||
|
|
06144b3b13 | ||
|
|
b19572351e |
@@ -4,6 +4,12 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.2.88 (2025-07-14)
|
||||
- Removed the foreign key constraint for `thread` in the `run` table to simplify database schema.
|
||||
|
||||
## v0.2.87 (2025-07-14)
|
||||
- Added more detailed logs for Redis worker signaling to improve debugging.
|
||||
|
||||
## v0.2.86 (2025-07-11)
|
||||
- Honored tool descriptions in the `/mcp` endpoint to align with expected functionality.
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@ print(graph.invoke({"x": 5}, stream_mode='updates')) # (2)!
|
||||
[{'expensive_node': {'result': 10}, '__metadata__': {'cached': True}}]
|
||||
```
|
||||
|
||||
1. First run takes the full second to run (due to mocked expensive computation).
|
||||
1. First run takes two seconds to run (due to mocked expensive computation).
|
||||
2. Second run utilizes cache and returns quickly.
|
||||
|
||||
## Edges
|
||||
|
||||
@@ -31,12 +31,12 @@ To leverage custom authentication and access user-level metadata in your deploym
|
||||
api_key = headers.get("x-api-key")
|
||||
if not api_key or not is_valid_key(api_key):
|
||||
raise Auth.exceptions.HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
# Fetch user-specific tokens from your secret store
|
||||
|
||||
# Fetch user-specific tokens from your secret store
|
||||
user_tokens = await fetch_user_tokens(api_key)
|
||||
|
||||
return { # (2)!
|
||||
"identity": api_key, # fetch user ID from LangSmith
|
||||
"identity": api_key, # fetch user ID from LangSmith
|
||||
"github_token" : user_tokens.github_token
|
||||
"jira_token" : user_tokens.jira_token
|
||||
# ... custom fields/secrets here
|
||||
@@ -50,14 +50,14 @@ To leverage custom authentication and access user-level metadata in your deploym
|
||||
|
||||
```json hl_lines="7-9"
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./agent.py:graph"
|
||||
},
|
||||
"env": ".env",
|
||||
"auth": {
|
||||
},
|
||||
"env": ".env",
|
||||
"auth": {
|
||||
"path": "./auth.py:my_auth"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -80,7 +80,7 @@ To leverage custom authentication and access user-level metadata in your deploym
|
||||
|
||||
```python
|
||||
from langgraph.pregel.remote import RemoteGraph
|
||||
|
||||
|
||||
my_token = "your-token" # In practice, you would generate a signed token with your auth provider
|
||||
remote_graph = RemoteGraph(
|
||||
"agent",
|
||||
@@ -133,15 +133,44 @@ To allow an agent to perform authenticated actions on behalf of the user, access
|
||||
def my_node(state, config):
|
||||
user_config = config["configurable"].get("langgraph_auth_user")
|
||||
# token was resolved during the @auth.authenticate function
|
||||
token = user_config.get("github_token","")
|
||||
token = user_config.get("github_token","")
|
||||
...
|
||||
```
|
||||
|
||||
!!! note
|
||||
Fetch user credentials from a secure secret store. Storing secrets in graph state is not recommended.
|
||||
|
||||
### Authorizing a Studio user
|
||||
|
||||
By default, if you add custom authorization on your resources, this will also apply to interactions made from the Studio. If you want, you can handle logged-in Studio users differently by checking [is_studio_user()](../../reference/functions/sdk_auth.isStudioUser.html).
|
||||
|
||||
!!! note
|
||||
`is_studio_user` was added in version 0.1.73 of the langgraph-sdk. If you're on an older version, you can still check whether `isinstance(ctx.user, StudioUser)`.
|
||||
|
||||
```python
|
||||
from langgraph_sdk.auth import is_studio_user, Auth
|
||||
auth = Auth()
|
||||
|
||||
# ... Setup authenticate, etc.
|
||||
|
||||
@auth.on
|
||||
async def add_owner(
|
||||
ctx: Auth.types.AuthContext,
|
||||
value: dict # The payload being sent to this access method
|
||||
) -> dict: # Returns a filter dict that restricts access to resources
|
||||
if is_studio_user(ctx.user):
|
||||
return {}
|
||||
|
||||
filters = {"owner": ctx.user.identity}
|
||||
metadata = value.setdefault("metadata", {})
|
||||
metadata.update(filters)
|
||||
return filters
|
||||
```
|
||||
|
||||
Only use this if you want to permit developer access to a graph deployed on the managed LangGraph Platform SaaS.
|
||||
|
||||
## Learn more
|
||||
|
||||
* [Authentication & Access Control](../../concepts/auth.md)
|
||||
* [LangGraph Platform](../../concepts/langgraph_platform.md)
|
||||
* [Setting up custom authentication tutorial](../../tutorials/auth/getting_started.md)
|
||||
- [Authentication & Access Control](../../concepts/auth.md)
|
||||
- [LangGraph Platform](../../concepts/langgraph_platform.md)
|
||||
- [Setting up custom authentication tutorial](../../tutorials/auth/getting_started.md)
|
||||
|
||||
@@ -1194,7 +1194,7 @@ from IPython.display import Image, display
|
||||
display(Image(graph.get_graph().draw_mermaid_png()))
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
```python
|
||||
# Call the graph: here we call it to generate a list of jokes
|
||||
@@ -1446,7 +1446,7 @@ Recursion Error
|
||||
display(Image(graph.get_graph().draw_mermaid_png()))
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
This graph looks complex, but can be conceptualized as loop of [supersteps](../concepts/low_level.md#graphs):
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
# Tech support bot with custom workflows
|
||||
|
||||
In this tutorial, you'll build a sophisticated tech support bot using LangGraph that demonstrates how to create custom workflows with conditional routing, loops, and human escalation. This bot will guide users through a structured support process, automatically routing them based on their responses and issue type.
|
||||
|
||||
!!! note "About escalation in this tutorial"
|
||||
|
||||
This tutorial demonstrates **workflow-based escalation** where the bot completes its workflow and indicates that human support is needed. This is different from LangGraph's **human-in-the-loop** functionality (using `interrupt`) which pauses execution and waits for human input. For human-in-the-loop examples, see the [human-in-the-loop tutorial](../get-started/4-human-in-the-loop.md).
|
||||
|
||||
## What you'll learn
|
||||
|
||||
By the end of this tutorial, you'll understand how to:
|
||||
|
||||
- Create **conditional routing** that adapts based on user responses
|
||||
- Implement **loops** for iterative troubleshooting
|
||||
- Handle **human escalation** at multiple decision points
|
||||
- Use **state management** to track complex multi-step conversations
|
||||
- Build a complete customer service workflow
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you start this tutorial, ensure you have access to a LLM that supports tool-calling features, such as [OpenAI](https://platform.openai.com/api-keys), [Anthropic](https://console.anthropic.com/settings/keys), or [Google Gemini](https://ai.google.dev/gemini-api/docs/api-key).
|
||||
|
||||
## The workflow
|
||||
|
||||
Our tech support bot follows a 4-step decision tree:
|
||||
|
||||
1. **Warranty Check** - Is the device under warranty?
|
||||
2. **Issue Classification** - Hardware or software issue?
|
||||
3. **Basic Troubleshooting** - Have they tried restarting/updating?
|
||||
4. **Solution Testing** - Did the suggested solution work?
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([Start]) --> Step1{Is device under warranty?}
|
||||
|
||||
Step1 -->|Yes| Step2{What type of issue?}
|
||||
Step1 -->|No| RepairChoice{Troubleshoot or speak to human?}
|
||||
|
||||
RepairChoice -->|Human| Escalate1[🧑 Escalate to Human]
|
||||
RepairChoice -->|Troubleshoot| Step2
|
||||
|
||||
Step2 -->|Hardware| Escalate2[🧑 Escalate to Human]
|
||||
Step2 -->|Software| Step3{Tried restarting/updating?}
|
||||
|
||||
Step3 -->|No| Suggest[Suggest restart/update]
|
||||
Step3 -->|Yes| Step4{Try solution - Did it work?}
|
||||
|
||||
Suggest --> Step3
|
||||
|
||||
Step4 -->|Yes| Success[✅ Issue Resolved]
|
||||
Step4 -->|No| Escalate3[🧑 Escalate to Human]
|
||||
|
||||
classDef stepNode fill:#e1f5fe,stroke:#0277bd,stroke-width:2px
|
||||
classDef escalateNode fill:#ffebee,stroke:#d32f2f,stroke-width:2px
|
||||
classDef successNode fill:#e8f5e8,stroke:#388e3c,stroke-width:2px
|
||||
classDef loopNode fill:#fff3e0,stroke:#f57c00,stroke-width:2px
|
||||
|
||||
class Step1,Step2,Step3,Step4,RepairChoice stepNode
|
||||
class Escalate1,Escalate2,Escalate3 escalateNode
|
||||
class Success successNode
|
||||
class Suggest loopNode
|
||||
```
|
||||
|
||||
## 1. Install packages
|
||||
|
||||
Install the required packages:
|
||||
|
||||
```bash
|
||||
pip install -U langgraph langsmith langchain-anthropic
|
||||
```
|
||||
|
||||
!!! tip
|
||||
|
||||
Sign up for LangSmith to quickly spot issues and improve the performance of your LangGraph projects. LangSmith lets you use trace data to debug, test, and monitor your LLM apps built with LangGraph. For more information on how to get started, see [LangSmith docs](https://docs.smith.langchain.com).
|
||||
|
||||
## 2. Define the state
|
||||
|
||||
First, define the state structure that will track the conversation and workflow progress:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Literal
|
||||
|
||||
from langchain_core.messages import BaseMessage
|
||||
|
||||
# Define all possible workflow steps
|
||||
WorkflowStep = Literal[
|
||||
"check_warranty",
|
||||
"ask_repair_or_continue",
|
||||
"ask_issue_type",
|
||||
"check_troubleshooting",
|
||||
"suggest_troubleshooting",
|
||||
"offer_solution",
|
||||
"success",
|
||||
"escalate"
|
||||
]
|
||||
|
||||
@dataclass
|
||||
class State:
|
||||
messages: List[BaseMessage]
|
||||
is_last_step: bool = False
|
||||
workflow_step: WorkflowStep = "check_warranty"
|
||||
|
||||
# State tracking for our 4-step workflow
|
||||
warranty_status: Optional[Literal["in", "out"]] = None
|
||||
wants_human_help: Optional[bool] = None # for out-of-warranty users
|
||||
issue_type: Optional[Literal["hardware", "software"]] = None
|
||||
tried_basic_steps: Optional[bool] = None
|
||||
solution_successful: Optional[bool] = None
|
||||
```
|
||||
|
||||
!!! tip "Concept"
|
||||
|
||||
The `State` class tracks both the conversation messages and the workflow progress. Each field represents a decision point in our support process, allowing the bot to remember where the user is in the troubleshooting flow.
|
||||
|
||||
## 3. Create tools for state management
|
||||
|
||||
Create tools that the LLM can use to update the workflow state based on user responses:
|
||||
|
||||
```python
|
||||
from typing import Literal, Annotated
|
||||
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.tools import tool, InjectedToolCallId
|
||||
from langgraph.types import Command
|
||||
|
||||
@tool
|
||||
def set_warranty_status(
|
||||
value: Literal["in", "out"],
|
||||
tool_call_id: Annotated[str, InjectedToolCallId]
|
||||
) -> Command:
|
||||
"""Set whether device is under warranty"""
|
||||
# Determine next step based on warranty status
|
||||
next_step: WorkflowStep = "ask_repair_or_continue" if value == "out" else "ask_issue_type"
|
||||
|
||||
return Command(update={
|
||||
"warranty_status": value,
|
||||
"workflow_step": next_step,
|
||||
"messages": [ToolMessage(content=f"Warranty status set to '{value}'",
|
||||
tool_call_id=tool_call_id)]
|
||||
})
|
||||
|
||||
@tool
|
||||
def set_wants_human_help(
|
||||
value: Literal[True, False],
|
||||
tool_call_id: Annotated[str, InjectedToolCallId]
|
||||
) -> Command:
|
||||
"""Set whether user wants human help for out-of-warranty device"""
|
||||
# If they want human help, escalate; otherwise continue to issue classification
|
||||
next_step: WorkflowStep = "escalate" if value else "ask_issue_type"
|
||||
|
||||
return Command(update={
|
||||
"wants_human_help": value,
|
||||
"workflow_step": next_step,
|
||||
"messages": [
|
||||
ToolMessage(content=f"Wants human help: {value}", tool_call_id=tool_call_id)]
|
||||
})
|
||||
|
||||
@tool
|
||||
def set_issue_type(
|
||||
value: Literal["hardware", "software"],
|
||||
tool_call_id: Annotated[str, InjectedToolCallId]
|
||||
) -> Command:
|
||||
"""Classify the issue as hardware or software related"""
|
||||
# Hardware issues escalate immediately; software issues go to troubleshooting
|
||||
next_step: WorkflowStep = "escalate" if value == "hardware" else "check_troubleshooting"
|
||||
|
||||
return Command(update={
|
||||
"issue_type": value,
|
||||
"workflow_step": next_step,
|
||||
"messages": [
|
||||
ToolMessage(content=f"Issue type set to '{value}'", tool_call_id=tool_call_id)]
|
||||
})
|
||||
|
||||
@tool
|
||||
def set_tried_basic_steps(
|
||||
value: Literal[True, False],
|
||||
tool_call_id: Annotated[str, InjectedToolCallId]
|
||||
) -> Command:
|
||||
"""Record whether user has tried basic troubleshooting"""
|
||||
# If they haven't tried basic steps, suggest them; otherwise offer solution
|
||||
next_step: WorkflowStep = "suggest_troubleshooting" if not value else "offer_solution"
|
||||
|
||||
return Command(update={
|
||||
"tried_basic_steps": value,
|
||||
"workflow_step": next_step,
|
||||
"messages": [
|
||||
ToolMessage(content=f"Tried basic steps: {value}", tool_call_id=tool_call_id)]
|
||||
})
|
||||
|
||||
@tool
|
||||
def confirm_troubleshooting_done(
|
||||
tool_call_id: Annotated[str, InjectedToolCallId]
|
||||
) -> Command:
|
||||
"""Confirm user has completed suggested troubleshooting steps"""
|
||||
next_step: WorkflowStep = "check_troubleshooting"
|
||||
|
||||
return Command(update={
|
||||
"tried_basic_steps": True,
|
||||
"workflow_step": next_step,
|
||||
"messages": [
|
||||
ToolMessage(content="Troubleshooting steps completed", tool_call_id=tool_call_id)]
|
||||
})
|
||||
|
||||
@tool
|
||||
def set_solution_successful(
|
||||
value: Literal[True, False],
|
||||
tool_call_id: Annotated[str, InjectedToolCallId]
|
||||
) -> Command:
|
||||
"""Record whether the suggested solution worked"""
|
||||
# If solution worked, success; otherwise escalate
|
||||
next_step: WorkflowStep = "success" if value else "escalate"
|
||||
|
||||
return Command(update={
|
||||
"solution_successful": value,
|
||||
"workflow_step": next_step,
|
||||
"messages": [
|
||||
ToolMessage(content=f"Solution successful: {value}", tool_call_id=tool_call_id)]
|
||||
})
|
||||
|
||||
ALL_TOOLS = [
|
||||
set_warranty_status,
|
||||
set_wants_human_help,
|
||||
set_issue_type,
|
||||
set_tried_basic_steps,
|
||||
confirm_troubleshooting_done,
|
||||
set_solution_successful,
|
||||
]
|
||||
```
|
||||
|
||||
These tools now handle both state updates and workflow transitions. Each tool determines the next step in the workflow based on the user's response, eliminating the need for complex routing logic.
|
||||
|
||||
## 4. Set up the chat model
|
||||
|
||||
{% include-markdown "../../snippets/chat_model_tabs.md" %}
|
||||
|
||||
## 5. Create step-specific prompts
|
||||
|
||||
Each workflow step needs a specific prompt to guide the LLM's behavior:
|
||||
|
||||
```python
|
||||
from typing import Dict, List
|
||||
|
||||
TOOL_MAP: Dict[WorkflowStep, List] = {
|
||||
"check_warranty": [set_warranty_status],
|
||||
"ask_repair_or_continue": [set_wants_human_help],
|
||||
"ask_issue_type": [set_issue_type],
|
||||
"check_troubleshooting": [set_tried_basic_steps],
|
||||
"suggest_troubleshooting": [confirm_troubleshooting_done],
|
||||
"offer_solution": [set_solution_successful],
|
||||
}
|
||||
|
||||
def get_prompt_for_step(step: WorkflowStep) -> str:
|
||||
"""Get the appropriate prompt for each workflow step"""
|
||||
prompts: Dict[WorkflowStep, str] = {
|
||||
"check_warranty": """
|
||||
Ask the user whether their device is under warranty.
|
||||
Use the set_warranty_status tool to record their response as 'in' or 'out'.
|
||||
""",
|
||||
|
||||
"ask_repair_or_continue": """
|
||||
The device is out of warranty. Ask if they'd like to:
|
||||
1. Continue troubleshooting themselves, or
|
||||
2. Speak to a human about repair options
|
||||
Use the set_wants_human_help tool to record their choice.
|
||||
""",
|
||||
|
||||
"ask_issue_type": """
|
||||
Ask what issue they are experiencing with their device.
|
||||
Based on their response, classify it as 'hardware' (physical problems, broken parts)
|
||||
or 'software' (app crashes, performance issues, etc.).
|
||||
Use the set_issue_type tool to record the classification.
|
||||
""",
|
||||
|
||||
"check_troubleshooting": """
|
||||
Ask if they have already tried basic troubleshooting steps like:
|
||||
- Restarting the device
|
||||
- Updating the software/app
|
||||
Use the set_tried_basic_steps tool to record their response.
|
||||
""",
|
||||
|
||||
"suggest_troubleshooting": """
|
||||
Suggest they try restarting their device and updating the software/app.
|
||||
Ask them to try these steps and confirm once they're done.
|
||||
Use the confirm_troubleshooting_done tool once they confirm they've tried.
|
||||
""",
|
||||
|
||||
"offer_solution": """
|
||||
Suggest they reset the app settings or clear the app cache.
|
||||
Ask them to try this solution and confirm if it resolved the issue.
|
||||
Use the set_solution_successful tool to record whether it worked.
|
||||
""",
|
||||
}
|
||||
return prompts.get(step, "Continue helping the user with their issue.")
|
||||
```
|
||||
|
||||
## 6. Create the model node
|
||||
|
||||
The model node handles LLM interactions with the appropriate tools for each step:
|
||||
|
||||
```python
|
||||
from typing import Dict, Literal
|
||||
|
||||
from langchain.chat_models import init_chat_model
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
# Initialize the chat model
|
||||
llm = init_chat_model("anthropic:claude-3-5-sonnet-latest")
|
||||
|
||||
async def call_model(state: State) -> Dict:
|
||||
"""Call the LLM with appropriate tools for the current step"""
|
||||
# Handle terminal states
|
||||
if state.workflow_step == "success":
|
||||
return {
|
||||
"messages": [AIMessage(
|
||||
content="Great! I'm glad we could resolve your issue. Is there anything else I can help you with today?")],
|
||||
"is_last_step": True
|
||||
}
|
||||
elif state.workflow_step == "escalate":
|
||||
return {
|
||||
"messages": [AIMessage(
|
||||
content="I'm going to connect you with one of our human support specialists who can better assist you with this issue. Please hold on while I transfer you.")],
|
||||
"is_last_step": True
|
||||
}
|
||||
|
||||
# For regular workflow steps, get the appropriate prompt and tools
|
||||
prompt = get_prompt_for_step(state.workflow_step)
|
||||
tools = TOOL_MAP.get(state.workflow_step, [])
|
||||
model = llm.bind_tools(tools)
|
||||
|
||||
response = await model.ainvoke(
|
||||
[{"role": "system", "content": prompt}, *state.messages]
|
||||
)
|
||||
|
||||
return {"messages": [response]}
|
||||
|
||||
def should_continue(state: State) -> Literal["tools", "call_model", "__end__"]:
|
||||
"""Determine whether to call tools or continue with the model"""
|
||||
# If we've reached a terminal state, stop
|
||||
if state.is_last_step:
|
||||
return "__end__"
|
||||
|
||||
# If the last message has tool calls, execute them
|
||||
last_msg = state.messages[-1]
|
||||
if isinstance(last_msg, AIMessage) and last_msg.tool_calls:
|
||||
return "tools"
|
||||
|
||||
# Otherwise, continue with the model
|
||||
return "call_model"
|
||||
```
|
||||
|
||||
!!! tip "Concept"
|
||||
|
||||
This simplified approach moves all the routing logic into the tools themselves. Each tool determines the next workflow step, eliminating the need for complex conditional routing functions. The `should_continue` function simply decides whether to execute tools or continue with the model.
|
||||
|
||||
## 8. Build and compile the graph
|
||||
|
||||
Now assemble all the components into a complete workflow:
|
||||
|
||||
```python
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
from langgraph.prebuilt import ToolNode
|
||||
|
||||
builder = StateGraph(State)
|
||||
|
||||
# Add nodes
|
||||
builder.add_node("call_model", call_model)
|
||||
builder.add_node("tools", ToolNode(ALL_TOOLS))
|
||||
|
||||
# Set entry point
|
||||
builder.add_edge(START, "call_model")
|
||||
|
||||
# Add conditional edges
|
||||
builder.add_conditional_edges(
|
||||
"call_model",
|
||||
should_continue,
|
||||
["tools", "call_model", END]
|
||||
)
|
||||
|
||||
# Tools flow back to model
|
||||
builder.add_edge("tools", "call_model")
|
||||
|
||||
# Compile the graph
|
||||
graph = builder.compile()
|
||||
```
|
||||
|
||||
## 9. Test the workflow
|
||||
|
||||
Run the tech support bot to see how it handles different scenarios:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from langchain_core.messages import HumanMessage, AIMessage
|
||||
|
||||
async def run_example():
|
||||
"""Run an example conversation"""
|
||||
print("\n🔁 Running Tech Support Workflow...\n")
|
||||
|
||||
initial_state = State(
|
||||
messages=[
|
||||
HumanMessage(content="Hi, my app is crashing a lot and I can't use it.")],
|
||||
workflow_step="check_warranty"
|
||||
)
|
||||
|
||||
final_state = await graph.ainvoke(initial_state)
|
||||
|
||||
print("\n✅ Conversation Complete!")
|
||||
print(f"Final workflow step: {final_state.workflow_step}")
|
||||
print(f"Warranty status: {final_state.warranty_status}")
|
||||
print(f"Issue type: {final_state.issue_type}")
|
||||
print(f"Tried basic steps: {final_state.tried_basic_steps}")
|
||||
print(f"Solution successful: {final_state.solution_successful}")
|
||||
|
||||
print("\n💬 Final messages:")
|
||||
for msg in final_state.messages[-3:]: # Show last 3 messages
|
||||
if isinstance(msg, HumanMessage):
|
||||
print(f"User: {msg.content}")
|
||||
elif isinstance(msg, AIMessage):
|
||||
print(f"Bot: {msg.content}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_example())
|
||||
```
|
||||
|
||||
!!! tip
|
||||
|
||||
You can exit the conversation at any time by typing `quit`, `exit`, or `q`.
|
||||
|
||||
## Key concepts demonstrated
|
||||
|
||||
This tech support bot showcases several important LangGraph concepts:
|
||||
|
||||
### 1. **Conditional routing**
|
||||
The `route_workflow` function implements complex decision logic based on user responses:
|
||||
- Warranty status determines the initial path
|
||||
- Issue type (hardware vs software) triggers different responses
|
||||
- Solution success determines the final outcome
|
||||
|
||||
### 2. **Looping behavior**
|
||||
Step 3 creates a loop where users who haven't tried basic troubleshooting are guided through it:
|
||||
```
|
||||
check_troubleshooting → suggest_troubleshooting → check_troubleshooting
|
||||
```
|
||||
|
||||
### 3. **Human escalation**
|
||||
Multiple escalation points ensure complex issues reach human agents:
|
||||
- Out-of-warranty users can choose human help
|
||||
- Hardware issues automatically escalate
|
||||
- Failed solutions trigger escalation
|
||||
|
||||
### 4. **State management**
|
||||
The workflow tracks user progress through structured state variables, enabling complex multi-turn conversations.
|
||||
|
||||
## Testing different scenarios
|
||||
|
||||
Try these conversation paths to see how the bot handles various situations:
|
||||
|
||||
1. **In-warranty software issue** → Full troubleshooting flow
|
||||
2. **Out-of-warranty hardware issue** → Immediate escalation
|
||||
3. **Software issue with successful solution** → Success completion
|
||||
4. **Software issue with failed solution** → Escalation
|
||||
|
||||
## Next steps
|
||||
|
||||
This implementation demonstrates how LangGraph can handle real-world customer service scenarios with sophisticated routing, looping, and escalation logic. You can extend this pattern to build more complex workflows for various business processes.
|
||||
@@ -270,6 +270,7 @@ nav:
|
||||
- examples/index.md
|
||||
- Template applications: concepts/template_applications.md # TODO: make tutorial
|
||||
- Agentic RAG: tutorials/rag/langgraph_agentic_rag.md
|
||||
- tutorials/tech-support-bot.md
|
||||
- Agent Supervisor: tutorials/multi_agent/agent_supervisor.md
|
||||
- SQL agent: tutorials/sql/sql-agent.md
|
||||
- Prebuilt chat UI: agents/ui.md
|
||||
|
||||
@@ -180,6 +180,7 @@ def task(
|
||||
warnings.warn(
|
||||
"`retry` is deprecated and will be removed. Please use `retry_policy` instead.",
|
||||
category=LangGraphDeprecatedSinceV05,
|
||||
stacklevel=2,
|
||||
)
|
||||
if retry_policy is None:
|
||||
retry_policy = retry # type: ignore[assignment]
|
||||
@@ -384,6 +385,7 @@ class entrypoint:
|
||||
warnings.warn(
|
||||
"`retry` is deprecated and will be removed. Please use `retry_policy` instead.",
|
||||
category=LangGraphDeprecatedSinceV05,
|
||||
stacklevel=2,
|
||||
)
|
||||
if retry_policy is None:
|
||||
retry_policy = retry # type: ignore[assignment]
|
||||
|
||||
Generated
+1
-1
@@ -1463,7 +1463,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.1.72"
|
||||
version = "0.1.73"
|
||||
source = { editable = "../sdk-py" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
"""Tool execution node for LangGraph workflows.
|
||||
|
||||
This module provides prebuilt functionality for executing tools in LangGraph.
|
||||
|
||||
Tools are functions that models can call to interact with external systems,
|
||||
APIs, databases, or perform computations.
|
||||
|
||||
The module implements several key design patterns:
|
||||
- Parallel execution of multiple tool calls for efficiency
|
||||
- Robust error handling with customizable error messages
|
||||
- State injection for tools that need access to graph state
|
||||
- Store injection for tools that need persistent storage
|
||||
- Command-based state updates for advanced control flow
|
||||
|
||||
Key Components:
|
||||
ToolNode: Main class for executing tools in LangGraph workflows
|
||||
InjectedState: Annotation for injecting graph state into tools
|
||||
InjectedStore: Annotation for injecting persistent store into tools
|
||||
tools_condition: Utility function for conditional routing based on tool calls
|
||||
|
||||
Typical Usage:
|
||||
```python
|
||||
from langchain_core.tools import tool
|
||||
from langgraph.prebuilt import ToolNode
|
||||
|
||||
@tool
|
||||
def my_tool(x: int) -> str:
|
||||
return f"Result: {x}"
|
||||
|
||||
tool_node = ToolNode([my_tool])
|
||||
```
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
@@ -49,6 +82,24 @@ TOOL_CALL_ERROR_TEMPLATE = "Error: {error}\n Please fix your mistakes."
|
||||
|
||||
|
||||
def msg_content_output(output: Any) -> Union[str, list[dict]]:
|
||||
"""Convert tool output to valid message content format.
|
||||
|
||||
LangChain ToolMessages accept either string content or a list of content blocks.
|
||||
This function ensures tool outputs are properly formatted for message consumption
|
||||
by attempting to preserve structured data when possible, falling back to JSON
|
||||
serialization or string conversion.
|
||||
|
||||
Args:
|
||||
output: The raw output from a tool execution. Can be any type.
|
||||
|
||||
Returns:
|
||||
Either a string representation of the output or a list of content blocks
|
||||
if the output is already in the correct format for structured content.
|
||||
|
||||
Note:
|
||||
This function prioritizes backward compatibility by defaulting to JSON
|
||||
serialization rather than supporting all possible message content formats.
|
||||
"""
|
||||
if isinstance(output, str):
|
||||
return output
|
||||
elif isinstance(output, list) and all(
|
||||
@@ -58,9 +109,10 @@ def msg_content_output(output: Any) -> Union[str, list[dict]]:
|
||||
]
|
||||
):
|
||||
return output
|
||||
# Technically a list of strings is also valid message content but it's not currently
|
||||
# well tested that all chat models support this. And for backwards compatibility
|
||||
# we want to make sure we don't break any existing ToolNode usage.
|
||||
# Technically a list of strings is also valid message content, but it's
|
||||
# not currently well tested that all chat models support this.
|
||||
# And for backwards compatibility we want to make sure we don't break
|
||||
# any existing ToolNode usage.
|
||||
else:
|
||||
try:
|
||||
return json.dumps(output, ensure_ascii=False)
|
||||
@@ -78,6 +130,30 @@ def _handle_tool_error(
|
||||
tuple[type[Exception], ...],
|
||||
],
|
||||
) -> str:
|
||||
"""Generate error message content based on exception handling configuration.
|
||||
|
||||
This function centralizes error message generation logic, supporting different
|
||||
error handling strategies configured via the ToolNode's handle_tool_errors
|
||||
parameter.
|
||||
|
||||
Args:
|
||||
e: The exception that occurred during tool execution.
|
||||
flag: Configuration for how to handle the error. Can be:
|
||||
- bool: If True, use default error template
|
||||
- str: Use this string as the error message
|
||||
- Callable: Call this function with the exception to get error message
|
||||
- tuple: Not used in this context (handled by caller)
|
||||
|
||||
Returns:
|
||||
A string containing the error message to include in the ToolMessage.
|
||||
|
||||
Raises:
|
||||
ValueError: If flag is not one of the supported types.
|
||||
|
||||
Note:
|
||||
The tuple case is handled by the caller through exception type checking,
|
||||
not by this function directly.
|
||||
"""
|
||||
if isinstance(flag, (bool, tuple)):
|
||||
content = TOOL_CALL_ERROR_TEMPLATE.format(error=repr(e))
|
||||
elif isinstance(flag, str):
|
||||
@@ -93,6 +169,29 @@ def _handle_tool_error(
|
||||
|
||||
|
||||
def _infer_handled_types(handler: Callable[..., str]) -> tuple[type[Exception], ...]:
|
||||
"""Infer exception types handled by a custom error handler function.
|
||||
|
||||
This function analyzes the type annotations of a custom error handler to determine
|
||||
which exception types it's designed to handle. This enables type-safe error handling
|
||||
where only specific exceptions are caught and processed by the handler.
|
||||
|
||||
Args:
|
||||
handler: A callable that takes an exception and returns an error message string.
|
||||
The first parameter (after self/cls if present) should be type-annotated
|
||||
with the exception type(s) to handle.
|
||||
|
||||
Returns:
|
||||
A tuple of exception types that the handler can process. Returns (Exception,)
|
||||
if no specific type information is available for backward compatibility.
|
||||
|
||||
Raises:
|
||||
ValueError: If the handler's annotation contains non-Exception types or
|
||||
if Union types contain non-Exception types.
|
||||
|
||||
Note:
|
||||
This function supports both single exception types and Union types for
|
||||
handlers that need to handle multiple exception types differently.
|
||||
"""
|
||||
sig = inspect.signature(handler)
|
||||
params = list(sig.parameters.values())
|
||||
if params:
|
||||
@@ -111,8 +210,9 @@ def _infer_handled_types(handler: Callable[..., str]) -> tuple[type[Exception],
|
||||
return tuple(args)
|
||||
else:
|
||||
raise ValueError(
|
||||
"All types in the error handler error annotation must be Exception types. "
|
||||
"For example, `def custom_handler(e: Union[ValueError, TypeError])`. "
|
||||
"All types in the error handler error annotation must be "
|
||||
"Exception types. For example, "
|
||||
"`def custom_handler(e: Union[ValueError, TypeError])`. "
|
||||
f"Got '{first_param.annotation}' instead."
|
||||
)
|
||||
|
||||
@@ -121,13 +221,16 @@ def _infer_handled_types(handler: Callable[..., str]) -> tuple[type[Exception],
|
||||
return (exception_type,)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Arbitrary types are not supported in the error handler signature. "
|
||||
"Please annotate the error with either a specific Exception type or a union of Exception types. "
|
||||
"For example, `def custom_handler(e: ValueError)` or `def custom_handler(e: Union[ValueError, TypeError])`. "
|
||||
f"Arbitrary types are not supported in the error handler "
|
||||
f"signature. Please annotate the error with either a "
|
||||
f"specific Exception type or a union of Exception types. "
|
||||
"For example, `def custom_handler(e: ValueError)` or "
|
||||
"`def custom_handler(e: Union[ValueError, TypeError])`. "
|
||||
f"Got '{exception_type}' instead."
|
||||
)
|
||||
|
||||
# If no type information is available, return (Exception,) for backwards compatibility.
|
||||
# If no type information is available, return (Exception,)
|
||||
# for backwards compatibility.
|
||||
return (Exception,)
|
||||
|
||||
|
||||
@@ -141,60 +244,72 @@ class ToolNode(RunnableCallable):
|
||||
Tool calls can also be passed directly as a list of `ToolCall` dicts.
|
||||
|
||||
Args:
|
||||
tools: A sequence of tools that can be invoked by the ToolNode.
|
||||
name: The name of the ToolNode in the graph. Defaults to "tools".
|
||||
tags: Optional tags to associate with the node. Defaults to None.
|
||||
handle_tool_errors: How to handle tool errors raised by tools inside the node. Defaults to True.
|
||||
Must be one of the following:
|
||||
tools: A sequence of tools that can be invoked by this node. Tools can be
|
||||
BaseTool instances or plain functions that will be converted to tools.
|
||||
name: The name identifier for this node in the graph. Used for debugging
|
||||
and visualization. Defaults to "tools".
|
||||
tags: Optional metadata tags to associate with the node for filtering
|
||||
and organization. Defaults to None.
|
||||
handle_tool_errors: Configuration for error handling during tool execution.
|
||||
Defaults to True. Supports multiple strategies:
|
||||
|
||||
- True: all errors will be caught and
|
||||
a ToolMessage with a default error message (TOOL_CALL_ERROR_TEMPLATE) will be returned.
|
||||
- str: all errors will be caught and
|
||||
a ToolMessage with the string value of 'handle_tool_errors' will be returned.
|
||||
- tuple[type[Exception], ...]: exceptions in the tuple will be caught and
|
||||
a ToolMessage with a default error message (TOOL_CALL_ERROR_TEMPLATE) will be returned.
|
||||
- Callable[..., str]: exceptions from the signature of the callable will be caught and
|
||||
a ToolMessage with the string value of the result of the 'handle_tool_errors' callable will be returned.
|
||||
- False: none of the errors raised by the tools will be caught
|
||||
messages_key: The state key in the input that contains the list of messages.
|
||||
The same key will be used for the output from the ToolNode.
|
||||
Defaults to "messages".
|
||||
- True: Catch all errors and return a ToolMessage with the default
|
||||
error template containing the exception details.
|
||||
- str: Catch all errors and return a ToolMessage with this custom
|
||||
error message string.
|
||||
- tuple[type[Exception], ...]: Only catch exceptions of the specified
|
||||
types and return default error messages for them.
|
||||
- Callable[..., str]: Catch exceptions matching the callable's signature
|
||||
and return the string result of calling it with the exception.
|
||||
- False: Disable error handling entirely, allowing exceptions to propagate.
|
||||
|
||||
The `ToolNode` is roughly analogous to:
|
||||
messages_key: The key in the state dictionary that contains the message list.
|
||||
This same key will be used for the output ToolMessages. Defaults to "messages".
|
||||
|
||||
```python
|
||||
tools_by_name = {tool.name: tool for tool in tools}
|
||||
def tool_node(state: dict):
|
||||
result = []
|
||||
for tool_call in state["messages"][-1].tool_calls:
|
||||
tool = tools_by_name[tool_call["name"]]
|
||||
observation = tool.invoke(tool_call["args"])
|
||||
result.append(ToolMessage(content=observation, tool_call_id=tool_call["id"]))
|
||||
return {"messages": result}
|
||||
```
|
||||
Example:
|
||||
Basic usage with simple tools:
|
||||
|
||||
Tool calls can also be passed directly to a ToolNode. This can be useful when using
|
||||
the Send API, e.g., in a conditional edge:
|
||||
```python
|
||||
from langgraph.prebuilt import ToolNode
|
||||
from langchain_core.tools import tool
|
||||
|
||||
```python
|
||||
def example_conditional_edge(state: dict) -> List[Send]:
|
||||
tool_calls = state["messages"][-1].tool_calls
|
||||
# If tools rely on state or store variables (whose values are not generated
|
||||
# directly by a model), you can inject them into the tool calls.
|
||||
tool_calls = [
|
||||
tool_node.inject_tool_args(call, state, store)
|
||||
for call in last_message.tool_calls
|
||||
]
|
||||
return [Send("tools", [tool_call]) for tool_call in tool_calls]
|
||||
```
|
||||
@tool
|
||||
def calculator(a: int, b: int) -> int:
|
||||
\"\"\"Add two numbers.\"\"\"
|
||||
return a + b
|
||||
|
||||
Important:
|
||||
- The input state can be one of the following:
|
||||
- A dict with a messages key containing a list of messages.
|
||||
- A list of messages.
|
||||
- A list of tool calls.
|
||||
- If operating on a message list, the last message must be an `AIMessage` with
|
||||
`tool_calls` populated.
|
||||
tool_node = ToolNode([calculator])
|
||||
```
|
||||
|
||||
Custom error handling:
|
||||
|
||||
```python
|
||||
def handle_math_errors(e: ZeroDivisionError) -> str:
|
||||
return "Cannot divide by zero!"
|
||||
|
||||
tool_node = ToolNode([calculator], handle_tool_errors=handle_math_errors)
|
||||
```
|
||||
|
||||
Direct tool call execution:
|
||||
|
||||
```python
|
||||
tool_calls = [{"name": "calculator", "args": {"a": 5, "b": 3}, "id": "1", "type": "tool_call"}]
|
||||
result = tool_node.invoke(tool_calls)
|
||||
```
|
||||
|
||||
Note:
|
||||
The ToolNode expects input in one of three formats:
|
||||
1. A dictionary with a messages key containing a list of messages
|
||||
2. A list of messages directly
|
||||
3. A list of tool call dictionaries
|
||||
|
||||
When using message formats, the last message must be an AIMessage with
|
||||
tool_calls populated. The node automatically extracts and processes these
|
||||
tool calls concurrently.
|
||||
|
||||
For advanced use cases involving state injection or store access, tools
|
||||
can be annotated with InjectedState or InjectedStore to receive graph
|
||||
context automatically.
|
||||
"""
|
||||
|
||||
name: str = "ToolNode"
|
||||
@@ -210,6 +325,15 @@ class ToolNode(RunnableCallable):
|
||||
] = True,
|
||||
messages_key: str = "messages",
|
||||
) -> None:
|
||||
"""Initialize the ToolNode with the provided tools and configuration.
|
||||
|
||||
Args:
|
||||
tools: Sequence of tools to make available for execution.
|
||||
name: Node name for graph identification.
|
||||
tags: Optional metadata tags.
|
||||
handle_tool_errors: Error handling configuration.
|
||||
messages_key: State key containing messages.
|
||||
"""
|
||||
super().__init__(self._func, self._afunc, name=name, tags=tags, trace=False)
|
||||
self.tools_by_name: dict[str, BaseTool] = {}
|
||||
self.tool_to_state_args: dict[str, dict[str, Optional[str]]] = {}
|
||||
@@ -541,20 +665,38 @@ class ToolNode(RunnableCallable):
|
||||
],
|
||||
store: Optional[BaseStore],
|
||||
) -> ToolCall:
|
||||
"""Injects the state and store into the tool call.
|
||||
"""Inject graph state and store into tool call arguments.
|
||||
|
||||
Tool arguments with types annotated as `InjectedState` and `InjectedStore` are
|
||||
ignored in tool schemas for generation purposes. This method injects them into
|
||||
tool calls for tool invocation.
|
||||
This method enables tools to access graph context that should not be controlled
|
||||
by the model. Tools can declare dependencies on graph state or persistent storage
|
||||
using InjectedState and InjectedStore annotations. This method automatically
|
||||
identifies these dependencies and injects the appropriate values.
|
||||
|
||||
The injection process preserves the original tool call structure while adding
|
||||
the necessary context arguments. This allows tools to be both model-callable
|
||||
and context-aware without exposing internal state management to the model.
|
||||
|
||||
Args:
|
||||
tool_call: The tool call to inject state and store into.
|
||||
input: The input state
|
||||
to inject.
|
||||
store: The store to inject.
|
||||
tool_call: The tool call dictionary to augment with injected arguments.
|
||||
Must contain 'name', 'args', 'id', and 'type' fields.
|
||||
input: The current graph state to inject into tools requiring state access.
|
||||
Can be a message list, state dictionary, or BaseModel instance.
|
||||
store: The persistent store instance to inject into tools requiring storage.
|
||||
Will be None if no store is configured for the graph.
|
||||
|
||||
Returns:
|
||||
ToolCall: The tool call with injected state and store.
|
||||
A new ToolCall dictionary with the same structure as the input but with
|
||||
additional arguments injected based on the tool's annotation requirements.
|
||||
|
||||
Raises:
|
||||
ValueError: If a tool requires store injection but no store is provided,
|
||||
or if state injection requirements cannot be satisfied.
|
||||
|
||||
Note:
|
||||
This method is automatically called during tool execution but can also
|
||||
be used manually when working with the Send API or custom routing logic.
|
||||
The injection is performed on a copy of the tool call to avoid mutating
|
||||
the original.
|
||||
"""
|
||||
if tool_call["name"] not in self.tools_by_name:
|
||||
return tool_call
|
||||
@@ -625,55 +767,66 @@ def tools_condition(
|
||||
state: Union[list[AnyMessage], dict[str, Any], BaseModel],
|
||||
messages_key: str = "messages",
|
||||
) -> Literal["tools", "__end__"]:
|
||||
"""Use in the conditional_edge to route to the ToolNode if the last message
|
||||
"""Conditional routing function for tool-calling workflows.
|
||||
|
||||
has tool calls. Otherwise, route to the end.
|
||||
This utility function implements the standard conditional logic for ReAct-style
|
||||
agents: if the last AI message contains tool calls, route to the tool execution
|
||||
node; otherwise, end the workflow. This pattern is fundamental to most tool-calling
|
||||
agent architectures.
|
||||
|
||||
The function handles multiple state formats commonly used in LangGraph applications,
|
||||
making it flexible for different graph designs while maintaining consistent behavior.
|
||||
|
||||
Args:
|
||||
state: The state to check for
|
||||
tool calls. Must have a list of messages (MessageGraph) or have the
|
||||
"messages" key (StateGraph).
|
||||
state: The current graph state to examine for tool calls. Supported formats:
|
||||
- List of messages (for MessageGraph)
|
||||
- Dictionary containing a messages key (for StateGraph)
|
||||
- BaseModel instance with a messages attribute
|
||||
messages_key: The key or attribute name containing the message list in the state.
|
||||
This allows customization for graphs using different state schemas.
|
||||
Defaults to "messages".
|
||||
|
||||
Returns:
|
||||
The next node to route to.
|
||||
Either "tools" if tool calls are present in the last AI message, or "__end__"
|
||||
to terminate the workflow. These are the standard routing destinations for
|
||||
tool-calling conditional edges.
|
||||
|
||||
Raises:
|
||||
ValueError: If no messages can be found in the provided state format.
|
||||
|
||||
Examples:
|
||||
Create a custom ReAct-style agent with tools.
|
||||
Example:
|
||||
Basic usage in a ReAct agent:
|
||||
|
||||
```pycon
|
||||
>>> from langchain_anthropic import ChatAnthropic
|
||||
>>> from langchain_core.tools import tool
|
||||
...
|
||||
>>> from langgraph.graph import StateGraph
|
||||
>>> from langgraph.prebuilt import ToolNode, tools_condition
|
||||
>>> from langgraph.graph.message import add_messages
|
||||
...
|
||||
>>> from typing import Annotated
|
||||
>>> from typing_extensions import TypedDict
|
||||
...
|
||||
>>> @tool
|
||||
>>> def divide(a: float, b: float) -> int:
|
||||
... \"\"\"Return a / b.\"\"\"
|
||||
... return a / b
|
||||
...
|
||||
>>> llm = ChatAnthropic(model="claude-3-haiku-20240307")
|
||||
>>> tools = [divide]
|
||||
...
|
||||
>>> class State(TypedDict):
|
||||
... messages: Annotated[list, add_messages]
|
||||
>>>
|
||||
>>> graph_builder = StateGraph(State)
|
||||
>>> graph_builder.add_node("tools", ToolNode(tools))
|
||||
>>> graph_builder.add_node("chatbot", lambda state: {"messages":llm.bind_tools(tools).invoke(state['messages'])})
|
||||
>>> graph_builder.add_edge("tools", "chatbot")
|
||||
>>> graph_builder.add_conditional_edges(
|
||||
... "chatbot", tools_condition
|
||||
... )
|
||||
>>> graph_builder.set_entry_point("chatbot")
|
||||
>>> graph = graph_builder.compile()
|
||||
>>> graph.invoke({"messages": {"role": "user", "content": "What's 329993 divided by 13662?"}})
|
||||
```python
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.prebuilt import ToolNode, tools_condition
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
class State(TypedDict):
|
||||
messages: list
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("llm", call_model)
|
||||
graph.add_node("tools", ToolNode([my_tool]))
|
||||
graph.add_conditional_edges(
|
||||
"llm",
|
||||
tools_condition, # Routes to "tools" or "__end__"
|
||||
{"tools": "tools", "__end__": "__end__"}
|
||||
)
|
||||
```
|
||||
|
||||
Custom messages key:
|
||||
|
||||
```python
|
||||
def custom_condition(state):
|
||||
return tools_condition(state, messages_key="chat_history")
|
||||
```
|
||||
|
||||
Note:
|
||||
This function is designed to work seamlessly with ToolNode and standard
|
||||
LangGraph patterns. It expects the last message to be an AIMessage when
|
||||
tool calls are present, which is the standard output format for tool-calling
|
||||
language models.
|
||||
"""
|
||||
if isinstance(state, list):
|
||||
ai_message = state[-1]
|
||||
@@ -689,16 +842,18 @@ def tools_condition(
|
||||
|
||||
|
||||
class InjectedState(InjectedToolArg):
|
||||
"""Annotation for a Tool arg that is meant to be populated with the graph state.
|
||||
"""Annotation for injecting graph state into tool arguments.
|
||||
|
||||
Any Tool argument annotated with InjectedState will be hidden from a tool-calling
|
||||
model, so that the model doesn't attempt to generate the argument. If using
|
||||
ToolNode, the appropriate graph state field will be automatically injected into
|
||||
the model-generated tool args.
|
||||
This annotation enables tools to access graph state without exposing state
|
||||
management details to the language model. Tools annotated with InjectedState
|
||||
receive state data automatically during execution while remaining invisible
|
||||
to the model's tool-calling interface.
|
||||
|
||||
Args:
|
||||
field: The key from state to insert. If None, the entire state is expected to
|
||||
be passed in.
|
||||
field: Optional key to extract from the state dictionary. If None, the entire
|
||||
state is injected. If specified, only that field's value is injected.
|
||||
This allows tools to request specific state components rather than
|
||||
processing the full state structure.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -745,6 +900,15 @@ class InjectedState(InjectedToolArg):
|
||||
ToolMessage(content='bar2', name='foo_tool', tool_call_id='2')
|
||||
]
|
||||
```
|
||||
|
||||
Note:
|
||||
- InjectedState arguments are automatically excluded from tool schemas
|
||||
presented to language models
|
||||
- ToolNode handles the injection process during execution
|
||||
- Tools can mix regular arguments (controlled by the model) with injected
|
||||
arguments (controlled by the system)
|
||||
- State injection occurs after the model generates tool calls but before
|
||||
tool execution
|
||||
""" # noqa: E501
|
||||
|
||||
def __init__(self, field: Optional[str] = None) -> None:
|
||||
@@ -752,61 +916,97 @@ class InjectedState(InjectedToolArg):
|
||||
|
||||
|
||||
class InjectedStore(InjectedToolArg):
|
||||
"""Annotation for a Tool arg that is meant to be populated with LangGraph store.
|
||||
"""Annotation for injecting persistent store into tool arguments.
|
||||
|
||||
Any Tool argument annotated with InjectedStore will be hidden from a tool-calling
|
||||
model, so that the model doesn't attempt to generate the argument. If using
|
||||
ToolNode, the appropriate store field will be automatically injected into
|
||||
the model-generated tool args. Note: if a graph is compiled with a store object,
|
||||
the store will be automatically propagated to the tools with InjectedStore args
|
||||
when using ToolNode.
|
||||
This annotation enables tools to access LangGraph's persistent storage system
|
||||
without exposing storage details to the language model. Tools annotated with
|
||||
InjectedStore receive the store instance automatically during execution while
|
||||
remaining invisible to the model's tool-calling interface.
|
||||
|
||||
The store provides persistent, cross-session data storage that tools can use
|
||||
for maintaining context, user preferences, or any other data that needs to
|
||||
persist beyond individual workflow executions.
|
||||
|
||||
!!! Warning
|
||||
`InjectedStore` annotation requires `langchain-core >= 0.3.8`
|
||||
|
||||
Example:
|
||||
```python
|
||||
from typing import Any
|
||||
from typing_extensions import Annotated
|
||||
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
from langgraph.prebuilt import InjectedStore, ToolNode
|
||||
|
||||
store = InMemoryStore()
|
||||
store.put(("values",), "foo", {"bar": 2})
|
||||
@tool
|
||||
def save_preference(
|
||||
key: str,
|
||||
value: str,
|
||||
store: Annotated[Any, InjectedStore()]
|
||||
) -> str:
|
||||
\"\"\"Save user preference to persistent storage.\"\"\"
|
||||
store.put(("preferences",), key, value)
|
||||
return f"Saved {key} = {value}"
|
||||
|
||||
@tool
|
||||
def store_tool(x: int, my_store: Annotated[Any, InjectedStore()]) -> str:
|
||||
'''Do something with store.'''
|
||||
stored_value = my_store.get(("values",), "foo").value["bar"]
|
||||
return stored_value + x
|
||||
|
||||
node = ToolNode([store_tool])
|
||||
|
||||
tool_call = {"name": "store_tool", "args": {"x": 1}, "id": "1", "type": "tool_call"}
|
||||
state = {
|
||||
"messages": [AIMessage("", tool_calls=[tool_call])],
|
||||
}
|
||||
|
||||
node.invoke(state, store=store)
|
||||
def get_preference(
|
||||
key: str,
|
||||
store: Annotated[Any, InjectedStore()]
|
||||
) -> str:
|
||||
\"\"\"Retrieve user preference from persistent storage.\"\"\"
|
||||
result = store.get(("preferences",), key)
|
||||
return result.value if result else "Not found"
|
||||
```
|
||||
|
||||
```pycon
|
||||
{
|
||||
"messages": [
|
||||
ToolMessage(content='3', name='store_tool', tool_call_id='1'),
|
||||
]
|
||||
}
|
||||
Usage with ToolNode and graph compilation:
|
||||
|
||||
```python
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
store = InMemoryStore()
|
||||
tool_node = ToolNode([save_preference, get_preference])
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("tools", tool_node)
|
||||
compiled_graph = graph.compile(store=store) # Store is injected automatically
|
||||
```
|
||||
|
||||
Cross-session persistence:
|
||||
|
||||
```python
|
||||
# First session
|
||||
result1 = graph.invoke({"messages": [HumanMessage("Save my favorite color as blue")]})
|
||||
|
||||
# Later session - data persists
|
||||
result2 = graph.invoke({"messages": [HumanMessage("What's my favorite color?")]})
|
||||
```
|
||||
|
||||
Note:
|
||||
- InjectedStore arguments are automatically excluded from tool schemas
|
||||
presented to language models
|
||||
- The store instance is automatically injected by ToolNode during execution
|
||||
- Tools can access namespaced storage using the store's get/put methods
|
||||
- Store injection requires the graph to be compiled with a store instance
|
||||
- Multiple tools can share the same store instance for data consistency
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
def _is_injection(
|
||||
type_arg: Any, injection_type: Union[Type[InjectedState], Type[InjectedStore]]
|
||||
) -> bool:
|
||||
"""Check if a type argument represents an injection annotation.
|
||||
|
||||
This utility function determines whether a type annotation indicates that
|
||||
an argument should be injected with state or store data. It handles both
|
||||
direct annotations and nested annotations within Union or Annotated types.
|
||||
|
||||
Args:
|
||||
type_arg: The type argument to check for injection annotations.
|
||||
injection_type: The injection type to look for (InjectedState or InjectedStore).
|
||||
|
||||
Returns:
|
||||
True if the type argument contains the specified injection annotation.
|
||||
"""
|
||||
if isinstance(type_arg, injection_type) or (
|
||||
isinstance(type_arg, type) and issubclass(type_arg, injection_type)
|
||||
):
|
||||
@@ -818,6 +1018,19 @@ def _is_injection(
|
||||
|
||||
|
||||
def _get_state_args(tool: BaseTool) -> dict[str, Optional[str]]:
|
||||
"""Extract state injection mappings from tool annotations.
|
||||
|
||||
This function analyzes a tool's input schema to identify arguments that should
|
||||
be injected with graph state. It processes InjectedState annotations to build
|
||||
a mapping of tool argument names to state field names.
|
||||
|
||||
Args:
|
||||
tool: The tool to analyze for state injection requirements.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping tool argument names to state field names. If a field
|
||||
name is None, the entire state should be injected for that argument.
|
||||
"""
|
||||
full_schema = tool.get_input_schema()
|
||||
tool_args_to_state_fields: dict = {}
|
||||
|
||||
@@ -844,6 +1057,22 @@ def _get_state_args(tool: BaseTool) -> dict[str, Optional[str]]:
|
||||
|
||||
|
||||
def _get_store_arg(tool: BaseTool) -> Optional[str]:
|
||||
"""Extract store injection argument from tool annotations.
|
||||
|
||||
This function analyzes a tool's input schema to identify the argument that
|
||||
should be injected with the graph store. Only one store argument is supported
|
||||
per tool.
|
||||
|
||||
Args:
|
||||
tool: The tool to analyze for store injection requirements.
|
||||
|
||||
Returns:
|
||||
The name of the argument that should receive the store injection, or None
|
||||
if no store injection is required.
|
||||
|
||||
Raises:
|
||||
ValueError: If a tool argument has multiple InjectedStore annotations.
|
||||
"""
|
||||
full_schema = tool.get_input_schema()
|
||||
for name, type_ in get_all_basemodel_annotations(full_schema).items():
|
||||
injections = [
|
||||
|
||||
Generated
+1
-1
@@ -507,7 +507,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.1.72"
|
||||
version = "0.1.73"
|
||||
source = { editable = "../sdk-py" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
|
||||
@@ -335,8 +335,10 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
|
||||
@typing.overload
|
||||
def __call__(
|
||||
self,
|
||||
fn: _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
|
||||
| _ActionHandler[dict[str, typing.Any]],
|
||||
fn: (
|
||||
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
|
||||
| _ActionHandler[dict[str, typing.Any]]
|
||||
),
|
||||
) -> _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]: ...
|
||||
|
||||
@typing.overload
|
||||
@@ -352,9 +354,11 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
fn: _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
|
||||
| _ActionHandler[dict[str, typing.Any]]
|
||||
| None = None,
|
||||
fn: (
|
||||
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
|
||||
| _ActionHandler[dict[str, typing.Any]]
|
||||
| None
|
||||
) = None,
|
||||
*,
|
||||
resources: str | Sequence[str] | None = None,
|
||||
actions: str | Sequence[str] | None = None,
|
||||
@@ -476,9 +480,13 @@ class _StoreOn:
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
actions: typing.Literal["put", "get", "search", "list_namespaces", "delete"]
|
||||
| Sequence[typing.Literal["put", "get", "search", "list_namespaces", "delete"]]
|
||||
| None = None,
|
||||
actions: (
|
||||
typing.Literal["put", "get", "search", "list_namespaces", "delete"]
|
||||
| Sequence[
|
||||
typing.Literal["put", "get", "search", "list_namespaces", "delete"]
|
||||
]
|
||||
| None
|
||||
) = None,
|
||||
) -> Callable[[AHO], AHO]: ...
|
||||
|
||||
@typing.overload
|
||||
@@ -488,9 +496,13 @@ class _StoreOn:
|
||||
self,
|
||||
fn: AHO | None = None,
|
||||
*,
|
||||
actions: typing.Literal["put", "get", "search", "list_namespaces", "delete"]
|
||||
| Sequence[typing.Literal["put", "get", "search", "list_namespaces", "delete"]]
|
||||
| None = None,
|
||||
actions: (
|
||||
typing.Literal["put", "get", "search", "list_namespaces", "delete"]
|
||||
| Sequence[
|
||||
typing.Literal["put", "get", "search", "list_namespaces", "delete"]
|
||||
]
|
||||
| None
|
||||
) = None,
|
||||
) -> AHO | Callable[[AHO], AHO]:
|
||||
"""Register a handler for specific resources and actions.
|
||||
|
||||
@@ -708,4 +720,12 @@ def _validate_handler(fn: Callable[..., typing.Any]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def is_studio_user(user: types.MinimalUser | types.User | types.UserDict) -> bool:
|
||||
return (
|
||||
isinstance(user, types.StudioUser)
|
||||
or isinstance(user, dict)
|
||||
and user.get("kind") == "StudioUser"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["Auth", "types", "exceptions"]
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.1.72"
|
||||
version = "0.1.73"
|
||||
description = "SDK for interacting with LangGraph API"
|
||||
authors = []
|
||||
requires-python = ">=3.9"
|
||||
|
||||
Generated
+37
-37
@@ -19,11 +19,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.7.9"
|
||||
version = "2025.7.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/8a/c729b6b60c66a38f590c4e774decc4b2ec7b0576be8f1aa984a53ffa812a/certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079", size = 160386, upload-time = "2025-07-09T02:13:58.874Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981, upload-time = "2025-07-14T03:29:28.449Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/66/f3/80a3f974c8b535d394ff960a11ac20368e06b736da395b551a49ce950cce/certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39", size = 159230, upload-time = "2025-07-09T02:13:57.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -119,7 +119,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.1.72"
|
||||
version = "0.1.73"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -156,7 +156,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.16.1"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mypy-extensions" },
|
||||
@@ -164,39 +164,39 @@ dependencies = [
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/e3/034322d5a779685218ed69286c32faa505247f1f096251ef66c8fd203b08/mypy-1.17.0.tar.gz", hash = "sha256:e5d7ccc08ba089c06e2f5629c660388ef1fee708444f1dee0b9203fa031dee03", size = 3352114, upload-time = "2025-07-14T20:34:30.181Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/12/2bf23a80fcef5edb75de9a1e295d778e0f46ea89eb8b115818b663eff42b/mypy-1.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4f0fed1022a63c6fec38f28b7fc77fca47fd490445c69d0a66266c59dd0b88a", size = 10958644, upload-time = "2025-06-16T16:51:11.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/50/bfe47b3b278eacf348291742fd5e6613bbc4b3434b72ce9361896417cfe5/mypy-1.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86042bbf9f5a05ea000d3203cf87aa9d0ccf9a01f73f71c58979eb9249f46d72", size = 10087033, upload-time = "2025-06-16T16:35:30.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/de/40307c12fe25675a0776aaa2cdd2879cf30d99eec91b898de00228dc3ab5/mypy-1.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea7469ee5902c95542bea7ee545f7006508c65c8c54b06dc2c92676ce526f3ea", size = 11875645, upload-time = "2025-06-16T16:35:48.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/d8/85bdb59e4a98b7a31495bd8f1a4445d8ffc86cde4ab1f8c11d247c11aedc/mypy-1.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352025753ef6a83cb9e7f2427319bb7875d1fdda8439d1e23de12ab164179574", size = 12616986, upload-time = "2025-06-16T16:48:39.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/d0/bb25731158fa8f8ee9e068d3e94fcceb4971fedf1424248496292512afe9/mypy-1.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff9fa5b16e4c1364eb89a4d16bcda9987f05d39604e1e6c35378a2987c1aac2d", size = 12878632, upload-time = "2025-06-16T16:36:08.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/11/822a9beb7a2b825c0cb06132ca0a5183f8327a5e23ef89717c9474ba0bc6/mypy-1.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:1256688e284632382f8f3b9e2123df7d279f603c561f099758e66dd6ed4e8bd6", size = 9484391, upload-time = "2025-06-16T16:37:56.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/61/ec1245aa1c325cb7a6c0f8570a2eee3bfc40fa90d19b1267f8e50b5c8645/mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc", size = 10890557, upload-time = "2025-06-16T16:37:21.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/bb/6eccc0ba0aa0c7a87df24e73f0ad34170514abd8162eb0c75fd7128171fb/mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782", size = 10012921, upload-time = "2025-06-16T16:51:28.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/80/b337a12e2006715f99f529e732c5f6a8c143bb58c92bb142d5ab380963a5/mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507", size = 11802887, upload-time = "2025-06-16T16:50:53.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/59/f7af072d09793d581a745a25737c7c0a945760036b16aeb620f658a017af/mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca", size = 12531658, upload-time = "2025-06-16T16:33:55.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/c4/607672f2d6c0254b94a646cfc45ad589dd71b04aa1f3d642b840f7cce06c/mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4", size = 12732486, upload-time = "2025-06-16T16:37:03.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/5e/136555ec1d80df877a707cebf9081bd3a9f397dedc1ab9750518d87489ec/mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6", size = 9479482, upload-time = "2025-06-16T16:47:37.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/f8/491997a9b8a554204f834ed4816bda813aefda31cf873bb099deee3c9a99/mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15", size = 12722980, upload-time = "2025-06-16T16:37:40.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/f0/2bd41e174b5fd93bc9de9a28e4fb673113633b8a7f3a607fa4a73595e468/mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd", size = 12903328, upload-time = "2025-06-16T16:34:35.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/81/5572108a7bec2c46b8aff7e9b524f371fe6ab5efb534d38d6b37b5490da8/mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b", size = 9562321, upload-time = "2025-06-16T16:48:58.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/5e/ed1e6a7344005df11dfd58b0fdd59ce939a0ba9f7ed37754bf20670b74db/mypy-1.16.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7fc688329af6a287567f45cc1cefb9db662defeb14625213a5b7da6e692e2069", size = 10959511, upload-time = "2025-06-16T16:47:21.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/88/a7cbc2541e91fe04f43d9e4577264b260fecedb9bccb64ffb1a34b7e6c22/mypy-1.16.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e198ab3f55924c03ead626ff424cad1732d0d391478dfbf7bb97b34602395da", size = 10075555, upload-time = "2025-06-16T16:50:14.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/f7/c62b1e31a32fbd1546cca5e0a2e5f181be5761265ad1f2e94f2a306fa906/mypy-1.16.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09aa4f91ada245f0a45dbc47e548fd94e0dd5a8433e0114917dc3b526912a30c", size = 11874169, upload-time = "2025-06-16T16:49:42.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/15/db580a28034657fb6cb87af2f8996435a5b19d429ea4dcd6e1c73d418e60/mypy-1.16.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13c7cd5b1cb2909aa318a90fd1b7e31f17c50b242953e7dd58345b2a814f6383", size = 12610060, upload-time = "2025-06-16T16:34:15.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/78/c17f48f6843048fa92d1489d3095e99324f2a8c420f831a04ccc454e2e51/mypy-1.16.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:58e07fb958bc5d752a280da0e890c538f1515b79a65757bbdc54252ba82e0b40", size = 12875199, upload-time = "2025-06-16T16:35:14.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/d6/ed42167d0a42680381653fd251d877382351e1bd2c6dd8a818764be3beb1/mypy-1.16.1-cp39-cp39-win_amd64.whl", hash = "sha256:f895078594d918f93337a505f8add9bd654d1a24962b4c6ed9390e12531eb31b", size = 9487033, upload-time = "2025-06-16T16:49:57.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/31/e762baa3b73905c856d45ab77b4af850e8159dffffd86a52879539a08c6b/mypy-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8e08de6138043108b3b18f09d3f817a4783912e48828ab397ecf183135d84d6", size = 10998313, upload-time = "2025-07-14T20:33:24.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/c1/25b2f0d46fb7e0b5e2bee61ec3a47fe13eff9e3c2f2234f144858bbe6485/mypy-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce4a17920ec144647d448fc43725b5873548b1aae6c603225626747ededf582d", size = 10128922, upload-time = "2025-07-14T20:34:06.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/78/6d646603a57aa8a2886df1b8881fe777ea60f28098790c1089230cd9c61d/mypy-1.17.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff25d151cc057fdddb1cb1881ef36e9c41fa2a5e78d8dd71bee6e4dcd2bc05b", size = 11913524, upload-time = "2025-07-14T20:33:19.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/19/dae6c55e87ee426fb76980f7e78484450cad1c01c55a1dc4e91c930bea01/mypy-1.17.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93468cf29aa9a132bceb103bd8475f78cacde2b1b9a94fd978d50d4bdf616c9a", size = 12650527, upload-time = "2025-07-14T20:32:44.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e1/f916845a235235a6c1e4d4d065a3930113767001d491b8b2e1b61ca56647/mypy-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:98189382b310f16343151f65dd7e6867386d3e35f7878c45cfa11383d175d91f", size = 12897284, upload-time = "2025-07-14T20:33:38.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/dc/414760708a4ea1b096bd214d26a24e30ac5e917ef293bc33cdb6fe22d2da/mypy-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:c004135a300ab06a045c1c0d8e3f10215e71d7b4f5bb9a42ab80236364429937", size = 9506493, upload-time = "2025-07-14T20:34:01.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/82efb502b0b0f661c49aa21cfe3e1999ddf64bf5500fc03b5a1536a39d39/mypy-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d4fe5c72fd262d9c2c91c1117d16aac555e05f5beb2bae6a755274c6eec42be", size = 10914150, upload-time = "2025-07-14T20:31:51.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/96/8ef9a6ff8cedadff4400e2254689ca1dc4b420b92c55255b44573de10c54/mypy-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d96b196e5c16f41b4f7736840e8455958e832871990c7ba26bf58175e357ed61", size = 10039845, upload-time = "2025-07-14T20:32:30.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/32/7ce359a56be779d38021d07941cfbb099b41411d72d827230a36203dbb81/mypy-1.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73a0ff2dd10337ceb521c080d4147755ee302dcde6e1a913babd59473904615f", size = 11837246, upload-time = "2025-07-14T20:32:01.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/16/b775047054de4d8dbd668df9137707e54b07fe18c7923839cd1e524bf756/mypy-1.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cfcc1179c4447854e9e406d3af0f77736d631ec87d31c6281ecd5025df625d", size = 12571106, upload-time = "2025-07-14T20:34:26.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/cf/fa33eaf29a606102c8d9ffa45a386a04c2203d9ad18bf4eef3e20c43ebc8/mypy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c56f180ff6430e6373db7a1d569317675b0a451caf5fef6ce4ab365f5f2f6c3", size = 12759960, upload-time = "2025-07-14T20:33:42.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/75/3f5a29209f27e739ca57e6350bc6b783a38c7621bdf9cac3ab8a08665801/mypy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:eafaf8b9252734400f9b77df98b4eee3d2eecab16104680d51341c75702cad70", size = 9503888, upload-time = "2025-07-14T20:32:34.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/e9/e6824ed620bbf51d3bf4d6cbbe4953e83eaf31a448d1b3cfb3620ccb641c/mypy-1.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f986f1cab8dbec39ba6e0eaa42d4d3ac6686516a5d3dccd64be095db05ebc6bb", size = 11086395, upload-time = "2025-07-14T20:34:11.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/51/a4afd1ae279707953be175d303f04a5a7bd7e28dc62463ad29c1c857927e/mypy-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51e455a54d199dd6e931cd7ea987d061c2afbaf0960f7f66deef47c90d1b304d", size = 10120052, upload-time = "2025-07-14T20:33:09.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/71/19adfeac926ba8205f1d1466d0d360d07b46486bf64360c54cb5a2bd86a8/mypy-1.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3204d773bab5ff4ebbd1f8efa11b498027cd57017c003ae970f310e5b96be8d8", size = 11861806, upload-time = "2025-07-14T20:32:16.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/64/d6120eca3835baf7179e6797a0b61d6c47e0bc2324b1f6819d8428d5b9ba/mypy-1.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1051df7ec0886fa246a530ae917c473491e9a0ba6938cfd0ec2abc1076495c3e", size = 12744371, upload-time = "2025-07-14T20:33:33.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/dc/56f53b5255a166f5bd0f137eed960e5065f2744509dfe69474ff0ba772a5/mypy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f773c6d14dcc108a5b141b4456b0871df638eb411a89cd1c0c001fc4a9d08fc8", size = 12914558, upload-time = "2025-07-14T20:33:56.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/ac/070bad311171badc9add2910e7f89271695a25c136de24bbafc7eded56d5/mypy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:1619a485fd0e9c959b943c7b519ed26b712de3002d7de43154a489a2d0fd817d", size = 9585447, upload-time = "2025-07-14T20:32:20.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/7b/5f8ab461369b9e62157072156935cec9d272196556bdc7c2ff5f4c7c0f9b/mypy-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c41aa59211e49d717d92b3bb1238c06d387c9325d3122085113c79118bebb06", size = 11070019, upload-time = "2025-07-14T20:32:07.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f8/c49c9e5a2ac0badcc54beb24e774d2499748302c9568f7f09e8730e953fa/mypy-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e69db1fb65b3114f98c753e3930a00514f5b68794ba80590eb02090d54a5d4a", size = 10114457, upload-time = "2025-07-14T20:33:47.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/0c/fb3f9c939ad9beed3e328008b3fb90b20fda2cddc0f7e4c20dbefefc3b33/mypy-1.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03ba330b76710f83d6ac500053f7727270b6b8553b0423348ffb3af6f2f7b889", size = 11857838, upload-time = "2025-07-14T20:33:14.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/66/85607ab5137d65e4f54d9797b77d5a038ef34f714929cf8ad30b03f628df/mypy-1.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:037bc0f0b124ce46bfde955c647f3e395c6174476a968c0f22c95a8d2f589bba", size = 12731358, upload-time = "2025-07-14T20:32:25.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/d0/341dbbfb35ce53d01f8f2969facbb66486cee9804048bf6c01b048127501/mypy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c38876106cb6132259683632b287238858bd58de267d80defb6f418e9ee50658", size = 12917480, upload-time = "2025-07-14T20:34:21.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/63/70c8b7dbfc520089ac48d01367a97e8acd734f65bd07813081f508a8c94c/mypy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:d30ba01c0f151998f367506fab31c2ac4527e6a7b2690107c7a7f9e3cb419a9c", size = 9589666, upload-time = "2025-07-14T20:34:16.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a0/6263dd11941231f688f0a8f2faf90ceac1dc243d148d314a089d2fe25108/mypy-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:63e751f1b5ab51d6f3d219fe3a2fe4523eaa387d854ad06906c63883fde5b1ab", size = 10988185, upload-time = "2025-07-14T20:33:04.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/13/b8f16d6b0dc80277129559c8e7dbc9011241a0da8f60d031edb0e6e9ac8f/mypy-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f7fb09d05e0f1c329a36dcd30e27564a3555717cde87301fae4fb542402ddfad", size = 10120169, upload-time = "2025-07-14T20:32:38.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/ef/978ba79df0d65af680e20d43121363cf643eb79b04bf3880d01fc8afeb6f/mypy-1.17.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b72c34ce05ac3a1361ae2ebb50757fb6e3624032d91488d93544e9f82db0ed6c", size = 11918121, upload-time = "2025-07-14T20:33:52.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/10/55ef70b104151a0d8280474f05268ff0a2a79be8d788d5e647257d121309/mypy-1.17.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434ad499ad8dde8b2f6391ddfa982f41cb07ccda8e3c67781b1bfd4e5f9450a8", size = 12648821, upload-time = "2025-07-14T20:32:59.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/8c/7781fcd2e1eef48fbedd3a422c21fe300a8e03ed5be2eb4bd10246a77f4e/mypy-1.17.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f105f61a5eff52e137fd73bee32958b2add9d9f0a856f17314018646af838e97", size = 12896955, upload-time = "2025-07-14T20:32:49.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/13/03ac759dabe86e98ca7b6681f114f90ee03f3ff8365a57049d311bd4a4e3/mypy-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:ba06254a5a22729853209550d80f94e28690d5530c661f9416a68ac097b13fc4", size = 9512957, upload-time = "2025-07-14T20:33:28.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/fc/ee058cc4316f219078464555873e99d170bde1d9569abd833300dbeb484a/mypy-1.17.0-py3-none-any.whl", hash = "sha256:15d9d0018237ab058e5de3d8fce61b6fa72cc59cc78fd91f1b474bce12abf496", size = 2283195, upload-time = "2025-07-14T20:31:54.753Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user