mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-27 01:52:25 +02:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f2453024a | ||
|
|
b22227588c | ||
|
|
f4cdeea6ad | ||
|
|
1cd1373788 | ||
|
|
f994d16b49 | ||
|
|
20953b4728 | ||
|
|
e670815780 | ||
|
|
4151861ca2 | ||
|
|
5239184ba6 | ||
|
|
a5aa9ce27d | ||
|
|
b58a7fb2fe | ||
|
|
ebae60045f | ||
|
|
42f9683d73 | ||
|
|
69249e724d | ||
|
|
e6d71a586d | ||
|
|
50601dc02c | ||
|
|
9e174e7e8b | ||
|
|
9e9a5d2498 | ||
|
|
7e257dadd6 | ||
|
|
2fed0e4852 | ||
|
|
d43eaf1f42 | ||
|
|
16b363fbb0 | ||
|
|
68a75135b0 | ||
|
|
5c0c0fb186 | ||
|
|
4571b708d9 | ||
|
|
e365b2b8bd | ||
|
|
b5504506a7 | ||
|
|
c6ae8d25b9 | ||
|
|
0bd7dd2c52 | ||
|
|
82978a8dd8 | ||
|
|
925150a35d |
@@ -17,7 +17,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.9"
|
||||
- "3.10"
|
||||
- "3.11"
|
||||
- "3.12"
|
||||
|
||||
@@ -12,7 +12,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.9"
|
||||
- "3.10"
|
||||
- "3.11"
|
||||
- "3.12"
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
# use minimum supported Python version
|
||||
python-version: "3.9"
|
||||
python-version: "3.10"
|
||||
enable-cache: true
|
||||
cache-suffix: "uv-lock-upgrade"
|
||||
|
||||
|
||||
@@ -188,13 +188,13 @@ REDIRECT_MAP = {
|
||||
"cloud/deployment/custom_docker.md": "https://docs.langchain.com/langgraph-platform/custom-docker",
|
||||
"cloud/deployment/graph_rebuild.md": "https://docs.langchain.com/langgraph-platform/graph-rebuild",
|
||||
"concepts/langgraph_cloud.md": "https://docs.langchain.com/langgraph-platform/cloud",
|
||||
"concepts/langgraph_self_hosted_data_plane.md": "https://docs.langchain.com/langgraph-platform/self-hosted-data-plane",
|
||||
"concepts/langgraph_self_hosted_control_plane.md": "https://docs.langchain.com/langgraph-platform/self-hosted-control-plane",
|
||||
"concepts/langgraph_standalone_container.md": "https://docs.langchain.com/langgraph-platform/standalone-container",
|
||||
"concepts/langgraph_self_hosted_data_plane.md": "https://docs.langchain.com/langgraph-platform/hybrid",
|
||||
"concepts/langgraph_self_hosted_control_plane.md": "https://docs.langchain.com/langgraph-platform/self-hosted",
|
||||
"concepts/langgraph_standalone_container.md": "https://docs.langchain.com/langgraph-platform/self-hosted#data-plane-only",
|
||||
"cloud/deployment/cloud.md": "https://docs.langchain.com/langgraph-platform/cloud",
|
||||
"cloud/deployment/self_hosted_data_plane.md": "https://docs.langchain.com/langgraph-platform/deploy-self-hosted-data-plane",
|
||||
"cloud/deployment/self_hosted_control_plane.md": "https://docs.langchain.com/langgraph-platform/deploy-self-hosted-control-plane",
|
||||
"cloud/deployment/standalone_container.md": "https://docs.langchain.com/langgraph-platform/deploy-standalone-container",
|
||||
"cloud/deployment/self_hosted_data_plane.md": "https://docs.langchain.com/langgraph-platform/deploy-hybrid",
|
||||
"cloud/deployment/self_hosted_control_plane.md": "https://docs.langchain.com/langgraph-platform/deploy-self-hosted-full-platform",
|
||||
"cloud/deployment/standalone_container.md": "https://docs.langchain.com/langgraph-platform/deploy-data-plane-only",
|
||||
"concepts/server-mcp.md": "https://docs.langchain.com/langgraph-platform/server-mcp",
|
||||
"cloud/how-tos/human_in_the_loop_time_travel.md": "https://docs.langchain.com/langgraph-platform/human-in-the-loop-time-travel",
|
||||
"cloud/how-tos/add-human-in-the-loop.md": "https://docs.langchain.com/langgraph-platform/add-human-in-the-loop",
|
||||
|
||||
@@ -367,13 +367,13 @@ To implement handoffs with `createReactAgent`, you need to:
|
||||
|
||||
3. Define a parent graph that contains individual agents as nodes:
|
||||
|
||||
```typescript
|
||||
import { StateGraph, MessagesZodState } from "@langchain/langgraph";
|
||||
const multiAgentGraph = new StateGraph(MessagesZodState)
|
||||
.addNode("flight_assistant", flightAssistant)
|
||||
.addNode("hotel_assistant", hotelAssistant)
|
||||
// ...
|
||||
```
|
||||
```typescript
|
||||
import { StateGraph, MessagesZodState } from "@langchain/langgraph";
|
||||
const multiAgentGraph = new StateGraph(MessagesZodState)
|
||||
.addNode("flight_assistant", flightAssistant)
|
||||
.addNode("hotel_assistant", hotelAssistant)
|
||||
// ...
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
@@ -619,7 +619,8 @@ for await (const chunk of multiAgentGraph.stream({
|
||||
3. Name of the agent or node to hand off to.
|
||||
4. Take the agent's messages and **add** them to the parent's **state** as part of the handoff. The next agent will see the parent state.
|
||||
5. Indicate to LangGraph that we need to navigate to agent node in a **parent** multi-agent graph.
|
||||
:::
|
||||
|
||||
:::
|
||||
|
||||
!!! Note
|
||||
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
|
||||
Install the `langchain-mcp-adapters` library to use MCP tools in LangGraph:
|
||||
|
||||
:::python
|
||||
```bash
|
||||
pip install langchain-mcp-adapters
|
||||
```
|
||||
:::
|
||||
|
||||
:::js
|
||||
```bash
|
||||
npm install @langchain/mcp-adapters
|
||||
```
|
||||
:::
|
||||
+1411
-9
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ To set up communication between the agents in a multi-agent system you can use [
|
||||
|
||||
To implement handoffs, you can return `Command` objects from your agent nodes or tools:
|
||||
|
||||
:::python
|
||||
```python
|
||||
from typing import Annotated
|
||||
from langchain_core.tools import tool, InjectedToolCallId
|
||||
@@ -73,25 +74,109 @@ def create_handoff_tool(*, agent_name: str, description: str | None = None):
|
||||
commands = [tools_by_name[tool_call["name"]].invoke(tool_call) for tool_call in tool_calls]
|
||||
return commands
|
||||
```
|
||||
:::
|
||||
|
||||
:::js
|
||||
```typescript
|
||||
import { tool } from "@langchain/core/tools";
|
||||
import { Command, MessagesZodState } from "@langchain/langgraph";
|
||||
import { z } from "zod";
|
||||
|
||||
function createHandoffTool({
|
||||
agentName,
|
||||
description,
|
||||
}: {
|
||||
agentName: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const name = `transfer_to_${agentName}`;
|
||||
const toolDescription = description || `Transfer to ${agentName}`;
|
||||
|
||||
return tool(
|
||||
async (_, config) => {
|
||||
// (1)!
|
||||
const state = config.state;
|
||||
const toolCallId = config.toolCall.id;
|
||||
|
||||
const toolMessage = {
|
||||
role: "tool" as const,
|
||||
content: `Successfully transferred to ${agentName}`,
|
||||
name: name,
|
||||
tool_call_id: toolCallId,
|
||||
};
|
||||
|
||||
return new Command({
|
||||
// (3)!
|
||||
goto: agentName,
|
||||
// (4)!
|
||||
update: { messages: [...state.messages, toolMessage] },
|
||||
// (5)!
|
||||
graph: Command.PARENT,
|
||||
});
|
||||
},
|
||||
{
|
||||
name,
|
||||
description: toolDescription,
|
||||
schema: z.object({}),
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
1. Access the [state](../concepts/low_level.md#state) of the agent that is calling the handoff tool through the `config` parameter.
|
||||
2. The `Command` primitive allows specifying a state update and a node transition as a single operation, making it useful for implementing handoffs.
|
||||
3. Name of the agent or node to hand off to.
|
||||
4. Take the agent's messages and **add** them to the parent's **state** as part of the handoff. The next agent will see the parent state.
|
||||
5. Indicate to LangGraph that we need to navigate to agent node in a **parent** multi-agent graph.
|
||||
|
||||
!!! tip
|
||||
|
||||
If you want to use tools that return `Command`, you can either use prebuilt @[`create_react_agent`][create_react_agent] / @[`ToolNode`][ToolNode] components, or implement your own tool-executing node that collects `Command` objects returned by the tools and returns a list of them, e.g.:
|
||||
|
||||
```typescript
|
||||
const callTools = async (state) => {
|
||||
// ...
|
||||
const commands = await Promise.all(
|
||||
toolCalls.map(toolCall => toolsByName[toolCall.name].invoke(toolCall))
|
||||
);
|
||||
return commands;
|
||||
};
|
||||
```
|
||||
:::
|
||||
|
||||
!!! Important
|
||||
|
||||
This handoff implementation assumes that:
|
||||
|
||||
- each agent receives overall message history (across all agents) in the multi-agent system as its input. If you want more control over agent inputs, see [this section](#control-agent-inputs)
|
||||
- each agent outputs its internal messages history to the overall message history of the multi-agent system. If you want more control over **how agent outputs are added**, wrap the agent in a separate node function:
|
||||
- each agent receives overall message history (across all agents) in the multi-agent system as its input. If you want more control over agent inputs, see [this section](#control-agent-inputs)
|
||||
- each agent outputs its internal messages history to the overall message history of the multi-agent system. If you want more control over **how agent outputs are added**, wrap the agent in a separate node function:
|
||||
|
||||
```python
|
||||
def call_hotel_assistant(state):
|
||||
# return agent's final response,
|
||||
# excluding inner monologue
|
||||
response = hotel_assistant.invoke(state)
|
||||
# highlight-next-line
|
||||
return {"messages": response["messages"][-1]}
|
||||
```
|
||||
:::python
|
||||
```python
|
||||
def call_hotel_assistant(state):
|
||||
# return agent's final response,
|
||||
# excluding inner monologue
|
||||
response = hotel_assistant.invoke(state)
|
||||
# highlight-next-line
|
||||
return {"messages": response["messages"][-1]}
|
||||
```
|
||||
:::
|
||||
|
||||
:::js
|
||||
```typescript
|
||||
const callHotelAssistant = async (state) => {
|
||||
// return agent's final response,
|
||||
// excluding inner monologue
|
||||
const response = await hotelAssistant.invoke(state);
|
||||
// highlight-next-line
|
||||
return { messages: [response.messages.at(-1)] };
|
||||
};
|
||||
```
|
||||
:::
|
||||
|
||||
### Control agent inputs
|
||||
|
||||
:::python
|
||||
You can use the @[`Send()`][Send] primitive to directly send data to the worker agents during the handoff. For example, you can request that the calling agent populate a task description for the next agent:
|
||||
|
||||
```python
|
||||
@@ -129,6 +214,63 @@ def create_task_description_handoff_tool(
|
||||
|
||||
return handoff_tool
|
||||
```
|
||||
:::
|
||||
|
||||
:::js
|
||||
You can use the @[`Send()`][Send] primitive to directly send data to the worker agents during the handoff. For example, you can request that the calling agent populate a task description for the next agent:
|
||||
|
||||
```typescript
|
||||
import { tool } from "@langchain/core/tools";
|
||||
import { Command, Send, MessagesZodState } from "@langchain/langgraph";
|
||||
import { z } from "zod";
|
||||
|
||||
function createTaskDescriptionHandoffTool({
|
||||
agentName,
|
||||
description,
|
||||
}: {
|
||||
agentName: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const name = `transfer_to_${agentName}`;
|
||||
const toolDescription = description || `Ask ${agentName} for help.`;
|
||||
|
||||
return tool(
|
||||
async (
|
||||
{ taskDescription },
|
||||
config
|
||||
) => {
|
||||
const state = config.state;
|
||||
|
||||
const taskDescriptionMessage = {
|
||||
role: "user" as const,
|
||||
content: taskDescription,
|
||||
};
|
||||
const agentInput = {
|
||||
...state,
|
||||
messages: [taskDescriptionMessage],
|
||||
};
|
||||
|
||||
return new Command({
|
||||
// highlight-next-line
|
||||
goto: [new Send(agentName, agentInput)],
|
||||
graph: Command.PARENT,
|
||||
});
|
||||
},
|
||||
{
|
||||
name,
|
||||
description: toolDescription,
|
||||
schema: z.object({
|
||||
taskDescription: z
|
||||
.string()
|
||||
.describe(
|
||||
"Description of what the next agent should do, including all of the relevant context."
|
||||
),
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
:::
|
||||
|
||||
See the multi-agent [supervisor](../tutorials/multi_agent/agent_supervisor.md#4-create-delegation-tasks) example for a full example of using @[`Send()`][Send] in handoffs.
|
||||
|
||||
@@ -136,6 +278,7 @@ See the multi-agent [supervisor](../tutorials/multi_agent/agent_supervisor.md#4-
|
||||
|
||||
You can use handoffs in any agents built with LangGraph. We recommend using the prebuilt [agent](../agents/overview.md) or [`ToolNode`](./tool-calling.md#toolnode), as they natively support handoffs tools returning `Command`. Below is an example of how you can implement a multi-agent system for booking travel using handoffs:
|
||||
|
||||
:::python
|
||||
```python
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
from langgraph.graph import StateGraph, START, MessagesState
|
||||
@@ -176,9 +319,65 @@ multi_agent_graph = (
|
||||
.compile()
|
||||
)
|
||||
```
|
||||
:::
|
||||
|
||||
:::js
|
||||
```typescript
|
||||
import { createReactAgent } from "@langchain/langgraph/prebuilt";
|
||||
import { StateGraph, START, MessagesZodState } from "@langchain/langgraph";
|
||||
import { z } from "zod";
|
||||
|
||||
function createHandoffTool({
|
||||
agentName,
|
||||
description,
|
||||
}: {
|
||||
agentName: string;
|
||||
description?: string;
|
||||
}) {
|
||||
// same implementation as above
|
||||
// ...
|
||||
return new Command(/* ... */);
|
||||
}
|
||||
|
||||
// Handoffs
|
||||
const transferToHotelAssistant = createHandoffTool({
|
||||
agentName: "hotel_assistant",
|
||||
});
|
||||
const transferToFlightAssistant = createHandoffTool({
|
||||
agentName: "flight_assistant",
|
||||
});
|
||||
|
||||
// Define agents
|
||||
const flightAssistant = createReactAgent({
|
||||
llm: model,
|
||||
// highlight-next-line
|
||||
tools: [/* ... */, transferToHotelAssistant],
|
||||
// highlight-next-line
|
||||
name: "flight_assistant",
|
||||
});
|
||||
|
||||
const hotelAssistant = createReactAgent({
|
||||
llm: model,
|
||||
// highlight-next-line
|
||||
tools: [/* ... */, transferToFlightAssistant],
|
||||
// highlight-next-line
|
||||
name: "hotel_assistant",
|
||||
});
|
||||
|
||||
// Define multi-agent graph
|
||||
const multiAgentGraph = new StateGraph(MessagesZodState)
|
||||
// highlight-next-line
|
||||
.addNode("flight_assistant", flightAssistant)
|
||||
// highlight-next-line
|
||||
.addNode("hotel_assistant", hotelAssistant)
|
||||
.addEdge(START, "flight_assistant")
|
||||
.compile();
|
||||
```
|
||||
:::
|
||||
|
||||
??? example "Full example: Multi-agent system for booking travel"
|
||||
|
||||
:::python
|
||||
```python
|
||||
from typing import Annotated
|
||||
from langchain_core.messages import convert_to_messages
|
||||
@@ -323,6 +522,183 @@ multi_agent_graph = (
|
||||
3. Name of the agent or node to hand off to.
|
||||
4. Take the agent's messages and **add** them to the parent's **state** as part of the handoff. The next agent will see the parent state.
|
||||
5. Indicate to LangGraph that we need to navigate to agent node in a **parent** multi-agent graph.
|
||||
:::
|
||||
|
||||
:::js
|
||||
```typescript
|
||||
import { tool } from "@langchain/core/tools";
|
||||
import { createReactAgent } from "@langchain/langgraph/prebuilt";
|
||||
import { StateGraph, START, MessagesZodState, Command } from "@langchain/langgraph";
|
||||
import { ChatAnthropic } from "@langchain/anthropic";
|
||||
import { isBaseMessage } from "@langchain/core/messages";
|
||||
import { z } from "zod";
|
||||
|
||||
// We'll use a helper to render the streamed agent outputs nicely
|
||||
const prettyPrintMessages = (update: Record<string, any>) => {
|
||||
// Handle tuple case with namespace
|
||||
if (Array.isArray(update)) {
|
||||
const [ns, updateData] = update;
|
||||
// Skip parent graph updates in the printouts
|
||||
if (ns.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const graphId = ns[ns.length - 1].split(":")[0];
|
||||
console.log(`Update from subgraph ${graphId}:\n`);
|
||||
update = updateData;
|
||||
}
|
||||
|
||||
for (const [nodeName, updateValue] of Object.entries(update)) {
|
||||
console.log(`Update from node ${nodeName}:\n`);
|
||||
|
||||
const messages = updateValue.messages || [];
|
||||
for (const message of messages) {
|
||||
if (isBaseMessage(message)) {
|
||||
const textContent =
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: JSON.stringify(message.content);
|
||||
console.log(`${message.getType()}: ${textContent}`);
|
||||
}
|
||||
}
|
||||
console.log("\n");
|
||||
}
|
||||
};
|
||||
|
||||
function createHandoffTool({
|
||||
agentName,
|
||||
description,
|
||||
}: {
|
||||
agentName: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const name = `transfer_to_${agentName}`;
|
||||
const toolDescription = description || `Transfer to ${agentName}`;
|
||||
|
||||
return tool(
|
||||
async (_, config) => {
|
||||
// highlight-next-line
|
||||
const state = config.state; // (1)!
|
||||
const toolCallId = config.toolCall.id;
|
||||
|
||||
const toolMessage = {
|
||||
role: "tool" as const,
|
||||
content: `Successfully transferred to ${agentName}`,
|
||||
name: name,
|
||||
tool_call_id: toolCallId,
|
||||
};
|
||||
|
||||
return new Command({
|
||||
// highlight-next-line
|
||||
goto: agentName, // (3)!
|
||||
// highlight-next-line
|
||||
update: { messages: [...state.messages, toolMessage] }, // (4)!
|
||||
// highlight-next-line
|
||||
graph: Command.PARENT, // (5)!
|
||||
});
|
||||
},
|
||||
{
|
||||
name,
|
||||
description: toolDescription,
|
||||
schema: z.object({}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Handoffs
|
||||
const transferToHotelAssistant = createHandoffTool({
|
||||
agentName: "hotel_assistant",
|
||||
description: "Transfer user to the hotel-booking assistant.",
|
||||
});
|
||||
|
||||
const transferToFlightAssistant = createHandoffTool({
|
||||
agentName: "flight_assistant",
|
||||
description: "Transfer user to the flight-booking assistant.",
|
||||
});
|
||||
|
||||
// Simple agent tools
|
||||
const bookHotel = tool(
|
||||
async ({ hotelName }) => {
|
||||
return `Successfully booked a stay at ${hotelName}.`;
|
||||
},
|
||||
{
|
||||
name: "book_hotel",
|
||||
description: "Book a hotel",
|
||||
schema: z.object({
|
||||
hotelName: z.string(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const bookFlight = tool(
|
||||
async ({ fromAirport, toAirport }) => {
|
||||
return `Successfully booked a flight from ${fromAirport} to ${toAirport}.`;
|
||||
},
|
||||
{
|
||||
name: "book_flight",
|
||||
description: "Book a flight",
|
||||
schema: z.object({
|
||||
fromAirport: z.string(),
|
||||
toAirport: z.string(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const model = new ChatAnthropic({
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
});
|
||||
|
||||
// Define agents
|
||||
const flightAssistant = createReactAgent({
|
||||
llm: model,
|
||||
// highlight-next-line
|
||||
tools: [bookFlight, transferToHotelAssistant],
|
||||
prompt: "You are a flight booking assistant",
|
||||
// highlight-next-line
|
||||
name: "flight_assistant",
|
||||
});
|
||||
|
||||
const hotelAssistant = createReactAgent({
|
||||
llm: model,
|
||||
// highlight-next-line
|
||||
tools: [bookHotel, transferToFlightAssistant],
|
||||
prompt: "You are a hotel booking assistant",
|
||||
// highlight-next-line
|
||||
name: "hotel_assistant",
|
||||
});
|
||||
|
||||
// Define multi-agent graph
|
||||
const multiAgentGraph = new StateGraph(MessagesZodState)
|
||||
.addNode("flight_assistant", flightAssistant)
|
||||
.addNode("hotel_assistant", hotelAssistant)
|
||||
.addEdge(START, "flight_assistant")
|
||||
.compile();
|
||||
|
||||
// Run the multi-agent graph
|
||||
const stream = await multiAgentGraph.stream(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "book a flight from BOS to JFK and a stay at McKittrick Hotel",
|
||||
},
|
||||
],
|
||||
},
|
||||
// highlight-next-line
|
||||
{ subgraphs: true }
|
||||
);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
prettyPrintMessages(chunk);
|
||||
}
|
||||
```
|
||||
|
||||
1. Access agent's state
|
||||
2. The `Command` primitive allows specifying a state update and a node transition as a single operation, making it useful for implementing handoffs.
|
||||
3. Name of the agent or node to hand off to.
|
||||
4. Take the agent's messages and **add** them to the parent's **state** as part of the handoff. The next agent will see the parent state.
|
||||
5. Indicate to LangGraph that we need to navigate to agent node in a **parent** multi-agent graph.
|
||||
:::
|
||||
|
||||
## Multi-turn conversation
|
||||
|
||||
@@ -333,6 +709,7 @@ The agents can then be implemented as nodes in a graph that executes agent steps
|
||||
1. **Wait for user input** to continue the conversation, or
|
||||
2. **Route to another agent** (or back to itself, such as in a loop) via a [handoff](#handoffs)
|
||||
|
||||
:::python
|
||||
```python
|
||||
def human(state) -> Command[Literal["agent", "another_agent"]]:
|
||||
"""A node for collecting user input."""
|
||||
@@ -360,6 +737,44 @@ def agent(state) -> Command[Literal["agent", "another_agent", "human"]]:
|
||||
else:
|
||||
return Command(goto="human") # Go to human node
|
||||
```
|
||||
:::
|
||||
|
||||
:::js
|
||||
```typescript
|
||||
import { interrupt, Command } from "@langchain/langgraph";
|
||||
|
||||
function human(state: MessagesState): Command {
|
||||
const userInput: string = interrupt("Ready for user input.");
|
||||
|
||||
// Determine the active agent
|
||||
const activeAgent = /* ... */;
|
||||
|
||||
return new Command({
|
||||
update: {
|
||||
messages: [{
|
||||
role: "human",
|
||||
content: userInput,
|
||||
}]
|
||||
},
|
||||
goto: activeAgent,
|
||||
});
|
||||
}
|
||||
|
||||
function agent(state: MessagesState): Command {
|
||||
// The condition for routing/halting can be anything, e.g. LLM tool call / structured output, etc.
|
||||
const goto = getNextAgent(/* ... */); // 'agent' / 'anotherAgent'
|
||||
|
||||
if (goto) {
|
||||
return new Command({
|
||||
goto,
|
||||
update: { myStateKey: "myStateValue" }
|
||||
});
|
||||
}
|
||||
|
||||
return new Command({ goto: "human" });
|
||||
}
|
||||
```
|
||||
:::
|
||||
|
||||
??? example "Full example: multi-agent system for travel recommendations"
|
||||
|
||||
@@ -370,6 +785,7 @@ def agent(state) -> Command[Literal["agent", "another_agent", "human"]]:
|
||||
* travel_advisor: can help with travel destination recommendations. Can ask hotel_advisor for help.
|
||||
* hotel_advisor: can help with hotel recommendations. Can ask travel_advisor for help.
|
||||
|
||||
:::python
|
||||
```python
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from langgraph.graph import MessagesState, StateGraph, START
|
||||
@@ -571,10 +987,267 @@ def agent(state) -> Command[Literal["agent", "another_agent", "human"]]:
|
||||
|
||||
Would you like more specific information about any of these activities or would you like to know about other options in the area?
|
||||
```
|
||||
:::
|
||||
|
||||
:::js
|
||||
```typescript
|
||||
import { ChatAnthropic } from "@langchain/anthropic";
|
||||
import { StateGraph, START, MessagesZodState, Command, interrupt, MemorySaver } from "@langchain/langgraph";
|
||||
import { createReactAgent } from "@langchain/langgraph/prebuilt";
|
||||
import { tool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
const model = new ChatAnthropic({ model: "claude-3-5-sonnet-latest" });
|
||||
|
||||
const MultiAgentState = MessagesZodState.extend({
|
||||
lastActiveAgent: z.string().optional(),
|
||||
});
|
||||
|
||||
// Define travel advisor tools
|
||||
const getTravelRecommendations = tool(
|
||||
async () => {
|
||||
// Placeholder implementation
|
||||
return "Based on current trends, I recommend visiting Japan, Portugal, or New Zealand.";
|
||||
},
|
||||
{
|
||||
name: "get_travel_recommendations",
|
||||
description: "Get current travel destination recommendations",
|
||||
schema: z.object({}),
|
||||
}
|
||||
);
|
||||
|
||||
const makeHandoffTool = (agentName: string) => {
|
||||
return tool(
|
||||
async (_, config) => {
|
||||
const state = config.state;
|
||||
const toolCallId = config.toolCall.id;
|
||||
|
||||
const toolMessage = {
|
||||
role: "tool" as const,
|
||||
content: `Successfully transferred to ${agentName}`,
|
||||
name: `transfer_to_${agentName}`,
|
||||
tool_call_id: toolCallId,
|
||||
};
|
||||
|
||||
return new Command({
|
||||
goto: agentName,
|
||||
update: { messages: [...state.messages, toolMessage] },
|
||||
graph: Command.PARENT,
|
||||
});
|
||||
},
|
||||
{
|
||||
name: `transfer_to_${agentName}`,
|
||||
description: `Transfer to ${agentName}`,
|
||||
schema: z.object({}),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const travelAdvisorTools = [
|
||||
getTravelRecommendations,
|
||||
makeHandoffTool("hotel_advisor"),
|
||||
];
|
||||
|
||||
const travelAdvisor = createReactAgent({
|
||||
llm: model,
|
||||
tools: travelAdvisorTools,
|
||||
prompt: [
|
||||
"You are a general travel expert that can recommend travel destinations (e.g. countries, cities, etc). ",
|
||||
"If you need hotel recommendations, ask 'hotel_advisor' for help. ",
|
||||
"You MUST include human-readable response before transferring to another agent."
|
||||
].join("")
|
||||
});
|
||||
|
||||
const callTravelAdvisor = async (
|
||||
state: z.infer<typeof MultiAgentState>
|
||||
): Promise<Command> => {
|
||||
const response = await travelAdvisor.invoke(state);
|
||||
const update = { ...response, lastActiveAgent: "travel_advisor" };
|
||||
return new Command({ update, goto: "human" });
|
||||
};
|
||||
|
||||
// Define hotel advisor tools
|
||||
const getHotelRecommendations = tool(
|
||||
async () => {
|
||||
// Placeholder implementation
|
||||
return "I recommend the Ritz-Carlton for luxury stays or boutique hotels for unique experiences.";
|
||||
},
|
||||
{
|
||||
name: "get_hotel_recommendations",
|
||||
description: "Get hotel recommendations for destinations",
|
||||
schema: z.object({}),
|
||||
}
|
||||
);
|
||||
|
||||
const hotelAdvisorTools = [
|
||||
getHotelRecommendations,
|
||||
makeHandoffTool("travel_advisor"),
|
||||
];
|
||||
|
||||
const hotelAdvisor = createReactAgent({
|
||||
llm: model,
|
||||
tools: hotelAdvisorTools,
|
||||
prompt: [
|
||||
"You are a hotel expert that can provide hotel recommendations for a given destination. ",
|
||||
"If you need help picking travel destinations, ask 'travel_advisor' for help.",
|
||||
"You MUST include human-readable response before transferring to another agent."
|
||||
].join("")
|
||||
});
|
||||
|
||||
const callHotelAdvisor = async (
|
||||
state: z.infer<typeof MultiAgentState>
|
||||
): Promise<Command> => {
|
||||
const response = await hotelAdvisor.invoke(state);
|
||||
const update = { ...response, lastActiveAgent: "hotel_advisor" };
|
||||
return new Command({ update, goto: "human" });
|
||||
};
|
||||
|
||||
const humanNode = async (
|
||||
state: z.infer<typeof MultiAgentState>
|
||||
): Promise<Command> => {
|
||||
const userInput: string = interrupt("Ready for user input.");
|
||||
const activeAgent = state.lastActiveAgent || "travel_advisor";
|
||||
|
||||
return new Command({
|
||||
update: {
|
||||
messages: [
|
||||
{
|
||||
role: "human",
|
||||
content: userInput,
|
||||
}
|
||||
]
|
||||
},
|
||||
goto: activeAgent,
|
||||
});
|
||||
};
|
||||
|
||||
const builder = new StateGraph(MultiAgentState)
|
||||
.addNode("travel_advisor", callTravelAdvisor)
|
||||
.addNode("hotel_advisor", callHotelAdvisor)
|
||||
.addNode("human", humanNode)
|
||||
.addEdge(START, "travel_advisor");
|
||||
|
||||
const checkpointer = new MemorySaver();
|
||||
const graph = builder.compile({ checkpointer });
|
||||
```
|
||||
|
||||
Let's test a multi turn conversation with this application.
|
||||
|
||||
```typescript
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Command } from "@langchain/langgraph";
|
||||
|
||||
const threadConfig = { configurable: { thread_id: uuidv4() } };
|
||||
|
||||
const inputs = [
|
||||
// 1st round of conversation
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "i wanna go somewhere warm in the caribbean" }
|
||||
]
|
||||
},
|
||||
// Since we're using `interrupt`, we'll need to resume using the Command primitive.
|
||||
// 2nd round of conversation
|
||||
new Command({
|
||||
resume: "could you recommend a nice hotel in one of the areas and tell me which area it is."
|
||||
}),
|
||||
// 3rd round of conversation
|
||||
new Command({
|
||||
resume: "i like the first one. could you recommend something to do near the hotel?"
|
||||
}),
|
||||
];
|
||||
|
||||
for (const [idx, userInput] of inputs.entries()) {
|
||||
console.log();
|
||||
console.log(`--- Conversation Turn ${idx + 1} ---`);
|
||||
console.log();
|
||||
console.log(`User: ${JSON.stringify(userInput)}`);
|
||||
console.log();
|
||||
|
||||
for await (const update of await graph.stream(
|
||||
userInput,
|
||||
{ ...threadConfig, streamMode: "updates" }
|
||||
)) {
|
||||
for (const [nodeId, value] of Object.entries(update)) {
|
||||
if (value?.messages?.length) {
|
||||
const lastMessage = value.messages.at(-1);
|
||||
if (lastMessage?.getType?.() === "ai") {
|
||||
console.log(`${nodeId}: ${lastMessage.content}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
--- Conversation Turn 1 ---
|
||||
|
||||
User: {"messages":[{"role":"user","content":"i wanna go somewhere warm in the caribbean"}]}
|
||||
|
||||
travel_advisor: Based on the recommendations, Aruba would be an excellent choice for your Caribbean getaway! Aruba is known as "One Happy Island" and offers:
|
||||
- Year-round warm weather with consistent temperatures around 82°F (28°C)
|
||||
- Beautiful white sand beaches like Eagle Beach and Palm Beach
|
||||
- Clear turquoise waters perfect for swimming and snorkeling
|
||||
- Minimal rainfall and location outside the hurricane belt
|
||||
- A blend of Caribbean and Dutch culture
|
||||
- Great dining options and nightlife
|
||||
- Various water sports and activities
|
||||
|
||||
Would you like me to get some specific hotel recommendations in Aruba for your stay? I can transfer you to our hotel advisor who can help with accommodations.
|
||||
|
||||
--- Conversation Turn 2 ---
|
||||
|
||||
User: Command { resume: 'could you recommend a nice hotel in one of the areas and tell me which area it is.' }
|
||||
|
||||
hotel_advisor: Based on the recommendations, I can suggest two excellent options:
|
||||
|
||||
1. The Ritz-Carlton, Aruba - Located in Palm Beach
|
||||
- This luxury resort is situated in the vibrant Palm Beach area
|
||||
- Known for its exceptional service and amenities
|
||||
- Perfect if you want to be close to dining, shopping, and entertainment
|
||||
- Features multiple restaurants, a casino, and a world-class spa
|
||||
- Located on a pristine stretch of Palm Beach
|
||||
|
||||
2. Bucuti & Tara Beach Resort - Located in Eagle Beach
|
||||
- An adults-only boutique resort on Eagle Beach
|
||||
- Known for being more intimate and peaceful
|
||||
- Award-winning for its sustainability practices
|
||||
- Perfect for a romantic getaway or peaceful vacation
|
||||
- Located on one of the most beautiful beaches in the Caribbean
|
||||
|
||||
Would you like more specific information about either of these properties or their locations?
|
||||
|
||||
--- Conversation Turn 3 ---
|
||||
|
||||
User: Command { resume: 'i like the first one. could you recommend something to do near the hotel?' }
|
||||
|
||||
travel_advisor: Near the Ritz-Carlton in Palm Beach, here are some highly recommended activities:
|
||||
|
||||
1. Visit the Palm Beach Plaza Mall - Just a short walk from the hotel, featuring shopping, dining, and entertainment
|
||||
2. Try your luck at the Stellaris Casino - It's right in the Ritz-Carlton
|
||||
3. Take a sunset sailing cruise - Many depart from the nearby pier
|
||||
4. Visit the California Lighthouse - A scenic landmark just north of Palm Beach
|
||||
5. Enjoy water sports at Palm Beach:
|
||||
- Jet skiing
|
||||
- Parasailing
|
||||
- Snorkeling
|
||||
- Stand-up paddleboarding
|
||||
|
||||
Would you like more specific information about any of these activities or would you like to know about other options in the area?
|
||||
```
|
||||
:::
|
||||
|
||||
## Prebuilt implementations
|
||||
|
||||
LangGraph comes with prebuilt implementations of two of the most popular multi-agent architectures:
|
||||
|
||||
:::python
|
||||
- [supervisor](../agents/multi-agent.md#supervisor) — individual agents are coordinated by a central supervisor agent. The supervisor controls all communication flow and task delegation, making decisions about which agent to invoke based on the current context and task requirements. You can use [`langgraph-supervisor`](https://github.com/langchain-ai/langgraph-supervisor-py) library to create a supervisor multi-agent systems.
|
||||
- [swarm](../agents/multi-agent.md#supervisor) — agents dynamically hand off control to one another based on their specializations. The system remembers which agent was last active, ensuring that on subsequent interactions, the conversation resumes with that agent. You can use [`langgraph-swarm`](https://github.com/langchain-ai/langgraph-swarm-py) library to create a swarm multi-agent systems.
|
||||
- [swarm](../agents/multi-agent.md#supervisor) — agents dynamically hand off control to one another based on their specializations. The system remembers which agent was last active, ensuring that on subsequent interactions, the conversation resumes with that agent. You can use [`langgraph-swarm`](https://github.com/langchain-ai/langgraph-swarm-py) library to create a swarm multi-agent systems.
|
||||
:::
|
||||
|
||||
:::js
|
||||
- [supervisor](../agents/multi-agent.md#supervisor) — individual agents are coordinated by a central supervisor agent. The supervisor controls all communication flow and task delegation, making decisions about which agent to invoke based on the current context and task requirements. You can use [`langgraph-supervisor`](https://github.com/langchain-ai/langgraph-supervisor-js) library to create a supervisor multi-agent systems.
|
||||
- [swarm](../agents/multi-agent.md#supervisor) — agents dynamically hand off control to one another based on their specializations. The system remembers which agent was last active, ensuring that on subsequent interactions, the conversation resumes with that agent. You can use [`langgraph-swarm`](https://github.com/langchain-ai/langgraph-swarm-js) library to create a swarm multi-agent systems.
|
||||
:::
|
||||
@@ -9,11 +9,20 @@ When adding subgraphs, you need to define how the parent graph and the subgraph
|
||||
|
||||
## Setup
|
||||
|
||||
:::python
|
||||
```bash
|
||||
pip install -U langgraph
|
||||
```
|
||||
:::
|
||||
|
||||
:::js
|
||||
```bash
|
||||
npm install @langchain/langgraph
|
||||
```
|
||||
:::
|
||||
|
||||
!!! tip "Set up LangSmith for LangGraph development"
|
||||
|
||||
Sign up for [LangSmith](https://smith.langchain.com) 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 — read more about how to get started [here](https://docs.smith.langchain.com).
|
||||
|
||||
## Shared state schemas
|
||||
@@ -22,6 +31,7 @@ A common case is for the parent graph and subgraph to communicate over a shared
|
||||
|
||||
If your subgraph shares state keys with the parent graph, you can follow these steps to add it to your graph:
|
||||
|
||||
:::python
|
||||
1. Define the subgraph workflow (`subgraph_builder` in the example below) and compile it
|
||||
2. Pass compiled subgraph to the `.add_node` method when defining the parent graph workflow
|
||||
|
||||
@@ -49,9 +59,41 @@ builder.add_node("node_1", subgraph)
|
||||
builder.add_edge(START, "node_1")
|
||||
graph = builder.compile()
|
||||
```
|
||||
:::
|
||||
|
||||
:::js
|
||||
1. Define the subgraph workflow (`subgraphBuilder` in the example below) and compile it
|
||||
2. Pass compiled subgraph to the `.addNode` method when defining the parent graph workflow
|
||||
|
||||
```typescript
|
||||
import { StateGraph, START } from "@langchain/langgraph";
|
||||
import { z } from "zod";
|
||||
|
||||
const State = z.object({
|
||||
foo: z.string(),
|
||||
});
|
||||
|
||||
// Subgraph
|
||||
const subgraphBuilder = new StateGraph(State)
|
||||
.addNode("subgraphNode1", (state) => {
|
||||
return { foo: "hi! " + state.foo };
|
||||
})
|
||||
.addEdge(START, "subgraphNode1");
|
||||
|
||||
const subgraph = subgraphBuilder.compile();
|
||||
|
||||
// Parent graph
|
||||
const builder = new StateGraph(State)
|
||||
.addNode("node1", subgraph)
|
||||
.addEdge(START, "node1");
|
||||
|
||||
const graph = builder.compile();
|
||||
```
|
||||
:::
|
||||
|
||||
??? example "Full example: shared state schemas"
|
||||
|
||||
:::python
|
||||
```python
|
||||
from typing_extensions import TypedDict
|
||||
from langgraph.graph.state import StateGraph, START
|
||||
@@ -101,6 +143,61 @@ graph = builder.compile()
|
||||
{'node_1': {'foo': 'hi! foo'}}
|
||||
{'node_2': {'foo': 'hi! foobar'}}
|
||||
```
|
||||
:::
|
||||
|
||||
:::js
|
||||
```typescript
|
||||
import { StateGraph, START } from "@langchain/langgraph";
|
||||
import { z } from "zod";
|
||||
|
||||
// Define subgraph
|
||||
const SubgraphState = z.object({
|
||||
foo: z.string(), // (1)!
|
||||
bar: z.string(), // (2)!
|
||||
});
|
||||
|
||||
const subgraphBuilder = new StateGraph(SubgraphState)
|
||||
.addNode("subgraphNode1", (state) => {
|
||||
return { bar: "bar" };
|
||||
})
|
||||
.addNode("subgraphNode2", (state) => {
|
||||
// note that this node is using a state key ('bar') that is only available in the subgraph
|
||||
// and is sending update on the shared state key ('foo')
|
||||
return { foo: state.foo + state.bar };
|
||||
})
|
||||
.addEdge(START, "subgraphNode1")
|
||||
.addEdge("subgraphNode1", "subgraphNode2");
|
||||
|
||||
const subgraph = subgraphBuilder.compile();
|
||||
|
||||
// Define parent graph
|
||||
const ParentState = z.object({
|
||||
foo: z.string(),
|
||||
});
|
||||
|
||||
const builder = new StateGraph(ParentState)
|
||||
.addNode("node1", (state) => {
|
||||
return { foo: "hi! " + state.foo };
|
||||
})
|
||||
.addNode("node2", subgraph)
|
||||
.addEdge(START, "node1")
|
||||
.addEdge("node1", "node2");
|
||||
|
||||
const graph = builder.compile();
|
||||
|
||||
for await (const chunk of await graph.stream({ foo: "foo" })) {
|
||||
console.log(chunk);
|
||||
}
|
||||
```
|
||||
|
||||
3. This key is shared with the parent graph state
|
||||
4. This key is private to the `SubgraphState` and is not visible to the parent graph
|
||||
|
||||
```
|
||||
{ node1: { foo: 'hi! foo' } }
|
||||
{ node2: { foo: 'hi! foobar' } }
|
||||
```
|
||||
:::
|
||||
|
||||
## Different state schemas
|
||||
|
||||
@@ -108,6 +205,7 @@ For more complex systems you might want to define subgraphs that have a **comple
|
||||
|
||||
If that's the case for your application, you need to define a node **function that invokes the subgraph**. This function needs to transform the input (parent) state to the subgraph state before invoking the subgraph, and transform the results back to the parent state before returning the state update from the node.
|
||||
|
||||
:::python
|
||||
```python
|
||||
from typing_extensions import TypedDict
|
||||
from langgraph.graph.state import StateGraph, START
|
||||
@@ -142,9 +240,48 @@ graph = builder.compile()
|
||||
|
||||
1. Transform the state to the subgraph state
|
||||
2. Transform response back to the parent state
|
||||
:::
|
||||
|
||||
:::js
|
||||
```typescript
|
||||
import { StateGraph, START } from "@langchain/langgraph";
|
||||
import { z } from "zod";
|
||||
|
||||
const SubgraphState = z.object({
|
||||
bar: z.string(),
|
||||
});
|
||||
|
||||
// Subgraph
|
||||
const subgraphBuilder = new StateGraph(SubgraphState)
|
||||
.addNode("subgraphNode1", (state) => {
|
||||
return { bar: "hi! " + state.bar };
|
||||
})
|
||||
.addEdge(START, "subgraphNode1");
|
||||
|
||||
const subgraph = subgraphBuilder.compile();
|
||||
|
||||
// Parent graph
|
||||
const State = z.object({
|
||||
foo: z.string(),
|
||||
});
|
||||
|
||||
const builder = new StateGraph(State)
|
||||
.addNode("node1", async (state) => {
|
||||
const subgraphOutput = await subgraph.invoke({ bar: state.foo }); // (1)!
|
||||
return { foo: subgraphOutput.bar }; // (2)!
|
||||
})
|
||||
.addEdge(START, "node1");
|
||||
|
||||
const graph = builder.compile();
|
||||
```
|
||||
|
||||
1. Transform the state to the subgraph state
|
||||
2. Transform response back to the parent state
|
||||
:::
|
||||
|
||||
??? example "Full example: different state schemas"
|
||||
|
||||
:::python
|
||||
```python
|
||||
from typing_extensions import TypedDict
|
||||
from langgraph.graph.state import StateGraph, START
|
||||
@@ -200,11 +337,74 @@ graph = builder.compile()
|
||||
(('node_2:9c36dd0f-151a-cb42-cbad-fa2f851f9ab7',), {'grandchild_2': {'bar': 'hi! foobaz'}})
|
||||
((), {'node_2': {'foo': 'hi! foobaz'}})
|
||||
```
|
||||
:::
|
||||
|
||||
:::js
|
||||
```typescript
|
||||
import { StateGraph, START } from "@langchain/langgraph";
|
||||
import { z } from "zod";
|
||||
|
||||
// Define subgraph
|
||||
const SubgraphState = z.object({
|
||||
// note that none of these keys are shared with the parent graph state
|
||||
bar: z.string(),
|
||||
baz: z.string(),
|
||||
});
|
||||
|
||||
const subgraphBuilder = new StateGraph(SubgraphState)
|
||||
.addNode("subgraphNode1", (state) => {
|
||||
return { baz: "baz" };
|
||||
})
|
||||
.addNode("subgraphNode2", (state) => {
|
||||
return { bar: state.bar + state.baz };
|
||||
})
|
||||
.addEdge(START, "subgraphNode1")
|
||||
.addEdge("subgraphNode1", "subgraphNode2");
|
||||
|
||||
const subgraph = subgraphBuilder.compile();
|
||||
|
||||
// Define parent graph
|
||||
const ParentState = z.object({
|
||||
foo: z.string(),
|
||||
});
|
||||
|
||||
const builder = new StateGraph(ParentState)
|
||||
.addNode("node1", (state) => {
|
||||
return { foo: "hi! " + state.foo };
|
||||
})
|
||||
.addNode("node2", async (state) => {
|
||||
const response = await subgraph.invoke({ bar: state.foo }); // (1)!
|
||||
return { foo: response.bar }; // (2)!
|
||||
})
|
||||
.addEdge(START, "node1")
|
||||
.addEdge("node1", "node2");
|
||||
|
||||
const graph = builder.compile();
|
||||
|
||||
for await (const chunk of await graph.stream(
|
||||
{ foo: "foo" },
|
||||
{ subgraphs: true }
|
||||
)) {
|
||||
console.log(chunk);
|
||||
}
|
||||
```
|
||||
|
||||
3. Transform the state to the subgraph state
|
||||
4. Transform response back to the parent state
|
||||
|
||||
```
|
||||
[[], { node1: { foo: 'hi! foo' } }]
|
||||
[['node2:9c36dd0f-151a-cb42-cbad-fa2f851f9ab7'], { subgraphNode1: { baz: 'baz' } }]
|
||||
[['node2:9c36dd0f-151a-cb42-cbad-fa2f851f9ab7'], { subgraphNode2: { bar: 'hi! foobaz' } }]
|
||||
[[], { node2: { foo: 'hi! foobaz' } }]
|
||||
```
|
||||
:::
|
||||
|
||||
??? example "Full example: different state schemas (two levels of subgraphs)"
|
||||
|
||||
This is an example with two levels of subgraphs: parent -> child -> grandchild.
|
||||
|
||||
:::python
|
||||
```python
|
||||
# Grandchild graph
|
||||
from typing_extensions import TypedDict
|
||||
@@ -288,14 +488,102 @@ graph = builder.compile()
|
||||
((), {'child': {'my_key': 'hi Bob, how are you today?'}})
|
||||
((), {'parent_2': {'my_key': 'hi Bob, how are you today? bye!'}})
|
||||
```
|
||||
:::
|
||||
|
||||
:::js
|
||||
```typescript
|
||||
import { StateGraph, START, END } from "@langchain/langgraph";
|
||||
import { z } from "zod";
|
||||
|
||||
// Grandchild graph
|
||||
const GrandChildState = z.object({
|
||||
myGrandchildKey: z.string(),
|
||||
});
|
||||
|
||||
const grandchild = new StateGraph(GrandChildState)
|
||||
.addNode("grandchild1", (state) => {
|
||||
// NOTE: child or parent keys will not be accessible here
|
||||
return { myGrandchildKey: state.myGrandchildKey + ", how are you" };
|
||||
})
|
||||
.addEdge(START, "grandchild1")
|
||||
.addEdge("grandchild1", END);
|
||||
|
||||
const grandchildGraph = grandchild.compile();
|
||||
|
||||
// Child graph
|
||||
const ChildState = z.object({
|
||||
myChildKey: z.string(),
|
||||
});
|
||||
|
||||
const child = new StateGraph(ChildState)
|
||||
.addNode("child1", async (state) => {
|
||||
// NOTE: parent or grandchild keys won't be accessible here
|
||||
const grandchildGraphInput = { myGrandchildKey: state.myChildKey }; // (1)!
|
||||
const grandchildGraphOutput = await grandchildGraph.invoke(grandchildGraphInput);
|
||||
return { myChildKey: grandchildGraphOutput.myGrandchildKey + " today?" }; // (2)!
|
||||
}) // (3)!
|
||||
.addEdge(START, "child1")
|
||||
.addEdge("child1", END);
|
||||
|
||||
const childGraph = child.compile();
|
||||
|
||||
// Parent graph
|
||||
const ParentState = z.object({
|
||||
myKey: z.string(),
|
||||
});
|
||||
|
||||
const parent = new StateGraph(ParentState)
|
||||
.addNode("parent1", (state) => {
|
||||
// NOTE: child or grandchild keys won't be accessible here
|
||||
return { myKey: "hi " + state.myKey };
|
||||
})
|
||||
.addNode("child", async (state) => {
|
||||
const childGraphInput = { myChildKey: state.myKey }; // (4)!
|
||||
const childGraphOutput = await childGraph.invoke(childGraphInput);
|
||||
return { myKey: childGraphOutput.myChildKey }; // (5)!
|
||||
}) // (6)!
|
||||
.addNode("parent2", (state) => {
|
||||
return { myKey: state.myKey + " bye!" };
|
||||
})
|
||||
.addEdge(START, "parent1")
|
||||
.addEdge("parent1", "child")
|
||||
.addEdge("child", "parent2")
|
||||
.addEdge("parent2", END);
|
||||
|
||||
const parentGraph = parent.compile();
|
||||
|
||||
for await (const chunk of await parentGraph.stream(
|
||||
{ myKey: "Bob" },
|
||||
{ subgraphs: true }
|
||||
)) {
|
||||
console.log(chunk);
|
||||
}
|
||||
```
|
||||
|
||||
7. We're transforming the state from the child state channels (`myChildKey`) to the grandchild state channels (`myGrandchildKey`)
|
||||
8. We're transforming the state from the grandchild state channels (`myGrandchildKey`) back to the child state channels (`myChildKey`)
|
||||
9. We're passing a function here instead of just compiled graph (`grandchildGraph`)
|
||||
10. We're transforming the state from the parent state channels (`myKey`) to the child state channels (`myChildKey`)
|
||||
11. We're transforming the state from the child state channels (`myChildKey`) back to the parent state channels (`myKey`)
|
||||
12. We're passing a function here instead of just a compiled graph (`childGraph`)
|
||||
|
||||
```
|
||||
[[], { parent1: { myKey: 'hi Bob' } }]
|
||||
[['child:2e26e9ce-602f-862c-aa66-1ea5a4655e3b', 'child1:781bb3b1-3971-84ce-810b-acf819a03f9c'], { grandchild1: { myGrandchildKey: 'hi Bob, how are you' } }]
|
||||
[['child:2e26e9ce-602f-862c-aa66-1ea5a4655e3b'], { child1: { myChildKey: 'hi Bob, how are you today?' } }]
|
||||
[[], { child: { myKey: 'hi Bob, how are you today?' } }]
|
||||
[[], { parent2: { myKey: 'hi Bob, how are you today? bye!' } }]
|
||||
```
|
||||
:::
|
||||
|
||||
## Add persistence
|
||||
|
||||
You only need to **provide the checkpointer when compiling the parent graph**. LangGraph will automatically propagate the checkpointer to the child subgraphs.
|
||||
|
||||
:::python
|
||||
```python
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
class State(TypedDict):
|
||||
@@ -317,20 +605,66 @@ builder = StateGraph(State)
|
||||
builder.add_node("node_1", subgraph)
|
||||
builder.add_edge(START, "node_1")
|
||||
|
||||
checkpointer = InMemorySaver()
|
||||
checkpointer = MemorySaver()
|
||||
graph = builder.compile(checkpointer=checkpointer)
|
||||
```
|
||||
:::
|
||||
|
||||
If you want the subgraph to **have its own memory**, you can compile it `with checkpointer=True`. This is useful in [multi-agent](../concepts/multi_agent.md) systems, if you want agents to keep track of their internal message histories:
|
||||
:::js
|
||||
```typescript
|
||||
import { StateGraph, START, MemorySaver } from "@langchain/langgraph";
|
||||
import { z } from "zod";
|
||||
|
||||
const State = z.object({
|
||||
foo: z.string(),
|
||||
});
|
||||
|
||||
// Subgraph
|
||||
const subgraphBuilder = new StateGraph(State)
|
||||
.addNode("subgraphNode1", (state) => {
|
||||
return { foo: state.foo + "bar" };
|
||||
})
|
||||
.addEdge(START, "subgraphNode1");
|
||||
|
||||
const subgraph = subgraphBuilder.compile();
|
||||
|
||||
// Parent graph
|
||||
const builder = new StateGraph(State)
|
||||
.addNode("node1", subgraph)
|
||||
.addEdge(START, "node1");
|
||||
|
||||
const checkpointer = new MemorySaver();
|
||||
const graph = builder.compile({ checkpointer });
|
||||
```
|
||||
:::
|
||||
|
||||
If you want the subgraph to **have its own memory**, you can compile it with the appropriate checkpointer option. This is useful in [multi-agent](../concepts/multi_agent.md) systems, if you want agents to keep track of their internal message histories:
|
||||
|
||||
:::python
|
||||
```python
|
||||
subgraph_builder = StateGraph(...)
|
||||
subgraph = subgraph_builder.compile(checkpointer=True)
|
||||
```
|
||||
:::
|
||||
|
||||
:::js
|
||||
```typescript
|
||||
const subgraphBuilder = new StateGraph(...)
|
||||
const subgraph = subgraphBuilder.compile({ checkpointer: true });
|
||||
```
|
||||
:::
|
||||
|
||||
## View subgraph state
|
||||
|
||||
When you enable [persistence](../concepts/persistence.md), you can [inspect the graph state](../concepts/persistence.md#checkpoints) (checkpoint) via `graph.get_state(config)`. To view the subgraph state, you can use `graph.get_state(config, subgraphs=True)`.
|
||||
When you enable [persistence](../concepts/persistence.md), you can [inspect the graph state](../concepts/persistence.md#checkpoints) (checkpoint) via the appropriate method. To view the subgraph state, you can use the subgraphs option.
|
||||
|
||||
:::python
|
||||
You can inspect the graph state via `graph.get_state(config)`. To view the subgraph state, you can use `graph.get_state(config, subgraphs=True)`.
|
||||
:::
|
||||
|
||||
:::js
|
||||
You can inspect the graph state via `graph.getState(config)`. To view the subgraph state, you can use `graph.getState(config, { subgraphs: true })`.
|
||||
:::
|
||||
|
||||
!!! important "Available **only** when interrupted"
|
||||
|
||||
@@ -338,9 +672,10 @@ When you enable [persistence](../concepts/persistence.md), you can [inspect the
|
||||
|
||||
??? example "View interrupted subgraph state"
|
||||
|
||||
:::python
|
||||
```python
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.types import interrupt, Command
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
@@ -365,7 +700,7 @@ When you enable [persistence](../concepts/persistence.md), you can [inspect the
|
||||
builder.add_node("node_1", subgraph)
|
||||
builder.add_edge(START, "node_1")
|
||||
|
||||
checkpointer = InMemorySaver()
|
||||
checkpointer = MemorySaver()
|
||||
graph = builder.compile(checkpointer=checkpointer)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
@@ -379,11 +714,53 @@ When you enable [persistence](../concepts/persistence.md), you can [inspect the
|
||||
```
|
||||
|
||||
1. This will be available only when the subgraph is interrupted. Once you resume the graph, you won't be able to access the subgraph state.
|
||||
:::
|
||||
|
||||
:::js
|
||||
```typescript
|
||||
import { StateGraph, START, MemorySaver, interrupt, Command } from "@langchain/langgraph";
|
||||
import { z } from "zod";
|
||||
|
||||
const State = z.object({
|
||||
foo: z.string(),
|
||||
});
|
||||
|
||||
// Subgraph
|
||||
const subgraphBuilder = new StateGraph(State)
|
||||
.addNode("subgraphNode1", (state) => {
|
||||
const value = interrupt("Provide value:");
|
||||
return { foo: state.foo + value };
|
||||
})
|
||||
.addEdge(START, "subgraphNode1");
|
||||
|
||||
const subgraph = subgraphBuilder.compile();
|
||||
|
||||
// Parent graph
|
||||
const builder = new StateGraph(State)
|
||||
.addNode("node1", subgraph)
|
||||
.addEdge(START, "node1");
|
||||
|
||||
const checkpointer = new MemorySaver();
|
||||
const graph = builder.compile({ checkpointer });
|
||||
|
||||
const config = { configurable: { thread_id: "1" } };
|
||||
|
||||
await graph.invoke({ foo: "" }, config);
|
||||
const parentState = await graph.getState(config);
|
||||
const subgraphState = (await graph.getState(config, { subgraphs: true })).tasks[0].state; // (1)!
|
||||
|
||||
// resume the subgraph
|
||||
await graph.invoke(new Command({ resume: "bar" }), config);
|
||||
```
|
||||
|
||||
2. This will be available only when the subgraph is interrupted. Once you resume the graph, you won't be able to access the subgraph state.
|
||||
:::
|
||||
|
||||
## Stream subgraph outputs
|
||||
|
||||
To include outputs from subgraphs in the streamed outputs, you can set `subgraphs=True` in the `.stream()` method of the parent graph. This will stream outputs from both the parent graph and any subgraphs.
|
||||
To include outputs from subgraphs in the streamed outputs, you can set the subgraphs option in the stream method of the parent graph. This will stream outputs from both the parent graph and any subgraphs.
|
||||
|
||||
:::python
|
||||
```python
|
||||
for chunk in graph.stream(
|
||||
{"foo": "foo"},
|
||||
@@ -394,9 +771,27 @@ for chunk in graph.stream(
|
||||
```
|
||||
|
||||
1. Set `subgraphs=True` to stream outputs from subgraphs.
|
||||
:::
|
||||
|
||||
:::js
|
||||
```typescript
|
||||
for await (const chunk of await graph.stream(
|
||||
{ foo: "foo" },
|
||||
{
|
||||
subgraphs: true, // (1)!
|
||||
streamMode: "updates",
|
||||
}
|
||||
)) {
|
||||
console.log(chunk);
|
||||
}
|
||||
```
|
||||
|
||||
1. Set `subgraphs: true` to stream outputs from subgraphs.
|
||||
:::
|
||||
|
||||
??? example "Stream from subgraphs"
|
||||
|
||||
:::python
|
||||
```python
|
||||
from typing_extensions import TypedDict
|
||||
from langgraph.graph.state import StateGraph, START
|
||||
@@ -450,4 +845,66 @@ for chunk in graph.stream(
|
||||
(('node_2:e58e5673-a661-ebb0-70d4-e298a7fc28b7',), {'subgraph_node_1': {'bar': 'bar'}})
|
||||
(('node_2:e58e5673-a661-ebb0-70d4-e298a7fc28b7',), {'subgraph_node_2': {'foo': 'hi! foobar'}})
|
||||
((), {'node_2': {'foo': 'hi! foobar'}})
|
||||
|
||||
```
|
||||
:::
|
||||
|
||||
:::js
|
||||
```typescript
|
||||
import { StateGraph, START } from "@langchain/langgraph";
|
||||
import { z } from "zod";
|
||||
|
||||
// Define subgraph
|
||||
const SubgraphState = z.object({
|
||||
foo: z.string(),
|
||||
bar: z.string(),
|
||||
});
|
||||
|
||||
const subgraphBuilder = new StateGraph(SubgraphState)
|
||||
.addNode("subgraphNode1", (state) => {
|
||||
return { bar: "bar" };
|
||||
})
|
||||
.addNode("subgraphNode2", (state) => {
|
||||
// note that this node is using a state key ('bar') that is only available in the subgraph
|
||||
// and is sending update on the shared state key ('foo')
|
||||
return { foo: state.foo + state.bar };
|
||||
})
|
||||
.addEdge(START, "subgraphNode1")
|
||||
.addEdge("subgraphNode1", "subgraphNode2");
|
||||
|
||||
const subgraph = subgraphBuilder.compile();
|
||||
|
||||
// Define parent graph
|
||||
const ParentState = z.object({
|
||||
foo: z.string(),
|
||||
});
|
||||
|
||||
const builder = new StateGraph(ParentState)
|
||||
.addNode("node1", (state) => {
|
||||
return { foo: "hi! " + state.foo };
|
||||
})
|
||||
.addNode("node2", subgraph)
|
||||
.addEdge(START, "node1")
|
||||
.addEdge("node1", "node2");
|
||||
|
||||
const graph = builder.compile();
|
||||
|
||||
for await (const chunk of await graph.stream(
|
||||
{ foo: "foo" },
|
||||
{
|
||||
streamMode: "updates",
|
||||
subgraphs: true, // (1)!
|
||||
}
|
||||
)) {
|
||||
console.log(chunk);
|
||||
}
|
||||
```
|
||||
|
||||
2. Set `subgraphs: true` to stream outputs from subgraphs.
|
||||
|
||||
```
|
||||
[[], { node1: { foo: 'hi! foo' } }]
|
||||
[['node2:e58e5673-a661-ebb0-70d4-e298a7fc28b7'], { subgraphNode1: { bar: 'bar' } }]
|
||||
[['node2:e58e5673-a661-ebb0-70d4-e298a7fc28b7'], { subgraphNode2: { foo: 'hi! foobar' } }]
|
||||
[[], { node2: { foo: 'hi! foobar' } }]
|
||||
```
|
||||
:::
|
||||
Generated
+1
@@ -329,6 +329,7 @@ dev = [
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-watcher" },
|
||||
{ name = "redis" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
|
||||
Generated
+1
@@ -341,6 +341,7 @@ dev = [
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-watcher" },
|
||||
{ name = "redis" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from langgraph.cache.base import BaseCache, FullKey, Namespace, ValueT
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
|
||||
|
||||
class RedisCache(BaseCache[ValueT]):
|
||||
"""Redis-based cache implementation with TTL support."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis: Any,
|
||||
*,
|
||||
serde: SerializerProtocol | None = None,
|
||||
prefix: str = "langgraph:cache:",
|
||||
) -> None:
|
||||
"""Initialize the cache with a Redis client.
|
||||
|
||||
Args:
|
||||
redis: Redis client instance (sync or async)
|
||||
serde: Serializer to use for values
|
||||
prefix: Key prefix for all cached values
|
||||
"""
|
||||
super().__init__(serde=serde)
|
||||
self.redis = redis
|
||||
self.prefix = prefix
|
||||
|
||||
def _make_key(self, ns: Namespace, key: str) -> str:
|
||||
"""Create a Redis key from namespace and key."""
|
||||
ns_str = ":".join(ns) if ns else ""
|
||||
return f"{self.prefix}{ns_str}:{key}" if ns_str else f"{self.prefix}{key}"
|
||||
|
||||
def _parse_key(self, redis_key: str) -> tuple[Namespace, str]:
|
||||
"""Parse a Redis key back to namespace and key."""
|
||||
if not redis_key.startswith(self.prefix):
|
||||
raise ValueError(
|
||||
f"Key {redis_key} does not start with prefix {self.prefix}"
|
||||
)
|
||||
|
||||
remaining = redis_key[len(self.prefix) :]
|
||||
if ":" in remaining:
|
||||
parts = remaining.split(":")
|
||||
key = parts[-1]
|
||||
ns_parts = parts[:-1]
|
||||
return (tuple(ns_parts), key)
|
||||
else:
|
||||
return (tuple(), remaining)
|
||||
|
||||
def get(self, keys: Sequence[FullKey]) -> dict[FullKey, ValueT]:
|
||||
"""Get the cached values for the given keys."""
|
||||
if not keys:
|
||||
return {}
|
||||
|
||||
# Build Redis keys
|
||||
redis_keys = [self._make_key(ns, key) for ns, key in keys]
|
||||
|
||||
# Get values from Redis using MGET
|
||||
try:
|
||||
raw_values = self.redis.mget(redis_keys)
|
||||
except Exception:
|
||||
# If Redis is unavailable, return empty dict
|
||||
return {}
|
||||
|
||||
values: dict[FullKey, ValueT] = {}
|
||||
for i, raw_value in enumerate(raw_values):
|
||||
if raw_value is not None:
|
||||
try:
|
||||
# Deserialize the value
|
||||
encoding, data = raw_value.split(b":", 1)
|
||||
values[keys[i]] = self.serde.loads_typed((encoding.decode(), data))
|
||||
except Exception:
|
||||
# Skip corrupted entries
|
||||
continue
|
||||
|
||||
return values
|
||||
|
||||
async def aget(self, keys: Sequence[FullKey]) -> dict[FullKey, ValueT]:
|
||||
"""Asynchronously get the cached values for the given keys."""
|
||||
return self.get(keys)
|
||||
|
||||
def set(self, mapping: Mapping[FullKey, tuple[ValueT, int | None]]) -> None:
|
||||
"""Set the cached values for the given keys and TTLs."""
|
||||
if not mapping:
|
||||
return
|
||||
|
||||
# Use pipeline for efficient batch operations
|
||||
pipe = self.redis.pipeline()
|
||||
|
||||
for (ns, key), (value, ttl) in mapping.items():
|
||||
redis_key = self._make_key(ns, key)
|
||||
encoding, data = self.serde.dumps_typed(value)
|
||||
|
||||
# Store as "encoding:data" format
|
||||
serialized_value = f"{encoding}:".encode() + data
|
||||
|
||||
if ttl is not None:
|
||||
pipe.setex(redis_key, ttl, serialized_value)
|
||||
else:
|
||||
pipe.set(redis_key, serialized_value)
|
||||
|
||||
try:
|
||||
pipe.execute()
|
||||
except Exception:
|
||||
# Silently fail if Redis is unavailable
|
||||
pass
|
||||
|
||||
async def aset(self, mapping: Mapping[FullKey, tuple[ValueT, int | None]]) -> None:
|
||||
"""Asynchronously set the cached values for the given keys and TTLs."""
|
||||
self.set(mapping)
|
||||
|
||||
def clear(self, namespaces: Sequence[Namespace] | None = None) -> None:
|
||||
"""Delete the cached values for the given namespaces.
|
||||
If no namespaces are provided, clear all cached values."""
|
||||
try:
|
||||
if namespaces is None:
|
||||
# Clear all keys with our prefix
|
||||
pattern = f"{self.prefix}*"
|
||||
keys = self.redis.keys(pattern)
|
||||
if keys:
|
||||
self.redis.delete(*keys)
|
||||
else:
|
||||
# Clear specific namespaces
|
||||
keys_to_delete = []
|
||||
for ns in namespaces:
|
||||
ns_str = ":".join(ns) if ns else ""
|
||||
pattern = (
|
||||
f"{self.prefix}{ns_str}:*" if ns_str else f"{self.prefix}*"
|
||||
)
|
||||
keys = self.redis.keys(pattern)
|
||||
keys_to_delete.extend(keys)
|
||||
|
||||
if keys_to_delete:
|
||||
self.redis.delete(*keys_to_delete)
|
||||
except Exception:
|
||||
# Silently fail if Redis is unavailable
|
||||
pass
|
||||
|
||||
async def aclear(self, namespaces: Sequence[Namespace] | None = None) -> None:
|
||||
"""Asynchronously delete the cached values for the given namespaces.
|
||||
If no namespaces are provided, clear all cached values."""
|
||||
self.clear(namespaces)
|
||||
@@ -81,6 +81,9 @@ class Checkpoint(TypedDict):
|
||||
This keeps track of the versions of the channels that each node has seen.
|
||||
Used to determine which nodes to execute next.
|
||||
"""
|
||||
updated_channels: list[str] | None
|
||||
"""The channels that were updated in this checkpoint.
|
||||
"""
|
||||
|
||||
|
||||
def copy_checkpoint(checkpoint: Checkpoint) -> Checkpoint:
|
||||
@@ -92,6 +95,7 @@ def copy_checkpoint(checkpoint: Checkpoint) -> Checkpoint:
|
||||
channel_versions=checkpoint["channel_versions"].copy(),
|
||||
versions_seen={k: v.copy() for k, v in checkpoint["versions_seen"].items()},
|
||||
pending_sends=checkpoint.get("pending_sends", []).copy(),
|
||||
updated_channels=checkpoint.get("updated_channels", None),
|
||||
)
|
||||
|
||||
|
||||
@@ -437,6 +441,7 @@ def empty_checkpoint() -> Checkpoint:
|
||||
channel_versions={},
|
||||
versions_seen={},
|
||||
pending_sends=[],
|
||||
updated_channels=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -470,4 +475,5 @@ def create_checkpoint(
|
||||
channel_versions=checkpoint["channel_versions"],
|
||||
versions_seen=checkpoint["versions_seen"],
|
||||
pending_sends=checkpoint.get("pending_sends", []),
|
||||
updated_channels=None,
|
||||
)
|
||||
|
||||
@@ -64,14 +64,21 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
super().__init__()
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._aqueue: asyncio.Queue[tuple[asyncio.Future, Op]] = asyncio.Queue()
|
||||
self._task = self._loop.create_task(_run(self._aqueue, weakref.ref(self)))
|
||||
self._task: asyncio.Task | None = None
|
||||
self._ensure_task()
|
||||
|
||||
def __del__(self) -> None:
|
||||
try:
|
||||
self._task.cancel()
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def _ensure_task(self) -> None:
|
||||
"""Ensure the background processing loop is running."""
|
||||
if self._task is None or self._task.done():
|
||||
self._task = self._loop.create_task(_run(self._aqueue, weakref.ref(self)))
|
||||
|
||||
async def aget(
|
||||
self,
|
||||
namespace: tuple[str, ...],
|
||||
@@ -79,7 +86,7 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
*,
|
||||
refresh_ttl: bool | None = None,
|
||||
) -> Item | None:
|
||||
assert not self._task.done()
|
||||
self._ensure_task()
|
||||
fut = self._loop.create_future()
|
||||
self._aqueue.put_nowait(
|
||||
(
|
||||
@@ -104,7 +111,7 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
offset: int = 0,
|
||||
refresh_ttl: bool | None = None,
|
||||
) -> list[SearchItem]:
|
||||
assert not self._task.done()
|
||||
self._ensure_task()
|
||||
fut = self._loop.create_future()
|
||||
self._aqueue.put_nowait(
|
||||
(
|
||||
@@ -130,7 +137,7 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
*,
|
||||
ttl: float | None | NotProvided = NOT_PROVIDED,
|
||||
) -> None:
|
||||
assert not self._task.done()
|
||||
self._ensure_task()
|
||||
_validate_namespace(namespace)
|
||||
fut = self._loop.create_future()
|
||||
self._aqueue.put_nowait(
|
||||
@@ -148,7 +155,7 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
namespace: tuple[str, ...],
|
||||
key: str,
|
||||
) -> None:
|
||||
assert not self._task.done()
|
||||
self._ensure_task()
|
||||
fut = self._loop.create_future()
|
||||
self._aqueue.put_nowait((fut, PutOp(namespace, key, None)))
|
||||
return await fut
|
||||
@@ -162,7 +169,7 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[tuple[str, ...]]:
|
||||
assert not self._task.done()
|
||||
self._ensure_task()
|
||||
fut = self._loop.create_future()
|
||||
match_conditions = []
|
||||
if prefix:
|
||||
|
||||
@@ -32,6 +32,7 @@ dev = [
|
||||
"numpy",
|
||||
"pandas",
|
||||
"pandas-stubs>=2.2.2.240807",
|
||||
"redis",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Unit tests for Redis cache implementation."""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import redis
|
||||
|
||||
from langgraph.cache.redis import RedisCache
|
||||
|
||||
|
||||
class TestRedisCache:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self):
|
||||
"""Set up test Redis client and cache."""
|
||||
self.client = redis.Redis(
|
||||
host="localhost", port=6379, db=0, decode_responses=False
|
||||
)
|
||||
try:
|
||||
self.client.ping()
|
||||
except redis.ConnectionError:
|
||||
pytest.skip("Redis server not available")
|
||||
|
||||
self.cache = RedisCache(self.client, prefix="test:cache:")
|
||||
|
||||
# Clean up before each test
|
||||
self.client.flushdb()
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up after each test."""
|
||||
try:
|
||||
self.client.flushdb()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def test_basic_set_and_get(self):
|
||||
"""Test basic set and get operations."""
|
||||
keys = [(("graph", "node"), "key1")]
|
||||
values = {keys[0]: ({"result": 42}, None)}
|
||||
|
||||
# Set value
|
||||
self.cache.set(values)
|
||||
|
||||
# Get value
|
||||
result = self.cache.get(keys)
|
||||
assert len(result) == 1
|
||||
assert result[keys[0]] == {"result": 42}
|
||||
|
||||
def test_batch_operations(self):
|
||||
"""Test batch set and get operations."""
|
||||
keys = [
|
||||
(("graph", "node1"), "key1"),
|
||||
(("graph", "node2"), "key2"),
|
||||
(("other", "node"), "key3"),
|
||||
]
|
||||
values = {
|
||||
keys[0]: ({"result": 1}, None),
|
||||
keys[1]: ({"result": 2}, 60), # With TTL
|
||||
keys[2]: ({"result": 3}, None),
|
||||
}
|
||||
|
||||
# Set values
|
||||
self.cache.set(values)
|
||||
|
||||
# Get all values
|
||||
result = self.cache.get(keys)
|
||||
assert len(result) == 3
|
||||
assert result[keys[0]] == {"result": 1}
|
||||
assert result[keys[1]] == {"result": 2}
|
||||
assert result[keys[2]] == {"result": 3}
|
||||
|
||||
def test_ttl_behavior(self):
|
||||
"""Test TTL (time-to-live) functionality."""
|
||||
key = (("graph", "node"), "ttl_key")
|
||||
values = {key: ({"data": "expires_soon"}, 1)} # 1 second TTL
|
||||
|
||||
# Set with TTL
|
||||
self.cache.set(values)
|
||||
|
||||
# Should be available immediately
|
||||
result = self.cache.get([key])
|
||||
assert len(result) == 1
|
||||
assert result[key] == {"data": "expires_soon"}
|
||||
|
||||
# Wait for expiration
|
||||
time.sleep(1.1)
|
||||
|
||||
# Should be expired
|
||||
result = self.cache.get([key])
|
||||
assert len(result) == 0
|
||||
|
||||
def test_namespace_isolation(self):
|
||||
"""Test that different namespaces are isolated."""
|
||||
key1 = (("graph1", "node"), "same_key")
|
||||
key2 = (("graph2", "node"), "same_key")
|
||||
|
||||
values = {key1: ({"graph": 1}, None), key2: ({"graph": 2}, None)}
|
||||
|
||||
self.cache.set(values)
|
||||
|
||||
result = self.cache.get([key1, key2])
|
||||
assert result[key1] == {"graph": 1}
|
||||
assert result[key2] == {"graph": 2}
|
||||
|
||||
def test_clear_all(self):
|
||||
"""Test clearing all cached values."""
|
||||
keys = [(("graph", "node1"), "key1"), (("graph", "node2"), "key2")]
|
||||
values = {keys[0]: ({"result": 1}, None), keys[1]: ({"result": 2}, None)}
|
||||
|
||||
self.cache.set(values)
|
||||
|
||||
# Verify data exists
|
||||
result = self.cache.get(keys)
|
||||
assert len(result) == 2
|
||||
|
||||
# Clear all
|
||||
self.cache.clear()
|
||||
|
||||
# Verify data is gone
|
||||
result = self.cache.get(keys)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_clear_by_namespace(self):
|
||||
"""Test clearing cached values by namespace."""
|
||||
keys = [
|
||||
(("graph1", "node"), "key1"),
|
||||
(("graph2", "node"), "key2"),
|
||||
(("graph1", "other"), "key3"),
|
||||
]
|
||||
values = {
|
||||
keys[0]: ({"result": 1}, None),
|
||||
keys[1]: ({"result": 2}, None),
|
||||
keys[2]: ({"result": 3}, None),
|
||||
}
|
||||
|
||||
self.cache.set(values)
|
||||
|
||||
# Clear only graph1 namespace
|
||||
self.cache.clear([("graph1", "node"), ("graph1", "other")])
|
||||
|
||||
# graph1 should be cleared, graph2 should remain
|
||||
result = self.cache.get(keys)
|
||||
assert len(result) == 1
|
||||
assert result[keys[1]] == {"result": 2}
|
||||
|
||||
def test_empty_operations(self):
|
||||
"""Test behavior with empty keys/values."""
|
||||
# Empty get
|
||||
result = self.cache.get([])
|
||||
assert result == {}
|
||||
|
||||
# Empty set
|
||||
self.cache.set({}) # Should not raise error
|
||||
|
||||
def test_nonexistent_keys(self):
|
||||
"""Test getting keys that don't exist."""
|
||||
keys = [(("graph", "node"), "nonexistent")]
|
||||
result = self.cache.get(keys)
|
||||
assert len(result) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_operations(self):
|
||||
"""Test async set and get operations with sync Redis client."""
|
||||
# Create sync Redis client and cache (like main integration tests)
|
||||
client = redis.Redis(
|
||||
host="localhost", port=6379, db=1, decode_responses=False
|
||||
)
|
||||
try:
|
||||
client.ping()
|
||||
except Exception:
|
||||
pytest.skip("Redis not available")
|
||||
|
||||
cache = RedisCache(client, prefix="test:async:")
|
||||
|
||||
keys = [(("graph", "node"), "async_key")]
|
||||
values = {keys[0]: ({"async": True}, None)}
|
||||
|
||||
# Async set (delegates to sync)
|
||||
await cache.aset(values)
|
||||
|
||||
# Async get (delegates to sync)
|
||||
result = await cache.aget(keys)
|
||||
assert len(result) == 1
|
||||
assert result[keys[0]] == {"async": True}
|
||||
|
||||
# Cleanup
|
||||
client.flushdb()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_clear(self):
|
||||
"""Test async clear operations with sync Redis client."""
|
||||
# Create sync Redis client and cache (like main integration tests)
|
||||
client = redis.Redis(
|
||||
host="localhost", port=6379, db=1, decode_responses=False
|
||||
)
|
||||
try:
|
||||
client.ping()
|
||||
except Exception:
|
||||
pytest.skip("Redis not available")
|
||||
|
||||
cache = RedisCache(client, prefix="test:async:")
|
||||
|
||||
keys = [(("graph", "node"), "key")]
|
||||
values = {keys[0]: ({"data": "test"}, None)}
|
||||
|
||||
await cache.aset(values)
|
||||
|
||||
# Verify data exists
|
||||
result = await cache.aget(keys)
|
||||
assert len(result) == 1
|
||||
|
||||
# Clear all (delegates to sync)
|
||||
await cache.aclear()
|
||||
|
||||
# Verify data is gone
|
||||
result = await cache.aget(keys)
|
||||
assert len(result) == 0
|
||||
|
||||
# Cleanup
|
||||
client.flushdb()
|
||||
|
||||
def test_redis_unavailable_get(self):
|
||||
"""Test behavior when Redis is unavailable during get operations."""
|
||||
# Create cache with non-existent Redis server
|
||||
bad_client = redis.Redis(
|
||||
host="nonexistent", port=9999, socket_connect_timeout=0.1
|
||||
)
|
||||
cache = RedisCache(bad_client, prefix="test:cache:")
|
||||
|
||||
keys = [(("graph", "node"), "key")]
|
||||
result = cache.get(keys)
|
||||
|
||||
# Should return empty dict when Redis unavailable
|
||||
assert result == {}
|
||||
|
||||
def test_redis_unavailable_set(self):
|
||||
"""Test behavior when Redis is unavailable during set operations."""
|
||||
# Create cache with non-existent Redis server
|
||||
bad_client = redis.Redis(
|
||||
host="nonexistent", port=9999, socket_connect_timeout=0.1
|
||||
)
|
||||
cache = RedisCache(bad_client, prefix="test:cache:")
|
||||
|
||||
keys = [(("graph", "node"), "key")]
|
||||
values = {keys[0]: ({"data": "test"}, None)}
|
||||
|
||||
# Should not raise exception when Redis unavailable
|
||||
cache.set(values) # Should silently fail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_unavailable_async(self):
|
||||
"""Test async behavior when Redis is unavailable."""
|
||||
# Create sync cache with non-existent Redis server (like main integration tests)
|
||||
bad_client = redis.Redis(
|
||||
host="nonexistent", port=9999, socket_connect_timeout=0.1
|
||||
)
|
||||
cache = RedisCache(bad_client, prefix="test:cache:")
|
||||
|
||||
keys = [(("graph", "node"), "key")]
|
||||
values = {keys[0]: ({"data": "test"}, None)}
|
||||
|
||||
# Should return empty dict for get (delegates to sync)
|
||||
result = await cache.aget(keys)
|
||||
assert result == {}
|
||||
|
||||
# Should not raise exception for set (delegates to sync)
|
||||
await cache.aset(values) # Should silently fail
|
||||
|
||||
def test_corrupted_data_handling(self):
|
||||
"""Test handling of corrupted data in Redis."""
|
||||
# Set some valid data first
|
||||
keys = [(("graph", "node"), "valid_key")]
|
||||
values = {keys[0]: ({"data": "valid"}, None)}
|
||||
self.cache.set(values)
|
||||
|
||||
# Manually insert corrupted data
|
||||
corrupted_key = self.cache._make_key(("graph", "node"), "corrupted_key")
|
||||
self.client.set(corrupted_key, b"invalid:data:format:too:many:colons")
|
||||
|
||||
# Should skip corrupted entry and return only valid ones
|
||||
all_keys = [keys[0], (("graph", "node"), "corrupted_key")]
|
||||
result = self.cache.get(all_keys)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[keys[0]] == {"data": "valid"}
|
||||
|
||||
def test_key_parsing_edge_cases(self):
|
||||
"""Test key parsing with edge cases."""
|
||||
# Test empty namespace
|
||||
key1 = ((), "empty_ns")
|
||||
values = {key1: ({"data": "empty_ns"}, None)}
|
||||
self.cache.set(values)
|
||||
result = self.cache.get([key1])
|
||||
assert result[key1] == {"data": "empty_ns"}
|
||||
|
||||
# Test namespace with special characters
|
||||
key2 = (("graph:with:colons", "node-with-dashes"), "key_with_underscores")
|
||||
values = {key2: ({"data": "special_chars"}, None)}
|
||||
self.cache.set(values)
|
||||
result = self.cache.get([key2])
|
||||
assert result[key2] == {"data": "special_chars"}
|
||||
|
||||
def test_large_data_serialization(self):
|
||||
"""Test handling of large data objects."""
|
||||
# Create a large data structure
|
||||
large_data = {"large_list": list(range(1000)), "nested": {"data": "x" * 1000}}
|
||||
key = (("graph", "node"), "large_key")
|
||||
values = {key: (large_data, None)}
|
||||
|
||||
self.cache.set(values)
|
||||
result = self.cache.get([key])
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[key] == large_data
|
||||
@@ -34,6 +34,42 @@ class MockAsyncBatchedStore(AsyncBatchedBaseStore):
|
||||
return self._store.batch(ops)
|
||||
|
||||
|
||||
async def test_async_batch_store_resilience() -> None:
|
||||
"""Test that AsyncBatchedBaseStore recovers gracefully from task cancellation."""
|
||||
doc = {"foo": "bar"}
|
||||
async_store = MockAsyncBatchedStore()
|
||||
|
||||
await async_store.aput(("foo", "langgraph", "foo"), "bar", doc)
|
||||
|
||||
# Store the original task reference
|
||||
original_task = async_store._task
|
||||
assert original_task is not None
|
||||
assert not original_task.done()
|
||||
|
||||
# Cancel the background task
|
||||
original_task.cancel()
|
||||
await asyncio.sleep(0.01)
|
||||
assert original_task.cancelled()
|
||||
|
||||
# Perform a new operation - this should trigger _ensure_task() to create a new task
|
||||
result = await async_store.asearch(("foo", "langgraph", "foo"))
|
||||
assert len(result) > 0
|
||||
assert result[0].value == doc
|
||||
|
||||
# Verify a new task was created
|
||||
new_task = async_store._task
|
||||
assert new_task is not None
|
||||
assert new_task is not original_task
|
||||
assert not new_task.done()
|
||||
|
||||
# Test that operations continue to work with the new task
|
||||
doc2 = {"baz": "qux"}
|
||||
await async_store.aput(("test", "namespace"), "key", doc2)
|
||||
result2 = await async_store.aget(("test", "namespace"), "key")
|
||||
assert result2 is not None
|
||||
assert result2.value == doc2
|
||||
|
||||
|
||||
def test_get_text_at_path() -> None:
|
||||
nested_data = {
|
||||
"name": "test",
|
||||
|
||||
Generated
+23
@@ -32,6 +32,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-timeout"
|
||||
version = "5.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.7.9"
|
||||
@@ -345,6 +354,7 @@ dev = [
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-watcher" },
|
||||
{ name = "redis" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
@@ -366,6 +376,7 @@ dev = [
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-watcher" },
|
||||
{ name = "redis" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
@@ -1153,6 +1164,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "6.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/cd/030274634a1a052b708756016283ea3d84e91ae45f74d7f5dcf55d753a0f/redis-6.3.0.tar.gz", hash = "sha256:3000dbe532babfb0999cdab7b3e5744bcb23e51923febcfaeb52c8cfb29632ef", size = 4647275, upload-time = "2025-08-05T08:12:31.648Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a7/2fe45801534a187543fc45d28b3844d84559c1589255bc2ece30d92dc205/redis-6.3.0-py3-none-any.whl", hash = "sha256:92f079d656ded871535e099080f70fab8e75273c0236797126ac60242d638e9b", size = 280018, upload-time = "2025-08-05T08:12:30.093Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.4"
|
||||
|
||||
+10
-10
@@ -37,11 +37,11 @@ coverage:
|
||||
--cov-report xml \
|
||||
--cov-report term-missing:skip-covered
|
||||
|
||||
start-postgres:
|
||||
docker compose -f tests/compose-postgres.yml up -V --force-recreate --wait --remove-orphans
|
||||
start-services:
|
||||
docker compose -f tests/compose-postgres.yml -f tests/compose-redis.yml up -V --force-recreate --wait --remove-orphans
|
||||
|
||||
stop-postgres:
|
||||
docker compose -f tests/compose-postgres.yml down -v
|
||||
stop-services:
|
||||
docker compose -f tests/compose-postgres.yml -f tests/compose-redis.yml down -v
|
||||
|
||||
start-dev-server:
|
||||
LOG_LEVEL=warning uv run langgraph dev --config tests/example_app/langgraph.json --no-browser & echo "$$!" > .devserver.pid
|
||||
@@ -60,11 +60,11 @@ NO_DOCKER ?= $(sh command -v docker >/dev/null 2>&1 && echo "false" || echo "tru
|
||||
|
||||
test:
|
||||
if [ "$(NO_DOCKER)" = "false" ]; then \
|
||||
make start-postgres &&\
|
||||
make start-services &&\
|
||||
make start-dev-server &&\
|
||||
uv run pytest $(TEST); \
|
||||
EXIT_CODE=$$?; \
|
||||
make stop-postgres; \
|
||||
make stop-services; \
|
||||
make stop-dev-server; \
|
||||
exit $$EXIT_CODE; \
|
||||
else \
|
||||
@@ -74,11 +74,11 @@ test:
|
||||
fi
|
||||
|
||||
test_parallel:
|
||||
make start-postgres &&\
|
||||
make start-services &&\
|
||||
make start-dev-server &&\
|
||||
uv run pytest -n auto --dist worksteal $(TEST); \
|
||||
EXIT_CODE=$$?; \
|
||||
make stop-postgres; \
|
||||
make stop-services; \
|
||||
make stop-dev-server; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
@@ -93,11 +93,11 @@ MAXFAIL_ARGS := $(if $(MAXFAIL),--maxfail $(MAXFAIL),)
|
||||
XDIST_ARGS := $(if $(WORKERS),-x $(XDIST_ARGS),)
|
||||
|
||||
test_watch:
|
||||
make start-postgres &&\
|
||||
make start-services &&\
|
||||
make start-dev-server &&\
|
||||
uv run ptw . -- --ff -vv $(XDIST_ARGS) $(MAXFAIL_ARGS) $(TEST); \
|
||||
EXIT_CODE=$$?; \
|
||||
make stop-postgres; \
|
||||
make stop-services; \
|
||||
make stop-dev-server; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
from langchain_core.tools import StructuredTool
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from langgraph.prebuilt.chat_agent_executor import create_react_agent
|
||||
from langgraph.prebuilt.chat_agent_executor import create_agent
|
||||
from langgraph.pregel import Pregel
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ def react_agent(n_tools: int, checkpointer: Optional[BaseCheckpointSaver]) -> Pr
|
||||
]
|
||||
)
|
||||
|
||||
return create_react_agent(model, [tool], checkpointer=checkpointer)
|
||||
return create_agent(model, [tool], checkpointer=checkpointer)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -41,16 +41,16 @@ _Writer = Callable[
|
||||
|
||||
|
||||
def _get_branch_path_input_schema(
|
||||
path: Callable[..., Hashable | list[Hashable]]
|
||||
| Callable[..., Awaitable[Hashable | list[Hashable]]]
|
||||
| Runnable[Any, Hashable | list[Hashable]],
|
||||
path: Callable[..., Hashable | Sequence[Hashable]]
|
||||
| Callable[..., Awaitable[Hashable | Sequence[Hashable]]]
|
||||
| Runnable[Any, Hashable | Sequence[Hashable]],
|
||||
) -> type[Any] | None:
|
||||
input = None
|
||||
# detect input schema annotation in the branch callable
|
||||
try:
|
||||
callable_: (
|
||||
Callable[..., Hashable | list[Hashable]]
|
||||
| Callable[..., Awaitable[Hashable | list[Hashable]]]
|
||||
Callable[..., Hashable | Sequence[Hashable]]
|
||||
| Callable[..., Awaitable[Hashable | Sequence[Hashable]]]
|
||||
| None
|
||||
) = None
|
||||
if isinstance(path, (RunnableCallable, RunnableLambda)):
|
||||
|
||||
@@ -22,10 +22,11 @@ from langchain_core.messages import (
|
||||
convert_to_messages,
|
||||
message_chunk_to_message,
|
||||
)
|
||||
from typing_extensions import TypedDict
|
||||
from typing_extensions import TypedDict, deprecated
|
||||
|
||||
from langgraph._internal._constants import CONF, CONFIG_KEY_SEND, NS_SEP
|
||||
from langgraph.graph.state import StateGraph
|
||||
from langgraph.warnings import LangGraphDeprecatedSinceV10
|
||||
|
||||
__all__ = (
|
||||
"add_messages",
|
||||
@@ -233,9 +234,16 @@ def add_messages(
|
||||
return merged
|
||||
|
||||
|
||||
@deprecated(
|
||||
"MessageGraph is deprecated in LangGraph v1.0.0, to be removed in v2.0.0. Please use StateGraph with a `messages` key instead.",
|
||||
category=None,
|
||||
)
|
||||
class MessageGraph(StateGraph):
|
||||
"""A StateGraph where every node receives a list of messages as input and returns one or more messages as output.
|
||||
|
||||
!!! warning "Deprecation"
|
||||
MessageGraph is deprecated in LangGraph v1.0.0, to be removed in v2.0.0. Please use StateGraph with a `messages` key instead.
|
||||
|
||||
MessageGraph is a subclass of StateGraph whose entire state is a single, append-only* list of messages.
|
||||
Each node in a MessageGraph takes a list of messages as input and returns zero or more
|
||||
messages as output. The `add_messages` function is used to merge the output messages from each node
|
||||
@@ -281,6 +289,11 @@ class MessageGraph(StateGraph):
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
warnings.warn(
|
||||
"MessageGraph is deprecated in LangGraph v1.0.0, to be removed in v2.0.0. Please use StateGraph with a `messages` key instead.",
|
||||
category=LangGraphDeprecatedSinceV10,
|
||||
stacklevel=2,
|
||||
)
|
||||
super().__init__(Annotated[list[AnyMessage], add_messages]) # type: ignore[arg-type]
|
||||
|
||||
|
||||
|
||||
@@ -607,9 +607,9 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
def add_conditional_edges(
|
||||
self,
|
||||
source: str,
|
||||
path: Callable[..., Hashable | list[Hashable]]
|
||||
| Callable[..., Awaitable[Hashable | list[Hashable]]]
|
||||
| Runnable[Any, Hashable | list[Hashable]],
|
||||
path: Callable[..., Hashable | Sequence[Hashable]]
|
||||
| Callable[..., Awaitable[Hashable | Sequence[Hashable]]]
|
||||
| Runnable[Any, Hashable | Sequence[Hashable]],
|
||||
path_map: dict[Hashable, str] | list[str] | None = None,
|
||||
) -> Self:
|
||||
"""Add a conditional edge from the starting node to any number of destination nodes.
|
||||
@@ -710,9 +710,9 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
|
||||
def set_conditional_entry_point(
|
||||
self,
|
||||
path: Callable[..., Hashable | list[Hashable]]
|
||||
| Callable[..., Awaitable[Hashable | list[Hashable]]]
|
||||
| Runnable[Any, Hashable | list[Hashable]],
|
||||
path: Callable[..., Hashable | Sequence[Hashable]]
|
||||
| Callable[..., Awaitable[Hashable | Sequence[Hashable]]]
|
||||
| Runnable[Any, Hashable | Sequence[Hashable]],
|
||||
path_map: dict[Hashable, str] | list[str] | None = None,
|
||||
) -> Self:
|
||||
"""Sets a conditional entry point in the graph.
|
||||
|
||||
@@ -29,6 +29,7 @@ def create_checkpoint(
|
||||
step: int,
|
||||
*,
|
||||
id: str | None = None,
|
||||
updated_channels: set[str] | None = None,
|
||||
) -> Checkpoint:
|
||||
"""Create a checkpoint for the given channels."""
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
@@ -49,6 +50,7 @@ def create_checkpoint(
|
||||
channel_values=values,
|
||||
channel_versions=checkpoint["channel_versions"],
|
||||
versions_seen=checkpoint["versions_seen"],
|
||||
updated_channels=None if updated_channels is None else sorted(updated_channels),
|
||||
)
|
||||
|
||||
|
||||
@@ -81,4 +83,5 @@ def copy_checkpoint(checkpoint: Checkpoint) -> Checkpoint:
|
||||
channel_values=checkpoint["channel_values"].copy(),
|
||||
channel_versions=checkpoint["channel_versions"].copy(),
|
||||
versions_seen={k: v.copy() for k, v in checkpoint["versions_seen"].items()},
|
||||
updated_channels=checkpoint.get("updated_channels", None),
|
||||
)
|
||||
|
||||
@@ -568,7 +568,9 @@ class PregelLoop:
|
||||
if task := tasks.get(tid):
|
||||
task.writes.append((k, v))
|
||||
|
||||
def _first(self, *, input_keys: str | Sequence[str]) -> set[str] | None:
|
||||
def _first(
|
||||
self, *, input_keys: str | Sequence[str], updated_channels: set[str] | None
|
||||
) -> set[str] | None:
|
||||
# resuming from previous checkpoint requires
|
||||
# - finding a previous checkpoint
|
||||
# - receiving None input (outer graph) or RESUMING flag (subgraph)
|
||||
@@ -585,8 +587,6 @@ class PregelLoop:
|
||||
),
|
||||
)
|
||||
)
|
||||
# this can be set only when there are input_writes
|
||||
updated_channels: set[str] | None = None
|
||||
|
||||
# map command to writes
|
||||
if isinstance(self.input, Command):
|
||||
@@ -614,13 +614,15 @@ class PregelLoop:
|
||||
if null_writes := [
|
||||
w[1:] for w in self.checkpoint_pending_writes if w[0] == NULL_TASK_ID
|
||||
]:
|
||||
apply_writes(
|
||||
null_updated_channels = apply_writes(
|
||||
self.checkpoint,
|
||||
self.channels,
|
||||
[PregelTaskWrites((), INPUT, null_writes, [])],
|
||||
self.checkpointer_get_next_version,
|
||||
self.trigger_to_nodes,
|
||||
)
|
||||
if updated_channels is not None:
|
||||
updated_channels.update(null_updated_channels)
|
||||
# proceed past previous checkpoint
|
||||
if is_resuming:
|
||||
self.checkpoint["versions_seen"].setdefault(INTERRUPT, {})
|
||||
@@ -648,6 +650,7 @@ class PregelLoop:
|
||||
store=None,
|
||||
checkpointer=None,
|
||||
manager=None,
|
||||
updated_channels=updated_channels,
|
||||
)
|
||||
# apply input writes
|
||||
updated_channels = apply_writes(
|
||||
@@ -661,6 +664,7 @@ class PregelLoop:
|
||||
self.trigger_to_nodes,
|
||||
)
|
||||
# save input checkpoint
|
||||
self.updated_channels = updated_channels
|
||||
self._put_checkpoint({"source": "input"})
|
||||
elif CONFIG_KEY_RESUMING not in configurable:
|
||||
raise EmptyInputError(f"Received no input for {input_keys}")
|
||||
@@ -693,6 +697,7 @@ class PregelLoop:
|
||||
self.channels if do_checkpoint else None,
|
||||
self.step,
|
||||
id=self.checkpoint["id"] if exiting else None,
|
||||
updated_channels=self.updated_channels,
|
||||
)
|
||||
# bail if no checkpointer
|
||||
if do_checkpoint and self._checkpointer_put_after_previous is not None:
|
||||
@@ -1036,7 +1041,12 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
self.step = self.checkpoint_metadata["step"] + 1
|
||||
self.stop = self.step + self.config["recursion_limit"] + 1
|
||||
self.checkpoint_previous_versions = self.checkpoint["channel_versions"].copy()
|
||||
self.updated_channels = self._first(input_keys=self.input_keys)
|
||||
self.updated_channels = self._first(
|
||||
input_keys=self.input_keys,
|
||||
updated_channels=set(self.checkpoint.get("updated_channels")) # type: ignore[arg-type]
|
||||
if self.checkpoint.get("updated_channels")
|
||||
else None,
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
@@ -1212,7 +1222,12 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
self.step = self.checkpoint_metadata["step"] + 1
|
||||
self.stop = self.step + self.config["recursion_limit"] + 1
|
||||
self.checkpoint_previous_versions = self.checkpoint["channel_versions"].copy()
|
||||
self.updated_channels = self._first(input_keys=self.input_keys)
|
||||
self.updated_channels = self._first(
|
||||
input_keys=self.input_keys,
|
||||
updated_channels=set(self.checkpoint.get("updated_channels")) # type: ignore[arg-type]
|
||||
if self.checkpoint.get("updated_channels")
|
||||
else None,
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
@@ -29,16 +29,51 @@ Meta = tuple[tuple[str, ...], dict[str, Any]]
|
||||
|
||||
class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
"""A callback handler that implements stream_mode=messages.
|
||||
Collects messages from (1) chat model stream events and (2) node outputs."""
|
||||
|
||||
Collects messages from:
|
||||
(1) chat model stream events; and
|
||||
(2) node outputs.
|
||||
"""
|
||||
|
||||
run_inline = True
|
||||
"""We want this callback to run in the main thread, to avoid order/locking issues."""
|
||||
"""We want this callback to run in the main thread to avoid order/locking issues."""
|
||||
|
||||
def __init__(self, stream: Callable[[StreamChunk], None], subgraphs: bool):
|
||||
def __init__(
|
||||
self,
|
||||
stream: Callable[[StreamChunk], None],
|
||||
subgraphs: bool,
|
||||
*,
|
||||
parent_ns: tuple[str, ...] | None = None,
|
||||
) -> None:
|
||||
"""Configure the handler to stream messages from LLMs and nodes.
|
||||
|
||||
Args:
|
||||
stream: A callable that takes a StreamChunk and emits it.
|
||||
subgraphs: Whether to emit messages from subgraphs.
|
||||
parent_ns: The namespace where the handler was created.
|
||||
We keep track of this namespace to allow calls to subgraphs that
|
||||
were explicitly requested as a stream with `messages` mode
|
||||
configured.
|
||||
|
||||
Example:
|
||||
parent_ns is used to handle scenarios where the subgraph is explicitly
|
||||
streamed with `stream_mode="messages"`.
|
||||
|
||||
```python
|
||||
def parent_graph_node():
|
||||
# This node is in the parent graph.
|
||||
async for event in some_subgraph(..., stream_mode="messages"):
|
||||
do something with event # <-- these events will be emitted
|
||||
return ...
|
||||
|
||||
parent_graph.invoke(subgraphs=False)
|
||||
```
|
||||
"""
|
||||
self.stream = stream
|
||||
self.subgraphs = subgraphs
|
||||
self.metadata: dict[UUID, Meta] = {}
|
||||
self.seen: set[int | str] = set()
|
||||
self.parent_ns = parent_ns
|
||||
|
||||
def _emit(self, meta: Meta, message: BaseMessage, *, dedupe: bool = False) -> None:
|
||||
if dedupe and message.id in self.seen:
|
||||
@@ -100,7 +135,7 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
ns = tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP))[
|
||||
:-1
|
||||
]
|
||||
if not self.subgraphs and len(ns) > 0:
|
||||
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
|
||||
return
|
||||
if tags:
|
||||
if filtered_tags := [t for t in tags if not t.startswith("seq:step")]:
|
||||
|
||||
@@ -11,7 +11,7 @@ from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
|
||||
from dataclasses import is_dataclass
|
||||
from functools import partial
|
||||
from inspect import isclass
|
||||
from typing import Any, Callable, Generic, Union, cast, get_type_hints
|
||||
from typing import Any, Callable, Generic, Optional, Union, cast, get_type_hints
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
from langchain_core.globals import get_debug
|
||||
@@ -2534,8 +2534,13 @@ class Pregel(
|
||||
config[CONF][CONFIG_KEY_CHECKPOINT_NS] = recast_checkpoint_ns(ns)
|
||||
# set up messages stream mode
|
||||
if "messages" in stream_modes:
|
||||
ns_ = cast(Optional[str], config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
|
||||
run_manager.inheritable_handlers.append(
|
||||
StreamMessagesHandler(stream.put, subgraphs)
|
||||
StreamMessagesHandler(
|
||||
stream.put,
|
||||
subgraphs,
|
||||
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
|
||||
)
|
||||
)
|
||||
|
||||
# set up custom stream mode
|
||||
@@ -2814,8 +2819,14 @@ class Pregel(
|
||||
config[CONF][CONFIG_KEY_CHECKPOINT_NS] = recast_checkpoint_ns(ns)
|
||||
# set up messages stream mode
|
||||
if "messages" in stream_modes:
|
||||
# namespace can be None in a root level graph?
|
||||
ns_ = cast(Optional[str], config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
|
||||
run_manager.inheritable_handlers.append(
|
||||
StreamMessagesHandler(stream_put, subgraphs)
|
||||
StreamMessagesHandler(
|
||||
stream_put,
|
||||
subgraphs,
|
||||
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
|
||||
)
|
||||
)
|
||||
|
||||
# set up custom stream mode
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.9"
|
||||
@@ -49,6 +49,7 @@ dev = [
|
||||
"types-requests",
|
||||
"pycryptodome",
|
||||
"langgraph-cli[inmem]",
|
||||
"redis",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -175,10 +175,10 @@
|
||||
'''
|
||||
# ---
|
||||
# name: test_prebuilt_tool_chat
|
||||
'{"$defs": {"BaseMessage": {"additionalProperties": true, "description": "Base abstract message class.\\n\\nMessages are the inputs and outputs of ChatModels.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content", "type"], "title": "BaseMessage", "type": "object"}}, "description": "The state of the agent.", "properties": {"messages": {"items": {"$ref": "#/$defs/BaseMessage"}, "title": "Messages", "type": "array"}, "remaining_steps": {"title": "Remaining Steps", "type": "integer"}}, "required": ["messages"], "title": "AgentState", "type": "object"}'
|
||||
'{"$defs": {"BaseMessage": {"additionalProperties": true, "description": "Base abstract message class.\\n\\nMessages are the inputs and outputs of ChatModels.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content", "type"], "title": "BaseMessage", "type": "object"}}, "description": "The state of the agent.", "properties": {"messages": {"items": {"$ref": "#/$defs/BaseMessage"}, "title": "Messages", "type": "array"}, "remaining_steps": {"title": "Remaining Steps", "type": "integer"}, "structured_response": {"title": "Structured Response", "type": "null"}}, "required": ["messages"], "title": "AgentState", "type": "object"}'
|
||||
# ---
|
||||
# name: test_prebuilt_tool_chat.1
|
||||
'{"$defs": {"BaseMessage": {"additionalProperties": true, "description": "Base abstract message class.\\n\\nMessages are the inputs and outputs of ChatModels.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content", "type"], "title": "BaseMessage", "type": "object"}}, "description": "The state of the agent.", "properties": {"messages": {"items": {"$ref": "#/$defs/BaseMessage"}, "title": "Messages", "type": "array"}, "remaining_steps": {"title": "Remaining Steps", "type": "integer"}}, "required": ["messages"], "title": "AgentState", "type": "object"}'
|
||||
'{"$defs": {"BaseMessage": {"additionalProperties": true, "description": "Base abstract message class.\\n\\nMessages are the inputs and outputs of ChatModels.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content", "type"], "title": "BaseMessage", "type": "object"}}, "description": "The state of the agent.", "properties": {"messages": {"items": {"$ref": "#/$defs/BaseMessage"}, "title": "Messages", "type": "array"}, "remaining_steps": {"title": "Remaining Steps", "type": "integer"}, "structured_response": {"title": "Structured Response", "type": "null"}}, "required": ["messages"], "title": "AgentState", "type": "object"}'
|
||||
# ---
|
||||
# name: test_prebuilt_tool_chat.2
|
||||
'''
|
||||
@@ -198,7 +198,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "agent",
|
||||
"id": "model",
|
||||
"type": "runnable",
|
||||
"data": {
|
||||
"id": [
|
||||
@@ -207,7 +207,7 @@
|
||||
"_runnable",
|
||||
"RunnableCallable"
|
||||
],
|
||||
"name": "agent"
|
||||
"name": "model"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -230,21 +230,21 @@
|
||||
"edges": [
|
||||
{
|
||||
"source": "__start__",
|
||||
"target": "agent"
|
||||
"target": "model"
|
||||
},
|
||||
{
|
||||
"source": "agent",
|
||||
"source": "model",
|
||||
"target": "__end__",
|
||||
"conditional": true
|
||||
},
|
||||
{
|
||||
"source": "agent",
|
||||
"source": "model",
|
||||
"target": "tools",
|
||||
"conditional": true
|
||||
},
|
||||
{
|
||||
"source": "tools",
|
||||
"target": "agent"
|
||||
"target": "model"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -253,10 +253,10 @@
|
||||
# name: test_prebuilt_tool_chat.3
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> agent;
|
||||
agent -.-> __end__;
|
||||
agent -.-> tools;
|
||||
tools --> agent;
|
||||
__start__ --> model;
|
||||
model -.-> __end__;
|
||||
model -.-> tools;
|
||||
tools --> model;
|
||||
|
||||
'''
|
||||
# ---
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
name: langgraph-tests
|
||||
services:
|
||||
redis-test:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
start_period: 10s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
interval: 5s
|
||||
start_interval: 1s
|
||||
tmpfs:
|
||||
- /data # Use tmpfs for faster testing
|
||||
@@ -3,10 +3,12 @@ from collections.abc import AsyncIterator, Iterator
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
import redis
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from langgraph.cache.base import BaseCache
|
||||
from langgraph.cache.memory import InMemoryCache
|
||||
from langgraph.cache.redis import RedisCache
|
||||
from langgraph.cache.sqlite import SqliteCache
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from langgraph.store.base import BaseStore
|
||||
@@ -55,12 +57,34 @@ def durability(request: pytest.FixtureRequest) -> Durability:
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", params=["sqlite", "memory"])
|
||||
@pytest.fixture(
|
||||
scope="function",
|
||||
params=["sqlite", "memory"] if NO_DOCKER else ["sqlite", "memory", "redis"],
|
||||
)
|
||||
def cache(request: pytest.FixtureRequest) -> Iterator[BaseCache]:
|
||||
if request.param == "sqlite":
|
||||
yield SqliteCache(path=":memory:")
|
||||
elif request.param == "memory":
|
||||
yield InMemoryCache()
|
||||
elif request.param == "redis":
|
||||
# Get worker ID for parallel test isolation
|
||||
worker_id = getattr(request.config, "workerinput", {}).get("workerid", "master")
|
||||
|
||||
redis_client = redis.Redis(
|
||||
host="localhost", port=6379, db=0, decode_responses=False
|
||||
)
|
||||
# Use worker-specific prefix to avoid cache pollution between parallel tests
|
||||
cache = RedisCache(redis_client, prefix=f"test:cache:{worker_id}:")
|
||||
yield cache
|
||||
|
||||
try:
|
||||
# Only clear keys with our specific prefix
|
||||
pattern = f"test:cache:{worker_id}:*"
|
||||
keys = redis_client.keys(pattern)
|
||||
if keys:
|
||||
redis_client.delete(*keys)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
raise ValueError(f"Unknown cache type: {request.param}")
|
||||
|
||||
|
||||
@@ -330,6 +330,7 @@ SAVED_CHECKPOINTS = {
|
||||
"docs": ["doc1", "doc2", "doc3", "doc4"],
|
||||
"answer": "doc1,doc2,doc3,doc4",
|
||||
},
|
||||
"updated_channels": None,
|
||||
},
|
||||
metadata={
|
||||
"source": "loop",
|
||||
@@ -390,6 +391,7 @@ SAVED_CHECKPOINTS = {
|
||||
"docs": ["doc1", "doc2", "doc3", "doc4"],
|
||||
"branch:to:qa": None,
|
||||
},
|
||||
"updated_channels": None,
|
||||
},
|
||||
metadata={
|
||||
"source": "loop",
|
||||
@@ -465,6 +467,7 @@ SAVED_CHECKPOINTS = {
|
||||
"branch:to:retriever_one": None,
|
||||
"docs": ["doc3", "doc4"],
|
||||
},
|
||||
"updated_channels": None,
|
||||
},
|
||||
metadata={
|
||||
"source": "loop",
|
||||
@@ -516,6 +519,7 @@ SAVED_CHECKPOINTS = {
|
||||
"branch:to:analyzer_one": None,
|
||||
"branch:to:retriever_two": None,
|
||||
},
|
||||
"updated_channels": None,
|
||||
},
|
||||
metadata={
|
||||
"source": "loop",
|
||||
@@ -570,6 +574,7 @@ SAVED_CHECKPOINTS = {
|
||||
"query": "what is weather in sf",
|
||||
"branch:to:rewrite_query": None,
|
||||
},
|
||||
"updated_channels": None,
|
||||
},
|
||||
metadata={
|
||||
"source": "loop",
|
||||
@@ -618,6 +623,7 @@ SAVED_CHECKPOINTS = {
|
||||
},
|
||||
"versions_seen": {"__input__": {}},
|
||||
"channel_values": {"__start__": {"query": "what is weather in sf"}},
|
||||
"updated_channels": None,
|
||||
},
|
||||
metadata={
|
||||
"source": "input",
|
||||
|
||||
@@ -12,6 +12,7 @@ from langgraph.channels.last_value import LastValue
|
||||
from langgraph.errors import NodeInterrupt
|
||||
from langgraph.func import entrypoint, task
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.graph.message import MessageGraph
|
||||
from langgraph.pregel import NodeBuilder, Pregel
|
||||
from langgraph.types import Interrupt, RetryPolicy
|
||||
from langgraph.warnings import LangGraphDeprecatedSinceV05, LangGraphDeprecatedSinceV10
|
||||
@@ -332,3 +333,11 @@ def test_config_parameter_incorrect_typing() -> None:
|
||||
|
||||
builder.add_node(async_node_with_untyped_config)
|
||||
assert len(w) == 0
|
||||
|
||||
|
||||
def test_message_graph_deprecation() -> None:
|
||||
with pytest.warns(
|
||||
LangGraphDeprecatedSinceV10,
|
||||
match="MessageGraph is deprecated in LangGraph v1.0.0, to be removed in v2.0.0. Please use StateGraph with a `messages` key instead.",
|
||||
):
|
||||
MessageGraph()
|
||||
|
||||
@@ -6,7 +6,9 @@ from dataclasses import replace
|
||||
from typing import Annotated, Any, Literal, Optional, Union, cast
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, AnyMessage, ToolCall
|
||||
from langchain_core.runnables import RunnableConfig, RunnableMap, RunnablePick
|
||||
from langchain_core.tools import tool
|
||||
from pytest_mock import MockerFixture
|
||||
from syrupy import SnapshotAssertion
|
||||
from typing_extensions import TypedDict
|
||||
@@ -18,8 +20,8 @@ from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.graph.message import MessageGraph, MessagesState, add_messages
|
||||
from langgraph.prebuilt.chat_agent_executor import create_react_agent
|
||||
from langgraph.graph.message import MessagesState, add_messages
|
||||
from langgraph.prebuilt.chat_agent_executor import create_agent
|
||||
from langgraph.prebuilt.tool_node import ToolNode
|
||||
from langgraph.pregel import NodeBuilder, Pregel
|
||||
from langgraph.types import (
|
||||
@@ -1299,7 +1301,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
|
||||
]
|
||||
)
|
||||
|
||||
app = create_react_agent(model, tools)
|
||||
app = create_agent(model, tools)
|
||||
|
||||
assert json.dumps(app.get_input_jsonschema()) == snapshot
|
||||
assert json.dumps(app.get_output_jsonschema()) == snapshot
|
||||
@@ -1388,11 +1390,11 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
|
||||
),
|
||||
{
|
||||
"langgraph_step": 1,
|
||||
"langgraph_node": "agent",
|
||||
"langgraph_triggers": ("branch:to:agent",),
|
||||
"langgraph_path": (PULL, "agent"),
|
||||
"langgraph_checkpoint_ns": AnyStr("agent:"),
|
||||
"checkpoint_ns": AnyStr("agent:"),
|
||||
"langgraph_node": "model",
|
||||
"langgraph_triggers": ("branch:to:model",),
|
||||
"langgraph_path": (PULL, "model"),
|
||||
"langgraph_checkpoint_ns": AnyStr("model:"),
|
||||
"checkpoint_ns": AnyStr("model:"),
|
||||
"ls_provider": "fakechatmodel",
|
||||
"ls_model_type": "chat",
|
||||
},
|
||||
@@ -1447,11 +1449,11 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
|
||||
),
|
||||
{
|
||||
"langgraph_step": 3,
|
||||
"langgraph_node": "agent",
|
||||
"langgraph_triggers": ("branch:to:agent",),
|
||||
"langgraph_path": (PULL, "agent"),
|
||||
"langgraph_checkpoint_ns": AnyStr("agent:"),
|
||||
"checkpoint_ns": AnyStr("agent:"),
|
||||
"langgraph_node": "model",
|
||||
"langgraph_triggers": ("branch:to:model",),
|
||||
"langgraph_path": (PULL, "model"),
|
||||
"langgraph_checkpoint_ns": AnyStr("model:"),
|
||||
"checkpoint_ns": AnyStr("model:"),
|
||||
"ls_provider": "fakechatmodel",
|
||||
"ls_model_type": "chat",
|
||||
},
|
||||
@@ -1495,11 +1497,11 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
|
||||
),
|
||||
{
|
||||
"langgraph_step": 5,
|
||||
"langgraph_node": "agent",
|
||||
"langgraph_triggers": ("branch:to:agent",),
|
||||
"langgraph_path": (PULL, "agent"),
|
||||
"langgraph_checkpoint_ns": AnyStr("agent:"),
|
||||
"checkpoint_ns": AnyStr("agent:"),
|
||||
"langgraph_node": "model",
|
||||
"langgraph_triggers": ("branch:to:model",),
|
||||
"langgraph_path": (PULL, "model"),
|
||||
"langgraph_checkpoint_ns": AnyStr("model:"),
|
||||
"checkpoint_ns": AnyStr("model:"),
|
||||
"ls_provider": "fakechatmodel",
|
||||
"ls_model_type": "chat",
|
||||
},
|
||||
@@ -1531,7 +1533,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
|
||||
for output in (invoke_updates_events, stream_updates_events):
|
||||
assert output[:3] == [
|
||||
{
|
||||
"agent": {
|
||||
"model": {
|
||||
"messages": [
|
||||
_AnyIdAIMessage(
|
||||
content="",
|
||||
@@ -1558,7 +1560,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
|
||||
}
|
||||
},
|
||||
{
|
||||
"agent": {
|
||||
"model": {
|
||||
"messages": [
|
||||
_AnyIdAIMessage(
|
||||
content="",
|
||||
@@ -1604,7 +1606,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
|
||||
},
|
||||
)
|
||||
assert output[5:] == [
|
||||
{"agent": {"messages": [_AnyIdAIMessage(content="answer")]}}
|
||||
{"model": {"messages": [_AnyIdAIMessage(content="answer")]}}
|
||||
]
|
||||
|
||||
|
||||
@@ -2441,7 +2443,7 @@ def test_message_graph(
|
||||
return "continue"
|
||||
|
||||
# Define a new graph
|
||||
workflow = MessageGraph()
|
||||
workflow = StateGraph(state_schema=Annotated[list[AnyMessage], add_messages]) # type: ignore[arg-type]
|
||||
|
||||
# Define the two nodes we will cycle between
|
||||
workflow.add_node("agent", model)
|
||||
@@ -2487,7 +2489,7 @@ def test_message_graph(
|
||||
assert json.dumps(app.get_graph().to_json(), indent=2) == snapshot
|
||||
assert app.get_graph().draw_mermaid(with_styles=False) == snapshot
|
||||
|
||||
assert app.invoke(HumanMessage(content="what is weather in sf")) == [
|
||||
assert app.invoke([HumanMessage(content="what is weather in sf")]) == [
|
||||
_AnyIdHumanMessage(
|
||||
content="what is weather in sf",
|
||||
),
|
||||
@@ -6435,10 +6437,6 @@ def test_weather_subgraph(
|
||||
from langchain_core.language_models.fake_chat_models import (
|
||||
FakeMessagesListChatModel,
|
||||
)
|
||||
from langchain_core.messages import AIMessage, ToolCall
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from langgraph.graph import MessagesState
|
||||
|
||||
# setup subgraph
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import (
|
||||
)
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import ToolCall
|
||||
from langchain_core.messages import AnyMessage, ToolCall
|
||||
from langchain_core.runnables import RunnableConfig, RunnablePick
|
||||
from pytest_mock import MockerFixture
|
||||
from typing_extensions import TypedDict
|
||||
@@ -21,9 +21,9 @@ from langgraph.channels.last_value import LastValue
|
||||
from langgraph.channels.untracked_value import UntrackedValue
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph.message import MessageGraph, add_messages
|
||||
from langgraph.graph.message import add_messages
|
||||
from langgraph.graph.state import StateGraph
|
||||
from langgraph.prebuilt.chat_agent_executor import create_react_agent
|
||||
from langgraph.prebuilt.chat_agent_executor import create_agent
|
||||
from langgraph.prebuilt.tool_node import ToolNode
|
||||
from langgraph.pregel import NodeBuilder, Pregel
|
||||
from langgraph.types import PregelTask, Send, StateSnapshot, StreamWriter
|
||||
@@ -1059,7 +1059,7 @@ async def test_prebuilt_tool_chat() -> None:
|
||||
|
||||
tools = [search_api]
|
||||
|
||||
app = create_react_agent(model, tools)
|
||||
app = create_agent(model, tools)
|
||||
|
||||
assert await app.ainvoke(
|
||||
{"messages": [HumanMessage(content="what is weather in sf")]}
|
||||
@@ -1143,11 +1143,11 @@ async def test_prebuilt_tool_chat() -> None:
|
||||
),
|
||||
{
|
||||
"langgraph_step": 1,
|
||||
"langgraph_node": "agent",
|
||||
"langgraph_triggers": ("branch:to:agent",),
|
||||
"langgraph_path": (PULL, "agent"),
|
||||
"langgraph_checkpoint_ns": AnyStr("agent:"),
|
||||
"checkpoint_ns": AnyStr("agent:"),
|
||||
"langgraph_node": "model",
|
||||
"langgraph_triggers": ("branch:to:model",),
|
||||
"langgraph_path": (PULL, "model"),
|
||||
"langgraph_checkpoint_ns": AnyStr("model:"),
|
||||
"checkpoint_ns": AnyStr("model:"),
|
||||
"ls_provider": "fakechatmodel",
|
||||
"ls_model_type": "chat",
|
||||
},
|
||||
@@ -1202,11 +1202,11 @@ async def test_prebuilt_tool_chat() -> None:
|
||||
),
|
||||
{
|
||||
"langgraph_step": 3,
|
||||
"langgraph_node": "agent",
|
||||
"langgraph_triggers": ("branch:to:agent",),
|
||||
"langgraph_path": (PULL, "agent"),
|
||||
"langgraph_checkpoint_ns": AnyStr("agent:"),
|
||||
"checkpoint_ns": AnyStr("agent:"),
|
||||
"langgraph_node": "model",
|
||||
"langgraph_triggers": ("branch:to:model",),
|
||||
"langgraph_path": (PULL, "model"),
|
||||
"langgraph_checkpoint_ns": AnyStr("model:"),
|
||||
"checkpoint_ns": AnyStr("model:"),
|
||||
"ls_provider": "fakechatmodel",
|
||||
"ls_model_type": "chat",
|
||||
},
|
||||
@@ -1250,11 +1250,11 @@ async def test_prebuilt_tool_chat() -> None:
|
||||
),
|
||||
{
|
||||
"langgraph_step": 5,
|
||||
"langgraph_node": "agent",
|
||||
"langgraph_triggers": ("branch:to:agent",),
|
||||
"langgraph_path": (PULL, "agent"),
|
||||
"langgraph_checkpoint_ns": AnyStr("agent:"),
|
||||
"checkpoint_ns": AnyStr("agent:"),
|
||||
"langgraph_node": "model",
|
||||
"langgraph_triggers": ("branch:to:model",),
|
||||
"langgraph_path": (PULL, "model"),
|
||||
"langgraph_checkpoint_ns": AnyStr("model:"),
|
||||
"checkpoint_ns": AnyStr("model:"),
|
||||
"ls_provider": "fakechatmodel",
|
||||
"ls_model_type": "chat",
|
||||
},
|
||||
@@ -1269,7 +1269,7 @@ async def test_prebuilt_tool_chat() -> None:
|
||||
]
|
||||
assert stream_updates_events[:3] == [
|
||||
{
|
||||
"agent": {
|
||||
"model": {
|
||||
"messages": [
|
||||
_AnyIdAIMessage(
|
||||
content="",
|
||||
@@ -1296,7 +1296,7 @@ async def test_prebuilt_tool_chat() -> None:
|
||||
}
|
||||
},
|
||||
{
|
||||
"agent": {
|
||||
"model": {
|
||||
"messages": [
|
||||
_AnyIdAIMessage(
|
||||
content="",
|
||||
@@ -1342,7 +1342,7 @@ async def test_prebuilt_tool_chat() -> None:
|
||||
},
|
||||
)
|
||||
assert stream_updates_events[5:] == [
|
||||
{"agent": {"messages": [_AnyIdAIMessage(content="answer")]}}
|
||||
{"model": {"messages": [_AnyIdAIMessage(content="answer")]}}
|
||||
]
|
||||
|
||||
|
||||
@@ -2117,7 +2117,7 @@ async def test_message_graph(async_checkpointer: BaseCheckpointSaver) -> None:
|
||||
return "continue"
|
||||
|
||||
# Define a new graph
|
||||
workflow = MessageGraph()
|
||||
workflow = StateGraph(state_schema=Annotated[list[AnyMessage], add_messages]) # type: ignore[arg-type]
|
||||
|
||||
# Define the two nodes we will cycle between
|
||||
workflow.add_node("agent", model)
|
||||
@@ -2157,7 +2157,7 @@ async def test_message_graph(async_checkpointer: BaseCheckpointSaver) -> None:
|
||||
# meaning you can use it as you would any other runnable
|
||||
app = workflow.compile()
|
||||
|
||||
assert await app.ainvoke(HumanMessage(content="what is weather in sf")) == [
|
||||
assert await app.ainvoke([HumanMessage(content="what is weather in sf")]) == [
|
||||
_AnyIdHumanMessage(
|
||||
content="what is weather in sf",
|
||||
),
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import Annotated, Any, Literal, Optional, Union, get_type_hints
|
||||
|
||||
import pytest
|
||||
from langchain_core.language_models import GenericFakeChatModel
|
||||
from langchain_core.messages import AnyMessage
|
||||
from langchain_core.runnables import (
|
||||
RunnableConfig,
|
||||
RunnableLambda,
|
||||
@@ -26,7 +27,7 @@ from langsmith import traceable
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
from pytest_mock import MockerFixture
|
||||
from syrupy import SnapshotAssertion
|
||||
from typing_extensions import TypedDict
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from langgraph._internal._constants import CONFIG_KEY_NODE_FINISHED, ERROR, PULL
|
||||
from langgraph.cache.base import BaseCache
|
||||
@@ -45,7 +46,7 @@ from langgraph.config import get_stream_writer
|
||||
from langgraph.errors import GraphRecursionError, InvalidUpdateError, ParentCommand
|
||||
from langgraph.func import entrypoint, task
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph.graph.message import MessageGraph, MessagesState, add_messages
|
||||
from langgraph.graph.message import MessagesState, add_messages
|
||||
from langgraph.prebuilt.tool_node import ToolNode
|
||||
from langgraph.pregel import (
|
||||
NodeBuilder,
|
||||
@@ -967,6 +968,7 @@ def test_pending_writes_resume(
|
||||
"branch:to:two": AnyVersion(),
|
||||
},
|
||||
"channel_values": {"value": 6},
|
||||
"updated_channels": ["value"],
|
||||
},
|
||||
metadata={
|
||||
"parents": {},
|
||||
@@ -1014,6 +1016,7 @@ def test_pending_writes_resume(
|
||||
"branch:to:one": None,
|
||||
"branch:to:two": None,
|
||||
},
|
||||
"updated_channels": ["branch:to:one", "branch:to:two", "value"],
|
||||
},
|
||||
metadata={
|
||||
"parents": {},
|
||||
@@ -1065,6 +1068,7 @@ def test_pending_writes_resume(
|
||||
"__start__": AnyVersion(),
|
||||
},
|
||||
"channel_values": {"__start__": {"value": 1}},
|
||||
"updated_channels": ["__start__"],
|
||||
},
|
||||
metadata={
|
||||
"parents": {},
|
||||
@@ -3907,7 +3911,7 @@ def test_remove_message_via_state_update(
|
||||
) -> None:
|
||||
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
|
||||
|
||||
workflow = MessageGraph()
|
||||
workflow = StateGraph(state_schema=Annotated[list[AnyMessage], add_messages]) # type: ignore[arg-type]
|
||||
workflow.add_node(
|
||||
"chatbot",
|
||||
lambda state: [
|
||||
@@ -3940,7 +3944,7 @@ def test_remove_message_via_state_update(
|
||||
def test_remove_message_from_node():
|
||||
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
|
||||
|
||||
workflow = MessageGraph()
|
||||
workflow = StateGraph(state_schema=Annotated[list[AnyMessage], add_messages]) # type: ignore[arg-type]
|
||||
workflow.add_node(
|
||||
"chatbot",
|
||||
lambda state: [
|
||||
@@ -8262,3 +8266,53 @@ def test_fork_and_update_task_results(sync_checkpointer: BaseCheckpointSaver) ->
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def test_subgraph_streaming_sync() -> None:
|
||||
"""Test subgraph streaming when used as a node in sync version"""
|
||||
|
||||
# Create a fake chat model that returns a simple response
|
||||
model = GenericFakeChatModel(messages=iter(["The weather is sunny today."]))
|
||||
|
||||
# Create a subgraph that uses the fake chat model
|
||||
def call_model_node(state: MessagesState, config: RunnableConfig) -> MessagesState:
|
||||
"""Node that calls the model with the last message."""
|
||||
messages = state["messages"]
|
||||
last_message = messages[-1].content if messages else ""
|
||||
response = model.invoke([("user", last_message)], config)
|
||||
return {"messages": [response]}
|
||||
|
||||
# Build the subgraph
|
||||
subgraph = StateGraph(MessagesState)
|
||||
subgraph.add_node("call_model", call_model_node)
|
||||
subgraph.add_edge(START, "call_model")
|
||||
compiled_subgraph = subgraph.compile()
|
||||
|
||||
class SomeCustomState(TypedDict):
|
||||
last_chunk: NotRequired[str]
|
||||
num_chunks: NotRequired[int]
|
||||
|
||||
# Will invoke a subgraph as a function
|
||||
def parent_node(state: SomeCustomState, config: RunnableConfig) -> dict:
|
||||
"""Node that runs the subgraph."""
|
||||
msgs = {"messages": [("user", "What is the weather in Tokyo?")]}
|
||||
events = []
|
||||
for event in compiled_subgraph.stream(msgs, config, stream_mode="messages"):
|
||||
events.append(event)
|
||||
ai_msg_chunks = [ai_msg_chunk for ai_msg_chunk, _ in events]
|
||||
return {
|
||||
"last_chunk": ai_msg_chunks[-1],
|
||||
"num_chunks": len(ai_msg_chunks),
|
||||
}
|
||||
|
||||
# Build the main workflow
|
||||
workflow = StateGraph(SomeCustomState)
|
||||
workflow.add_node("subgraph", parent_node)
|
||||
workflow.add_edge(START, "subgraph")
|
||||
compiled_workflow = workflow.compile()
|
||||
|
||||
# Test the basic functionality
|
||||
result = compiled_workflow.invoke({})
|
||||
|
||||
assert result["last_chunk"].content == "today."
|
||||
assert result["num_chunks"] == 9
|
||||
|
||||
@@ -26,7 +26,7 @@ from langchain_core.utils.aiter import aclosing
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
from pytest_mock import MockerFixture
|
||||
from syrupy import SnapshotAssertion
|
||||
from typing_extensions import TypedDict
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from langgraph._internal._constants import CONFIG_KEY_NODE_FINISHED, ERROR, PULL
|
||||
from langgraph.cache.base import BaseCache
|
||||
@@ -1908,6 +1908,7 @@ async def test_pending_writes_resume(
|
||||
"branch:to:two": AnyVersion(),
|
||||
},
|
||||
"channel_values": {"value": 6},
|
||||
"updated_channels": ["value"],
|
||||
},
|
||||
metadata={
|
||||
"parents": {},
|
||||
@@ -1955,6 +1956,7 @@ async def test_pending_writes_resume(
|
||||
"branch:to:one": None,
|
||||
"branch:to:two": None,
|
||||
},
|
||||
"updated_channels": ["branch:to:one", "branch:to:two", "value"],
|
||||
},
|
||||
metadata={
|
||||
"parents": {},
|
||||
@@ -2002,6 +2004,7 @@ async def test_pending_writes_resume(
|
||||
"__start__": AnyVersion(),
|
||||
},
|
||||
"channel_values": {"__start__": {"value": 1}},
|
||||
"updated_channels": ["__start__"],
|
||||
},
|
||||
metadata={
|
||||
"parents": {},
|
||||
@@ -9050,3 +9053,57 @@ async def test_fork_and_update_task_results(
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
async def test_subgraph_streaming_async() -> None:
|
||||
"""Test subgraph streaming when used as a node in async version"""
|
||||
|
||||
# Create a fake chat model that returns a simple response
|
||||
model = GenericFakeChatModel(messages=iter(["The weather is sunny today."]))
|
||||
|
||||
# Create a subgraph that uses the fake chat model
|
||||
async def call_model_node(
|
||||
state: MessagesState, config: RunnableConfig
|
||||
) -> MessagesState:
|
||||
"""Node that calls the model with the last message."""
|
||||
messages = state["messages"]
|
||||
last_message = messages[-1].content if messages else ""
|
||||
response = await model.ainvoke([("user", last_message)], config)
|
||||
return {"messages": [response]}
|
||||
|
||||
# Build the subgraph
|
||||
subgraph = StateGraph(MessagesState)
|
||||
subgraph.add_node("call_model", call_model_node)
|
||||
subgraph.add_edge(START, "call_model")
|
||||
compiled_subgraph = subgraph.compile()
|
||||
|
||||
class SomeCustomState(TypedDict):
|
||||
last_chunk: NotRequired[str]
|
||||
num_chunks: NotRequired[int]
|
||||
|
||||
# Will invoke a subgraph as a function
|
||||
async def parent_node(state: SomeCustomState, config: RunnableConfig) -> dict:
|
||||
"""Node that runs the subgraph."""
|
||||
msgs = {"messages": [("user", "What is the weather in Tokyo?")]}
|
||||
events = []
|
||||
async for event in compiled_subgraph.astream(
|
||||
msgs, config, stream_mode="messages"
|
||||
):
|
||||
events.append(event)
|
||||
ai_msg_chunks = [ai_msg_chunk for ai_msg_chunk, _ in events]
|
||||
return {
|
||||
"last_chunk": ai_msg_chunks[-1],
|
||||
"num_chunks": len(ai_msg_chunks),
|
||||
}
|
||||
|
||||
# Build the main workflow
|
||||
workflow = StateGraph(SomeCustomState)
|
||||
workflow.add_node("subgraph", parent_node)
|
||||
workflow.add_edge(START, "subgraph")
|
||||
compiled_workflow = workflow.compile()
|
||||
|
||||
# Test the basic functionality
|
||||
result = await compiled_workflow.ainvoke({})
|
||||
|
||||
assert result["last_chunk"].content == "today."
|
||||
assert result["num_chunks"] == 9
|
||||
|
||||
Generated
+26
-2
@@ -119,6 +119,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069, upload-time = "2025-03-16T17:25:35.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-timeout"
|
||||
version = "5.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.3.0"
|
||||
@@ -1192,7 +1201,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1225,6 +1234,7 @@ dev = [
|
||||
{ name = "pytest-repeat" },
|
||||
{ name = "pytest-watcher" },
|
||||
{ name = "pytest-xdist", extra = ["psutil"] },
|
||||
{ name = "redis" },
|
||||
{ name = "ruff" },
|
||||
{ name = "syrupy" },
|
||||
{ name = "types-requests" },
|
||||
@@ -1263,6 +1273,7 @@ dev = [
|
||||
{ name = "pytest-repeat" },
|
||||
{ name = "pytest-watcher" },
|
||||
{ name = "pytest-xdist", extras = ["psutil"] },
|
||||
{ name = "redis" },
|
||||
{ name = "ruff" },
|
||||
{ name = "syrupy" },
|
||||
{ name = "types-requests" },
|
||||
@@ -1326,6 +1337,7 @@ dev = [
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-watcher" },
|
||||
{ name = "redis" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
@@ -1433,7 +1445,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = { editable = "../prebuilt" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -2628,6 +2640,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/8b/619a9ee2fa4d3c724fbadde946427735ade64da03894b071bbdc3b789d83/pyzmq-27.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:096af9e133fec3a72108ddefba1e42985cb3639e9de52cfd336b6fc23aa083e9", size = 544715, upload-time = "2025-06-13T14:09:05.579Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "6.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/cd/030274634a1a052b708756016283ea3d84e91ae45f74d7f5dcf55d753a0f/redis-6.3.0.tar.gz", hash = "sha256:3000dbe532babfb0999cdab7b3e5744bcb23e51923febcfaeb52c8cfb29632ef", size = 4647275, upload-time = "2025-08-05T08:12:31.648Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a7/2fe45801534a187543fc45d28b3844d84559c1589255bc2ece30d92dc205/redis-6.3.0-py3-none-any.whl", hash = "sha256:92f079d656ded871535e099080f70fab8e75273c0236797126ac60242d638e9b", size = 280018, upload-time = "2025-08-05T08:12:30.093Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.36.2"
|
||||
|
||||
@@ -7,11 +7,11 @@ all: help
|
||||
# TESTING AND COVERAGE
|
||||
######################
|
||||
|
||||
start-postgres:
|
||||
docker compose -f tests/compose-postgres.yml up -V --force-recreate --wait --remove-orphans
|
||||
start-services:
|
||||
docker compose -f tests/compose-postgres.yml -f tests/compose-redis.yml up -V --force-recreate --wait --remove-orphans
|
||||
|
||||
stop-postgres:
|
||||
docker compose -f tests/compose-postgres.yml down -v
|
||||
stop-services:
|
||||
docker compose -f tests/compose-postgres.yml -f tests/compose-redis.yml down -v
|
||||
|
||||
TEST ?= .
|
||||
|
||||
@@ -19,15 +19,15 @@ test-fast:
|
||||
LANGGRAPH_TEST_FAST=1 uv run pytest $(TEST)
|
||||
|
||||
test:
|
||||
make start-postgres && LANGGRAPH_TEST_FAST=0 uv run pytest $(TEST); \
|
||||
make start-services && LANGGRAPH_TEST_FAST=0 uv run pytest $(TEST); \
|
||||
EXIT_CODE=$$?; \
|
||||
make stop-postgres; \
|
||||
make stop-services; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
test_watch:
|
||||
make start-postgres && LANGGRAPH_TEST_FAST=0 uv run ptw $(TEST); \
|
||||
make start-services && LANGGRAPH_TEST_FAST=0 uv run ptw $(TEST); \
|
||||
EXIT_CODE=$$?; \
|
||||
make stop-postgres; \
|
||||
make stop-services; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
######################
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""langgraph.prebuilt exposes a higher-level API for creating and executing agents and tools."""
|
||||
|
||||
from langgraph.prebuilt.chat_agent_executor import create_react_agent
|
||||
from langgraph.prebuilt.chat_agent_executor import create_agent
|
||||
from langgraph.prebuilt.tool_node import (
|
||||
InjectedState,
|
||||
InjectedStore,
|
||||
@@ -10,7 +10,7 @@ from langgraph.prebuilt.tool_node import (
|
||||
from langgraph.prebuilt.tool_validator import ValidationNode
|
||||
|
||||
__all__ = [
|
||||
"create_react_agent",
|
||||
"create_agent",
|
||||
"ToolNode",
|
||||
"tools_condition",
|
||||
"ValidationNode",
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
from langchain_core.messages import ToolCall
|
||||
|
||||
|
||||
class ToolCallWithContext(TypedDict):
|
||||
"""ToolCall with additional context for graph state.
|
||||
|
||||
This is an internal data-structure meant to help the ToolNode accept
|
||||
tools calls with additional context (e.g. state) when dispatched using the
|
||||
`Send` API.
|
||||
|
||||
The Send API is used in create_react_agent to be able to distribute the tool
|
||||
calls in parallel and support human-in-the-loop workflows where graph execution
|
||||
may be paused for an indefinite time.
|
||||
"""
|
||||
|
||||
tool_call: ToolCall
|
||||
__type: Literal["tool_call_with_context"]
|
||||
"""Type to parameterize the payload.
|
||||
|
||||
Using "__" as a prefix to be defensive against potential name collisions with
|
||||
regular user state.
|
||||
"""
|
||||
state: Any
|
||||
"""The state is provided as additional context."""
|
||||
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TypeVar
|
||||
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
SyncOrAsync = Callable[P, R | Awaitable[R]]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
from typing import Literal, Optional, Union
|
||||
from typing import Literal
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
@@ -68,7 +68,7 @@ class HumanInterrupt(TypedDict):
|
||||
|
||||
action_request: ActionRequest
|
||||
config: HumanInterruptConfig
|
||||
description: Optional[str]
|
||||
description: str | None
|
||||
|
||||
|
||||
class HumanResponse(TypedDict):
|
||||
@@ -87,4 +87,4 @@ class HumanResponse(TypedDict):
|
||||
"""
|
||||
|
||||
type: Literal["accept", "ignore", "response", "edit"]
|
||||
args: Union[None, str, ActionRequest]
|
||||
args: None | str | ActionRequest
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Types for setting agent response formats."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, is_dataclass
|
||||
from types import UnionType
|
||||
from typing import Any, Generic, Literal, TypeVar, Union, get_args, get_origin
|
||||
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.tools import BaseTool, StructuredTool
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from typing_extensions import Self, is_typeddict
|
||||
|
||||
# Supported schema types: Pydantic models, dataclasses, TypedDict, JSON schema dicts
|
||||
SchemaT = TypeVar("SchemaT")
|
||||
|
||||
SchemaKind = Literal["pydantic", "dataclass", "typeddict", "json_schema"]
|
||||
|
||||
|
||||
def _parse_with_schema(
|
||||
schema: type[SchemaT] | dict, schema_kind: SchemaKind, data: dict[str, Any]
|
||||
) -> Any:
|
||||
"""Parse data using for any supported schema type.
|
||||
|
||||
Args:
|
||||
schema: The schema type (Pydantic model, dataclass, or TypedDict)
|
||||
data: The data to parse
|
||||
|
||||
Returns:
|
||||
The parsed instance according to the schema type
|
||||
|
||||
Raises:
|
||||
ValueError: If parsing fails
|
||||
"""
|
||||
if schema_kind == "json_schema":
|
||||
return data
|
||||
else:
|
||||
try:
|
||||
adapter: TypeAdapter[SchemaT] = TypeAdapter(schema)
|
||||
return adapter.validate_python(data)
|
||||
except Exception as e:
|
||||
schema_name = getattr(schema, "__name__", str(schema))
|
||||
raise ValueError(f"Failed to parse data to {schema_name}: {e}") from e
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class _SchemaSpec(Generic[SchemaT]):
|
||||
"""Describes a structured output schema."""
|
||||
|
||||
schema: type[SchemaT] | dict[str, Any]
|
||||
"""The schema for the response, can be a Pydantic model, dataclass, TypedDict, or JSON schema dict."""
|
||||
|
||||
name: str
|
||||
"""Name of the schema, used for tool calling.
|
||||
|
||||
If not provided, the name will be the model name or "structured_output" if it's a JSON schema.
|
||||
"""
|
||||
|
||||
description: str
|
||||
"""Custom description of the schema.
|
||||
|
||||
If not provided, provided will use the model's docstring.
|
||||
"""
|
||||
|
||||
schema_kind: SchemaKind
|
||||
"""The kind of schema."""
|
||||
|
||||
json_schema: dict[str, Any]
|
||||
"""JSON schema associated with the schema."""
|
||||
|
||||
strict: bool = False
|
||||
"""Whether to enforce strict validation of the schema."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schema: type[SchemaT] | dict[str, Any],
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
strict: bool = False,
|
||||
) -> None:
|
||||
"""Initialize SchemaSpec with schema and optional parameters."""
|
||||
self.schema = schema
|
||||
|
||||
self.name = name or (
|
||||
schema.get("title", "structured_output")
|
||||
if isinstance(schema, dict)
|
||||
else getattr(schema, "__name__", "structured_output")
|
||||
)
|
||||
|
||||
self.description = description or (
|
||||
schema.get("description", "")
|
||||
if isinstance(schema, dict)
|
||||
else getattr(schema, "__doc__", None) or ""
|
||||
)
|
||||
|
||||
self.strict = strict
|
||||
|
||||
if isinstance(schema, dict):
|
||||
self.schema_kind = "json_schema"
|
||||
self.json_schema = schema
|
||||
elif isinstance(schema, type) and issubclass(schema, BaseModel):
|
||||
self.schema_kind = "pydantic"
|
||||
self.json_schema = schema.model_json_schema()
|
||||
elif is_dataclass(schema):
|
||||
self.schema_kind = "dataclass"
|
||||
self.json_schema = TypeAdapter(schema).json_schema()
|
||||
elif is_typeddict(schema):
|
||||
self.schema_kind = "typeddict"
|
||||
self.json_schema = TypeAdapter(schema).json_schema()
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported schema type: {type(schema)}. "
|
||||
f"Supported types: Pydantic models, dataclasses, TypedDicts, and JSON schema dicts."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class ToolOutput(Generic[SchemaT]):
|
||||
"""Use a tool calling strategy for model responses."""
|
||||
|
||||
schema: type[SchemaT] | dict[str, Any]
|
||||
"""Schema for the tool calls."""
|
||||
|
||||
schema_specs: list[_SchemaSpec[SchemaT]]
|
||||
"""Schema specs for the tool calls."""
|
||||
|
||||
tool_message_content: str | None
|
||||
"""The content of the tool message to be returned when the model calls an artificial structured output tool."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schema: type[SchemaT] | dict[str, Any],
|
||||
tool_message_content: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize ToolOutput with schemas and tool message content."""
|
||||
self.schema = schema
|
||||
self.tool_message_content = tool_message_content
|
||||
|
||||
if get_origin(schema) in (UnionType, Union):
|
||||
self.schema_specs = [_SchemaSpec(s) for s in get_args(schema)]
|
||||
else:
|
||||
self.schema_specs = [_SchemaSpec(schema)]
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class NativeOutput(Generic[SchemaT]):
|
||||
"""Use the model provider's native structured output method."""
|
||||
|
||||
schema: type[SchemaT] | dict[str, Any]
|
||||
"""Schema for native mode."""
|
||||
|
||||
schema_spec: _SchemaSpec[SchemaT]
|
||||
"""Schema spec for native mode."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schema: type[SchemaT] | dict[str, Any],
|
||||
) -> None:
|
||||
self.schema = schema
|
||||
self.schema_spec = _SchemaSpec(schema)
|
||||
|
||||
def to_model_kwargs(self) -> dict[str, Any]:
|
||||
# OpenAI:
|
||||
# - see https://platform.openai.com/docs/guides/structured-outputs
|
||||
response_format = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": self.schema_spec.name,
|
||||
"schema": self.schema_spec.json_schema,
|
||||
},
|
||||
}
|
||||
return {"response_format": response_format}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputToolBinding(Generic[SchemaT]):
|
||||
"""Information for tracking structured output tool metadata.
|
||||
|
||||
This contains all necessary information to handle structured responses
|
||||
generated via tool calls, including the original schema, its type classification,
|
||||
and the corresponding tool implementation used by the tools strategy.
|
||||
"""
|
||||
|
||||
schema: type[SchemaT] | dict[str, Any]
|
||||
"""The original schema provided for structured output (Pydantic model, dataclass, TypedDict, or JSON schema dict)."""
|
||||
|
||||
schema_kind: SchemaKind
|
||||
"""Classification of the schema type for proper response construction."""
|
||||
|
||||
tool: BaseTool
|
||||
"""LangChain tool instance created from the schema for model binding."""
|
||||
|
||||
@classmethod
|
||||
def from_schema_spec(cls, schema_spec: _SchemaSpec[SchemaT]) -> Self:
|
||||
"""Create an OutputToolBinding instance from a SchemaSpec.
|
||||
|
||||
Args:
|
||||
schema_spec: The SchemaSpec to convert
|
||||
|
||||
Returns:
|
||||
An OutputToolBinding instance with the appropriate tool created
|
||||
"""
|
||||
return cls(
|
||||
schema=schema_spec.schema,
|
||||
schema_kind=schema_spec.schema_kind,
|
||||
tool=StructuredTool(
|
||||
args_schema=schema_spec.json_schema,
|
||||
name=schema_spec.name,
|
||||
description=schema_spec.description,
|
||||
),
|
||||
)
|
||||
|
||||
def parse(self, tool_args: dict[str, Any]) -> SchemaT:
|
||||
"""Parse tool arguments according to the schema.
|
||||
|
||||
Args:
|
||||
tool_args: The arguments from the tool call
|
||||
|
||||
Returns:
|
||||
The parsed response according to the schema type
|
||||
|
||||
Raises:
|
||||
ValueError: If parsing fails
|
||||
"""
|
||||
return _parse_with_schema(self.schema, self.schema_kind, tool_args)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NativeOutputBinding(Generic[SchemaT]):
|
||||
"""Information for tracking native structured output metadata.
|
||||
|
||||
This contains all necessary information to handle structured responses
|
||||
generated via native provider output, including the original schema,
|
||||
its type classification, and parsing logic for provider-enforced JSON.
|
||||
"""
|
||||
|
||||
schema: type[SchemaT] | dict[str, Any]
|
||||
"""The original schema provided for structured output (Pydantic model, dataclass, TypedDict, or JSON schema dict)."""
|
||||
|
||||
schema_kind: SchemaKind
|
||||
"""Classification of the schema type for proper response construction."""
|
||||
|
||||
@classmethod
|
||||
def from_schema_spec(cls, schema_spec: _SchemaSpec[SchemaT]) -> Self:
|
||||
"""Create a NativeOutputBinding instance from a SchemaSpec.
|
||||
|
||||
Args:
|
||||
schema_spec: The SchemaSpec to convert
|
||||
|
||||
Returns:
|
||||
A NativeOutputBinding instance for parsing native structured output
|
||||
"""
|
||||
return cls(
|
||||
schema=schema_spec.schema,
|
||||
schema_kind=schema_spec.schema_kind,
|
||||
)
|
||||
|
||||
def parse(self, response: AIMessage) -> SchemaT:
|
||||
"""Parse AIMessage content according to the schema.
|
||||
|
||||
Args:
|
||||
response: The AI message containing the structured output
|
||||
|
||||
Returns:
|
||||
The parsed response according to the schema
|
||||
|
||||
Raises:
|
||||
ValueError: If text extraction, JSON parsing or schema validation fails
|
||||
"""
|
||||
# Extract text content from AIMessage and parse as JSON
|
||||
raw_text = self._extract_text_content_from_message(response)
|
||||
|
||||
import json
|
||||
|
||||
try:
|
||||
data = json.loads(raw_text)
|
||||
except Exception as e:
|
||||
schema_name = getattr(self.schema, "__name__", "structured_output")
|
||||
raise ValueError(
|
||||
f"Native structured output expected valid JSON for {schema_name}, but parsing failed: {e}."
|
||||
) from e
|
||||
|
||||
# Parse according to schema
|
||||
return _parse_with_schema(self.schema, self.schema_kind, data)
|
||||
|
||||
def _extract_text_content_from_message(self, message: AIMessage) -> str:
|
||||
"""Extract text content from an AIMessage.
|
||||
|
||||
Args:
|
||||
message: The AI message to extract text from
|
||||
|
||||
Returns:
|
||||
The extracted text content
|
||||
"""
|
||||
content = message.content
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for c in content:
|
||||
if isinstance(c, dict):
|
||||
if c.get("type") == "text" and "text" in c:
|
||||
parts.append(str(c["text"]))
|
||||
elif "content" in c and isinstance(c["content"], str):
|
||||
parts.append(c["content"])
|
||||
else:
|
||||
parts.append(str(c))
|
||||
return "".join(parts)
|
||||
return str(content)
|
||||
|
||||
|
||||
ResponseFormat = ToolOutput[SchemaT] | NativeOutput[SchemaT]
|
||||
@@ -31,21 +31,22 @@ Typical Usage:
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from collections.abc import Callable, Sequence
|
||||
from copy import copy, deepcopy
|
||||
from dataclasses import replace
|
||||
from typing import (
|
||||
Annotated,
|
||||
Any,
|
||||
Callable,
|
||||
Literal,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Type,
|
||||
Union,
|
||||
cast,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
@@ -69,12 +70,10 @@ from langchain_core.tools.base import (
|
||||
get_all_basemodel_annotations,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import Annotated, get_args, get_origin
|
||||
|
||||
from langgraph._internal._runnable import RunnableCallable
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
||||
from langgraph.prebuilt._internal import ToolCallWithContext
|
||||
from langgraph.store.base import BaseStore
|
||||
from langgraph.types import Command, Send
|
||||
|
||||
@@ -84,7 +83,7 @@ INVALID_TOOL_NAME_ERROR_TEMPLATE = (
|
||||
TOOL_CALL_ERROR_TEMPLATE = "Error: {error}\n Please fix your mistakes."
|
||||
|
||||
|
||||
def msg_content_output(output: Any) -> Union[str, list[dict]]:
|
||||
def msg_content_output(output: Any) -> str | list[dict]:
|
||||
"""Convert tool output to valid message content format.
|
||||
|
||||
LangChain ToolMessages accept either string content or a list of content blocks.
|
||||
@@ -126,12 +125,7 @@ def msg_content_output(output: Any) -> Union[str, list[dict]]:
|
||||
def _handle_tool_error(
|
||||
e: Exception,
|
||||
*,
|
||||
flag: Union[
|
||||
bool,
|
||||
str,
|
||||
Callable[..., str],
|
||||
tuple[type[Exception], ...],
|
||||
],
|
||||
flag: bool | str | Callable[..., str] | tuple[type[Exception], ...],
|
||||
) -> str:
|
||||
"""Generate error message content based on exception handling configuration.
|
||||
|
||||
@@ -157,7 +151,7 @@ def _handle_tool_error(
|
||||
The tuple case is handled by the caller through exception type checking,
|
||||
not by this function directly.
|
||||
"""
|
||||
if isinstance(flag, (bool, tuple)):
|
||||
if isinstance(flag, bool | tuple):
|
||||
content = TOOL_CALL_ERROR_TEMPLATE.format(error=repr(e))
|
||||
elif isinstance(flag, str):
|
||||
content = flag
|
||||
@@ -238,17 +232,39 @@ def _infer_handled_types(handler: Callable[..., str]) -> tuple[type[Exception],
|
||||
|
||||
|
||||
class ToolNode(RunnableCallable):
|
||||
"""A node that runs the tools called in the last AIMessage.
|
||||
"""A node for executing tools in LangGraph workflows.
|
||||
|
||||
It can be used either in StateGraph with a "messages" state key (or a custom key passed via ToolNode's 'messages_key').
|
||||
If multiple tool calls are requested, they will be run in parallel. The output will be
|
||||
a list of ToolMessages, one for each tool call.
|
||||
Handles tool execution patterns including function calls, state injection,
|
||||
persistent storage, and control flow. Manages parallel execution,
|
||||
error handling.
|
||||
|
||||
Tool calls can also be passed directly as a list of `ToolCall` dicts.
|
||||
Input Formats:
|
||||
1. Graph state with `messages` key that has a list of messages:
|
||||
- Common representation for agentic workflows
|
||||
- Supports custom messages key via ``messages_key`` parameter
|
||||
|
||||
2. **Message List**: ``[AIMessage(..., tool_calls=[...])]``
|
||||
- List of messages with tool calls in the last AIMessage
|
||||
|
||||
3. **Direct Tool Calls**: ``[{"name": "tool", "args": {...}, "id": "1", "type": "tool_call"}]``
|
||||
- Bypasses message parsing for direct tool execution
|
||||
- For programmatic tool invocation and testing
|
||||
|
||||
Output Formats:
|
||||
Output format depends on input type and tool behavior:
|
||||
|
||||
**For Regular tools**:
|
||||
- Dict input → ``{"messages": [ToolMessage(...)]}``
|
||||
- List input → ``[ToolMessage(...)]``
|
||||
|
||||
**For Command tools**:
|
||||
- Returns ``[Command(...)]`` or mixed list with regular tool outputs
|
||||
- Commands can update state, trigger navigation, or send messages
|
||||
|
||||
Args:
|
||||
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.
|
||||
tools: A sequence of tools that can be invoked by this node. Supports:
|
||||
- **BaseTool instances**: Tools with schemas and metadata
|
||||
- **Plain functions**: Automatically converted to tools with inferred schemas
|
||||
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
|
||||
@@ -256,21 +272,24 @@ class ToolNode(RunnableCallable):
|
||||
handle_tool_errors: Configuration for error handling during tool execution.
|
||||
Defaults to True. Supports multiple strategies:
|
||||
|
||||
- True: Catch all errors and return a ToolMessage with the default
|
||||
- **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
|
||||
- **str**: Catch all errors and return a ToolMessage with this custom
|
||||
error message string.
|
||||
- tuple[type[Exception], ...]: Only catch exceptions of the specified
|
||||
- **tuple[type[Exception], ...]**: Only catch exceptions with the specified
|
||||
types and return default error messages for them.
|
||||
- Callable[..., str]: Catch exceptions matching the callable's signature
|
||||
- **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.
|
||||
- **False**: Disable error handling entirely, allowing exceptions to
|
||||
propagate.
|
||||
|
||||
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".
|
||||
This same key will be used for the output ToolMessages.
|
||||
Defaults to "messages".
|
||||
Allows custom state schemas with different message field names.
|
||||
|
||||
Example:
|
||||
Basic usage with simple tools:
|
||||
Examples:
|
||||
Basic usage:
|
||||
|
||||
```python
|
||||
from langgraph.prebuilt import ToolNode
|
||||
@@ -284,48 +303,42 @@ class ToolNode(RunnableCallable):
|
||||
tool_node = ToolNode([calculator])
|
||||
```
|
||||
|
||||
Custom error handling:
|
||||
State injection:
|
||||
|
||||
```python
|
||||
def handle_math_errors(e: ZeroDivisionError) -> str:
|
||||
return "Cannot divide by zero!"
|
||||
from typing_extensions import Annotated
|
||||
from langgraph.prebuilt import InjectedState
|
||||
|
||||
tool_node = ToolNode([calculator], handle_tool_errors=handle_math_errors)
|
||||
@tool
|
||||
def context_tool(query: str, state: Annotated[dict, InjectedState]) -> str:
|
||||
\"\"\"Some tool that uses state.\"\"\"
|
||||
return f"Query: {query}, Messages: {len(state['messages'])}"
|
||||
|
||||
tool_node = ToolNode([context_tool])
|
||||
```
|
||||
|
||||
Direct tool call execution:
|
||||
Error handling:
|
||||
|
||||
```python
|
||||
tool_calls = [{"name": "calculator", "args": {"a": 5, "b": 3}, "id": "1", "type": "tool_call"}]
|
||||
result = tool_node.invoke(tool_calls)
|
||||
def handle_errors(e: ValueError) -> str:
|
||||
return "Invalid input provided"
|
||||
|
||||
tool_node = ToolNode([my_tool], handle_tool_errors=handle_errors)
|
||||
```
|
||||
|
||||
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"
|
||||
name: str = "tools"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tools: Sequence[Union[BaseTool, Callable]],
|
||||
tools: Sequence[BaseTool | Callable],
|
||||
*,
|
||||
name: str = "tools",
|
||||
tags: Optional[list[str]] = None,
|
||||
handle_tool_errors: Union[
|
||||
bool, str, Callable[..., str], tuple[type[Exception], ...]
|
||||
] = True,
|
||||
tags: list[str] | None = None,
|
||||
handle_tool_errors: bool
|
||||
| str
|
||||
| Callable[..., str]
|
||||
| tuple[type[Exception], ...] = True,
|
||||
messages_key: str = "messages",
|
||||
) -> None:
|
||||
"""Initialize the ToolNode with the provided tools and configuration.
|
||||
@@ -338,31 +351,33 @@ class ToolNode(RunnableCallable):
|
||||
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]]] = {}
|
||||
self.tool_to_store_arg: dict[str, Optional[str]] = {}
|
||||
self.handle_tool_errors = handle_tool_errors
|
||||
self.messages_key = messages_key
|
||||
for tool_ in tools:
|
||||
if not isinstance(tool_, BaseTool):
|
||||
tool_ = create_tool(tool_)
|
||||
self.tools_by_name[tool_.name] = tool_
|
||||
self.tool_to_state_args[tool_.name] = _get_state_args(tool_)
|
||||
self.tool_to_store_arg[tool_.name] = _get_store_arg(tool_)
|
||||
self._tools_by_name: dict[str, BaseTool] = {}
|
||||
self._tool_to_state_args: dict[str, dict[str, str | None]] = {}
|
||||
self._tool_to_store_arg: dict[str, str | None] = {}
|
||||
self._handle_tool_errors = handle_tool_errors
|
||||
self._messages_key = messages_key
|
||||
for tool in tools:
|
||||
if not isinstance(tool, BaseTool):
|
||||
tool_ = create_tool(cast(type[BaseTool], tool))
|
||||
else:
|
||||
tool_ = tool
|
||||
self._tools_by_name[tool_.name] = tool_
|
||||
self._tool_to_state_args[tool_.name] = _get_state_args(tool_)
|
||||
self._tool_to_store_arg[tool_.name] = _get_store_arg(tool_)
|
||||
|
||||
@property
|
||||
def tools_by_name(self) -> dict[str, BaseTool]:
|
||||
"""Mapping from tool name to BaseTool instance."""
|
||||
return self._tools_by_name
|
||||
|
||||
def _func(
|
||||
self,
|
||||
input: Union[
|
||||
list[AnyMessage],
|
||||
dict[str, Any],
|
||||
BaseModel,
|
||||
],
|
||||
input: list[AnyMessage] | dict[str, Any] | BaseModel,
|
||||
config: RunnableConfig,
|
||||
*,
|
||||
store: Optional[BaseStore],
|
||||
store: BaseStore | None,
|
||||
) -> Any:
|
||||
tool_calls, input_type = self._parse_input(input)
|
||||
tool_calls = [self.inject_tool_args(call, input, store) for call in tool_calls]
|
||||
tool_calls, input_type = self._parse_input(input, store)
|
||||
config_list = get_config_list(config, len(tool_calls))
|
||||
input_types = [input_type] * len(tool_calls)
|
||||
with get_executor_for_config(config) as executor:
|
||||
@@ -374,17 +389,12 @@ class ToolNode(RunnableCallable):
|
||||
|
||||
async def _afunc(
|
||||
self,
|
||||
input: Union[
|
||||
list[AnyMessage],
|
||||
dict[str, Any],
|
||||
BaseModel,
|
||||
],
|
||||
input: list[AnyMessage] | dict[str, Any] | BaseModel,
|
||||
config: RunnableConfig,
|
||||
*,
|
||||
store: Optional[BaseStore],
|
||||
store: BaseStore | None,
|
||||
) -> Any:
|
||||
tool_calls, input_type = self._parse_input(input)
|
||||
tool_calls = [self.inject_tool_args(call, input, store) for call in tool_calls]
|
||||
tool_calls, input_type = self._parse_input(input, store)
|
||||
outputs = await asyncio.gather(
|
||||
*(self._arun_one(call, input_type, config) for call in tool_calls)
|
||||
)
|
||||
@@ -393,14 +403,14 @@ class ToolNode(RunnableCallable):
|
||||
|
||||
def _combine_tool_outputs(
|
||||
self,
|
||||
outputs: list[ToolMessage],
|
||||
outputs: list[ToolMessage | Command],
|
||||
input_type: Literal["list", "dict", "tool_calls"],
|
||||
) -> list[Union[Command, list[ToolMessage], dict[str, list[ToolMessage]]]]:
|
||||
) -> list[Command | list[ToolMessage] | dict[str, list[ToolMessage]]]:
|
||||
# preserve existing behavior for non-command tool outputs for backwards
|
||||
# compatibility
|
||||
if not any(isinstance(output, Command) for output in outputs):
|
||||
# TypedDict, pydantic, dataclass, etc. should all be able to load from dict
|
||||
return outputs if input_type == "list" else {self.messages_key: outputs}
|
||||
return outputs if input_type == "list" else {self._messages_key: outputs}
|
||||
|
||||
# LangGraph will automatically handle list of Command and non-command node
|
||||
# updates
|
||||
@@ -409,7 +419,7 @@ class ToolNode(RunnableCallable):
|
||||
] = []
|
||||
|
||||
# combine all parent commands with goto into a single parent command
|
||||
parent_command: Optional[Command] = None
|
||||
parent_command: Command | None = None
|
||||
for output in outputs:
|
||||
if isinstance(output, Command):
|
||||
if (
|
||||
@@ -428,7 +438,7 @@ class ToolNode(RunnableCallable):
|
||||
combined_outputs.append(output)
|
||||
else:
|
||||
combined_outputs.append(
|
||||
[output] if input_type == "list" else {self.messages_key: [output]}
|
||||
[output] if input_type == "list" else {self._messages_key: [output]}
|
||||
)
|
||||
|
||||
if parent_command:
|
||||
@@ -440,13 +450,15 @@ class ToolNode(RunnableCallable):
|
||||
call: ToolCall,
|
||||
input_type: Literal["list", "dict", "tool_calls"],
|
||||
config: RunnableConfig,
|
||||
) -> ToolMessage:
|
||||
) -> ToolMessage | Command:
|
||||
"""Run a single tool call synchronously."""
|
||||
if invalid_tool_message := self._validate_tool_call(call):
|
||||
return invalid_tool_message
|
||||
|
||||
try:
|
||||
call_args = {**call, **{"type": "tool_call"}}
|
||||
response = self.tools_by_name[call["name"]].invoke(call_args, config)
|
||||
tool = self.tools_by_name[call["name"]]
|
||||
response = tool.invoke(call_args, config)
|
||||
|
||||
# GraphInterrupt is a special exception that will always be raised.
|
||||
# It can be triggered in the following scenarios,
|
||||
@@ -458,20 +470,20 @@ class ToolNode(RunnableCallable):
|
||||
except GraphBubbleUp as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
if isinstance(self.handle_tool_errors, tuple):
|
||||
handled_types: tuple = self.handle_tool_errors
|
||||
elif callable(self.handle_tool_errors):
|
||||
handled_types = _infer_handled_types(self.handle_tool_errors)
|
||||
if isinstance(self._handle_tool_errors, tuple):
|
||||
handled_types: tuple = self._handle_tool_errors
|
||||
elif callable(self._handle_tool_errors):
|
||||
handled_types = _infer_handled_types(self._handle_tool_errors)
|
||||
else:
|
||||
# default behavior is catching all exceptions
|
||||
handled_types = (Exception,)
|
||||
|
||||
# Unhandled
|
||||
if not self.handle_tool_errors or not isinstance(e, handled_types):
|
||||
if not self._handle_tool_errors or not isinstance(e, handled_types):
|
||||
raise e
|
||||
# Handled
|
||||
else:
|
||||
content = _handle_tool_error(e, flag=self.handle_tool_errors)
|
||||
content = _handle_tool_error(e, flag=self._handle_tool_errors)
|
||||
return ToolMessage(
|
||||
content=content,
|
||||
name=call["name"],
|
||||
@@ -482,9 +494,7 @@ class ToolNode(RunnableCallable):
|
||||
if isinstance(response, Command):
|
||||
return self._validate_tool_command(response, call, input_type)
|
||||
elif isinstance(response, ToolMessage):
|
||||
response.content = cast(
|
||||
Union[str, list], msg_content_output(response.content)
|
||||
)
|
||||
response.content = cast(str | list, msg_content_output(response.content))
|
||||
return response
|
||||
else:
|
||||
raise TypeError(
|
||||
@@ -496,38 +506,39 @@ class ToolNode(RunnableCallable):
|
||||
call: ToolCall,
|
||||
input_type: Literal["list", "dict", "tool_calls"],
|
||||
config: RunnableConfig,
|
||||
) -> ToolMessage:
|
||||
) -> ToolMessage | Command:
|
||||
"""Run a single tool call asynchronously."""
|
||||
if invalid_tool_message := self._validate_tool_call(call):
|
||||
return invalid_tool_message
|
||||
|
||||
try:
|
||||
input = {**call, **{"type": "tool_call"}}
|
||||
response = await self.tools_by_name[call["name"]].ainvoke(input, config)
|
||||
|
||||
call_args = {**call, **{"type": "tool_call"}}
|
||||
tool = self.tools_by_name[call["name"]]
|
||||
response = await tool.ainvoke(call_args, config)
|
||||
# GraphInterrupt is a special exception that will always be raised.
|
||||
# It can be triggered in the following scenarios:
|
||||
# (1) a NodeInterrupt is raised inside a tool
|
||||
# (2) a NodeInterrupt is raised inside a graph node for a graph called as a tool
|
||||
# It can be triggered in the following scenarios,
|
||||
# Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation most commonly:
|
||||
# (1) a GraphInterrupt is raised inside a tool
|
||||
# (2) a GraphInterrupt is raised inside a graph node for a graph called as a tool
|
||||
# (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph called as a tool
|
||||
# (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture)
|
||||
except GraphBubbleUp as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
if isinstance(self.handle_tool_errors, tuple):
|
||||
handled_types: tuple = self.handle_tool_errors
|
||||
elif callable(self.handle_tool_errors):
|
||||
handled_types = _infer_handled_types(self.handle_tool_errors)
|
||||
if isinstance(self._handle_tool_errors, tuple):
|
||||
handled_types: tuple = self._handle_tool_errors
|
||||
elif callable(self._handle_tool_errors):
|
||||
handled_types = _infer_handled_types(self._handle_tool_errors)
|
||||
else:
|
||||
# default behavior is catching all exceptions
|
||||
handled_types = (Exception,)
|
||||
|
||||
# Unhandled
|
||||
if not self.handle_tool_errors or not isinstance(e, handled_types):
|
||||
if not self._handle_tool_errors or not isinstance(e, handled_types):
|
||||
raise e
|
||||
# Handled
|
||||
else:
|
||||
content = _handle_tool_error(e, flag=self.handle_tool_errors)
|
||||
content = _handle_tool_error(e, flag=self._handle_tool_errors)
|
||||
|
||||
return ToolMessage(
|
||||
content=content,
|
||||
@@ -539,9 +550,7 @@ class ToolNode(RunnableCallable):
|
||||
if isinstance(response, Command):
|
||||
return self._validate_tool_command(response, call, input_type)
|
||||
elif isinstance(response, ToolMessage):
|
||||
response.content = cast(
|
||||
Union[str, list], msg_content_output(response.content)
|
||||
)
|
||||
response.content = cast(str | list, msg_content_output(response.content))
|
||||
return response
|
||||
else:
|
||||
raise TypeError(
|
||||
@@ -550,12 +559,9 @@ class ToolNode(RunnableCallable):
|
||||
|
||||
def _parse_input(
|
||||
self,
|
||||
input: Union[
|
||||
list[AnyMessage],
|
||||
dict[str, Any],
|
||||
BaseModel,
|
||||
],
|
||||
) -> Tuple[list[ToolCall], Literal["list", "dict", "tool_calls"]]:
|
||||
input: list[AnyMessage] | dict[str, Any] | BaseModel,
|
||||
store: BaseStore | None,
|
||||
) -> tuple[list[ToolCall], Literal["list", "dict", "tool_calls"]]:
|
||||
input_type: Literal["list", "dict", "tool_calls"]
|
||||
if isinstance(input, list):
|
||||
if isinstance(input[-1], dict) and input[-1].get("type") == "tool_call":
|
||||
@@ -565,18 +571,11 @@ class ToolNode(RunnableCallable):
|
||||
else:
|
||||
input_type = "list"
|
||||
messages = input
|
||||
elif (
|
||||
isinstance(input, dict) and input.get("__type") == "tool_call_with_context"
|
||||
elif isinstance(input, dict) and (
|
||||
messages := input.get(self._messages_key, [])
|
||||
):
|
||||
# mypy will not be able to type narrow correctly since the signature
|
||||
# for input contains dict[str, Any]. We'd need to type dict[str, Any]
|
||||
# before we can apply correct typing.
|
||||
input = cast(ToolCallWithContext, input) # type: ignore[assignment]
|
||||
input_type = "tool_calls"
|
||||
return [input["tool_call"]], input_type
|
||||
elif isinstance(input, dict) and (messages := input.get(self.messages_key, [])):
|
||||
input_type = "dict"
|
||||
elif messages := getattr(input, self.messages_key, []):
|
||||
elif messages := getattr(input, self._messages_key, []):
|
||||
# Assume dataclass-like state that can coerce from dict
|
||||
input_type = "dict"
|
||||
else:
|
||||
@@ -589,14 +588,19 @@ class ToolNode(RunnableCallable):
|
||||
except StopIteration:
|
||||
raise ValueError("No AIMessage found in input")
|
||||
|
||||
tool_calls = [call for call in latest_ai_message.tool_calls]
|
||||
tool_calls = [
|
||||
self.inject_tool_args(call, input, store)
|
||||
for call in latest_ai_message.tool_calls
|
||||
]
|
||||
return tool_calls, input_type
|
||||
|
||||
def _validate_tool_call(self, call: ToolCall) -> Optional[ToolMessage]:
|
||||
if (requested_tool := call["name"]) not in self.tools_by_name:
|
||||
def _validate_tool_call(self, call: ToolCall) -> ToolMessage | None:
|
||||
requested_tool = call["name"]
|
||||
if requested_tool not in self.tools_by_name:
|
||||
all_tool_names = list(self.tools_by_name.keys())
|
||||
content = INVALID_TOOL_NAME_ERROR_TEMPLATE.format(
|
||||
requested_tool=requested_tool,
|
||||
available_tools=", ".join(self.tools_by_name.keys()),
|
||||
available_tools=", ".join(all_tool_names),
|
||||
)
|
||||
return ToolMessage(
|
||||
content, name=requested_tool, tool_call_id=call["id"], status="error"
|
||||
@@ -607,21 +611,17 @@ class ToolNode(RunnableCallable):
|
||||
def _inject_state(
|
||||
self,
|
||||
tool_call: ToolCall,
|
||||
input: Union[
|
||||
list[AnyMessage],
|
||||
dict[str, Any],
|
||||
BaseModel,
|
||||
],
|
||||
input: list[AnyMessage] | dict[str, Any] | BaseModel,
|
||||
) -> ToolCall:
|
||||
state_args = self.tool_to_state_args[tool_call["name"]]
|
||||
state_args = self._tool_to_state_args[tool_call["name"]]
|
||||
if state_args and isinstance(input, list):
|
||||
required_fields = list(state_args.values())
|
||||
if (
|
||||
len(required_fields) == 1
|
||||
and required_fields[0] == self.messages_key
|
||||
and required_fields[0] == self._messages_key
|
||||
or required_fields[0] is None
|
||||
):
|
||||
input = {self.messages_key: input}
|
||||
input = {self._messages_key: input}
|
||||
else:
|
||||
err_msg = (
|
||||
f"Invalid input to ToolNode. Tool {tool_call['name']} requires "
|
||||
@@ -632,19 +632,14 @@ class ToolNode(RunnableCallable):
|
||||
err_msg += f" State should contain fields {required_fields_str}."
|
||||
raise ValueError(err_msg)
|
||||
|
||||
if isinstance(input, dict) and input.get("__type") == "tool_call_with_context":
|
||||
state = input["state"]
|
||||
else:
|
||||
state = input
|
||||
|
||||
if isinstance(state, dict):
|
||||
if isinstance(input, dict):
|
||||
tool_state_args = {
|
||||
tool_arg: state[state_field] if state_field else state
|
||||
tool_arg: input[state_field] if state_field else input
|
||||
for tool_arg, state_field in state_args.items()
|
||||
}
|
||||
else:
|
||||
tool_state_args = {
|
||||
tool_arg: getattr(state, state_field) if state_field else state
|
||||
tool_arg: getattr(input, state_field) if state_field else input
|
||||
for tool_arg, state_field in state_args.items()
|
||||
}
|
||||
|
||||
@@ -654,10 +649,8 @@ class ToolNode(RunnableCallable):
|
||||
}
|
||||
return tool_call
|
||||
|
||||
def _inject_store(
|
||||
self, tool_call: ToolCall, store: Optional[BaseStore]
|
||||
) -> ToolCall:
|
||||
store_arg = self.tool_to_store_arg[tool_call["name"]]
|
||||
def _inject_store(self, tool_call: ToolCall, store: BaseStore | None) -> ToolCall:
|
||||
store_arg = self._tool_to_store_arg[tool_call["name"]]
|
||||
if not store_arg:
|
||||
return tool_call
|
||||
|
||||
@@ -676,12 +669,8 @@ class ToolNode(RunnableCallable):
|
||||
def inject_tool_args(
|
||||
self,
|
||||
tool_call: ToolCall,
|
||||
input: Union[
|
||||
list[AnyMessage],
|
||||
dict[str, Any],
|
||||
BaseModel,
|
||||
],
|
||||
store: Optional[BaseStore],
|
||||
input: list[AnyMessage] | dict[str, Any] | BaseModel,
|
||||
store: BaseStore | None,
|
||||
) -> ToolCall:
|
||||
"""Inject graph state and store into tool call arguments.
|
||||
|
||||
@@ -734,15 +723,15 @@ class ToolNode(RunnableCallable):
|
||||
# input type is dict when ToolNode is invoked with a dict input (e.g. {"messages": [AIMessage(..., tool_calls=[...])]})
|
||||
if input_type not in ("dict", "tool_calls"):
|
||||
raise ValueError(
|
||||
f"Tools can provide a dict in Command.update only when using dict with '{self.messages_key}' key as ToolNode input, "
|
||||
f"Tools can provide a dict in Command.update only when using dict with '{self._messages_key}' key as ToolNode input, "
|
||||
f"got: {command.update} for tool '{call['name']}'"
|
||||
)
|
||||
|
||||
updated_command = deepcopy(command)
|
||||
state_update = cast(dict[str, Any], updated_command.update) or {}
|
||||
messages_update = state_update.get(self.messages_key, [])
|
||||
messages_update = state_update.get(self._messages_key, [])
|
||||
elif isinstance(command.update, list):
|
||||
# input type is list when ToolNode is invoked with a list input (e.g. [AIMessage(..., tool_calls=[...])])
|
||||
# Input type is list when ToolNode is invoked with a list input (e.g. [AIMessage(..., tool_calls=[...])])
|
||||
if input_type != "list":
|
||||
raise ValueError(
|
||||
f"Tools can provide a list of messages in Command.update only when using list of messages as ToolNode input, "
|
||||
@@ -787,7 +776,7 @@ class ToolNode(RunnableCallable):
|
||||
|
||||
|
||||
def tools_condition(
|
||||
state: Union[list[AnyMessage], dict[str, Any], BaseModel],
|
||||
state: list[AnyMessage] | dict[str, Any] | BaseModel,
|
||||
messages_key: str = "messages",
|
||||
) -> Literal["tools", "__end__"]:
|
||||
"""Conditional routing function for tool-calling workflows.
|
||||
@@ -802,7 +791,6 @@ def tools_condition(
|
||||
|
||||
Args:
|
||||
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.
|
||||
@@ -934,7 +922,7 @@ class InjectedState(InjectedToolArg):
|
||||
tool execution
|
||||
""" # noqa: E501
|
||||
|
||||
def __init__(self, field: Optional[str] = None) -> None:
|
||||
def __init__(self, field: str | None = None) -> None:
|
||||
self.field = field
|
||||
|
||||
|
||||
@@ -1015,7 +1003,7 @@ class InjectedStore(InjectedToolArg):
|
||||
|
||||
|
||||
def _is_injection(
|
||||
type_arg: Any, injection_type: Union[Type[InjectedState], Type[InjectedStore]]
|
||||
type_arg: Any, injection_type: type[InjectedState] | type[InjectedStore]
|
||||
) -> bool:
|
||||
"""Check if a type argument represents an injection annotation.
|
||||
|
||||
@@ -1040,7 +1028,7 @@ def _is_injection(
|
||||
return False
|
||||
|
||||
|
||||
def _get_state_args(tool: BaseTool) -> dict[str, Optional[str]]:
|
||||
def _get_state_args(tool: BaseTool) -> dict[str, str | None]:
|
||||
"""Extract state injection mappings from tool annotations.
|
||||
|
||||
This function analyzes a tool's input schema to identify arguments that should
|
||||
@@ -1079,7 +1067,7 @@ def _get_state_args(tool: BaseTool) -> dict[str, Optional[str]]:
|
||||
return tool_args_to_state_fields
|
||||
|
||||
|
||||
def _get_store_arg(tool: BaseTool) -> Optional[str]:
|
||||
def _get_store_arg(tool: BaseTool) -> str | None:
|
||||
"""Extract store injection argument from tool annotations.
|
||||
|
||||
This function analyzes a tool's input schema to identify the argument that
|
||||
|
||||
@@ -2,19 +2,12 @@
|
||||
in a langchain graph. It applies a pydantic schema to tool_calls in the models' outputs,
|
||||
and returns a ToolMessage with the validated content. If the schema is not valid, it
|
||||
returns a ToolMessage with the error message. The ValidationNode can be used in a
|
||||
StateGraph with a "messages" key or in a MessageGraph. If multiple tool calls are
|
||||
requested, they will be run in parallel.
|
||||
StateGraph with a "messages" key. If multiple tool calls are requested, they will be run in parallel.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Type,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
@@ -40,7 +33,7 @@ from langgraph._internal._runnable import RunnableCallable
|
||||
def _default_format_error(
|
||||
error: BaseException,
|
||||
call: ToolCall,
|
||||
schema: Union[Type[BaseModel], Type[BaseModelV1]],
|
||||
schema: type[BaseModel] | type[BaseModelV1],
|
||||
) -> str:
|
||||
"""Default error formatting function."""
|
||||
return f"{repr(error)}\n\nRespond after fixing all validation errors."
|
||||
@@ -49,7 +42,7 @@ def _default_format_error(
|
||||
class ValidationNode(RunnableCallable):
|
||||
"""A node that validates all tools requests from the last AIMessage.
|
||||
|
||||
It can be used either in StateGraph with a "messages" key or in MessageGraph.
|
||||
It can be used either in StateGraph with a "messages" key.
|
||||
|
||||
!!! note
|
||||
|
||||
@@ -128,17 +121,16 @@ class ValidationNode(RunnableCallable):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schemas: Sequence[Union[BaseTool, Type[BaseModel], Callable]],
|
||||
schemas: Sequence[BaseTool | type[BaseModel] | Callable],
|
||||
*,
|
||||
format_error: Optional[
|
||||
Callable[[BaseException, ToolCall, Type[BaseModel]], str]
|
||||
] = None,
|
||||
format_error: Callable[[BaseException, ToolCall, type[BaseModel]], str]
|
||||
| None = None,
|
||||
name: str = "validation",
|
||||
tags: Optional[list[str]] = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> None:
|
||||
super().__init__(self._func, None, name=name, tags=tags, trace=False)
|
||||
self._format_error = format_error or _default_format_error
|
||||
self.schemas_by_name: Dict[str, Type[BaseModel]] = {}
|
||||
self.schemas_by_name: dict[str, type[BaseModel]] = {}
|
||||
for schema in schemas:
|
||||
if isinstance(schema, BaseTool):
|
||||
if schema.args_schema is None:
|
||||
@@ -154,9 +146,9 @@ class ValidationNode(RunnableCallable):
|
||||
)
|
||||
self.schemas_by_name[schema.name] = schema.args_schema
|
||||
elif isinstance(schema, type) and issubclass(
|
||||
schema, (BaseModel, BaseModelV1)
|
||||
schema, BaseModel | BaseModelV1
|
||||
):
|
||||
self.schemas_by_name[schema.__name__] = cast(Type[BaseModel], schema)
|
||||
self.schemas_by_name[schema.__name__] = cast(type[BaseModel], schema)
|
||||
elif callable(schema):
|
||||
base_model = create_schema_from_function("Validation", schema)
|
||||
self.schemas_by_name[schema.__name__] = base_model
|
||||
@@ -166,8 +158,8 @@ class ValidationNode(RunnableCallable):
|
||||
)
|
||||
|
||||
def _get_message(
|
||||
self, input: Union[list[AnyMessage], dict[str, Any]]
|
||||
) -> Tuple[str, AIMessage]:
|
||||
self, input: list[AnyMessage] | dict[str, Any]
|
||||
) -> tuple[str, AIMessage]:
|
||||
"""Extract the last AIMessage from the input."""
|
||||
if isinstance(input, list):
|
||||
output_type = "list"
|
||||
@@ -182,7 +174,7 @@ class ValidationNode(RunnableCallable):
|
||||
return output_type, message
|
||||
|
||||
def _func(
|
||||
self, input: Union[list[AnyMessage], dict[str, Any]], config: RunnableConfig
|
||||
self, input: list[AnyMessage] | dict[str, Any], config: RunnableConfig
|
||||
) -> Any:
|
||||
"""Validate and run tool calls synchronously."""
|
||||
output_type, message = self._get_message(input)
|
||||
|
||||
@@ -4,10 +4,10 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
|
||||
authors = []
|
||||
requires-python = ">=3.9"
|
||||
requires-python = ">=3.10"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
@@ -52,8 +52,9 @@ addopts = "--strict-markers --strict-config --durations=5 -vv"
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff]
|
||||
lint.select = [ "E", "F", "I", "TID251" ]
|
||||
lint.select = [ "E", "F", "I", "TID251", "UP" ]
|
||||
lint.ignore = [ "E501" ]
|
||||
target-version = "py310"
|
||||
|
||||
[tool.pytest-watcher]
|
||||
now = true
|
||||
|
||||
@@ -1,173 +1,83 @@
|
||||
# serializer version: 1
|
||||
# name: test_react_agent_graph_structure[None-None-None-tools0]
|
||||
# name: test_react_agent_graph_structure[None-None-tools0]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> agent;
|
||||
agent --> __end__;
|
||||
__start__ --> model;
|
||||
model --> __end__;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[None-None-None-tools1]
|
||||
# name: test_react_agent_graph_structure[None-None-tools1]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> agent;
|
||||
agent -.-> __end__;
|
||||
agent -.-> tools;
|
||||
tools --> agent;
|
||||
__start__ --> model;
|
||||
model -.-> __end__;
|
||||
model -.-> tools;
|
||||
tools --> model;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[None-None-pre_model_hook-tools0]
|
||||
# name: test_react_agent_graph_structure[None-pre_model_hook-tools0]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> pre_model_hook;
|
||||
pre_model_hook --> agent;
|
||||
agent --> __end__;
|
||||
pre_model_hook --> model;
|
||||
model --> __end__;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[None-None-pre_model_hook-tools1]
|
||||
# name: test_react_agent_graph_structure[None-pre_model_hook-tools1]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> pre_model_hook;
|
||||
agent -.-> __end__;
|
||||
agent -.-> tools;
|
||||
pre_model_hook --> agent;
|
||||
model -.-> __end__;
|
||||
model -.-> tools;
|
||||
pre_model_hook --> model;
|
||||
tools --> pre_model_hook;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[None-post_model_hook-None-tools0]
|
||||
# name: test_react_agent_graph_structure[post_model_hook-None-tools0]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> agent;
|
||||
agent --> post_model_hook;
|
||||
__start__ --> model;
|
||||
model --> post_model_hook;
|
||||
post_model_hook --> __end__;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[None-post_model_hook-None-tools1]
|
||||
# name: test_react_agent_graph_structure[post_model_hook-None-tools1]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> agent;
|
||||
agent --> post_model_hook;
|
||||
__start__ --> model;
|
||||
model --> post_model_hook;
|
||||
post_model_hook -.-> __end__;
|
||||
post_model_hook -.-> agent;
|
||||
post_model_hook -.-> model;
|
||||
post_model_hook -.-> tools;
|
||||
tools --> agent;
|
||||
tools --> model;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[None-post_model_hook-pre_model_hook-tools0]
|
||||
# name: test_react_agent_graph_structure[post_model_hook-pre_model_hook-tools0]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> pre_model_hook;
|
||||
agent --> post_model_hook;
|
||||
pre_model_hook --> agent;
|
||||
model --> post_model_hook;
|
||||
pre_model_hook --> model;
|
||||
post_model_hook --> __end__;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[None-post_model_hook-pre_model_hook-tools1]
|
||||
# name: test_react_agent_graph_structure[post_model_hook-pre_model_hook-tools1]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> pre_model_hook;
|
||||
agent --> post_model_hook;
|
||||
model --> post_model_hook;
|
||||
post_model_hook -.-> __end__;
|
||||
post_model_hook -.-> pre_model_hook;
|
||||
post_model_hook -.-> tools;
|
||||
pre_model_hook --> agent;
|
||||
pre_model_hook --> model;
|
||||
tools --> pre_model_hook;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[ResponseFormat-None-None-tools0]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> agent;
|
||||
agent --> generate_structured_response;
|
||||
generate_structured_response --> __end__;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[ResponseFormat-None-None-tools1]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> agent;
|
||||
agent -.-> generate_structured_response;
|
||||
agent -.-> tools;
|
||||
tools --> agent;
|
||||
generate_structured_response --> __end__;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[ResponseFormat-None-pre_model_hook-tools0]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> pre_model_hook;
|
||||
agent --> generate_structured_response;
|
||||
pre_model_hook --> agent;
|
||||
generate_structured_response --> __end__;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[ResponseFormat-None-pre_model_hook-tools1]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> pre_model_hook;
|
||||
agent -.-> generate_structured_response;
|
||||
agent -.-> tools;
|
||||
pre_model_hook --> agent;
|
||||
tools --> pre_model_hook;
|
||||
generate_structured_response --> __end__;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[ResponseFormat-post_model_hook-None-tools0]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> agent;
|
||||
agent --> post_model_hook;
|
||||
post_model_hook --> generate_structured_response;
|
||||
generate_structured_response --> __end__;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[ResponseFormat-post_model_hook-None-tools1]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> agent;
|
||||
agent --> post_model_hook;
|
||||
post_model_hook -.-> agent;
|
||||
post_model_hook -.-> generate_structured_response;
|
||||
post_model_hook -.-> tools;
|
||||
tools --> agent;
|
||||
generate_structured_response --> __end__;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[ResponseFormat-post_model_hook-pre_model_hook-tools0]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> pre_model_hook;
|
||||
agent --> post_model_hook;
|
||||
post_model_hook --> generate_structured_response;
|
||||
pre_model_hook --> agent;
|
||||
generate_structured_response --> __end__;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_react_agent_graph_structure[ResponseFormat-post_model_hook-pre_model_hook-tools1]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> pre_model_hook;
|
||||
agent --> post_model_hook;
|
||||
post_model_hook -.-> generate_structured_response;
|
||||
post_model_hook -.-> pre_model_hook;
|
||||
post_model_hook -.-> tools;
|
||||
pre_model_hook --> agent;
|
||||
tools --> pre_model_hook;
|
||||
generate_structured_response --> __end__;
|
||||
|
||||
'''
|
||||
# ---
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import re
|
||||
from typing import Union
|
||||
|
||||
|
||||
class AnyStr(str):
|
||||
def __init__(self, prefix: Union[str, re.Pattern] = "") -> None:
|
||||
def __init__(self, prefix: str | re.Pattern = "") -> None:
|
||||
super().__init__()
|
||||
self.prefix = prefix
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
name: langgraph-tests-redis
|
||||
services:
|
||||
redis-test:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
start_period: 10s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
interval: 5s
|
||||
start_interval: 1s
|
||||
tmpfs:
|
||||
- /data # Use tmpfs for faster testing
|
||||
@@ -1,8 +1,6 @@
|
||||
import sys
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from psycopg import AsyncConnection, Connection
|
||||
from psycopg_pool import AsyncConnectionPool, ConnectionPool
|
||||
|
||||
@@ -95,8 +93,6 @@ async def _checkpointer_sqlite_aio():
|
||||
|
||||
@asynccontextmanager
|
||||
async def _checkpointer_postgres_aio():
|
||||
if sys.version_info < (3, 10):
|
||||
pytest.skip("Async Postgres tests require Python 3.10+")
|
||||
database = f"test_{uuid4().hex[:16]}"
|
||||
# create unique db
|
||||
async with await AsyncConnection.connect(
|
||||
@@ -120,8 +116,6 @@ async def _checkpointer_postgres_aio():
|
||||
|
||||
@asynccontextmanager
|
||||
async def _checkpointer_postgres_aio_pipe():
|
||||
if sys.version_info < (3, 10):
|
||||
pytest.skip("Async Postgres tests require Python 3.10+")
|
||||
database = f"test_{uuid4().hex[:16]}"
|
||||
# create unique db
|
||||
async with await AsyncConnection.connect(
|
||||
@@ -148,8 +142,6 @@ async def _checkpointer_postgres_aio_pipe():
|
||||
|
||||
@asynccontextmanager
|
||||
async def _checkpointer_postgres_aio_pool():
|
||||
if sys.version_info < (3, 10):
|
||||
pytest.skip("Async Postgres tests require Python 3.10+")
|
||||
database = f"test_{uuid4().hex[:16]}"
|
||||
# create unique db
|
||||
async with await AsyncConnection.connect(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import sys
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from psycopg import AsyncConnection, Connection
|
||||
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
@@ -75,8 +73,6 @@ def _store_postgres_pool():
|
||||
|
||||
@asynccontextmanager
|
||||
async def _store_postgres_aio():
|
||||
if sys.version_info < (3, 10):
|
||||
pytest.skip("Async Postgres tests require Python 3.10+")
|
||||
database = f"test_{uuid4().hex[:16]}"
|
||||
async with await AsyncConnection.connect(
|
||||
DEFAULT_POSTGRES_URI, autocommit=True
|
||||
@@ -97,8 +93,6 @@ async def _store_postgres_aio():
|
||||
|
||||
@asynccontextmanager
|
||||
async def _store_postgres_aio_pipe():
|
||||
if sys.version_info < (3, 10):
|
||||
pytest.skip("Async Postgres tests require Python 3.10+")
|
||||
database = f"test_{uuid4().hex[:16]}"
|
||||
async with await AsyncConnection.connect(
|
||||
DEFAULT_POSTGRES_URI, autocommit=True
|
||||
@@ -122,8 +116,6 @@ async def _store_postgres_aio_pipe():
|
||||
|
||||
@asynccontextmanager
|
||||
async def _store_postgres_aio_pool():
|
||||
if sys.version_info < (3, 10):
|
||||
pytest.skip("Async Postgres tests require Python 3.10+")
|
||||
database = f"test_{uuid4().hex[:16]}"
|
||||
async with await AsyncConnection.connect(
|
||||
DEFAULT_POSTGRES_URI, autocommit=True
|
||||
|
||||
@@ -2,7 +2,6 @@ import os
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from functools import partial
|
||||
from typing import Optional
|
||||
|
||||
from langgraph.checkpoint.base import (
|
||||
ChannelVersions,
|
||||
@@ -20,8 +19,8 @@ class MemorySaverAssertImmutable(InMemorySaver):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
put_sleep: Optional[float] = None,
|
||||
serde: SerializerProtocol | None = None,
|
||||
put_sleep: float | None = None,
|
||||
) -> None:
|
||||
_, filename = tempfile.mkstemp()
|
||||
super().__init__(
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import json
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Generic,
|
||||
Literal,
|
||||
Optional,
|
||||
Sequence,
|
||||
Type,
|
||||
Union,
|
||||
)
|
||||
|
||||
from langchain_core.callbacks import CallbackManagerForLLMRun
|
||||
@@ -18,36 +15,59 @@ from langchain_core.messages import (
|
||||
ToolCall,
|
||||
)
|
||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
from langchain_core.runnables import Runnable, RunnableLambda
|
||||
from langchain_core.runnables import Runnable
|
||||
from langchain_core.tools import BaseTool
|
||||
from pydantic import BaseModel
|
||||
|
||||
from langgraph.prebuilt.chat_agent_executor import StructuredResponse
|
||||
from langgraph.prebuilt.chat_agent_executor import StructuredResponseT
|
||||
|
||||
|
||||
class FakeToolCallingModel(BaseChatModel):
|
||||
tool_calls: Optional[list[list[ToolCall]]] = None
|
||||
structured_response: Optional[StructuredResponse] = None
|
||||
class FakeToolCallingModel(BaseChatModel, Generic[StructuredResponseT]):
|
||||
tool_calls: list[list[ToolCall]] | list[list[dict]] | None = None
|
||||
structured_response: StructuredResponseT | None = None
|
||||
index: int = 0
|
||||
tool_style: Literal["openai", "anthropic"] = "openai"
|
||||
|
||||
def _generate(
|
||||
self,
|
||||
messages: List[BaseMessage],
|
||||
stop: Optional[List[str]] = None,
|
||||
run_manager: Optional[CallbackManagerForLLMRun] = None,
|
||||
messages: list[BaseMessage],
|
||||
stop: list[str] | None = None,
|
||||
run_manager: CallbackManagerForLLMRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatResult:
|
||||
"""Top Level call"""
|
||||
messages_string = "-".join([m.content for m in messages])
|
||||
tool_calls = (
|
||||
self.tool_calls[self.index % len(self.tool_calls)]
|
||||
if self.tool_calls
|
||||
else []
|
||||
)
|
||||
message = AIMessage(
|
||||
content=messages_string, id=str(self.index), tool_calls=tool_calls.copy()
|
||||
)
|
||||
rf = kwargs.get("response_format")
|
||||
is_native = isinstance(rf, dict) and rf.get("type") == "json_schema"
|
||||
if is_native:
|
||||
print("NATIVE. tool_calls: ", self.tool_calls)
|
||||
|
||||
if self.tool_calls:
|
||||
if is_native:
|
||||
tool_calls = (
|
||||
self.tool_calls[self.index]
|
||||
if self.index < len(self.tool_calls)
|
||||
else []
|
||||
)
|
||||
else:
|
||||
tool_calls = self.tool_calls[self.index % len(self.tool_calls)]
|
||||
else:
|
||||
tool_calls = []
|
||||
|
||||
if is_native and not tool_calls:
|
||||
if isinstance(self.structured_response, BaseModel):
|
||||
content_obj = self.structured_response.model_dump()
|
||||
elif is_dataclass(self.structured_response):
|
||||
content_obj = asdict(self.structured_response)
|
||||
elif isinstance(self.structured_response, dict):
|
||||
content_obj = self.structured_response
|
||||
message = AIMessage(content=json.dumps(content_obj), id=str(self.index))
|
||||
else:
|
||||
messages_string = "-".join([m.content for m in messages])
|
||||
message = AIMessage(
|
||||
content=messages_string,
|
||||
id=str(self.index),
|
||||
tool_calls=tool_calls.copy(),
|
||||
)
|
||||
self.index += 1
|
||||
return ChatResult(generations=[ChatGeneration(message=message)])
|
||||
|
||||
@@ -55,17 +75,9 @@ class FakeToolCallingModel(BaseChatModel):
|
||||
def _llm_type(self) -> str:
|
||||
return "fake-tool-call-model"
|
||||
|
||||
def with_structured_output(
|
||||
self, schema: Type[BaseModel]
|
||||
) -> Runnable[LanguageModelInput, StructuredResponse]:
|
||||
if self.structured_response is None:
|
||||
raise ValueError("Structured response is not set")
|
||||
|
||||
return RunnableLambda(lambda x: self.structured_response)
|
||||
|
||||
def bind_tools(
|
||||
self,
|
||||
tools: Sequence[Union[Dict[str, Any], Type[BaseModel], Callable, BaseTool]],
|
||||
tools: Sequence[dict[str, Any] | type[BaseModel] | Callable | BaseTool],
|
||||
**kwargs: Any,
|
||||
) -> Runnable[LanguageModelInput, BaseMessage]:
|
||||
if len(tools) == 0:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
[
|
||||
{
|
||||
"name": "updated structured response",
|
||||
"responseFormat": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"role": { "type": "string" }
|
||||
},
|
||||
"required": ["name", "role"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"department": { "type": "string" }
|
||||
},
|
||||
"required": ["name", "department"]
|
||||
}
|
||||
],
|
||||
"assertionsByInvocation": [
|
||||
{
|
||||
"prompt": "What is the role of Sabine?",
|
||||
"toolsWithExpectedCalls": {
|
||||
"getEmployeeRole": 1,
|
||||
"getEmployeeDepartment": 0
|
||||
},
|
||||
"expectedLastMessage": "Returning structured response: {'name': 'Sabine', 'role': 'Developer'}",
|
||||
"expectedStructuredResponse": { "name": "Sabine", "role": "Developer" },
|
||||
"llmRequestCount": 2
|
||||
},
|
||||
{
|
||||
"prompt": "In which department does Henrik work?",
|
||||
"toolsWithExpectedCalls": {
|
||||
"getEmployeeRole": 1,
|
||||
"getEmployeeDepartment": 1
|
||||
},
|
||||
"expectedLastMessage": "Returning structured response: {'name': 'Henrik', 'department': 'IT'}",
|
||||
"expectedStructuredResponse": { "name": "Henrik", "department": "IT" },
|
||||
"llmRequestCount": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
]
|
||||
@@ -1,33 +0,0 @@
|
||||
import pytest
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
from langgraph.warnings import LangGraphDeprecatedSinceV10
|
||||
from tests.model import FakeToolCallingModel
|
||||
|
||||
|
||||
class Config(TypedDict):
|
||||
model: str
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore:`config_schema` is deprecated")
|
||||
@pytest.mark.filterwarnings("ignore:`get_config_jsonschema` is deprecated")
|
||||
def test_config_schema_deprecation() -> None:
|
||||
with pytest.warns(
|
||||
LangGraphDeprecatedSinceV10,
|
||||
match="`config_schema` is deprecated and will be removed. Please use `context_schema` instead.",
|
||||
):
|
||||
agent = create_react_agent(FakeToolCallingModel(), [], config_schema=Config)
|
||||
assert agent.context_schema == Config
|
||||
|
||||
with pytest.warns(
|
||||
LangGraphDeprecatedSinceV10,
|
||||
match="`config_schema` is deprecated. Use `get_context_jsonschema` for the relevant schema instead.",
|
||||
):
|
||||
assert agent.config_schema() is not None
|
||||
|
||||
with pytest.warns(
|
||||
LangGraphDeprecatedSinceV10,
|
||||
match="`get_config_jsonschema` is deprecated. Use `get_context_jsonschema` instead.",
|
||||
):
|
||||
assert agent.get_config_jsonschema() is not None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
from typing import Callable, Union
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
from syrupy import SnapshotAssertion
|
||||
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
from langgraph.prebuilt import create_agent
|
||||
from tests.model import FakeToolCallingModel
|
||||
|
||||
model = FakeToolCallingModel()
|
||||
@@ -34,19 +34,25 @@ class ResponseFormat(BaseModel):
|
||||
@pytest.mark.parametrize("tools", [[], [tool]])
|
||||
@pytest.mark.parametrize("pre_model_hook", [None, pre_model_hook])
|
||||
@pytest.mark.parametrize("post_model_hook", [None, post_model_hook])
|
||||
@pytest.mark.parametrize("response_format", [None, ResponseFormat])
|
||||
def test_react_agent_graph_structure(
|
||||
snapshot: SnapshotAssertion,
|
||||
tools: list[Callable],
|
||||
pre_model_hook: Union[Callable, None],
|
||||
post_model_hook: Union[Callable, None],
|
||||
response_format: Union[type[BaseModel], None],
|
||||
pre_model_hook: Callable | None,
|
||||
post_model_hook: Callable | None,
|
||||
) -> None:
|
||||
agent = create_react_agent(
|
||||
agent = create_agent(
|
||||
model,
|
||||
tools=tools,
|
||||
pre_model_hook=pre_model_hook,
|
||||
post_model_hook=post_model_hook,
|
||||
response_format=response_format,
|
||||
)
|
||||
assert agent.get_graph().draw_mermaid(with_styles=False) == snapshot
|
||||
try:
|
||||
assert agent.get_graph().draw_mermaid(with_styles=False) == snapshot
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
"The graph structure has changed. Please update the snapshot."
|
||||
"Configuration used:\n"
|
||||
f"tools: {tools}, "
|
||||
f"pre_model_hook: {pre_model_hook}, "
|
||||
f"post_model_hook: {post_model_hook}, "
|
||||
) from e
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
"""Test suite for create_react_agent with structured output response_format permutations."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import HumanMessage
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.prebuilt import create_agent
|
||||
from langgraph.prebuilt.responses import NativeOutput, ToolOutput
|
||||
from tests.model import FakeToolCallingModel
|
||||
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI
|
||||
except ImportError:
|
||||
skip_openai_integration_tests = True
|
||||
else:
|
||||
skip_openai_integration_tests = False
|
||||
|
||||
|
||||
# Test data models
|
||||
class WeatherBaseModel(BaseModel):
|
||||
"""Weather response."""
|
||||
|
||||
temperature: float = Field(description="The temperature in fahrenheit")
|
||||
condition: str = Field(description="Weather condition")
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeatherDataclass:
|
||||
"""Weather response."""
|
||||
|
||||
temperature: float
|
||||
condition: str
|
||||
|
||||
|
||||
class WeatherTypedDict(TypedDict):
|
||||
"""Weather response."""
|
||||
|
||||
temperature: float
|
||||
condition: str
|
||||
|
||||
|
||||
weather_json_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"temperature": {"type": "number", "description": "Temperature in fahrenheit"},
|
||||
"condition": {"type": "string", "description": "Weather condition"},
|
||||
},
|
||||
"title": "weather_schema",
|
||||
"required": ["temperature", "condition"],
|
||||
}
|
||||
|
||||
|
||||
class LocationResponse(BaseModel):
|
||||
city: str = Field(description="The city name")
|
||||
country: str = Field(description="The country name")
|
||||
|
||||
|
||||
def get_weather() -> str:
|
||||
"""Get the weather."""
|
||||
|
||||
return "The weather is sunny and 75°F."
|
||||
|
||||
|
||||
def get_location() -> str:
|
||||
"""Get the current location."""
|
||||
|
||||
return "You are in New York, USA."
|
||||
|
||||
|
||||
# Standardized test data
|
||||
WEATHER_DATA = {"temperature": 75.0, "condition": "sunny"}
|
||||
LOCATION_DATA = {"city": "New York", "country": "USA"}
|
||||
|
||||
# Standardized expected responses
|
||||
EXPECTED_WEATHER_PYDANTIC = WeatherBaseModel(**WEATHER_DATA)
|
||||
EXPECTED_WEATHER_DATACLASS = WeatherDataclass(**WEATHER_DATA)
|
||||
EXPECTED_WEATHER_DICT: WeatherTypedDict = {"temperature": 75.0, "condition": "sunny"}
|
||||
EXPECTED_LOCATION = LocationResponse(**LOCATION_DATA)
|
||||
|
||||
|
||||
class TestResponseFormatAsModel:
|
||||
def test_pydantic_model(self) -> None:
|
||||
"""Test response_format as Pydantic model."""
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
[
|
||||
{
|
||||
"name": "WeatherBaseModel",
|
||||
"id": "2",
|
||||
"args": WEATHER_DATA,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel(tool_calls=tool_calls)
|
||||
|
||||
agent = create_agent(model, [get_weather], response_format=WeatherBaseModel)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert response["structured_response"] == EXPECTED_WEATHER_PYDANTIC
|
||||
assert len(response["messages"]) == 5
|
||||
|
||||
def test_dataclass(self) -> None:
|
||||
"""Test response_format as dataclass."""
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
[
|
||||
{
|
||||
"name": "WeatherDataclass",
|
||||
"id": "2",
|
||||
"args": WEATHER_DATA,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel(tool_calls=tool_calls)
|
||||
|
||||
agent = create_agent(model, [get_weather], response_format=WeatherDataclass)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert response["structured_response"] == EXPECTED_WEATHER_DATACLASS
|
||||
assert len(response["messages"]) == 5
|
||||
|
||||
def test_typed_dict(self) -> None:
|
||||
"""Test response_format as TypedDict."""
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
[
|
||||
{
|
||||
"name": "WeatherTypedDict",
|
||||
"id": "2",
|
||||
"args": WEATHER_DATA,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel(tool_calls=tool_calls)
|
||||
|
||||
agent = create_agent(model, [get_weather], response_format=WeatherTypedDict)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert response["structured_response"] == EXPECTED_WEATHER_DICT
|
||||
assert len(response["messages"]) == 5
|
||||
|
||||
def test_json_schema(self) -> None:
|
||||
"""Test response_format as JSON schema."""
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
[
|
||||
{
|
||||
"name": "weather_schema",
|
||||
"id": "2",
|
||||
"args": WEATHER_DATA,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel(tool_calls=tool_calls)
|
||||
|
||||
agent = create_agent(model, [get_weather], response_format=weather_json_schema)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert response["structured_response"] == EXPECTED_WEATHER_DICT
|
||||
assert len(response["messages"]) == 5
|
||||
|
||||
|
||||
class TestResponseFormatAsToolOutput:
|
||||
def test_pydantic_model(self) -> None:
|
||||
"""Test response_format as ToolOutput with Pydantic model."""
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
[
|
||||
{
|
||||
"name": "WeatherBaseModel",
|
||||
"id": "2",
|
||||
"args": WEATHER_DATA,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel(tool_calls=tool_calls)
|
||||
|
||||
agent = create_agent(
|
||||
model, [get_weather], response_format=ToolOutput(WeatherBaseModel)
|
||||
)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert response["structured_response"] == EXPECTED_WEATHER_PYDANTIC
|
||||
assert len(response["messages"]) == 5
|
||||
|
||||
def test_dataclass(self) -> None:
|
||||
"""Test response_format as ToolOutput with dataclass."""
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
[
|
||||
{
|
||||
"name": "WeatherDataclass",
|
||||
"id": "2",
|
||||
"args": WEATHER_DATA,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel(tool_calls=tool_calls)
|
||||
|
||||
agent = create_agent(
|
||||
model, [get_weather], response_format=ToolOutput(WeatherDataclass)
|
||||
)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert response["structured_response"] == EXPECTED_WEATHER_DATACLASS
|
||||
assert len(response["messages"]) == 5
|
||||
|
||||
def test_typed_dict(self) -> None:
|
||||
"""Test response_format as ToolOutput with TypedDict."""
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
[
|
||||
{
|
||||
"name": "WeatherTypedDict",
|
||||
"id": "2",
|
||||
"args": WEATHER_DATA,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel(tool_calls=tool_calls)
|
||||
|
||||
agent = create_agent(
|
||||
model, [get_weather], response_format=ToolOutput(WeatherTypedDict)
|
||||
)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert response["structured_response"] == EXPECTED_WEATHER_DICT
|
||||
assert len(response["messages"]) == 5
|
||||
|
||||
def test_json_schema(self) -> None:
|
||||
"""Test response_format as ToolOutput with JSON schema."""
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
[
|
||||
{
|
||||
"name": "weather_schema",
|
||||
"id": "2",
|
||||
"args": WEATHER_DATA,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel(tool_calls=tool_calls)
|
||||
|
||||
agent = create_agent(
|
||||
model, [get_weather], response_format=ToolOutput(weather_json_schema)
|
||||
)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert response["structured_response"] == EXPECTED_WEATHER_DICT
|
||||
assert len(response["messages"]) == 5
|
||||
|
||||
def test_union_of_types(self) -> None:
|
||||
"""Test response_format as ToolOutput with Union of various types."""
|
||||
# Test with WeatherBaseModel
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
[
|
||||
{
|
||||
"name": "WeatherBaseModel",
|
||||
"id": "2",
|
||||
"args": WEATHER_DATA,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel(tool_calls=tool_calls)
|
||||
|
||||
agent = create_agent(
|
||||
model,
|
||||
[get_weather, get_location],
|
||||
response_format=ToolOutput(WeatherBaseModel | LocationResponse),
|
||||
)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert response["structured_response"] == EXPECTED_WEATHER_PYDANTIC
|
||||
assert len(response["messages"]) == 5
|
||||
|
||||
# Test with LocationResponse
|
||||
tool_calls_location = [
|
||||
[{"args": {}, "id": "1", "name": "get_location"}],
|
||||
[
|
||||
{
|
||||
"name": "LocationResponse",
|
||||
"id": "2",
|
||||
"args": LOCATION_DATA,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
model_location = FakeToolCallingModel(tool_calls=tool_calls_location)
|
||||
|
||||
agent_location = create_agent(
|
||||
model_location,
|
||||
[get_weather, get_location],
|
||||
response_format=ToolOutput(WeatherBaseModel | LocationResponse),
|
||||
)
|
||||
response_location = agent_location.invoke(
|
||||
{"messages": [HumanMessage("Where am I?")]}
|
||||
)
|
||||
|
||||
assert response_location["structured_response"] == EXPECTED_LOCATION
|
||||
assert len(response_location["messages"]) == 5
|
||||
|
||||
def test_multiple_tool_messages(self) -> None:
|
||||
"""Test response_format as ToolOutput with Pydantic model."""
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
[
|
||||
{
|
||||
"name": "WeatherBaseModel",
|
||||
"id": "2",
|
||||
"args": WEATHER_DATA,
|
||||
},
|
||||
{
|
||||
"name": "WeatherDataclass",
|
||||
"id": "3",
|
||||
"args": WEATHER_DATA,
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel(tool_calls=tool_calls)
|
||||
|
||||
agent = create_agent(
|
||||
model,
|
||||
[get_weather],
|
||||
response_format=ToolOutput(WeatherBaseModel | WeatherDataclass),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
AssertionError,
|
||||
match="Model incorrectly returned multiple structured responses.",
|
||||
):
|
||||
agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
|
||||
class TestResponseFormatAsNativeOutput:
|
||||
def test_pydantic_model(self) -> None:
|
||||
"""Test response_format as NativeOutput with Pydantic model."""
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel[WeatherBaseModel](
|
||||
tool_calls=tool_calls, structured_response=EXPECTED_WEATHER_PYDANTIC
|
||||
)
|
||||
|
||||
agent = create_agent(
|
||||
model, [get_weather], response_format=NativeOutput(WeatherBaseModel)
|
||||
)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert response["structured_response"] == EXPECTED_WEATHER_PYDANTIC
|
||||
assert len(response["messages"]) == 4
|
||||
|
||||
def test_dataclass(self) -> None:
|
||||
"""Test response_format as NativeOutput with dataclass."""
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel[WeatherDataclass](
|
||||
tool_calls=tool_calls, structured_response=EXPECTED_WEATHER_DATACLASS
|
||||
)
|
||||
|
||||
agent = create_agent(
|
||||
model, [get_weather], response_format=NativeOutput(WeatherDataclass)
|
||||
)
|
||||
response = agent.invoke(
|
||||
{"messages": [HumanMessage("What's the weather?")]},
|
||||
)
|
||||
|
||||
assert response["structured_response"] == EXPECTED_WEATHER_DATACLASS
|
||||
assert len(response["messages"]) == 4
|
||||
|
||||
def test_typed_dict(self) -> None:
|
||||
"""Test response_format as NativeOutput with TypedDict."""
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel[WeatherTypedDict](
|
||||
tool_calls=tool_calls, structured_response=EXPECTED_WEATHER_DICT
|
||||
)
|
||||
|
||||
agent = create_agent(
|
||||
model, [get_weather], response_format=NativeOutput(WeatherTypedDict)
|
||||
)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert response["structured_response"] == EXPECTED_WEATHER_DICT
|
||||
assert len(response["messages"]) == 4
|
||||
|
||||
def test_json_schema(self) -> None:
|
||||
"""Test response_format as NativeOutput with JSON schema."""
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel[dict](
|
||||
tool_calls=tool_calls, structured_response=EXPECTED_WEATHER_DICT
|
||||
)
|
||||
|
||||
agent = create_agent(
|
||||
model, [get_weather], response_format=NativeOutput(weather_json_schema)
|
||||
)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert response["structured_response"] == EXPECTED_WEATHER_DICT
|
||||
assert len(response["messages"]) == 4
|
||||
|
||||
|
||||
def test_union_of_types() -> None:
|
||||
"""Test response_format as NativeOutput with Union (if supported)."""
|
||||
tool_calls = [
|
||||
[{"args": {}, "id": "1", "name": "get_weather"}],
|
||||
[
|
||||
{
|
||||
"name": "WeatherBaseModel",
|
||||
"id": "2",
|
||||
"args": WEATHER_DATA,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
model = FakeToolCallingModel[WeatherBaseModel | LocationResponse](
|
||||
tool_calls=tool_calls, structured_response=EXPECTED_WEATHER_PYDANTIC
|
||||
)
|
||||
|
||||
agent = create_agent(
|
||||
model,
|
||||
[get_weather, get_location],
|
||||
response_format=ToolOutput(WeatherBaseModel | LocationResponse),
|
||||
)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert response["structured_response"] == EXPECTED_WEATHER_PYDANTIC
|
||||
assert len(response["messages"]) == 5
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
skip_openai_integration_tests, reason="OpenAI integration tests are disabled."
|
||||
)
|
||||
def test_inference_to_native_output() -> None:
|
||||
"""Test that native output is inferred when a model supports it."""
|
||||
model = ChatOpenAI(model="gpt-5")
|
||||
agent = create_agent(
|
||||
model,
|
||||
prompt="You are a helpful weather assistant. Please call the get_weather tool, then use the WeatherReport tool to generate the final response.",
|
||||
tools=[get_weather],
|
||||
response_format=WeatherBaseModel,
|
||||
)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert isinstance(response["structured_response"], WeatherBaseModel)
|
||||
assert response["structured_response"].temperature == 75.0
|
||||
assert response["structured_response"].condition.lower() == "sunny"
|
||||
assert len(response["messages"]) == 4
|
||||
|
||||
assert [m.type for m in response["messages"]] == [
|
||||
"human", # "What's the weather?"
|
||||
"ai", # "What's the weather?"
|
||||
"tool", # "The weather is sunny and 75°F."
|
||||
"ai", # structured response
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
skip_openai_integration_tests, reason="OpenAI integration tests are disabled."
|
||||
)
|
||||
def test_inference_to_tool_output() -> None:
|
||||
"""Test that tool output is inferred when a model supports it."""
|
||||
model = ChatOpenAI(model="gpt-4")
|
||||
agent = create_agent(
|
||||
model,
|
||||
prompt="You are a helpful weather assistant. Please call the get_weather tool, then use the WeatherReport tool to generate the final response.",
|
||||
tools=[get_weather],
|
||||
response_format=ToolOutput(WeatherBaseModel),
|
||||
)
|
||||
response = agent.invoke({"messages": [HumanMessage("What's the weather?")]})
|
||||
|
||||
assert isinstance(response["structured_response"], WeatherBaseModel)
|
||||
assert response["structured_response"].temperature == 75.0
|
||||
assert response["structured_response"].condition.lower() == "sunny"
|
||||
assert len(response["messages"]) == 5
|
||||
|
||||
assert [m.type for m in response["messages"]] == [
|
||||
"human", # "What's the weather?"
|
||||
"ai", # "What's the weather?"
|
||||
"tool", # "The weather is sunny and 75°F."
|
||||
"ai", # structured response
|
||||
"tool", # artificial tool message
|
||||
]
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Unit tests for langgraph.prebuilt.responses module."""
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from langgraph.prebuilt.responses import (
|
||||
OutputToolBinding,
|
||||
ToolOutput,
|
||||
_SchemaSpec,
|
||||
)
|
||||
|
||||
|
||||
class _TestModel(BaseModel):
|
||||
"""A test model for structured output."""
|
||||
|
||||
name: str
|
||||
age: int
|
||||
email: str = "default@example.com"
|
||||
|
||||
|
||||
class CustomModel(BaseModel):
|
||||
"""Custom model with a custom docstring."""
|
||||
|
||||
value: float
|
||||
description: str
|
||||
|
||||
|
||||
class EmptyDocModel(BaseModel):
|
||||
# No custom docstring, should have no description in tool
|
||||
data: str
|
||||
|
||||
|
||||
class TestUsingToolStrategy:
|
||||
"""Test UsingToolStrategy dataclass."""
|
||||
|
||||
def test_basic_creation(self):
|
||||
"""Test basic UsingToolStrategy creation."""
|
||||
strategy = ToolOutput(schema=_TestModel)
|
||||
assert strategy.schema == _TestModel
|
||||
assert strategy.tool_message_content is None
|
||||
assert len(strategy.schema_specs) == 1
|
||||
|
||||
def test_multiple_schemas(self):
|
||||
"""Test UsingToolStrategy with multiple schemas."""
|
||||
strategy = ToolOutput(schema=_TestModel | CustomModel)
|
||||
assert len(strategy.schema_specs) == 2
|
||||
assert strategy.schema_specs[0].schema == _TestModel
|
||||
assert strategy.schema_specs[1].schema == CustomModel
|
||||
|
||||
def test_schema_with_tool_message_content(self):
|
||||
"""Test UsingToolStrategy with tool message content."""
|
||||
strategy = ToolOutput(schema=_TestModel, tool_message_content="custom message")
|
||||
assert strategy.schema == _TestModel
|
||||
assert strategy.tool_message_content == "custom message"
|
||||
assert len(strategy.schema_specs) == 1
|
||||
|
||||
|
||||
class TestOutputToolBinding:
|
||||
"""Test OutputToolBinding dataclass and its methods."""
|
||||
|
||||
def test_from_schema_spec_basic(self):
|
||||
"""Test basic OutputToolBinding creation from SchemaSpec."""
|
||||
schema_spec = _SchemaSpec(schema=_TestModel)
|
||||
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
|
||||
|
||||
assert tool_binding.schema == _TestModel
|
||||
assert tool_binding.schema_kind == "pydantic"
|
||||
assert tool_binding.tool is not None
|
||||
assert tool_binding.tool.name == "_TestModel"
|
||||
|
||||
def test_from_schema_spec_with_custom_name(self):
|
||||
"""Test OutputToolBinding creation with custom name."""
|
||||
schema_spec = _SchemaSpec(schema=_TestModel, name="custom_tool_name")
|
||||
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
|
||||
assert tool_binding.tool.name == "custom_tool_name"
|
||||
|
||||
def test_from_schema_spec_with_custom_description(self):
|
||||
"""Test OutputToolBinding creation with custom description."""
|
||||
schema_spec = _SchemaSpec(
|
||||
schema=_TestModel, description="Custom tool description"
|
||||
)
|
||||
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
|
||||
|
||||
assert tool_binding.tool.description == "Custom tool description"
|
||||
|
||||
def test_from_schema_spec_with_model_docstring(self):
|
||||
"""Test OutputToolBinding creation using model docstring as description."""
|
||||
schema_spec = _SchemaSpec(schema=CustomModel)
|
||||
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
|
||||
|
||||
assert tool_binding.tool.description == "Custom model with a custom docstring."
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="Need to fix bug in langchain-core for inheritance of doc-strings."
|
||||
)
|
||||
def test_from_schema_spec_empty_docstring(self):
|
||||
"""Test OutputToolBinding creation with model that has default docstring."""
|
||||
|
||||
# Create a model with the same docstring as BaseModel
|
||||
class DefaultDocModel(BaseModel):
|
||||
# This should have the same docstring as BaseModel
|
||||
pass
|
||||
|
||||
schema_spec = _SchemaSpec(schema=DefaultDocModel)
|
||||
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
|
||||
|
||||
# Should use empty description when model has default BaseModel docstring
|
||||
assert tool_binding.tool.description == ""
|
||||
|
||||
def test_parse_payload_pydantic_success(self):
|
||||
"""Test successful parsing for Pydantic model."""
|
||||
schema_spec = _SchemaSpec(schema=_TestModel)
|
||||
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
|
||||
|
||||
tool_args = {"name": "John", "age": 30}
|
||||
result = tool_binding.parse(tool_args)
|
||||
|
||||
assert isinstance(result, _TestModel)
|
||||
assert result.name == "John"
|
||||
assert result.age == 30
|
||||
assert result.email == "default@example.com" # default value
|
||||
|
||||
def test_parse_payload_pydantic_validation_error(self):
|
||||
"""Test parsing failure for invalid Pydantic data."""
|
||||
schema_spec = _SchemaSpec(schema=_TestModel)
|
||||
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
|
||||
|
||||
# Missing required field 'name'
|
||||
tool_args = {"age": 30}
|
||||
|
||||
with pytest.raises(ValueError, match="Failed to parse data to _TestModel"):
|
||||
tool_binding.parse(tool_args)
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error conditions."""
|
||||
|
||||
def test_empty_schemas_list(self) -> None:
|
||||
"""Test UsingToolStrategy with empty schemas list."""
|
||||
strategy = ToolOutput(EmptyDocModel)
|
||||
assert len(strategy.schema_specs) == 1
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="Need to fix bug in langchain-core for inheritance of doc-strings."
|
||||
)
|
||||
def test_base_model_doc_constant(self) -> None:
|
||||
"""Test that BASE_MODEL_DOC constant is set correctly."""
|
||||
binding = OutputToolBinding.from_schema_spec(_SchemaSpec(EmptyDocModel))
|
||||
assert binding.tool.name == "EmptyDocModel"
|
||||
assert (
|
||||
binding.tool.description[:5] == ""
|
||||
) # Should be empty for default docstring
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.tools import tool
|
||||
from pydantic import BaseModel, create_model
|
||||
|
||||
from langgraph.prebuilt import create_agent
|
||||
from langgraph.prebuilt.responses import ToolOutput
|
||||
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI
|
||||
except ImportError:
|
||||
skip_openai_integration_tests = True
|
||||
else:
|
||||
skip_openai_integration_tests = False
|
||||
|
||||
|
||||
def _load_spec() -> list[dict[str, Any]]:
|
||||
with (Path(__file__).parent / "specifications" / "responses.json").open(
|
||||
"r", encoding="utf-8"
|
||||
) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
TEST_CASES = _load_spec()
|
||||
|
||||
AGENT_PROMPT = "You are an HR assistant."
|
||||
|
||||
EMPLOYEES = [
|
||||
{"name": "Sabine", "role": "Developer", "department": "IT"},
|
||||
{"name": "Henrik", "role": "Product Manager", "department": "IT"},
|
||||
{"name": "Jessica", "role": "HR", "department": "People"},
|
||||
]
|
||||
|
||||
|
||||
def _make_tool(fn, *, name: str, description: str):
|
||||
mock = MagicMock(side_effect=lambda *, name: fn(name=name))
|
||||
InputModel = create_model(f"{name}_input", name=(str, ...))
|
||||
|
||||
@tool(name, description=description, args_schema=InputModel)
|
||||
def _wrapped(name: str):
|
||||
return mock(name=name)
|
||||
|
||||
return {"tool": _wrapped, "mock": mock}
|
||||
|
||||
|
||||
def _build_tool_output_response_format(
|
||||
response_format_spec: Sequence[dict[str, Any]],
|
||||
) -> ToolOutput:
|
||||
models: list[type[BaseModel]] = []
|
||||
keyset_to_tool_name: dict[frozenset[str], str] = {}
|
||||
type_map = {
|
||||
"string": str,
|
||||
"number": float,
|
||||
"integer": int,
|
||||
"boolean": bool,
|
||||
"object": dict,
|
||||
"array": list,
|
||||
}
|
||||
|
||||
for idx, schema in enumerate(response_format_spec):
|
||||
properties = schema["properties"]
|
||||
required = set(schema["required"])
|
||||
type_name = schema.get("title") or f"structured_output_format_{idx + 1}"
|
||||
fields = {}
|
||||
for k, prop in properties.items():
|
||||
py_type = type_map.get(prop.get("type"), Any)
|
||||
fields[k] = (py_type, ...) if k in required else (Optional[py_type], None) # noqa: UP045
|
||||
model = create_model(type_name, **fields)
|
||||
models.append(model)
|
||||
keyset_to_tool_name[frozenset(required)] = type_name
|
||||
|
||||
union_type = Union[tuple(models)] # noqa: UP045, UP007
|
||||
return ToolOutput(union_type)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
skip_openai_integration_tests, reason="OpenAI integration tests are disabled."
|
||||
)
|
||||
@pytest.mark.xfail(
|
||||
reason="currently failing due to undefined behavior for multiple structured responses."
|
||||
)
|
||||
@pytest.mark.parametrize("case", TEST_CASES, ids=[c["name"] for c in TEST_CASES])
|
||||
def test_responses_integration_matrix(case: dict[str, Any]) -> None:
|
||||
def get_employee_role(*, name: str) -> str | None:
|
||||
for e in EMPLOYEES:
|
||||
if e["name"] == name:
|
||||
return e["role"]
|
||||
return None
|
||||
|
||||
def get_employee_department(*, name: str) -> str | None:
|
||||
for e in EMPLOYEES:
|
||||
if e["name"] == name:
|
||||
return e["department"]
|
||||
return None
|
||||
|
||||
role_tool = _make_tool(
|
||||
get_employee_role,
|
||||
name="getEmployeeRole",
|
||||
description="Get the employee role by name",
|
||||
)
|
||||
dept_tool = _make_tool(
|
||||
get_employee_department,
|
||||
name="getEmployeeDepartment",
|
||||
description="Get the employee department by name",
|
||||
)
|
||||
|
||||
response_spec = case["responseFormat"]
|
||||
if isinstance(response_spec, dict):
|
||||
response_spec = [response_spec]
|
||||
tool_output = _build_tool_output_response_format(response_spec)
|
||||
|
||||
for assertion in case["assertionsByInvocation"]:
|
||||
prompt: str = assertion["prompt"]
|
||||
expected_calls: dict[str, int] = assertion["toolsWithExpectedCalls"]
|
||||
expected_structured = assertion.get("expectedStructuredResponse")
|
||||
expected_last_message = assertion.get("expectedLastMessage")
|
||||
|
||||
model = ChatOpenAI(
|
||||
model="gpt-4o-mini",
|
||||
temperature=0,
|
||||
)
|
||||
|
||||
agent = create_agent(
|
||||
model,
|
||||
tools=[role_tool["tool"], dept_tool["tool"]],
|
||||
prompt=AGENT_PROMPT,
|
||||
response_format=tool_output,
|
||||
)
|
||||
result = agent.invoke({"messages": [HumanMessage(prompt)]})
|
||||
|
||||
# TODO: Count LLM calls. JS handles with mock fetch. Could pass in mock http_client?
|
||||
|
||||
# Count tool calls
|
||||
assert role_tool["mock"].call_count == expected_calls["getEmployeeRole"]
|
||||
assert dept_tool["mock"].call_count == expected_calls["getEmployeeDepartment"]
|
||||
|
||||
# Check last message content
|
||||
last_message = result["messages"][-1]
|
||||
assert last_message.content == expected_last_message
|
||||
|
||||
# Check structured response
|
||||
structured_response_json = result["structured_response"].model_dump()
|
||||
assert structured_response_json == expected_structured
|
||||
|
||||
print("Passed test for: ", case["name"])
|
||||
@@ -1,25 +1,46 @@
|
||||
import dataclasses
|
||||
import json
|
||||
from functools import partial
|
||||
from typing import (
|
||||
Annotated,
|
||||
Any,
|
||||
Union,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
AnyMessage,
|
||||
HumanMessage,
|
||||
RemoveMessage,
|
||||
ToolCall,
|
||||
ToolMessage,
|
||||
)
|
||||
from langchain_core.tools import BaseTool, ToolException
|
||||
from langchain_core.tools import tool as dec_tool
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic.v1 import BaseModel as BaseModelV1
|
||||
from pydantic.v1 import ValidationError as ValidationErrorV1
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.config import get_stream_writer
|
||||
from langgraph.errors import GraphBubbleUp, GraphInterrupt
|
||||
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
||||
from langgraph.prebuilt import ToolNode
|
||||
from langgraph.prebuilt.tool_node import TOOL_CALL_ERROR_TEMPLATE
|
||||
from langgraph.graph import START, MessagesState, StateGraph
|
||||
from langgraph.graph.message import REMOVE_ALL_MESSAGES, add_messages
|
||||
from langgraph.prebuilt import (
|
||||
ToolNode,
|
||||
)
|
||||
from langgraph.prebuilt.tool_node import (
|
||||
TOOL_CALL_ERROR_TEMPLATE,
|
||||
InjectedState,
|
||||
InjectedStore,
|
||||
tools_condition,
|
||||
)
|
||||
from langgraph.store.base import BaseStore
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
from langgraph.types import Command, Send
|
||||
from tests.messages import _AnyIdHumanMessage, _AnyIdToolMessage
|
||||
from tests.model import FakeToolCallingModel
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
@@ -62,7 +83,8 @@ def tool5(some_val: int):
|
||||
tool5.handle_tool_error = "foo"
|
||||
|
||||
|
||||
async def test_tool_node():
|
||||
async def test_tool_node() -> None:
|
||||
"""Test tool node."""
|
||||
result = ToolNode([tool1]).invoke(
|
||||
{
|
||||
"messages": [
|
||||
@@ -154,7 +176,7 @@ async def test_tool_node():
|
||||
assert tool_message.tool_call_id == "some 3"
|
||||
|
||||
|
||||
async def test_tool_node_tool_call_input():
|
||||
async def test_tool_node_tool_call_input() -> None:
|
||||
# Single tool call
|
||||
tool_call_1 = {
|
||||
"name": "tool1",
|
||||
@@ -195,8 +217,8 @@ async def test_tool_node_tool_call_input():
|
||||
]
|
||||
|
||||
|
||||
async def test_tool_node_error_handling():
|
||||
def handle_all(e: Union[ValueError, ToolException, ValidationError]):
|
||||
async def test_tool_node_error_handling() -> None:
|
||||
def handle_all(e: ValueError | ToolException | ValidationError):
|
||||
return TOOL_CALL_ERROR_TEMPLATE.format(error=repr(e))
|
||||
|
||||
# test catching all exceptions, via:
|
||||
@@ -257,7 +279,7 @@ async def test_tool_node_error_handling():
|
||||
assert result_error["messages"][2].tool_call_id == "another id"
|
||||
|
||||
|
||||
async def test_tool_node_error_handling_callable():
|
||||
async def test_tool_node_error_handling_callable() -> None:
|
||||
def handle_value_error(e: ValueError):
|
||||
return "Value error"
|
||||
|
||||
@@ -1156,3 +1178,304 @@ async def test_tool_node_command_remove_all_messages():
|
||||
command = result[0]
|
||||
assert isinstance(command, Command)
|
||||
assert command.update == {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)]}
|
||||
|
||||
|
||||
class _InjectStateSchema(TypedDict):
|
||||
messages: list
|
||||
foo: str
|
||||
|
||||
|
||||
class _InjectedStatePydanticSchema(BaseModelV1):
|
||||
messages: list
|
||||
foo: str
|
||||
|
||||
|
||||
class _InjectedStatePydanticV2Schema(BaseModel):
|
||||
messages: list
|
||||
foo: str
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _InjectedStateDataclassSchema:
|
||||
messages: list
|
||||
foo: str
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema_",
|
||||
[
|
||||
_InjectStateSchema,
|
||||
_InjectedStatePydanticSchema,
|
||||
_InjectedStatePydanticV2Schema,
|
||||
_InjectedStateDataclassSchema,
|
||||
],
|
||||
)
|
||||
def test_tool_node_inject_state(schema_: type[T]) -> None:
|
||||
def tool1(some_val: int, state: Annotated[T, InjectedState]) -> str:
|
||||
"""Tool 1 docstring."""
|
||||
if isinstance(state, dict):
|
||||
return state["foo"]
|
||||
else:
|
||||
return getattr(state, "foo")
|
||||
|
||||
def tool2(some_val: int, state: Annotated[T, InjectedState()]) -> str:
|
||||
"""Tool 2 docstring."""
|
||||
if isinstance(state, dict):
|
||||
return state["foo"]
|
||||
else:
|
||||
return getattr(state, "foo")
|
||||
|
||||
def tool3(
|
||||
some_val: int,
|
||||
foo: Annotated[str, InjectedState("foo")],
|
||||
msgs: Annotated[list[AnyMessage], InjectedState("messages")],
|
||||
) -> str:
|
||||
"""Tool 1 docstring."""
|
||||
return foo
|
||||
|
||||
def tool4(
|
||||
some_val: int, msgs: Annotated[list[AnyMessage], InjectedState("messages")]
|
||||
) -> str:
|
||||
"""Tool 1 docstring."""
|
||||
return msgs[0].content
|
||||
|
||||
node = ToolNode([tool1, tool2, tool3, tool4])
|
||||
for tool_name in ("tool1", "tool2", "tool3"):
|
||||
tool_call = {
|
||||
"name": tool_name,
|
||||
"args": {"some_val": 1},
|
||||
"id": "some 0",
|
||||
"type": "tool_call",
|
||||
}
|
||||
msg = AIMessage("hi?", tool_calls=[tool_call])
|
||||
result = node.invoke(schema_(**{"messages": [msg], "foo": "bar"}))
|
||||
tool_message = result["messages"][-1]
|
||||
assert tool_message.content == "bar", f"Failed for tool={tool_name}"
|
||||
|
||||
if tool_name == "tool3":
|
||||
failure_input = None
|
||||
try:
|
||||
failure_input = schema_(**{"messages": [msg], "notfoo": "bar"})
|
||||
except Exception:
|
||||
pass
|
||||
if failure_input is not None:
|
||||
with pytest.raises(KeyError):
|
||||
node.invoke(failure_input)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
node.invoke([msg])
|
||||
else:
|
||||
failure_input = None
|
||||
try:
|
||||
failure_input = schema_(**{"messages": [msg], "notfoo": "bar"})
|
||||
except Exception:
|
||||
# We'd get a validation error from pydantic state and wouldn't make it to the node
|
||||
# anyway
|
||||
pass
|
||||
if failure_input is not None:
|
||||
messages_ = node.invoke(failure_input)
|
||||
tool_message = messages_["messages"][-1]
|
||||
assert "KeyError" in tool_message.content
|
||||
tool_message = node.invoke([msg])[-1]
|
||||
assert "KeyError" in tool_message.content
|
||||
|
||||
tool_call = {
|
||||
"name": "tool4",
|
||||
"args": {"some_val": 1},
|
||||
"id": "some 0",
|
||||
"type": "tool_call",
|
||||
}
|
||||
msg = AIMessage("hi?", tool_calls=[tool_call])
|
||||
result = node.invoke(schema_(**{"messages": [msg], "foo": ""}))
|
||||
tool_message = result["messages"][-1]
|
||||
assert tool_message.content == "hi?"
|
||||
|
||||
result = node.invoke([msg])
|
||||
tool_message = result[-1]
|
||||
assert tool_message.content == "hi?"
|
||||
|
||||
|
||||
def test_tool_node_inject_store() -> None:
|
||||
store = InMemoryStore()
|
||||
namespace = ("test",)
|
||||
|
||||
def tool1(some_val: int, store: Annotated[BaseStore, InjectedStore()]) -> str:
|
||||
"""Tool 1 docstring."""
|
||||
store_val = store.get(namespace, "test_key").value["foo"]
|
||||
return f"Some val: {some_val}, store val: {store_val}"
|
||||
|
||||
def tool2(some_val: int, store: Annotated[BaseStore, InjectedStore()]) -> str:
|
||||
"""Tool 2 docstring."""
|
||||
store_val = store.get(namespace, "test_key").value["foo"]
|
||||
return f"Some val: {some_val}, store val: {store_val}"
|
||||
|
||||
def tool3(
|
||||
some_val: int,
|
||||
bar: Annotated[str, InjectedState("bar")],
|
||||
store: Annotated[BaseStore, InjectedStore()],
|
||||
) -> str:
|
||||
"""Tool 3 docstring."""
|
||||
store_val = store.get(namespace, "test_key").value["foo"]
|
||||
return f"Some val: {some_val}, store val: {store_val}, state val: {bar}"
|
||||
|
||||
node = ToolNode([tool1, tool2, tool3], handle_tool_errors=True)
|
||||
store.put(namespace, "test_key", {"foo": "bar"})
|
||||
|
||||
class State(MessagesState):
|
||||
bar: str
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("tools", node)
|
||||
builder.add_edge(START, "tools")
|
||||
graph = builder.compile(store=store)
|
||||
|
||||
for tool_name in ("tool1", "tool2"):
|
||||
tool_call = {
|
||||
"name": tool_name,
|
||||
"args": {"some_val": 1},
|
||||
"id": "some 0",
|
||||
"type": "tool_call",
|
||||
}
|
||||
msg = AIMessage("hi?", tool_calls=[tool_call])
|
||||
node_result = node.invoke({"messages": [msg]}, store=store)
|
||||
graph_result = graph.invoke({"messages": [msg]})
|
||||
for result in (node_result, graph_result):
|
||||
result["messages"][-1]
|
||||
tool_message = result["messages"][-1]
|
||||
assert tool_message.content == "Some val: 1, store val: bar", (
|
||||
f"Failed for tool={tool_name}"
|
||||
)
|
||||
|
||||
tool_call = {
|
||||
"name": "tool3",
|
||||
"args": {"some_val": 1},
|
||||
"id": "some 0",
|
||||
"type": "tool_call",
|
||||
}
|
||||
msg = AIMessage("hi?", tool_calls=[tool_call])
|
||||
node_result = node.invoke({"messages": [msg], "bar": "baz"}, store=store)
|
||||
graph_result = graph.invoke({"messages": [msg], "bar": "baz"})
|
||||
for result in (node_result, graph_result):
|
||||
result["messages"][-1]
|
||||
tool_message = result["messages"][-1]
|
||||
assert tool_message.content == "Some val: 1, store val: bar, state val: baz", (
|
||||
f"Failed for tool={tool_name}"
|
||||
)
|
||||
|
||||
# test injected store without passing store to compiled graph
|
||||
failing_graph = builder.compile()
|
||||
with pytest.raises(ValueError):
|
||||
failing_graph.invoke({"messages": [msg], "bar": "baz"})
|
||||
|
||||
|
||||
def test_tool_node_ensure_utf8() -> None:
|
||||
@dec_tool
|
||||
def get_day_list(days: list[str]) -> list[str]:
|
||||
"""choose days"""
|
||||
return days
|
||||
|
||||
data = ["星期一", "水曜日", "목요일", "Friday"]
|
||||
tools = [get_day_list]
|
||||
tool_calls = [ToolCall(name=get_day_list.name, args={"days": data}, id="test_id")]
|
||||
outputs: list[ToolMessage] = ToolNode(tools).invoke(
|
||||
[AIMessage(content="", tool_calls=tool_calls)]
|
||||
)
|
||||
assert outputs[0].content == json.dumps(data, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_tool_node_messages_key() -> None:
|
||||
@dec_tool
|
||||
def add(a: int, b: int):
|
||||
"""Adds a and b."""
|
||||
return a + b
|
||||
|
||||
model = FakeToolCallingModel(
|
||||
tool_calls=[[ToolCall(name=add.name, args={"a": 1, "b": 2}, id="test_id")]]
|
||||
)
|
||||
|
||||
class State(TypedDict):
|
||||
subgraph_messages: Annotated[list[AnyMessage], add_messages]
|
||||
|
||||
def call_model(state: State):
|
||||
response = model.invoke(state["subgraph_messages"])
|
||||
model.tool_calls = []
|
||||
return {"subgraph_messages": response}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("agent", call_model)
|
||||
builder.add_node("tools", ToolNode([add], messages_key="subgraph_messages"))
|
||||
builder.add_conditional_edges(
|
||||
"agent", partial(tools_condition, messages_key="subgraph_messages")
|
||||
)
|
||||
builder.add_edge(START, "agent")
|
||||
builder.add_edge("tools", "agent")
|
||||
|
||||
graph = builder.compile()
|
||||
result = graph.invoke({"subgraph_messages": [HumanMessage(content="hi")]})
|
||||
assert result["subgraph_messages"] == [
|
||||
_AnyIdHumanMessage(content="hi"),
|
||||
AIMessage(
|
||||
content="hi",
|
||||
id="0",
|
||||
tool_calls=[ToolCall(name=add.name, args={"a": 1, "b": 2}, id="test_id")],
|
||||
),
|
||||
_AnyIdToolMessage(content="3", name=add.name, tool_call_id="test_id"),
|
||||
AIMessage(content="hi-hi-3", id="1"),
|
||||
]
|
||||
|
||||
|
||||
def test_tool_node_stream_writer() -> None:
|
||||
@dec_tool
|
||||
def streaming_tool(x: int) -> str:
|
||||
"""Do something with writer."""
|
||||
my_writer = get_stream_writer()
|
||||
for value in ["foo", "bar", "baz"]:
|
||||
my_writer({"custom_tool_value": value})
|
||||
|
||||
return x
|
||||
|
||||
tool_node = ToolNode([streaming_tool])
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("tools", tool_node)
|
||||
.add_edge(START, "tools")
|
||||
.compile()
|
||||
)
|
||||
|
||||
tool_call = {
|
||||
"name": "streaming_tool",
|
||||
"args": {"x": 1},
|
||||
"id": "1",
|
||||
"type": "tool_call",
|
||||
}
|
||||
inputs = {
|
||||
"messages": [AIMessage("", tool_calls=[tool_call])],
|
||||
}
|
||||
|
||||
assert list(graph.stream(inputs, stream_mode="custom")) == [
|
||||
{"custom_tool_value": "foo"},
|
||||
{"custom_tool_value": "bar"},
|
||||
{"custom_tool_value": "baz"},
|
||||
]
|
||||
assert list(graph.stream(inputs, stream_mode=["custom", "updates"])) == [
|
||||
("custom", {"custom_tool_value": "foo"}),
|
||||
("custom", {"custom_tool_value": "bar"}),
|
||||
("custom", {"custom_tool_value": "baz"}),
|
||||
(
|
||||
"updates",
|
||||
{
|
||||
"tools": {
|
||||
"messages": [
|
||||
_AnyIdToolMessage(
|
||||
content="1",
|
||||
name="streaming_tool",
|
||||
tool_call_id="1",
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
Generated
+5
-128
@@ -1,6 +1,6 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.9"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "aiosqlite"
|
||||
@@ -102,18 +102,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220, upload-time = "2024-09-04T20:45:01.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605, upload-time = "2024-09-04T20:45:03.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910, upload-time = "2024-09-04T20:45:05.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200, upload-time = "2024-09-04T20:45:06.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565, upload-time = "2024-09-04T20:45:08.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635, upload-time = "2024-09-04T20:45:10.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218, upload-time = "2024-09-04T20:45:12.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486, upload-time = "2024-09-04T20:45:13.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911, upload-time = "2024-09-04T20:45:15.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632, upload-time = "2024-09-04T20:45:17.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820, upload-time = "2024-09-04T20:45:18.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290, upload-time = "2024-09-04T20:45:20.226Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -174,19 +162,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671, upload-time = "2025-05-02T08:34:12.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744, upload-time = "2025-05-02T08:34:14.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993, upload-time = "2025-05-02T08:34:17.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/a2/5e4c187680728219254ef107a6949c60ee0e9a916a5dadb148c7ae82459c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", size = 147382, upload-time = "2025-05-02T08:34:19.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/fe/56aca740dda674f0cc1ba1418c4d84534be51f639b5f98f538b332dc9a95/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", size = 149536, upload-time = "2025-05-02T08:34:21.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/13/db2e7779f892386b589173dd689c1b1e304621c5792046edd8a978cbf9e0/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", size = 151349, upload-time = "2025-05-02T08:34:23.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/35/e52ab9a276186f729bce7a0638585d2982f50402046e4b0faa5d2c3ef2da/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", size = 146365, upload-time = "2025-05-02T08:34:25.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/d8/af7333f732fc2e7635867d56cb7c349c28c7094910c72267586947561b4b/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", size = 154499, upload-time = "2025-05-02T08:34:27.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/3d/a5b2e48acef264d71e036ff30bcc49e51bde80219bb628ba3e00cf59baac/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", size = 157735, upload-time = "2025-05-02T08:34:29.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/d8/23e2c112532a29f3eef374375a8684a4f3b8e784f62b01da931186f43494/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", size = 154786, upload-time = "2025-05-02T08:34:31.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/57/93e0169f08ecc20fe82d12254a200dfaceddc1c12a4077bf454ecc597e33/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", size = 150203, upload-time = "2025-05-02T08:34:33.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/9d/9bf2b005138e7e060d7ebdec7503d0ef3240141587651f4b445bdf7286c2/charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", size = 98436, upload-time = "2025-05-02T08:34:35.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/24/5849d46cf4311bbf21b424c443b09b459f5b436b1558c04e45dbb7cc478b/charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", size = 105772, upload-time = "2025-05-02T08:34:37.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
|
||||
]
|
||||
|
||||
@@ -316,7 +291,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -359,6 +334,7 @@ dev = [
|
||||
{ name = "pytest-repeat" },
|
||||
{ name = "pytest-watcher" },
|
||||
{ name = "pytest-xdist", extras = ["psutil"] },
|
||||
{ name = "redis" },
|
||||
{ name = "ruff" },
|
||||
{ name = "syrupy" },
|
||||
{ name = "types-requests" },
|
||||
@@ -392,6 +368,7 @@ dev = [
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-watcher" },
|
||||
{ name = "redis" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
@@ -460,7 +437,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -585,12 +562,6 @@ wheels = [
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
@@ -667,19 +638,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/fd/7f1d3edd4ffcd944a6a40e9f88af2197b619c931ac4d3cfba4798d4d3815/orjson-3.10.18-cp313-cp313-win32.whl", hash = "sha256:ad8eacbb5d904d5591f27dee4031e2c1db43d559edb8f91778efd642d70e6bea", size = 142687, upload-time = "2025-04-29T23:29:38.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/03/c75c6ad46be41c16f4cfe0352a2d1450546f3c09ad2c9d341110cd87b025/orjson-3.10.18-cp313-cp313-win_amd64.whl", hash = "sha256:aed411bcb68bf62e85588f2a7e03a6082cc42e5a2796e06e72a962d7c6310b52", size = 134794, upload-time = "2025-04-29T23:29:40.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/28/f53038a5a72cc4fd0b56c1eafb4ef64aec9685460d5ac34de98ca78b6e29/orjson-3.10.18-cp313-cp313-win_arm64.whl", hash = "sha256:f54c1385a0e6aba2f15a40d703b858bedad36ded0491e55d35d905b2c34a4cc3", size = 131186, upload-time = "2025-04-29T23:29:41.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/db/69488acaa2316788b7e171f024912c6fe8193aa2e24e9cfc7bc41c3669ba/orjson-3.10.18-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c95fae14225edfd699454e84f61c3dd938df6629a00c6ce15e704f57b58433bb", size = 249301, upload-time = "2025-04-29T23:29:44.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/21/d816c44ec5d1482c654e1d23517d935bb2716e1453ff9380e861dc6efdd3/orjson-3.10.18-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5232d85f177f98e0cefabb48b5e7f60cff6f3f0365f9c60631fecd73849b2a82", size = 136786, upload-time = "2025-04-29T23:29:46.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/9f/f68d8a9985b717e39ba7bf95b57ba173fcd86aeca843229ec60d38f1faa7/orjson-3.10.18-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2783e121cafedf0d85c148c248a20470018b4ffd34494a68e125e7d5857655d1", size = 132711, upload-time = "2025-04-29T23:29:48.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/63/447f5955439bf7b99bdd67c38a3f689d140d998ac58e3b7d57340520343c/orjson-3.10.18-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e54ee3722caf3db09c91f442441e78f916046aa58d16b93af8a91500b7bbf273", size = 136841, upload-time = "2025-04-29T23:29:50.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/9e/4855972f2be74097242e4681ab6766d36638a079e09d66f3d6a5d1188ce7/orjson-3.10.18-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2daf7e5379b61380808c24f6fc182b7719301739e4271c3ec88f2984a2d61f89", size = 138082, upload-time = "2025-04-29T23:29:51.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/0f/e68431e53a39698d2355faf1f018c60a3019b4b54b4ea6be9dc6b8208a3d/orjson-3.10.18-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f39b371af3add20b25338f4b29a8d6e79a8c7ed0e9dd49e008228a065d07781", size = 142618, upload-time = "2025-04-29T23:29:53.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/da/bdcfff239ddba1b6ef465efe49d7e43cc8c30041522feba9fd4241d47c32/orjson-3.10.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b819ed34c01d88c6bec290e6842966f8e9ff84b7694632e88341363440d4cc0", size = 132627, upload-time = "2025-04-29T23:29:55.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/28/bc634da09bbe972328f615b0961f1e7d91acb3cc68bddbca9e8dd64e8e24/orjson-3.10.18-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2f6c57debaef0b1aa13092822cbd3698a1fb0209a9ea013a969f4efa36bdea57", size = 134832, upload-time = "2025-04-29T23:29:56.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/d2/e8ac0c2d0ec782ed8925b4eb33f040cee1f1fbd1d8b268aeb84b94153e49/orjson-3.10.18-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:755b6d61ffdb1ffa1e768330190132e21343757c9aa2308c67257cc81a1a6f5a", size = 413161, upload-time = "2025-04-29T23:29:59.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f0/397e98c352a27594566e865999dc6b88d6f37d5bbb87b23c982af24114c4/orjson-3.10.18-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ce8d0a875a85b4c8579eab5ac535fb4b2a50937267482be402627ca7e7570ee3", size = 153012, upload-time = "2025-04-29T23:30:01.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/bf/2c7334caeb48bdaa4cae0bde17ea417297ee136598653b1da7ae1f98c785/orjson-3.10.18-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57b5d0673cbd26781bebc2bf86f99dd19bd5a9cb55f71cc4f66419f6b50f3d77", size = 136999, upload-time = "2025-04-29T23:30:02.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/72/4827b1c0c31621c2aa1e661a899cdd2cfac0565c6cd7131890daa4ef7535/orjson-3.10.18-cp39-cp39-win32.whl", hash = "sha256:951775d8b49d1d16ca8818b1f20c4965cae9157e7b562a2ae34d3967b8f21c8e", size = 142560, upload-time = "2025-04-29T23:30:04.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/91/ef8e76868e7eed478887c82f60607a8abf58dadd24e95817229a4b2e2639/orjson-3.10.18-cp39-cp39-win_amd64.whl", hash = "sha256:fdd9d68f83f0bc4406610b1ac68bdcded8c5ee58605cc69e643a06f4d075f429", size = 134455, upload-time = "2025-04-29T23:30:06.588Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -720,14 +678,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/94/687a0ad8afd17e4bce1892145d6a1111e58987ddb176810d02a1f3f18686/ormsgpack-1.10.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:33afe143a7b61ad21bb60109a86bb4e87fec70ef35db76b89c65b17e32da7935", size = 479076, upload-time = "2025-05-24T19:07:37.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/34/68925232e81e0e062a2f0ac678f62aa3b6f7009d6a759e19324dbbaebae7/ormsgpack-1.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f23d45080846a7b90feabec0d330a9cc1863dc956728412e4f7986c80ab3a668", size = 390446, upload-time = "2025-05-24T19:07:39.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/ad/f4e1a36a6d1714afb7ffb74b3ababdcb96529cf4e7a216f9f7c8eda837b6/ormsgpack-1.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:534d18acb805c75e5fba09598bf40abe1851c853247e61dda0c01f772234da69", size = 121399, upload-time = "2025-05-24T19:07:40.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/8f/bb80469db9d5b10708cba6997463d140486ca7053a5d18f99b5739cfecf7/ormsgpack-1.10.0-cp39-cp39-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:efdb25cf6d54085f7ae557268d59fd2d956f1a09a340856e282d2960fe929f32", size = 376272, upload-time = "2025-05-24T19:07:42.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/9c/48f714ed3d5a153f25e3b490496e6ba214aee265a82be1b61e39019ea146/ormsgpack-1.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ddfcb30d4b1be2439836249d675f297947f4fb8efcd3eeb6fd83021d773cadc4", size = 204314, upload-time = "2025-05-24T19:07:43.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/42/7f9edf6e5511120b5304c76c5d3a8b4719ff927555a6dba41b6f9d041b30/ormsgpack-1.10.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee0944b6ccfd880beb1ca29f9442a774683c366f17f4207f8b81c5e24cadb453", size = 215386, upload-time = "2025-05-24T19:07:45.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/87/41e14485857fbe4ed5a530677fe60dd6910a254825c0b1cb5b04baaa4be0/ormsgpack-1.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35cdff6a0d3ba04e40a751129763c3b9b57a602c02944138e4b760ec99ae80a1", size = 216466, upload-time = "2025-05-24T19:07:46.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/68/769fa1c721d8aa6799c0ce98b1711ae57de3e6379b554ebf9a11be4c62ff/ormsgpack-1.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:599ccdabc19c618ef5de6e6f2e7f5d48c1f531a625fa6772313b8515bc710681", size = 384600, upload-time = "2025-05-24T19:07:47.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/f9/b57fd387fe16753783a3cea0ed2471c727bbed4356d8a08e3f0340251870/ormsgpack-1.10.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:bf46f57da9364bd5eefd92365c1b78797f56c6f780581eecd60cd7b367f9b4d3", size = 478888, upload-time = "2025-05-24T19:07:49.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/0f/464cdfa7f9ee817c2d94485880b6c3c4b9f22df9fcbf21c303bbfebcb3ed/ormsgpack-1.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b796f64fdf823dedb1e35436a4a6f889cf78b1aa42d3097c66e5adfd8c3bd72d", size = 390118, upload-time = "2025-05-24T19:07:51.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/03/b9146dff5458def4c0a2b1e35c1c24e4d5e8083899aa0718b6eccba39317/ormsgpack-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:106253ac9dc08520951e556b3c270220fcb8b4fef0d30b71eedac4befa4de749", size = 121199, upload-time = "2025-05-24T19:07:52.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -873,19 +823,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677, upload-time = "2025-04-23T18:32:27.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735, upload-time = "2025-04-23T18:32:29.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467, upload-time = "2025-04-23T18:32:31.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041, upload-time = "2025-04-23T18:32:33.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503, upload-time = "2025-04-23T18:32:35.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079, upload-time = "2025-04-23T18:32:37.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508, upload-time = "2025-04-23T18:32:39.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693, upload-time = "2025-04-23T18:32:41.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224, upload-time = "2025-04-23T18:32:44.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403, upload-time = "2025-04-23T18:32:45.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331, upload-time = "2025-04-23T18:32:47.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571, upload-time = "2025-04-23T18:32:49.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504, upload-time = "2025-04-23T18:32:51.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" },
|
||||
@@ -904,15 +841,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034, upload-time = "2025-04-23T18:33:32.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578, upload-time = "2025-04-23T18:33:34.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858, upload-time = "2025-04-23T18:33:36.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498, upload-time = "2025-04-23T18:33:38.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428, upload-time = "2025-04-23T18:33:41.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854, upload-time = "2025-04-23T18:33:43.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859, upload-time = "2025-04-23T18:33:45.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059, upload-time = "2025-04-23T18:33:47.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661, upload-time = "2025-04-23T18:33:49.995Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -948,7 +876,6 @@ version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960, upload-time = "2025-05-26T04:54:40.484Z" }
|
||||
wheels = [
|
||||
@@ -1022,15 +949,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload-time = "2024-08-06T20:33:25.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload-time = "2024-08-06T20:33:27.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload-time = "2024-08-06T20:33:28.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload-time = "2024-08-06T20:33:34.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload-time = "2024-08-06T20:33:35.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload-time = "2024-08-06T20:33:37.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload-time = "2024-08-06T20:33:39.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload-time = "2024-08-06T20:33:46.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1223,13 +1141,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/52/7223011bb760fce8ddc53416beb65b83a3ea6d7d13738dde75eeb2c89679/watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8", size = 96390, upload-time = "2024-11-01T14:06:49.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/62/d2b21bc4e706d3a9d467561f487c2938cbd881c69f3808c43ac1ec242391/watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a", size = 88386, upload-time = "2024-11-01T14:06:50.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/22/1c90b20eda9f4132e4603a26296108728a8bfe9584b006bd05dd94548853/watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c", size = 89017, upload-time = "2024-11-01T14:06:51.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/79/69f2b0e8d3f2afd462029031baafb1b75d11bb62703f0e1022b2e54d49ee/watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa", size = 87903, upload-time = "2024-11-01T14:06:57.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/2b/dc048dd71c2e5f0f7ebc04dd7912981ec45793a03c0dc462438e0591ba5d/watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e", size = 88381, upload-time = "2024-11-01T14:06:58.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
|
||||
@@ -1308,31 +1221,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/6d/c61e0668943a034abc3a569cdc5aeae37d686d9da7e39cf2ed621d533e36/xxhash-3.5.0-cp313-cp313-win32.whl", hash = "sha256:53a068fe70301ec30d868ece566ac90d873e3bb059cf83c32e76012c889b8637", size = 30172, upload-time = "2024-08-17T09:19:04.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/14/8416dce965f35e3d24722cdf79361ae154fa23e2ab730e5323aa98d7919e/xxhash-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:80babcc30e7a1a484eab952d76a4f4673ff601f54d5142c26826502740e70b43", size = 30041, upload-time = "2024-08-17T09:19:05.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/ee/518b72faa2073f5aa8e3262408d284892cb79cf2754ba0c3a5870645ef73/xxhash-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:4811336f1ce11cac89dcbd18f3a25c527c16311709a89313c3acaf771def2d4b", size = 26801, upload-time = "2024-08-17T09:19:06.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/f6/531dd6858adf8877675270b9d6989b6dacfd1c2d7135b17584fc29866df3/xxhash-3.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bfc8cdd7f33d57f0468b0614ae634cc38ab9202c6957a60e31d285a71ebe0301", size = 31971, upload-time = "2024-08-17T09:19:47.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/a8/b2a42b6c9ae46e233f474f3d307c2e7bca8d9817650babeca048d2ad01d6/xxhash-3.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e0c48b6300cd0b0106bf49169c3e0536408dfbeb1ccb53180068a18b03c662ab", size = 30801, upload-time = "2024-08-17T09:19:48.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/92/9ac297e3487818f429bcf369c1c6a097edf5b56ed6fc1feff4c1882e87ef/xxhash-3.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe1a92cfbaa0a1253e339ccec42dbe6db262615e52df591b68726ab10338003f", size = 220644, upload-time = "2024-08-17T09:19:51.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/48/c1426dd3c86fc4a52f983301867463472f6a9013fb32d15991e60c9919b6/xxhash-3.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:33513d6cc3ed3b559134fb307aae9bdd94d7e7c02907b37896a6c45ff9ce51bd", size = 200021, upload-time = "2024-08-17T09:19:52.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/de/0ab8c79993765c94fc0d0c1a22b454483c58a0161e1b562f58b654f47660/xxhash-3.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eefc37f6138f522e771ac6db71a6d4838ec7933939676f3753eafd7d3f4c40bc", size = 428217, upload-time = "2024-08-17T09:19:54.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/b4/332647451ed7d2c021294b7c1e9c144dbb5586b1fb214ad4f5a404642835/xxhash-3.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a606c8070ada8aa2a88e181773fa1ef17ba65ce5dd168b9d08038e2a61b33754", size = 193868, upload-time = "2024-08-17T09:19:55.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/1c/a42c0a6cac752f84f7b44a90d1a9fa9047cf70bdba5198a304fde7cc471f/xxhash-3.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42eca420c8fa072cc1dd62597635d140e78e384a79bb4944f825fbef8bfeeef6", size = 207403, upload-time = "2024-08-17T09:19:57.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/d7/04e1b0daae9dc9b02c73c1664cc8aa527498c3f66ccbc586eeb25bbe9f14/xxhash-3.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:604253b2143e13218ff1ef0b59ce67f18b8bd1c4205d2ffda22b09b426386898", size = 215978, upload-time = "2024-08-17T09:19:59.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f4/05e15e67505228fc19ee98a79e427b3a0b9695f5567cd66ced5d66389883/xxhash-3.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:6e93a5ad22f434d7876665444a97e713a8f60b5b1a3521e8df11b98309bff833", size = 202416, upload-time = "2024-08-17T09:20:01.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/fb/e9028d3645bba5412a09de13ee36df276a567e60bdb31d499dafa46d76ae/xxhash-3.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:7a46e1d6d2817ba8024de44c4fd79913a90e5f7265434cef97026215b7d30df6", size = 209853, upload-time = "2024-08-17T09:20:03.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/2c/18c6a622429368274739372d2f86c8125413ec169025c7d8ffb051784bba/xxhash-3.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:30eb2efe6503c379b7ab99c81ba4a779748e3830241f032ab46bd182bf5873af", size = 413926, upload-time = "2024-08-17T09:20:04.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/bb/5b55c391084a0321c3809632a018b9b657e59d5966289664f85a645942ac/xxhash-3.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c8aa771ff2c13dd9cda8166d685d7333d389fae30a4d2bb39d63ab5775de8606", size = 191156, upload-time = "2024-08-17T09:20:06.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/2b/915049db13401792fec159f57e4f4a5ca7a9768e83ef71d6645b9d0cd749/xxhash-3.5.0-cp39-cp39-win32.whl", hash = "sha256:5ed9ebc46f24cf91034544b26b131241b699edbfc99ec5e7f8f3d02d6eb7fba4", size = 30122, upload-time = "2024-08-17T09:20:07.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/87/382ef7b24917d7cf4c540ee30f29b283bc87ac5893d2f89b23ea3cdf7d77/xxhash-3.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:220f3f896c6b8d0316f63f16c077d52c412619e475f9372333474ee15133a558", size = 30021, upload-time = "2024-08-17T09:20:08.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/47/d06b24e2d9c3dcabccfd734d11b5bbebfdf59ceac2c61509d8205dd20ac6/xxhash-3.5.0-cp39-cp39-win_arm64.whl", hash = "sha256:a7b1d8315d9b5e9f89eb2933b73afae6ec9597a258d52190944437158b49d38e", size = 26780, upload-time = "2024-08-17T09:20:09.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/9a/233606bada5bd6f50b2b72c45de3d9868ad551e83893d2ac86dc7bb8553a/xxhash-3.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2014c5b3ff15e64feecb6b713af12093f75b7926049e26a580e94dcad3c73d8c", size = 29732, upload-time = "2024-08-17T09:20:11.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/67/f75276ca39e2c6604e3bee6c84e9db8a56a4973fde9bf35989787cf6e8aa/xxhash-3.5.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fab81ef75003eda96239a23eda4e4543cedc22e34c373edcaf744e721a163986", size = 36214, upload-time = "2024-08-17T09:20:12.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/f8/f6c61fd794229cc3848d144f73754a0c107854372d7261419dcbbd286299/xxhash-3.5.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e2febf914ace002132aa09169cc572e0d8959d0f305f93d5828c4836f9bc5a6", size = 32020, upload-time = "2024-08-17T09:20:13.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/d3/c029c99801526f859e6b38d34ab87c08993bf3dcea34b11275775001638a/xxhash-3.5.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d3a10609c51da2a1c0ea0293fc3968ca0a18bd73838455b5bca3069d7f8e32b", size = 40515, upload-time = "2024-08-17T09:20:14.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/e3/bef7b82c1997579c94de9ac5ea7626d01ae5858aa22bf4fcb38bf220cb3e/xxhash-3.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5a74f23335b9689b66eb6dbe2a931a88fcd7a4c2cc4b1cb0edba8ce381c7a1da", size = 30064, upload-time = "2024-08-17T09:20:15.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/56/30d3df421814947f9d782b20c9b7e5e957f3791cbd89874578011daafcbd/xxhash-3.5.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:531af8845aaadcadf951b7e0c1345c6b9c68a990eeb74ff9acd8501a0ad6a1c9", size = 29734, upload-time = "2024-08-17T09:20:30.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/dd/3c42a1f022ad0d82c852d3cb65493ebac03dcfa8c994465a5fb052b00e3c/xxhash-3.5.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ce379bcaa9fcc00f19affa7773084dd09f5b59947b3fb47a1ceb0179f91aaa1", size = 36216, upload-time = "2024-08-17T09:20:32.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/40/8f902ab3bebda228a9b4de69eba988280285a7f7f167b942bc20bb562df9/xxhash-3.5.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd1b2281d01723f076df3c8188f43f2472248a6b63118b036e641243656b1b0f", size = 32042, upload-time = "2024-08-17T09:20:33.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/87/bd06beb8ccaa0e9e577c9b909a49cfa5c5cd2ca46034342d72dd9ce5bc56/xxhash-3.5.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c770750cc80e8694492244bca7251385188bc5597b6a39d98a9f30e8da984e0", size = 40516, upload-time = "2024-08-17T09:20:36.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f8/505385e2fbd753ddcaafd5550eabe86f6232cbebabad3b2508d411b19153/xxhash-3.5.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b150b8467852e1bd844387459aa6fbe11d7f38b56e901f9f3b3e6aba0d660240", size = 30108, upload-time = "2024-08-17T09:20:37.214Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1408,20 +1301,4 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/90/2633473864f67a15526324b007a9f96c96f56d5f32ef2a56cc12f9548723/zstandard-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ce8b52c5987b3e34d5674b0ab529a4602b632ebab0a93b07bfb4dfc8f8a33", size = 5191299, upload-time = "2024-07-15T00:16:49.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/4c/315ca5c32da7e2dc3455f3b2caee5c8c2246074a61aac6ec3378a97b7136/zstandard-0.23.0-cp313-cp313-win32.whl", hash = "sha256:a9b07268d0c3ca5c170a385a0ab9fb7fdd9f5fd866be004c4ea39e44edce47dd", size = 430862, upload-time = "2024-07-15T00:16:51.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/bf/c6aaba098e2d04781e8f4f7c0ba3c7aa73d00e4c436bcc0cf059a66691d1/zstandard-0.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:f3513916e8c645d0610815c257cbfd3242adfd5c4cfa78be514e5a3ebb42a41b", size = 495578, upload-time = "2024-07-15T00:16:53.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/96/4fcafeb7e013a2386d22f974b5b97a0b9a65004ed58c87ae001599bfbd48/zstandard-0.23.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa014d55c3af933c1315eb4bb06dd0459661cc0b15cd61077afa6489bec63bb", size = 788697, upload-time = "2024-07-15T00:17:31.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/ff/a52ce725be69b86a2967ecba0497a8184540cc284c0991125515449e54e2/zstandard-0.23.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7f0804bb3799414af278e9ad51be25edf67f78f916e08afdb983e74161b916", size = 633679, upload-time = "2024-07-15T00:17:32.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/0f/3dc62db122f6a9c481c335fff6fc9f4e88d8f6e2d47321ee3937328addb4/zstandard-0.23.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb2b1ecfef1e67897d336de3a0e3f52478182d6a47eda86cbd42504c5cbd009a", size = 4940416, upload-time = "2024-07-15T00:17:34.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/e5/9fe0dd8c85fdc2f635e6660d07872a5dc4b366db566630161e39f9f804e1/zstandard-0.23.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:837bb6764be6919963ef41235fd56a6486b132ea64afe5fafb4cb279ac44f259", size = 5307693, upload-time = "2024-07-15T00:17:37.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/bf/fe62c0cd865c171ee8ed5bc83174b5382a2cb729c8d6162edfb99a83158b/zstandard-0.23.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1516c8c37d3a053b01c1c15b182f3b5f5eef19ced9b930b684a73bad121addf4", size = 5341236, upload-time = "2024-07-15T00:17:40.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/86/4fe79b30c794286110802a6cd44a73b6a314ac8196b9338c0fbd78c2407d/zstandard-0.23.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48ef6a43b1846f6025dde6ed9fee0c24e1149c1c25f7fb0a0585572b2f3adc58", size = 5439101, upload-time = "2024-07-15T00:17:42.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/ed/cacec235c581ebf8c608c7fb3d4b6b70d1b490d0e5128ea6996f809ecaef/zstandard-0.23.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11e3bf3c924853a2d5835b24f03eeba7fc9b07d8ca499e247e06ff5676461a15", size = 4860320, upload-time = "2024-07-15T00:17:44.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/1e/2c589a2930f93946b132fc852c574a19d5edc23fad2b9e566f431050c7ec/zstandard-0.23.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2fb4535137de7e244c230e24f9d1ec194f61721c86ebea04e1581d9d06ea1269", size = 4931933, upload-time = "2024-07-15T00:17:46.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/f5/30eadde3686d902b5d4692bb5f286977cbc4adc082145eb3f49d834b2eae/zstandard-0.23.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8c24f21fa2af4bb9f2c492a86fe0c34e6d2c63812a839590edaf177b7398f700", size = 5463878, upload-time = "2024-07-15T00:17:48.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/c8/8aed1f0ab9854ef48e5ad4431367fcb23ce73f0304f7b72335a8edc66556/zstandard-0.23.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a8c86881813a78a6f4508ef9daf9d4995b8ac2d147dcb1a450448941398091c9", size = 4857192, upload-time = "2024-07-15T00:17:51.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/c6/55e666cfbcd032b9e271865e8578fec56e5594d4faeac379d371526514f5/zstandard-0.23.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fe3b385d996ee0822fd46528d9f0443b880d4d05528fd26a9119a54ec3f91c69", size = 4696513, upload-time = "2024-07-15T00:17:53.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/bd/720b65bea63ec9de0ac7414c33b9baf271c8de8996e5ff324dc93fc90ff1/zstandard-0.23.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:82d17e94d735c99621bf8ebf9995f870a6b3e6d14543b99e201ae046dfe7de70", size = 5204823, upload-time = "2024-07-15T00:17:55.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/40/d678db1556e3941d330cd4e95623a63ef235b18547da98fa184cbc028ecf/zstandard-0.23.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c7c517d74bea1a6afd39aa612fa025e6b8011982a0897768a2f7c8ab4ebb78a2", size = 5666490, upload-time = "2024-07-15T00:17:58.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/cc/c89329723d7515898a1fc7ef5d251264078548c505719d13e9511800a103/zstandard-0.23.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fd7e0f1cfb70eb2f95a19b472ee7ad6d9a0a992ec0ae53286870c104ca939e5", size = 5196622, upload-time = "2024-07-15T00:18:00.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/4c/634289d41e094327a94500dfc919e58841b10ea3a9efdfafbac614797ec2/zstandard-0.23.0-cp39-cp39-win32.whl", hash = "sha256:43da0f0092281bf501f9c5f6f3b4c975a8a0ea82de49ba3f7100e64d422a1274", size = 430620, upload-time = "2024-07-15T00:18:02.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/e2/0b0c5a0f4f7699fecd92c1ba6278ef9b01f2b0b0dd46f62bfc6729c05659/zstandard-0.23.0-cp39-cp39-win_amd64.whl", hash = "sha256:f8346bfa098532bc1fb6c7ef06783e969d87a99dd1d2a5a18a892c1d7a643c58", size = 495528, upload-time = "2024-07-15T00:18:04.452Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user