mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-20 06:35:46 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
defcf13431 |
@@ -87,27 +87,3 @@ jobs:
|
||||
working-directory: libs/cli/js-examples
|
||||
run: |
|
||||
langgraph build -t langgraph-test-e
|
||||
|
||||
- name: Build JS monorepo service
|
||||
if: steps.changed-files.outputs.all
|
||||
working-directory: libs/cli/js-monorepo-example
|
||||
run: |
|
||||
langgraph build -t langgraph-test-f -c apps/agent/langgraph.json --build-command "yarn run turbo build" --install-command "yarn install"
|
||||
|
||||
- name: Build Python monorepo service
|
||||
if: steps.changed-files.outputs.all
|
||||
working-directory: libs/cli/python-monorepo-example
|
||||
run: |
|
||||
langgraph build -t langgraph-test-g -c apps/agent/langgraph.json
|
||||
cp apps/agent/.env.example apps/agent/.env
|
||||
if [ -n "${{ secrets.LANGSMITH_API_KEY }}" ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> apps/agent/.env; fi
|
||||
timeout 60 python ../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-g -c apps/agent/langgraph.json
|
||||
|
||||
- name: Build and test prerelease reqs service
|
||||
if: steps.changed-files.outputs.all
|
||||
working-directory: libs/cli/examples/graph_prerelease_reqs
|
||||
run: |
|
||||
langgraph build -t langgraph-test-h
|
||||
cp ../.env.example .env
|
||||
if [ -n "${{ secrets.LANGSMITH_API_KEY }}" ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env; fi
|
||||
timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-h
|
||||
|
||||
@@ -29,10 +29,6 @@
|
||||
"name": "Store",
|
||||
"description": "Store is an API for managing persistent key-value store (long-term memory) that is available from any thread."
|
||||
},
|
||||
{
|
||||
"name": "A2A",
|
||||
"description": "Agent-to-Agent Protocol related endpoints for exposing assistants as A2A-compliant agents."
|
||||
},
|
||||
{
|
||||
"name": "MCP",
|
||||
"description": "Model Context Protocol related endpoints for exposing an agent as an MCP server."
|
||||
@@ -3186,195 +3182,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/a2a/{assistant_id}": {
|
||||
"post": {
|
||||
"operationId": "post_a2a",
|
||||
"summary": "A2A Post",
|
||||
"description": "Communicate with an assistant using the Agent-to-Agent Protocol.\nSends a JSON-RPC 2.0 message to the assistant.\n\n- **Request**: Provide an object with `jsonrpc`, `id`, `method`, and optional `params`.\n- **Response**: Returns a JSON-RPC response with task information or error.\n\n**Supported Methods:**\n- `message/send`: Send a message to the assistant\n- `tasks/get`: Get the status and result of a task\n\n**Notes:**\n- Supports threaded conversations via thread context\n- Messages can contain text and data parts\n- Tasks run asynchronously and return completion status\n",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "assistant_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"description": "The ID of the assistant to communicate with"
|
||||
},
|
||||
{
|
||||
"name": "Accept",
|
||||
"in": "header",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["application/json"]
|
||||
},
|
||||
"description": "Must be application/json"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"jsonrpc": {
|
||||
"type": "string",
|
||||
"enum": ["2.0"],
|
||||
"description": "JSON-RPC version"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Request identifier"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["message/send", "tasks/get"],
|
||||
"description": "The method to invoke"
|
||||
},
|
||||
"params": {
|
||||
"type": "object",
|
||||
"description": "Method parameters",
|
||||
"oneOf": [
|
||||
{
|
||||
"title": "Message Send Parameters",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["user", "assistant"],
|
||||
"description": "Message role"
|
||||
},
|
||||
"parts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"title": "Text Part",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["text"]
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["kind", "text"]
|
||||
},
|
||||
{
|
||||
"title": "Data Part",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["data"]
|
||||
},
|
||||
"data": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["kind", "data"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Message parts"
|
||||
},
|
||||
"messageId": {
|
||||
"type": "string",
|
||||
"description": "Unique message identifier"
|
||||
}
|
||||
},
|
||||
"required": ["role", "parts", "messageId"]
|
||||
},
|
||||
"thread": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string",
|
||||
"description": "Thread identifier for conversation context"
|
||||
}
|
||||
},
|
||||
"description": "Optional thread context"
|
||||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
},
|
||||
{
|
||||
"title": "Task Get Parameters",
|
||||
"properties": {
|
||||
"taskId": {
|
||||
"type": "string",
|
||||
"description": "Task identifier to retrieve"
|
||||
}
|
||||
},
|
||||
"required": ["taskId"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["jsonrpc", "id", "method"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "JSON-RPC response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"jsonrpc": {
|
||||
"type": "string",
|
||||
"enum": ["2.0"]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"result": {
|
||||
"type": "object",
|
||||
"description": "Success result containing task information or task details"
|
||||
},
|
||||
"error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Error information if request failed"
|
||||
}
|
||||
},
|
||||
"required": ["jsonrpc", "id"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request - invalid JSON-RPC or missing Accept header"
|
||||
},
|
||||
"404": {
|
||||
"description": "Assistant not found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"A2A"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/mcp/": {
|
||||
"post": {
|
||||
"operationId": "post_mcp",
|
||||
@@ -5015,12 +4822,6 @@
|
||||
},
|
||||
"ThreadSearchRequest": {
|
||||
"properties": {
|
||||
"ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "format": "uuid"},
|
||||
"title": "Ids",
|
||||
"description": "List of thread IDs to include. Others are excluded."
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"title": "Metadata",
|
||||
@@ -5261,30 +5062,11 @@
|
||||
"type": "object",
|
||||
"title": "Metadata",
|
||||
"description": "Metadata to merge with existing thread metadata."
|
||||
},
|
||||
"ttl": {
|
||||
"type": "object",
|
||||
"title": "TTL",
|
||||
"description": "The time-to-live for the thread.",
|
||||
"properties": {
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"delete"
|
||||
],
|
||||
"description": "The TTL strategy. 'delete' removes the entire thread.",
|
||||
"default": "delete"
|
||||
},
|
||||
"ttl": {
|
||||
"type": "number",
|
||||
"description": "The time-to-live in minutes from now until thread should be swept."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "ThreadPatch",
|
||||
"description": "Payload for updating a thread."
|
||||
"description": "Payload for creating a thread."
|
||||
},
|
||||
"ThreadStateCheckpointRequest": {
|
||||
"properties": {
|
||||
|
||||
@@ -483,19 +483,19 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
|
||||
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt langchain_community langchain_anthropic langchain_openai wikipedia scikit-learn
|
||||
|
||||
ADD ./graphs /deps/outer-graphs/src
|
||||
ADD ./graphs /deps/__outer_graphs/src
|
||||
RUN set -ex && \
|
||||
for line in '[project]' \
|
||||
'name = "graphs"' \
|
||||
'version = "0.1"' \
|
||||
'[tool.setuptools.package-data]' \
|
||||
'"*" = ["**/*"]'; do \
|
||||
echo "$line" >> /deps/outer-graphs/pyproject.toml; \
|
||||
echo "$line" >> /deps/__outer_graphs/pyproject.toml; \
|
||||
done
|
||||
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/outer-graphs/src/agent.py:graph", "storm": "/deps/outer-graphs/src/storm.py:graph"}'
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_graphs/src/agent.py:graph", "storm": "/deps/__outer_graphs/src/storm.py:graph"}'
|
||||
```
|
||||
|
||||
???+ note "Updating your langgraph.json file"
|
||||
|
||||
@@ -1040,7 +1040,7 @@ def node_a(state: State, runtime: Runtime[ContextSchema]):
|
||||
...
|
||||
```
|
||||
|
||||
See [this guide](../how-tos/graph-api.md#add-runtime-configuration) for a full breakdown on configuration.
|
||||
See [this guide](../how-tos/graph-api.ipynb#add-runtime-configuration) for a full breakdown on configuration.
|
||||
:::
|
||||
|
||||
:::js
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Annotated, Literal, TypedDict
|
||||
|
||||
from langchain.chat_models import init_chat_model
|
||||
from langchain_community.tools.tavily_search import TavilySearchResults
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.graph import END, StateGraph, add_messages
|
||||
from langgraph.prebuilt import ToolNode
|
||||
|
||||
tools = [TavilySearchResults(max_results=1)]
|
||||
|
||||
model_anth = init_chat_model("claude-3-7-sonnet-20250219", model_provider="anthropic")
|
||||
model_oai = ChatOpenAI(temperature=0)
|
||||
|
||||
model_anth = model_anth.bind_tools(tools)
|
||||
model_oai = model_oai.bind_tools(tools)
|
||||
|
||||
|
||||
class AgentState(TypedDict):
|
||||
messages: Annotated[Sequence[BaseMessage], add_messages]
|
||||
|
||||
|
||||
# Define the function that determines whether to continue or not
|
||||
def should_continue(state):
|
||||
messages = state["messages"]
|
||||
last_message = messages[-1]
|
||||
# If there are no tool calls, then we finish
|
||||
if not last_message.tool_calls:
|
||||
return "end"
|
||||
# Otherwise if there is, we continue
|
||||
else:
|
||||
return "continue"
|
||||
|
||||
|
||||
# Define the function that calls the model
|
||||
def call_model(state, config):
|
||||
if config["configurable"].get("model", "anthropic") == "anthropic":
|
||||
model = model_anth
|
||||
else:
|
||||
model = model_oai
|
||||
messages = state["messages"]
|
||||
response = model.invoke(messages)
|
||||
# We return a list, because this will get added to the existing list
|
||||
return {"messages": [response]}
|
||||
|
||||
|
||||
# Define the function to execute tools
|
||||
tool_node = ToolNode(tools)
|
||||
|
||||
|
||||
class ContextSchema(TypedDict):
|
||||
model: Literal["anthropic", "openai"]
|
||||
|
||||
|
||||
# Define a new graph
|
||||
workflow = StateGraph(AgentState, context_schema=ContextSchema)
|
||||
|
||||
# Define the two nodes we will cycle between
|
||||
workflow.add_node("agent", call_model)
|
||||
workflow.add_node("action", tool_node)
|
||||
|
||||
# Set the entrypoint as `agent`
|
||||
# This means that this node is the first one called
|
||||
workflow.set_entry_point("agent")
|
||||
|
||||
# We now add a conditional edge
|
||||
workflow.add_conditional_edges(
|
||||
# First, we define the start node. We use `agent`.
|
||||
# This means these are the edges taken after the `agent` node is called.
|
||||
"agent",
|
||||
# Next, we pass in the function that will determine which node is called next.
|
||||
should_continue,
|
||||
# Finally we pass in a mapping.
|
||||
# The keys are strings, and the values are other nodes.
|
||||
# END is a special node marking that the graph should finish.
|
||||
# What will happen is we will call `should_continue`, and then the output of that
|
||||
# will be matched against the keys in this mapping.
|
||||
# Based on which one it matches, that node will then be called.
|
||||
{
|
||||
# If `tools`, then we call the tool node.
|
||||
"continue": "action",
|
||||
# Otherwise we finish.
|
||||
"end": END,
|
||||
},
|
||||
)
|
||||
|
||||
# We now add a normal edge from `tools` to `agent`.
|
||||
# This means that after `tools` is called, `agent` node is called next.
|
||||
workflow.add_edge("action", "agent")
|
||||
|
||||
# Finally, we compile it!
|
||||
# This compiles it into a LangChain Runnable,
|
||||
# meaning you can use it as you would any other runnable
|
||||
graph = workflow.compile()
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": [
|
||||
"."
|
||||
],
|
||||
"graphs": {
|
||||
"agent": "./agent.py:graph"
|
||||
},
|
||||
"env": "../.env"
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
requests
|
||||
langchain_anthropic
|
||||
langchain_openai
|
||||
langchain_community
|
||||
langchain
|
||||
langgraph==1.0.0a2
|
||||
@@ -1,62 +0,0 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"prettier",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 12,
|
||||
parser: "@typescript-eslint/parser",
|
||||
project: "./tsconfig.json",
|
||||
sourceType: "module",
|
||||
},
|
||||
plugins: ["import", "@typescript-eslint", "no-instanceof"],
|
||||
ignorePatterns: [
|
||||
".eslintrc.cjs",
|
||||
"scripts",
|
||||
"src/utils/lodash/*",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"dist-cjs",
|
||||
"*.js",
|
||||
"*.cjs",
|
||||
"*.d.ts",
|
||||
],
|
||||
rules: {
|
||||
"no-process-env": 2,
|
||||
"no-instanceof/no-instanceof": 2,
|
||||
"@typescript-eslint/explicit-module-boundary-types": 0,
|
||||
"@typescript-eslint/no-empty-function": 0,
|
||||
"@typescript-eslint/no-shadow": 0,
|
||||
"@typescript-eslint/no-empty-interface": 0,
|
||||
"@typescript-eslint/no-use-before-define": ["error", "nofunc"],
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { args: "none" }],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/no-misused-promises": "error",
|
||||
camelcase: 0,
|
||||
"class-methods-use-this": 0,
|
||||
"import/extensions": [2, "ignorePackages"],
|
||||
"import/no-extraneous-dependencies": [
|
||||
"error",
|
||||
{ devDependencies: ["**/*.test.ts"] },
|
||||
],
|
||||
"import/no-unresolved": 0,
|
||||
"import/prefer-default-export": 0,
|
||||
"keyword-spacing": "error",
|
||||
"max-classes-per-file": 0,
|
||||
"max-len": 0,
|
||||
"no-await-in-loop": 0,
|
||||
"no-bitwise": 0,
|
||||
"no-console": 0,
|
||||
"no-restricted-syntax": 0,
|
||||
"no-shadow": 0,
|
||||
"no-continue": 0,
|
||||
"no-underscore-dangle": 0,
|
||||
"no-use-before-define": 0,
|
||||
"no-useless-constructor": 0,
|
||||
"no-return-await": 0,
|
||||
"consistent-return": 0,
|
||||
"no-else-return": 0,
|
||||
"new-cap": ["error", { properties: false, capIsNew: false }],
|
||||
},
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"node_version": "20",
|
||||
"graphs": {
|
||||
"agent": "./src/graph.ts:graph"
|
||||
},
|
||||
"env": "../../.env"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "@js-monorepo-example/agent",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "src/graph.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@js-monorepo-example/shared": "*",
|
||||
"@langchain/core": "^0.3.2",
|
||||
"@langchain/langgraph": "^0.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Simple LangGraph.js example for monorepo testing
|
||||
*/
|
||||
import { StateGraph } from "@langchain/langgraph";
|
||||
import { RunnableConfig } from "@langchain/core/runnables";
|
||||
import { StateAnnotation } from "./state.js";
|
||||
import { getGreeting } from "@js-monorepo-example/shared";
|
||||
|
||||
/**
|
||||
* Simple node that uses the shared library
|
||||
*/
|
||||
const callModel = async (
|
||||
state: typeof StateAnnotation.State,
|
||||
_config: RunnableConfig,
|
||||
): Promise<typeof StateAnnotation.Update> => {
|
||||
// Use functions from the shared library
|
||||
const greeting = getGreeting();
|
||||
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: `${greeting}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple routing function
|
||||
*/
|
||||
export const route = (
|
||||
state: typeof StateAnnotation.State,
|
||||
): "__end__" | "callModel" => {
|
||||
if (state.messages.length > 0) {
|
||||
return "__end__";
|
||||
}
|
||||
return "callModel";
|
||||
};
|
||||
|
||||
// Create the graph
|
||||
const builder = new StateGraph(StateAnnotation)
|
||||
.addNode("callModel", callModel)
|
||||
.addEdge("__start__", "callModel")
|
||||
.addConditionalEdges("callModel", route);
|
||||
|
||||
export const graph = builder.compile();
|
||||
@@ -1,15 +0,0 @@
|
||||
import { BaseMessage, BaseMessageLike } from "@langchain/core/messages";
|
||||
import { Annotation, messagesStateReducer } from "@langchain/langgraph";
|
||||
|
||||
/**
|
||||
* Simple state annotation for the agent
|
||||
*/
|
||||
export const StateAnnotation = Annotation.Root({
|
||||
/**
|
||||
* Messages track the primary execution state of the agent.
|
||||
*/
|
||||
messages: Annotation<BaseMessage[], BaseMessageLike[]>({
|
||||
reducer: messagesStateReducer,
|
||||
default: () => [],
|
||||
}),
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "@js-monorepo-example/shared",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Simple utility functions for monorepo testing
|
||||
*/
|
||||
export function getGreeting(): string {
|
||||
return "Hello from shared library!";
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"name": "js-monorepo-example",
|
||||
"version": "0.0.1",
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"description": "A simple monorepo example for LangGraph integration testing.",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"libs/*",
|
||||
"apps/*"
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
"clean": "turbo clean",
|
||||
"test": "turbo test",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint 'apps/**/*.ts' 'libs/**/*.ts'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.5.0",
|
||||
"typescript": "^5.3.3",
|
||||
"@tsconfig/recommended": "^1.0.7",
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "^9.9.1",
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-no-instanceof": "^1.0.1",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.8",
|
||||
"@typescript-eslint/parser": "^5.59.8",
|
||||
"prettier": "^3.3.3"
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"extends": "@tsconfig/recommended",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["apps/**/*", "libs/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"clean": {
|
||||
"dependsOn": ["^clean"]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^test"]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
||||
__version__ = "0.4.2"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
@@ -303,8 +303,6 @@ def _build(
|
||||
pull: bool,
|
||||
tag: str,
|
||||
passthrough: Sequence[str] = (),
|
||||
install_command: Optional[str] = None,
|
||||
build_command: Optional[str] = None,
|
||||
):
|
||||
# pull latest images
|
||||
if pull:
|
||||
@@ -324,38 +322,22 @@ def _build(
|
||||
"-t",
|
||||
tag,
|
||||
]
|
||||
# determine build context: use current directory for JS projects, config parent for Python
|
||||
is_js_project = config_json.get("node_version") and not config_json.get(
|
||||
"python_version"
|
||||
)
|
||||
# build/install commands only apply to JS projects for now
|
||||
# without install/build command, JS projects will follow the old behavior
|
||||
if is_js_project and (build_command or install_command):
|
||||
build_context = str(pathlib.Path.cwd())
|
||||
else:
|
||||
build_context = str(config.parent)
|
||||
|
||||
# apply config
|
||||
stdin, additional_contexts = langgraph_cli.config.config_to_docker(
|
||||
config,
|
||||
config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
install_command,
|
||||
build_command,
|
||||
build_context,
|
||||
config, config_json, base_image, api_version
|
||||
)
|
||||
# add additional_contexts
|
||||
if additional_contexts:
|
||||
for k, v in additional_contexts.items():
|
||||
args.extend(["--build-context", f"{k}={v}"])
|
||||
# run docker build
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"build",
|
||||
*args,
|
||||
*passthrough,
|
||||
build_context,
|
||||
str(config.parent),
|
||||
input=stdin,
|
||||
verbose=True,
|
||||
)
|
||||
@@ -384,14 +366,6 @@ def _build(
|
||||
"\n --base-image langchain/langgraph-server:0.2 # Pin to a minor version (Python)",
|
||||
)
|
||||
@OPT_API_VERSION
|
||||
@click.option(
|
||||
"--install-command",
|
||||
help="Custom install command to run from the build context root. If not provided, auto-detects based on package manager files.",
|
||||
)
|
||||
@click.option(
|
||||
"--build-command",
|
||||
help="Custom build command to run from the langgraph.json directory. If not provided, uses default build process.",
|
||||
)
|
||||
@click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED)
|
||||
@cli.command(
|
||||
help="📦 Build LangGraph API server Docker image.",
|
||||
@@ -407,8 +381,6 @@ def build(
|
||||
api_version: Optional[str],
|
||||
pull: bool,
|
||||
tag: str,
|
||||
install_command: Optional[str],
|
||||
build_command: Optional[str],
|
||||
):
|
||||
with Runner() as runner, Progress(message="Pulling...") as set:
|
||||
if shutil.which("docker") is None:
|
||||
@@ -425,8 +397,6 @@ def build(
|
||||
pull,
|
||||
tag,
|
||||
docker_build_args,
|
||||
install_command,
|
||||
build_command,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -913,10 +913,10 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps
|
||||
"Rename the directory to use it as flat-layout package."
|
||||
)
|
||||
check_reserved(resolved.name, local_dep)
|
||||
container_path = f"/deps/outer-{resolved.name}/{resolved.name}"
|
||||
container_path = f"/deps/__outer_{resolved.name}/{resolved.name}"
|
||||
else:
|
||||
# src layout
|
||||
container_path = f"/deps/outer-{resolved.name}/src"
|
||||
container_path = f"/deps/__outer_{resolved.name}/src"
|
||||
for file in files:
|
||||
rfile = resolved / file
|
||||
if (
|
||||
@@ -1256,7 +1256,7 @@ def python_config_to_docker(
|
||||
else:
|
||||
pip_installer = "pip"
|
||||
if pip_installer == "uv":
|
||||
install_cmd = "uv pip install --system --prerelease=allow"
|
||||
install_cmd = "uv pip install --system"
|
||||
elif pip_installer == "pip":
|
||||
install_cmd = "pip install"
|
||||
else:
|
||||
@@ -1286,7 +1286,7 @@ def python_config_to_docker(
|
||||
if local_deps.pip_reqs:
|
||||
pip_reqs_str = os.linesep.join(
|
||||
(
|
||||
f"COPY --from=outer-{reqpath.name} requirements.txt {destpath}"
|
||||
f"COPY --from=__outer_{reqpath.name} requirements.txt {destpath}"
|
||||
if reqpath.parent in local_deps.additional_contexts
|
||||
else f"ADD {reqpath.relative_to(config_path.parent)} {destpath}"
|
||||
)
|
||||
@@ -1305,7 +1305,7 @@ def python_config_to_docker(
|
||||
faux_pkgs_str = f"{os.linesep}{os.linesep}".join(
|
||||
(
|
||||
f"""# -- Adding non-package dependency {fullpath.name} --
|
||||
COPY --from=outer-{fullpath.name} . {destpath}"""
|
||||
COPY --from=__outer_{fullpath.name} . {destpath}"""
|
||||
if fullpath in local_deps.additional_contexts
|
||||
else f"""# -- Adding non-package dependency {fullpath.name} --
|
||||
ADD {relpath} {destpath}"""
|
||||
@@ -1320,7 +1320,7 @@ RUN set -ex && \\
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-{fullpath.name}/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_{fullpath.name}/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency {fullpath.name} --"""
|
||||
for fullpath, (relpath, destpath) in local_deps.faux_pkgs.items()
|
||||
@@ -1423,7 +1423,7 @@ ADD {relpath} /deps/{name}
|
||||
if p in local_deps.real_pkgs:
|
||||
name = local_deps.real_pkgs[p][1]
|
||||
elif p in local_deps.faux_pkgs:
|
||||
name = f"outer-{p.name}"
|
||||
name = f"__outer_{p.name}"
|
||||
else:
|
||||
raise RuntimeError(f"Unknown additional context: {p}")
|
||||
additional_contexts[name] = str(p)
|
||||
@@ -1436,28 +1436,9 @@ def node_config_to_docker(
|
||||
config: Config,
|
||||
base_image: str,
|
||||
api_version: Optional[str] = None,
|
||||
install_command: Optional[str] = None,
|
||||
build_command: Optional[str] = None,
|
||||
build_context: Optional[str] = None,
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
# Calculate paths for monorepo support
|
||||
if build_context:
|
||||
relative_workdir = _calculate_relative_workdir(config_path, build_context)
|
||||
container_name = pathlib.Path(build_context).name
|
||||
if relative_workdir:
|
||||
faux_path = f"/deps/{container_name}/{relative_workdir}"
|
||||
else:
|
||||
faux_path = f"/deps/{container_name}"
|
||||
else:
|
||||
# Backward compatibility: use the original behavior
|
||||
faux_path = f"/deps/{config_path.parent.name}"
|
||||
|
||||
# Use custom install command or auto-detect
|
||||
if install_command:
|
||||
install_cmd = install_command
|
||||
else:
|
||||
install_cmd = _get_node_pm_install_cmd(config_path, config)
|
||||
|
||||
faux_path = f"/deps/{config_path.parent.name}"
|
||||
install_cmd = _get_node_pm_install_cmd(config_path, config)
|
||||
image_str = docker_tag(config, base_image, api_version)
|
||||
|
||||
env_vars: list[str] = []
|
||||
@@ -1484,35 +1465,20 @@ def node_config_to_docker(
|
||||
|
||||
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
|
||||
|
||||
# For monorepo support, we need to handle install and build commands differently
|
||||
if build_context:
|
||||
# Monorepo case: install from root, build from config directory
|
||||
container_root = f"/deps/{pathlib.Path(build_context).name}"
|
||||
install_step = f"RUN cd {container_root} && {install_cmd}"
|
||||
|
||||
if build_command:
|
||||
build_step = f"RUN cd {faux_path} && {build_command}"
|
||||
else:
|
||||
build_step = 'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts'
|
||||
else:
|
||||
# Original behavior: everything happens in the same directory
|
||||
install_step = f"RUN cd {faux_path} && {install_cmd}"
|
||||
build_step = 'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts'
|
||||
|
||||
docker_file_contents = [
|
||||
f"FROM {image_str}",
|
||||
"",
|
||||
os.linesep.join(config["dockerfile_lines"]),
|
||||
"",
|
||||
f"ADD . {faux_path if not build_context else container_root}",
|
||||
f"ADD . {faux_path}",
|
||||
"",
|
||||
install_step,
|
||||
f"RUN cd {faux_path} && {install_cmd}",
|
||||
"",
|
||||
os.linesep.join(env_vars),
|
||||
"",
|
||||
f"WORKDIR {faux_path}",
|
||||
"",
|
||||
build_step,
|
||||
'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts',
|
||||
]
|
||||
|
||||
return os.linesep.join(docker_file_contents), {}
|
||||
@@ -1560,42 +1526,16 @@ def docker_tag(
|
||||
return f"{base_image}:{full_tag}"
|
||||
|
||||
|
||||
def _calculate_relative_workdir(config_path: pathlib.Path, build_context: str) -> str:
|
||||
"""Calculate the relative path from build context to langgraph.json directory."""
|
||||
config_dir = config_path.parent.resolve()
|
||||
build_context_path = pathlib.Path(build_context).resolve()
|
||||
|
||||
try:
|
||||
relative_path = config_dir.relative_to(build_context_path)
|
||||
return str(relative_path) if str(relative_path) != "." else ""
|
||||
except ValueError as _:
|
||||
raise ValueError(
|
||||
f"Configuration file {config_path} is not under the build context {build_context}. "
|
||||
f"Please run the command from a directory that contains your langgraph.json file, "
|
||||
) from None
|
||||
|
||||
|
||||
def config_to_docker(
|
||||
config_path: pathlib.Path,
|
||||
config: Config,
|
||||
base_image: Optional[str] = None,
|
||||
api_version: Optional[str] = None,
|
||||
install_command: Optional[str] = None,
|
||||
build_command: Optional[str] = None,
|
||||
build_context: Optional[str] = None,
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
base_image = base_image or default_base_image(config)
|
||||
|
||||
if config.get("node_version") and not config.get("python_version"):
|
||||
return node_config_to_docker(
|
||||
config_path,
|
||||
config,
|
||||
base_image,
|
||||
api_version,
|
||||
install_command,
|
||||
build_command,
|
||||
build_context,
|
||||
)
|
||||
return node_config_to_docker(config_path, config, base_image, api_version)
|
||||
|
||||
return python_config_to_docker(config_path, config, base_image, api_version)
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"dependencies": [".", "../../libs/shared", "../../libs/common"],
|
||||
"graphs": {
|
||||
"agent": "./src/agent/graph.py:graph"
|
||||
},
|
||||
"env": ".env"
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
[project]
|
||||
name = "agent"
|
||||
version = "0.0.1"
|
||||
description = "Agent for the Python monorepo"
|
||||
authors = [
|
||||
{ name = "Developer", email = "dev@example.com" },
|
||||
]
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=73.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["agent"]
|
||||
|
||||
[tool.setuptools.package-dir]
|
||||
"agent" = "src/agent"
|
||||
@@ -1 +0,0 @@
|
||||
"""Agent package."""
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Simple LangGraph agent for monorepo testing."""
|
||||
|
||||
from common import get_common_prefix
|
||||
from langchain_core.messages import AIMessage
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from shared import get_dummy_message
|
||||
|
||||
from agent.state import State
|
||||
|
||||
|
||||
def call_model(state: State) -> dict:
|
||||
"""Simple node that uses the shared libraries."""
|
||||
# Use functions from both shared packages
|
||||
dummy_message = get_dummy_message()
|
||||
prefix = get_common_prefix()
|
||||
|
||||
message = AIMessage(content=f"{prefix} Agent says: {dummy_message}")
|
||||
|
||||
return {"messages": [message]}
|
||||
|
||||
|
||||
def should_continue(state: State):
|
||||
"""Conditional edge - end after first message."""
|
||||
messages = state["messages"]
|
||||
if len(messages) > 0:
|
||||
return END
|
||||
return "call_model"
|
||||
|
||||
|
||||
# Build the graph
|
||||
workflow = StateGraph(State)
|
||||
|
||||
# Add the node
|
||||
workflow.add_node("call_model", call_model)
|
||||
|
||||
# Add edges
|
||||
workflow.add_edge(START, "call_model")
|
||||
workflow.add_conditional_edges("call_model", should_continue)
|
||||
|
||||
graph = workflow.compile()
|
||||
@@ -1,13 +0,0 @@
|
||||
"""State definition for the agent."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Annotated, TypedDict
|
||||
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langgraph.graph.message import add_messages
|
||||
|
||||
|
||||
class State(TypedDict):
|
||||
"""The state of the agent."""
|
||||
|
||||
messages: Annotated[Sequence[BaseMessage], add_messages]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Common helper functions package."""
|
||||
|
||||
from .helpers import get_common_prefix
|
||||
|
||||
__all__ = ["get_common_prefix"]
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Common helper functions."""
|
||||
|
||||
|
||||
def get_common_prefix() -> str:
|
||||
"""Get a common prefix for messages."""
|
||||
return "[COMMON]"
|
||||
@@ -1,20 +0,0 @@
|
||||
[project]
|
||||
name = "shared"
|
||||
version = "0.0.1"
|
||||
description = "Shared utilities for the Python monorepo"
|
||||
authors = [
|
||||
{ name = "Developer", email = "dev@example.com" },
|
||||
]
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.11,<4.0"
|
||||
dependencies = []
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=73.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["shared"]
|
||||
|
||||
[tool.setuptools.package-dir]
|
||||
"shared" = "src/shared"
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Shared utilities package."""
|
||||
|
||||
from .utils import get_dummy_message
|
||||
|
||||
__all__ = ["get_dummy_message"]
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Shared utility functions."""
|
||||
|
||||
|
||||
def get_dummy_message() -> str:
|
||||
"""Get a dummy message for testing."""
|
||||
return "Hello from shared library!"
|
||||
@@ -1,46 +0,0 @@
|
||||
[project]
|
||||
name = "python-monorepo-example"
|
||||
version = "0.0.1"
|
||||
description = "A Python monorepo example with LangGraph agents and shared packages"
|
||||
authors = [
|
||||
{ name = "Developer", email = "dev@example.com" },
|
||||
]
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.11,<4.0"
|
||||
dependencies = [
|
||||
"langgraph>=0.6.0,<0.7.0",
|
||||
"langchain-core>=0.2.14",
|
||||
]
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = ["apps/*", "libs/shared"]
|
||||
|
||||
[tool.uv.sources]
|
||||
shared = { workspace = true }
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["mypy>=1.11.1", "ruff>=0.6.1"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=73.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.ruff]
|
||||
lint.select = [
|
||||
"E", # pycodestyle
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"D", # pydocstyle
|
||||
"UP",
|
||||
]
|
||||
lint.ignore = [
|
||||
"D100", # Missing docstring in public module
|
||||
"D101", # Missing docstring in public class
|
||||
"D102", # Missing docstring in public method
|
||||
"D103", # Missing docstring in public function
|
||||
"D104", # Missing docstring in public package
|
||||
"D105", # Missing docstring in magic method
|
||||
]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
@@ -15,7 +15,7 @@ from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Versi
|
||||
from langgraph_cli.util import clean_empty_lines
|
||||
|
||||
FORMATTED_CLEANUP_LINES = _get_pip_cleanup_lines(
|
||||
install_cmd="uv pip install --system --prerelease=allow",
|
||||
install_cmd="uv pip install --system",
|
||||
to_uninstall=("pip", "setuptools", "wheel"),
|
||||
pip_installer="uv",
|
||||
)
|
||||
@@ -149,7 +149,7 @@ services:
|
||||
COPY --from=cli_1 . /deps/cli_1
|
||||
# -- End of local package ../../.. --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "agent.py:graph"}}'
|
||||
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), " ")}
|
||||
@@ -570,7 +570,7 @@ def test_build_generate_proper_build_context():
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
build_context_pattern = re.compile(r"--build-context\s+([\w-]+)=([^\s]+)")
|
||||
build_context_pattern = re.compile(r"--build-context\s+(\w+)=([^\s]+)")
|
||||
|
||||
build_contexts = re.findall(build_context_pattern, result.output)
|
||||
assert len(build_contexts) == 2, (
|
||||
|
||||
@@ -20,7 +20,7 @@ from langgraph_cli.config import (
|
||||
from langgraph_cli.util import clean_empty_lines
|
||||
|
||||
FORMATTED_CLEANUP_LINES = _get_pip_cleanup_lines(
|
||||
install_cmd="uv pip install --system --prerelease=allow",
|
||||
install_cmd="uv pip install --system",
|
||||
to_uninstall=("pip", "setuptools", "wheel"),
|
||||
pip_installer="uv",
|
||||
)
|
||||
@@ -421,14 +421,14 @@ def test_config_to_docker_simple():
|
||||
expected_docker_stdin = f"""\
|
||||
FROM langchain/langgraph-api:3.11
|
||||
# -- Installing local requirements --
|
||||
COPY --from=outer-requirements.txt requirements.txt /deps/outer-graphs_reqs_a/graphs_reqs_a/requirements.txt
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -r /deps/outer-graphs_reqs_a/graphs_reqs_a/requirements.txt
|
||||
COPY --from=__outer_requirements.txt requirements.txt /deps/__outer_graphs_reqs_a/graphs_reqs_a/requirements.txt
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -r /deps/__outer_graphs_reqs_a/graphs_reqs_a/requirements.txt
|
||||
# -- End of local requirements install --
|
||||
# -- Adding local package ../../examples --
|
||||
COPY --from=examples . /deps/examples
|
||||
# -- End of local package ../../examples --
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/outer-unit_tests/unit_tests
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
@@ -438,11 +438,11 @@ RUN set -ex && \\
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Adding non-package dependency graphs_reqs_a --
|
||||
COPY --from=outer-graphs_reqs_a . /deps/outer-graphs_reqs_a/graphs_reqs_a
|
||||
COPY --from=__outer_graphs_reqs_a . /deps/__outer_graphs_reqs_a/graphs_reqs_a
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "graphs_reqs_a"' \\
|
||||
@@ -452,21 +452,21 @@ RUN set -ex && \\
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-graphs_reqs_a/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_graphs_reqs_a/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency graphs_reqs_a --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGGRAPH_HTTP='{{"app": "/deps/examples/my_app.py:app"}}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
|
||||
{FORMATTED_CLEANUP_LINES}
|
||||
WORKDIR /deps/outer-unit_tests/unit_tests\
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests\
|
||||
"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
|
||||
assert additional_contexts == {
|
||||
"outer-graphs_reqs_a": str(
|
||||
"__outer_graphs_reqs_a": str(
|
||||
(pathlib.Path(__file__).parent / "../../examples/graphs_reqs_a").resolve()
|
||||
),
|
||||
"examples": str((pathlib.Path(__file__).parent / "../../examples").resolve()),
|
||||
@@ -484,7 +484,7 @@ def test_config_to_docker_outside_path():
|
||||
"""\
|
||||
FROM langchain/langgraph-api:3.11
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/outer-unit_tests/unit_tests
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
@@ -494,11 +494,11 @@ RUN set -ex && \\
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Adding non-package dependency tests --
|
||||
COPY --from=outer-tests . /deps/outer-tests/tests
|
||||
COPY --from=__outer_tests . /deps/__outer_tests/tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "tests"' \\
|
||||
@@ -508,22 +508,22 @@ RUN set -ex && \\
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-tests/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}'
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
"""
|
||||
+ FORMATTED_CLEANUP_LINES
|
||||
+ """
|
||||
WORKDIR /deps/outer-unit_tests/unit_tests\
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests\
|
||||
"""
|
||||
)
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {
|
||||
"outer-tests": str(pathlib.Path(__file__).parent.parent.absolute()),
|
||||
"__outer_tests": str(pathlib.Path(__file__).parent.parent.absolute()),
|
||||
}
|
||||
|
||||
|
||||
@@ -545,7 +545,7 @@ def test_config_to_docker_pipconfig():
|
||||
FROM langchain/langgraph-api:3.11
|
||||
ADD pipconfig.txt /pipconfig.txt
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/outer-unit_tests/unit_tests
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
@@ -555,17 +555,17 @@ RUN set -ex && \\
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}'
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
"""
|
||||
+ FORMATTED_CLEANUP_LINES
|
||||
+ """
|
||||
WORKDIR /deps/outer-unit_tests/unit_tests\
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests\
|
||||
"""
|
||||
)
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
@@ -607,7 +607,7 @@ def test_config_to_docker_local_deps():
|
||||
expected_docker_stdin = f"""\
|
||||
FROM langchain/langgraph-api-custom:3.11
|
||||
# -- Adding non-package dependency graphs --
|
||||
ADD ./graphs /deps/outer-graphs/src
|
||||
ADD ./graphs /deps/__outer_graphs/src
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "graphs"' \\
|
||||
@@ -617,13 +617,13 @@ RUN set -ex && \\
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-graphs/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_graphs/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency graphs --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-graphs/src/agent.py:graph"}}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_graphs/src/agent.py:graph"}}'
|
||||
{FORMATTED_CLEANUP_LINES}\
|
||||
"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
@@ -657,7 +657,7 @@ dependencies = ["langchain"]"""
|
||||
ADD . /deps/unit_tests
|
||||
# -- End of local package . --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/unit_tests/graphs/agent.py:graph"}'
|
||||
"""
|
||||
@@ -689,9 +689,9 @@ def test_config_to_docker_end_to_end():
|
||||
ARG meow
|
||||
ARG foo
|
||||
ADD pipconfig.txt /pipconfig.txt
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt langchain langchain_openai
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt langchain langchain_openai
|
||||
# -- Adding non-package dependency graphs --
|
||||
ADD ./graphs/ /deps/outer-graphs/src
|
||||
ADD ./graphs/ /deps/__outer_graphs/src
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "graphs"' \\
|
||||
@@ -701,13 +701,13 @@ RUN set -ex && \\
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-graphs/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_graphs/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency graphs --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-graphs/src/agent.py:graph"}}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_graphs/src/agent.py:graph"}}'
|
||||
{FORMATTED_CLEANUP_LINES}"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
@@ -797,7 +797,7 @@ def test_config_to_docker_gen_ui_python():
|
||||
expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11
|
||||
RUN /storage/install-node.sh
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/outer-unit_tests/unit_tests
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
@@ -807,21 +807,21 @@ RUN set -ex && \\
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGGRAPH_UI='{{"agent": "./graphs/agent.ui.jsx"}}'
|
||||
ENV LANGGRAPH_UI_CONFIG='{{"shared": ["nuqs"]}}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
|
||||
# -- Installing JS dependencies --
|
||||
ENV NODE_VERSION=20
|
||||
RUN cd /deps/outer-unit_tests/unit_tests && npm i && tsx /api/langgraph_api/js/build.mts
|
||||
RUN cd /deps/__outer_unit_tests/unit_tests && npm i && tsx /api/langgraph_api/js/build.mts
|
||||
# -- End of JS dependencies install --
|
||||
{FORMATTED_CLEANUP_LINES}
|
||||
WORKDIR /deps/outer-unit_tests/unit_tests"""
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests"""
|
||||
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
@@ -843,7 +843,7 @@ def test_config_to_docker_multiplatform():
|
||||
expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11
|
||||
RUN /storage/install-node.sh
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/outer-unit_tests/unit_tests
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
@@ -853,19 +853,19 @@ RUN set -ex && \\
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{{"python": "/deps/outer-unit_tests/unit_tests/multiplatform/python.py:graph", "js": "/deps/outer-unit_tests/unit_tests/multiplatform/js.mts:graph"}}'
|
||||
ENV LANGSERVE_GRAPHS='{{"python": "/deps/__outer_unit_tests/unit_tests/multiplatform/python.py:graph", "js": "/deps/__outer_unit_tests/unit_tests/multiplatform/js.mts:graph"}}'
|
||||
# -- Installing JS dependencies --
|
||||
ENV NODE_VERSION=22
|
||||
RUN cd /deps/outer-unit_tests/unit_tests && npm i && tsx /api/langgraph_api/js/build.mts
|
||||
RUN cd /deps/__outer_unit_tests/unit_tests && npm i && tsx /api/langgraph_api/js/build.mts
|
||||
# -- End of JS dependencies install --
|
||||
{FORMATTED_CLEANUP_LINES}
|
||||
WORKDIR /deps/outer-unit_tests/unit_tests"""
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests"""
|
||||
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
@@ -887,7 +887,7 @@ def test_config_to_docker_pip_installer():
|
||||
docker_auto, _ = config_to_docker(
|
||||
PATH_TO_CONFIG, config_auto, "langchain/langgraph-api:0.2.47"
|
||||
)
|
||||
assert "uv pip install --system --prerelease=allow" in docker_auto
|
||||
assert "uv pip install --system" in docker_auto
|
||||
assert "rm /usr/bin/uv /usr/bin/uvx" in docker_auto
|
||||
|
||||
# Test explicit pip setting
|
||||
@@ -895,7 +895,7 @@ def test_config_to_docker_pip_installer():
|
||||
docker_pip, _ = config_to_docker(
|
||||
PATH_TO_CONFIG, config_pip, "langchain/langgraph-api:0.2.47"
|
||||
)
|
||||
assert "uv pip install --system --prerelease=allow" not in docker_pip
|
||||
assert "uv pip install --system" not in docker_pip
|
||||
assert "pip install" in docker_pip
|
||||
assert "rm /usr/bin/uv" not in docker_pip
|
||||
|
||||
@@ -904,7 +904,7 @@ def test_config_to_docker_pip_installer():
|
||||
docker_uv, _ = config_to_docker(
|
||||
PATH_TO_CONFIG, config_uv, "langchain/langgraph-api:0.2.47"
|
||||
)
|
||||
assert "uv pip install --system --prerelease=allow" in docker_uv
|
||||
assert "uv pip install --system" in docker_uv
|
||||
assert "rm /usr/bin/uv /usr/bin/uvx" in docker_uv
|
||||
|
||||
# Test auto behavior with older image (should use pip)
|
||||
@@ -914,7 +914,7 @@ def test_config_to_docker_pip_installer():
|
||||
docker_auto_old, _ = config_to_docker(
|
||||
PATH_TO_CONFIG, config_auto_old, "langchain/langgraph-api:0.2.46"
|
||||
)
|
||||
assert "uv pip install --system --prerelease=allow" not in docker_auto_old
|
||||
assert "uv pip install --system" not in docker_auto_old
|
||||
assert "pip install" in docker_auto_old
|
||||
assert "rm /usr/bin/uv" not in docker_auto_old
|
||||
|
||||
@@ -923,7 +923,7 @@ def test_config_to_docker_pip_installer():
|
||||
docker_default, _ = config_to_docker(
|
||||
PATH_TO_CONFIG, config_default, "langchain/langgraph-api:0.2.47"
|
||||
)
|
||||
assert "uv pip install --system --prerelease=allow" in docker_default
|
||||
assert "uv pip install --system" in docker_default
|
||||
|
||||
|
||||
def test_config_retain_build_tools():
|
||||
@@ -984,7 +984,7 @@ def test_config_to_compose_simple_config():
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/outer-unit_tests/unit_tests
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
@@ -994,15 +994,15 @@ def test_config_to_compose_simple_config():
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
|
||||
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), " ")}
|
||||
WORKDIR /deps/outer-unit_tests/unit_tests
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
"""
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
@@ -1025,7 +1025,7 @@ def test_config_to_compose_env_vars():
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api-custom:3.11
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/outer-unit_tests/unit_tests
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
@@ -1035,15 +1035,15 @@ def test_config_to_compose_env_vars():
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
|
||||
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), " ")}
|
||||
WORKDIR /deps/outer-unit_tests/unit_tests
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
"""
|
||||
openai_api_key = "key"
|
||||
actual_compose_stdin = config_to_compose(
|
||||
@@ -1070,7 +1070,7 @@ def test_config_to_compose_env_file():
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/outer-unit_tests/unit_tests
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
@@ -1080,15 +1080,15 @@ def test_config_to_compose_env_file():
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
|
||||
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), " ")}
|
||||
WORKDIR /deps/outer-unit_tests/unit_tests
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
"""
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
@@ -1108,7 +1108,7 @@ def test_config_to_compose_watch():
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/outer-unit_tests/unit_tests
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
@@ -1118,15 +1118,15 @@ def test_config_to_compose_watch():
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
|
||||
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), " ")}
|
||||
WORKDIR /deps/outer-unit_tests/unit_tests
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
|
||||
develop:
|
||||
watch:
|
||||
@@ -1155,7 +1155,7 @@ def test_config_to_compose_end_to_end():
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/outer-unit_tests/unit_tests
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
@@ -1165,15 +1165,15 @@ def test_config_to_compose_end_to_end():
|
||||
'[build-system]' \\
|
||||
'requires = ["setuptools>=61"]' \\
|
||||
'build-backend = "setuptools.build_meta"'; do \\
|
||||
echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --prerelease=allow --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
|
||||
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), " ")}
|
||||
WORKDIR /deps/outer-unit_tests/unit_tests
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
|
||||
develop:
|
||||
watch:
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
"Bash(python:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(awk:*)",
|
||||
"Bash(uv run mypy:*)",
|
||||
"Bash(uv run:*)"
|
||||
"Bash(awk:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
# Understanding `channel_versions` for State Channels in LangGraph
|
||||
|
||||
## Overview
|
||||
|
||||
While `versions_seen` only tracks trigger channels, `channel_versions` tracks **ALL channels** including state channels like `fieldA` and `fieldB`. This document explains why state channel versions matter.
|
||||
|
||||
## The Two Version Tracking Mechanisms
|
||||
|
||||
| Mechanism | What it tracks | Purpose |
|
||||
|-----------|---------------|---------|
|
||||
| `channel_versions` | **All channels** (state + triggers) | Storage, recovery, change tracking |
|
||||
| `versions_seen` | **Only triggers** | Scheduling, prevent duplicate execution |
|
||||
|
||||
---
|
||||
|
||||
## Why Track State Channel Versions?
|
||||
|
||||
### Purpose 1: Incremental Storage
|
||||
|
||||
When saving a checkpoint, LangGraph only serializes channels that have **changed** since the last checkpoint.
|
||||
|
||||
```python
|
||||
# In checkpointer.put()
|
||||
def put(self, config, checkpoint, metadata, new_versions):
|
||||
for k, v in new_versions.items(): # Only changed channels!
|
||||
self.blobs[(thread_id, ns, k, v)] = serialize(values[k])
|
||||
```
|
||||
|
||||
The `new_versions` parameter is computed by comparing current versions with previous versions:
|
||||
|
||||
```python
|
||||
def get_new_channel_versions(previous_versions, current_versions):
|
||||
"""Get subset of current_versions that are newer than previous_versions."""
|
||||
return {
|
||||
k: v
|
||||
for k, v in current_versions.items()
|
||||
if v > previous_versions.get(k, null_version)
|
||||
}
|
||||
```
|
||||
|
||||
**Benefit**: If `fieldA` didn't change in a step, it won't be re-serialized!
|
||||
|
||||
### Purpose 2: Version-Keyed Storage
|
||||
|
||||
Channel values are stored with their version as part of the key:
|
||||
|
||||
```python
|
||||
# Storage structure in InMemorySaver
|
||||
blobs = {
|
||||
(thread_id, ns, "fieldA", v02): b"Hello", # Step 0
|
||||
(thread_id, ns, "fieldA", v03): b"Hello->A", # Step 1
|
||||
(thread_id, ns, "fieldA", v04): b"Hello->A->B", # Step 2
|
||||
(thread_id, ns, "fieldB", v02): b"World", # Step 0
|
||||
(thread_id, ns, "fieldB", v03): b"World->A", # Step 1
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Benefit**: Can restore to ANY historical checkpoint - each version's value is stored independently.
|
||||
|
||||
### Purpose 3: Precise Recovery
|
||||
|
||||
When restoring a checkpoint, use `channel_versions` to load the correct value:
|
||||
|
||||
```python
|
||||
# Restoring Step 1's checkpoint
|
||||
checkpoint["channel_versions"] = {"fieldA": v03, "fieldB": v03}
|
||||
|
||||
# Load correct version of each value
|
||||
fieldA = blobs[(thread_id, ns, "fieldA", v03)] # "Hello->A"
|
||||
fieldB = blobs[(thread_id, ns, "fieldB", v03)] # "World->A"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example: Step-by-Step Channel Version Changes
|
||||
|
||||
Using the same graph:
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ nodeA │ (reads fieldA + fieldB)
|
||||
└────┬─────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ nodeB │ │ nodeC │ (nodeB reads fieldA, nodeC reads fieldB)
|
||||
└────┬─────┘ └────┬─────┘
|
||||
│ │
|
||||
└──────┬──────┘
|
||||
▼
|
||||
┌──────────┐
|
||||
│ nodeD │ (reads fieldA + fieldB)
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
### Step -1: Input
|
||||
|
||||
```
|
||||
channel_versions:
|
||||
__start__: v01
|
||||
|
||||
channel_values:
|
||||
__start__: {'fieldA': 'Hello', 'fieldB': 'World'}
|
||||
|
||||
new_versions (to save): {__start__: v01}
|
||||
→ Only __start__ is saved
|
||||
```
|
||||
|
||||
### Step 0: `__start__` executes
|
||||
|
||||
```
|
||||
channel_versions:
|
||||
__start__: v02
|
||||
branch:to:nodeA: v02
|
||||
fieldA: v02 ← NEW!
|
||||
fieldB: v02 ← NEW!
|
||||
|
||||
channel_values:
|
||||
branch:to:nodeA: None
|
||||
fieldA: Hello
|
||||
fieldB: World
|
||||
|
||||
new_versions (to save): {__start__: v02, branch:to:nodeA: v02, fieldA: v02, fieldB: v02}
|
||||
→ All changed channels are saved
|
||||
```
|
||||
|
||||
### Step 1: nodeA executes
|
||||
|
||||
```
|
||||
channel_versions:
|
||||
__start__: v02 ← unchanged
|
||||
branch:to:nodeA: v03 ← updated (consumed)
|
||||
branch:to:nodeB: v03 ← NEW!
|
||||
branch:to:nodeC: v03 ← NEW!
|
||||
fieldA: v03 ← updated!
|
||||
fieldB: v03 ← updated!
|
||||
|
||||
channel_values:
|
||||
branch:to:nodeB: None
|
||||
branch:to:nodeC: None
|
||||
fieldA: Hello->A
|
||||
fieldB: World->A
|
||||
|
||||
new_versions (to save): {branch:to:nodeA: v03, branch:to:nodeB: v03, branch:to:nodeC: v03, fieldA: v03, fieldB: v03}
|
||||
→ __start__ NOT saved (unchanged at v02)
|
||||
```
|
||||
|
||||
### Step 2: nodeB and nodeC execute (parallel)
|
||||
|
||||
```
|
||||
channel_versions:
|
||||
__start__: v02 ← unchanged
|
||||
branch:to:nodeA: v03 ← unchanged
|
||||
branch:to:nodeB: v04 ← updated (consumed)
|
||||
branch:to:nodeC: v04 ← updated (consumed)
|
||||
fieldA: v04 ← updated by nodeB!
|
||||
fieldB: v04 ← updated by nodeC!
|
||||
join:nodeB+nodeC:nodeD: v04 ← NEW!
|
||||
|
||||
channel_values:
|
||||
fieldA: Hello->A->B
|
||||
fieldB: World->A->C
|
||||
join:nodeB+nodeC:nodeD: {'nodeB', 'nodeC'}
|
||||
|
||||
new_versions (to save): {branch:to:nodeB: v04, branch:to:nodeC: v04, fieldA: v04, fieldB: v04, join:...: v04}
|
||||
→ Only changed channels saved
|
||||
```
|
||||
|
||||
### Step 3: nodeD executes
|
||||
|
||||
```
|
||||
channel_versions:
|
||||
__start__: v02 ← unchanged since Step 0!
|
||||
branch:to:nodeA: v03 ← unchanged since Step 1
|
||||
branch:to:nodeB: v04 ← unchanged
|
||||
branch:to:nodeC: v04 ← unchanged
|
||||
fieldA: v05 ← updated by nodeD!
|
||||
fieldB: v05 ← updated by nodeD!
|
||||
join:nodeB+nodeC:nodeD: v05 ← updated (consumed)
|
||||
|
||||
channel_values:
|
||||
fieldA: Hello->A->B->D
|
||||
fieldB: World->A->C->D
|
||||
join:nodeB+nodeC:nodeD: set()
|
||||
|
||||
new_versions (to save): {fieldA: v05, fieldB: v05, join:...: v05}
|
||||
→ Only 3 channels saved, not all 7!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Storage Efficiency Visualization
|
||||
|
||||
```
|
||||
Step 0: Save [__start__, branch:to:nodeA, fieldA, fieldB] = 4 channels
|
||||
Step 1: Save [branch:to:nodeA, branch:to:nodeB, branch:to:nodeC, fieldA, fieldB] = 5 channels
|
||||
Step 2: Save [branch:to:nodeB, branch:to:nodeC, fieldA, fieldB, join:...] = 5 channels
|
||||
Step 3: Save [fieldA, fieldB, join:...] = 3 channels
|
||||
|
||||
Without incremental storage: 7 channels × 4 steps = 28 serializations
|
||||
With incremental storage: 4 + 5 + 5 + 3 = 17 serializations
|
||||
= 39% savings!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Time Travel: Restoring Any Checkpoint
|
||||
|
||||
Because each version is stored separately, you can restore to any point:
|
||||
|
||||
```
|
||||
Want to restore Step 1?
|
||||
→ checkpoint["channel_versions"] = {fieldA: v03, fieldB: v03, ...}
|
||||
→ Load blobs[(thread_id, ns, "fieldA", v03)] = "Hello->A"
|
||||
→ Load blobs[(thread_id, ns, "fieldB", v03)] = "World->A"
|
||||
|
||||
Want to restore Step 2?
|
||||
→ checkpoint["channel_versions"] = {fieldA: v04, fieldB: v04, ...}
|
||||
→ Load blobs[(thread_id, ns, "fieldA", v04)] = "Hello->A->B"
|
||||
→ Load blobs[(thread_id, ns, "fieldB", v04)] = "World->A->C"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary: State Channel Versions vs Trigger Channel Versions
|
||||
|
||||
| Aspect | State Channels (fieldA, fieldB) | Trigger Channels (branch:to:*) |
|
||||
|--------|--------------------------------|-------------------------------|
|
||||
| In `channel_versions`? | ✅ Yes | ✅ Yes |
|
||||
| In `versions_seen`? | ❌ No | ✅ Yes |
|
||||
| Version increases when? | Value is updated | Written to OR consumed |
|
||||
| Used for scheduling? | ❌ No | ✅ Yes |
|
||||
| Used for storage? | ✅ Yes (incremental save) | ✅ Yes |
|
||||
| Used for recovery? | ✅ Yes (load correct version) | ✅ Yes |
|
||||
|
||||
---
|
||||
|
||||
## Code Reference
|
||||
|
||||
### Where `channel_versions` is updated
|
||||
|
||||
```python
|
||||
# In apply_writes() - libs/langgraph/langgraph/pregel/_algo.py
|
||||
for chan, vals in pending_writes_by_channel.items():
|
||||
if channels[chan].update(vals) and next_version is not None:
|
||||
checkpoint["channel_versions"][chan] = next_version # ← Update version
|
||||
updated_channels.add(chan)
|
||||
```
|
||||
|
||||
### Where incremental storage happens
|
||||
|
||||
```python
|
||||
# In InMemorySaver.put() - libs/checkpoint/langgraph/checkpoint/memory/__init__.py
|
||||
def put(self, config, checkpoint, metadata, new_versions):
|
||||
values = checkpoint.pop("channel_values")
|
||||
for k, v in new_versions.items(): # ← Only save changed channels
|
||||
self.blobs[(thread_id, checkpoint_ns, k, v)] = (
|
||||
self.serde.dumps_typed(values[k]) if k in values else ("empty", b"")
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running the Test
|
||||
|
||||
To see channel versions in action:
|
||||
|
||||
```bash
|
||||
cd libs/langgraph
|
||||
uv run python test_versions_seen.py
|
||||
```
|
||||
|
||||
The output shows `channel_versions` for each step, where you can observe:
|
||||
1. All channels (state + triggers) are tracked
|
||||
2. Versions increment when values change
|
||||
3. Some channels stay at the same version across multiple steps (unchanged)
|
||||
|
||||
@@ -25,7 +25,7 @@ from typing import (
|
||||
|
||||
from langchain_core.runnables import Runnable, RunnableConfig
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from typing_extensions import NotRequired, Required, Self, Unpack, is_typeddict
|
||||
from typing_extensions import Self, Unpack, is_typeddict
|
||||
|
||||
from langgraph._internal._constants import (
|
||||
INTERRUPT,
|
||||
@@ -1334,12 +1334,6 @@ def _get_channel(
|
||||
def _get_channel(
|
||||
name: str, annotation: Any, *, allow_managed: bool = True
|
||||
) -> BaseChannel | ManagedValueSpec:
|
||||
# Strip out Required and NotRequired wrappers
|
||||
if hasattr(annotation, "__origin__") and annotation.__origin__ in (
|
||||
Required,
|
||||
NotRequired,
|
||||
):
|
||||
annotation = annotation.__args__[0]
|
||||
if manager := _is_field_managed_value(name, annotation):
|
||||
if allow_managed:
|
||||
return manager
|
||||
|
||||
@@ -194,12 +194,15 @@ def local_read(
|
||||
for c, v in task.writes:
|
||||
if c in select:
|
||||
updated[c].append(v)
|
||||
if fresh:
|
||||
if fresh and updated:
|
||||
# apply writes
|
||||
local_channels: dict[str, BaseChannel] = {}
|
||||
for k in channels:
|
||||
cc = channels[k].copy()
|
||||
cc.update(updated[k])
|
||||
if k in updated:
|
||||
cc = channels[k].copy()
|
||||
cc.update(updated[k])
|
||||
else:
|
||||
cc = channels[k]
|
||||
local_channels[k] = cc
|
||||
# read fresh values
|
||||
values = read_channels(local_channels, select)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "0.6.7"
|
||||
version = "0.6.6"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
"""
|
||||
Test script to demonstrate versions_seen in StateGraph
|
||||
|
||||
Graph structure:
|
||||
nodeA -> nodeB + nodeC -> nodeD
|
||||
|
||||
State:
|
||||
fieldA: str
|
||||
fieldB: str
|
||||
"""
|
||||
|
||||
from typing import TypedDict
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from pprint import pprint
|
||||
import json
|
||||
|
||||
|
||||
class State(TypedDict):
|
||||
fieldA: str
|
||||
fieldB: str
|
||||
|
||||
|
||||
class StateOnlyA(TypedDict):
|
||||
"""Input schema for nodeB - only reads fieldA"""
|
||||
fieldA: str
|
||||
|
||||
|
||||
class StateOnlyB(TypedDict):
|
||||
"""Input schema for nodeC - only reads fieldB"""
|
||||
fieldB: str
|
||||
|
||||
|
||||
def nodeA(state: State) -> dict:
|
||||
"""Reads fieldA + fieldB"""
|
||||
print(f" [nodeA] Reading: fieldA='{state['fieldA']}', fieldB='{state['fieldB']}'")
|
||||
return {"fieldA": state["fieldA"] + "->A", "fieldB": state["fieldB"] + "->A"}
|
||||
|
||||
|
||||
def nodeB(state: StateOnlyA) -> dict:
|
||||
"""Reads only fieldA"""
|
||||
print(f" [nodeB] Reading: fieldA='{state['fieldA']}'")
|
||||
return {"fieldA": state["fieldA"] + "->B"}
|
||||
|
||||
|
||||
def nodeC(state: StateOnlyB) -> dict:
|
||||
"""Reads only fieldB"""
|
||||
print(f" [nodeC] Reading: fieldB='{state['fieldB']}'")
|
||||
return {"fieldB": state["fieldB"] + "->C"}
|
||||
|
||||
|
||||
def nodeD(state: State) -> dict:
|
||||
"""Reads fieldA + fieldB"""
|
||||
print(f" [nodeD] Reading: fieldA='{state['fieldA']}', fieldB='{state['fieldB']}'")
|
||||
return {"fieldA": state["fieldA"] + "->D", "fieldB": state["fieldB"] + "->D"}
|
||||
|
||||
|
||||
# Build the graph
|
||||
graph = StateGraph(State)
|
||||
|
||||
graph.add_node("nodeA", nodeA) # reads fieldA + fieldB (default: full state)
|
||||
graph.add_node("nodeB", nodeB, input_schema=StateOnlyA) # reads only fieldA
|
||||
graph.add_node("nodeC", nodeC, input_schema=StateOnlyB) # reads only fieldB
|
||||
graph.add_node("nodeD", nodeD) # reads fieldA + fieldB (default: full state)
|
||||
|
||||
graph.add_edge(START, "nodeA")
|
||||
graph.add_edge("nodeA", "nodeB")
|
||||
graph.add_edge("nodeA", "nodeC")
|
||||
graph.add_edge(["nodeB", "nodeC"], "nodeD")
|
||||
graph.add_edge("nodeD", END)
|
||||
|
||||
# Compile with checkpointer
|
||||
checkpointer = InMemorySaver()
|
||||
app = graph.compile(checkpointer=checkpointer)
|
||||
|
||||
# Print compiled graph info
|
||||
print("=" * 60)
|
||||
print("COMPILED GRAPH INFO")
|
||||
print("=" * 60)
|
||||
print("\nChannels created:")
|
||||
for name, channel in app.channels.items():
|
||||
print(f" - {name}: {type(channel).__name__}")
|
||||
|
||||
print("\nNodes with their triggers and channels:")
|
||||
for name, node in app.nodes.items():
|
||||
print(f" - {name}:")
|
||||
print(f" triggers: {node.triggers}")
|
||||
print(f" channels: {node.channels}")
|
||||
|
||||
# Run the graph
|
||||
print("\n" + "=" * 60)
|
||||
print("EXECUTION")
|
||||
print("=" * 60)
|
||||
|
||||
config = {"configurable": {"thread_id": "test-1"}}
|
||||
input_state = {"fieldA": "Hello", "fieldB": "World"}
|
||||
|
||||
print(f"\nInput: {input_state}\n")
|
||||
|
||||
# Run the graph to completion
|
||||
result = app.invoke(input_state, config)
|
||||
print(f"Final result: {result}\n")
|
||||
|
||||
# Now use get_state_history to get all checkpoints in order
|
||||
print("=" * 60)
|
||||
print("CHECKPOINT HISTORY (using get_state_history)")
|
||||
print("=" * 60)
|
||||
|
||||
# get_state_history returns checkpoints in reverse order (newest first)
|
||||
history = list(app.get_state_history(config))
|
||||
history.reverse() # Reverse to get oldest first
|
||||
|
||||
for idx, state_snapshot in enumerate(history):
|
||||
metadata = state_snapshot.metadata
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Step {metadata.get('step', '?')} - Source: {metadata.get('source', '?')}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Show which node(s) wrote this checkpoint
|
||||
if "writes" in metadata and metadata["writes"]:
|
||||
print(f"Writes by: {list(metadata['writes'].keys())}")
|
||||
|
||||
print(f"\nState values: {state_snapshot.values}")
|
||||
|
||||
# Access the actual checkpoint data
|
||||
checkpoint_tuple = checkpointer.get_tuple(state_snapshot.config)
|
||||
if checkpoint_tuple:
|
||||
cp = checkpoint_tuple.checkpoint
|
||||
|
||||
# Helper to simplify version string
|
||||
def simplify_version(ver):
|
||||
return str(ver).split(".")[0][-2:] if "." in str(ver) else str(ver)
|
||||
|
||||
# Pretty print checkpoint with simplified versions
|
||||
print("\nCheckpoint (raw):")
|
||||
print(f" v: {cp['v']}")
|
||||
print(f" id: {cp['id'][:20]}...")
|
||||
print(f" ts: {cp['ts']}")
|
||||
print(f" updated_channels: {cp.get('updated_channels')}")
|
||||
|
||||
print(f"\n channel_values:")
|
||||
for ch, val in sorted(cp["channel_values"].items()):
|
||||
val_str = str(val)[:50] + "..." if len(str(val)) > 50 else str(val)
|
||||
print(f" {ch}: {val_str}")
|
||||
|
||||
print(f"\n channel_versions:")
|
||||
for ch, ver in sorted(cp["channel_versions"].items()):
|
||||
print(f" {ch}: v{simplify_version(ver)}")
|
||||
|
||||
print(f"\n versions_seen:")
|
||||
for node_name, seen in sorted(cp["versions_seen"].items()):
|
||||
if seen:
|
||||
print(f" {node_name}:")
|
||||
for ch, ver in sorted(seen.items()):
|
||||
print(f" {ch}: v{simplify_version(ver)}")
|
||||
else:
|
||||
print(f" {node_name}: {{}}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("SUMMARY")
|
||||
print("=" * 60)
|
||||
print("""
|
||||
Key observations:
|
||||
1. versions_seen only records TRIGGER channels (branch:to:*, join:*)
|
||||
2. State channels (fieldA, fieldB) are NEVER in versions_seen
|
||||
3. Each node only records the trigger channel that activated it
|
||||
""")
|
||||
|
||||
@@ -79,151 +79,9 @@ from tests.messages import (
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def pregel_pretty(data):
|
||||
"""Pretty print pregel nodes, channels, or graph with nice formatting."""
|
||||
if not data:
|
||||
return "Empty"
|
||||
|
||||
# Check if this is a graph object
|
||||
if hasattr(data, 'nodes') and hasattr(data, 'channels'):
|
||||
# This is a graph object, show comprehensive info
|
||||
result = []
|
||||
|
||||
# Show nodes
|
||||
result.append("="*50)
|
||||
result.append("🔗 GRAPH NODES")
|
||||
result.append("="*50)
|
||||
for name, node in data.nodes.items():
|
||||
node_type = type(node).__name__
|
||||
result.append(f" 📍 {name:<12} → {node_type}")
|
||||
|
||||
# Show channels
|
||||
result.append("\n" + "="*50)
|
||||
result.append("📡 GRAPH CHANNELS")
|
||||
result.append("="*50)
|
||||
for name, channel in data.channels.items():
|
||||
channel_type = type(channel).__name__
|
||||
if name in ['hello', 'messages']:
|
||||
result.append(f" 🎯 {name:<20} → {channel_type} (user defined)")
|
||||
elif name.startswith('branch:'):
|
||||
result.append(f" 🌿 {name:<20} → {channel_type} (branch)")
|
||||
else:
|
||||
result.append(f" ⚙️ {name:<20} → {channel_type} (system)")
|
||||
|
||||
# Show graph structure
|
||||
result.append("\n" + "="*50)
|
||||
result.append("🏗️ GRAPH STRUCTURE")
|
||||
result.append("="*50)
|
||||
try:
|
||||
graph_info = data.get_graph()
|
||||
result.append(f" Nodes: {len(graph_info.nodes)}")
|
||||
result.append(f" Edges: {len(graph_info.edges)}")
|
||||
result.append("\n 📊 Execution Flow:")
|
||||
for edge in graph_info.edges:
|
||||
arrow = " ├─" if edge != graph_info.edges[-1] else " └─"
|
||||
result.append(f"{arrow} {edge.source} → {edge.target}")
|
||||
except Exception as e:
|
||||
result.append(f" Could not get graph structure: {e}")
|
||||
|
||||
result.append("="*50)
|
||||
return "\n".join(result)
|
||||
|
||||
# Check if this is nodes or channels dict
|
||||
first_key, first_value = next(iter(data.items()))
|
||||
|
||||
# Detect if this is nodes or channels
|
||||
is_nodes = hasattr(first_value, '__class__') and 'Node' in first_value.__class__.__name__
|
||||
is_channels = hasattr(first_value, '__class__') and ('Channel' in first_value.__class__.__name__ or
|
||||
'Value' in first_value.__class__.__name__ or
|
||||
'Topic' in first_value.__class__.__name__ or
|
||||
'Aggregate' in first_value.__class__.__name__)
|
||||
|
||||
result = []
|
||||
|
||||
if is_nodes:
|
||||
result.append("="*50)
|
||||
result.append("🔗 GRAPH NODES")
|
||||
result.append("="*50)
|
||||
for name, node in data.items():
|
||||
node_type = type(node).__name__
|
||||
result.append(f" 📍 {name:<12} → {node_type}")
|
||||
|
||||
elif is_channels:
|
||||
result.append("="*50)
|
||||
result.append("📡 GRAPH CHANNELS")
|
||||
result.append("="*50)
|
||||
for name, channel in data.items():
|
||||
channel_type = type(channel).__name__
|
||||
if name in ['hello', 'messages']:
|
||||
result.append(f" 🎯 {name:<20} → {channel_type} (user defined)")
|
||||
elif name.startswith('branch:'):
|
||||
result.append(f" 🌿 {name:<20} → {channel_type} (branch)")
|
||||
else:
|
||||
result.append(f" ⚙️ {name:<20} → {channel_type} (system)")
|
||||
|
||||
else:
|
||||
# Fallback for unknown data types
|
||||
result.append("="*50)
|
||||
result.append("🔍 UNKNOWN DATA TYPE")
|
||||
result.append("="*50)
|
||||
for name, item in data.items():
|
||||
item_type = type(item).__name__
|
||||
result.append(f" ❓ {name:<20} → {item_type}")
|
||||
|
||||
result.append("="*50)
|
||||
return "\n".join(result)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def test_parallel_nodes() -> None:
|
||||
class State(TypedDict):
|
||||
hello: str
|
||||
messages: Annotated[list[str], add_messages]
|
||||
|
||||
def node_a(state: State) -> State:
|
||||
return {"hello": "world-a", "messages": [_AnyIdHumanMessage(content="hello-a")]}
|
||||
|
||||
def node_b(state: State) -> State:
|
||||
return {"messages": [_AnyIdHumanMessage(content="hello-b")]}
|
||||
|
||||
def node_c(state: State) -> State:
|
||||
return {"messages": [_AnyIdHumanMessage(content="hello-c")]}
|
||||
|
||||
def node_d(state: State) -> State:
|
||||
return {"hello": "world-d", "messages": [_AnyIdHumanMessage(content="hello-d")]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("a", node_a)
|
||||
builder.add_node("b", node_b)
|
||||
builder.add_node("c", node_c)
|
||||
builder.add_node("d", node_d)
|
||||
|
||||
builder.set_entry_point("a")
|
||||
builder.add_edge("a", "b")
|
||||
builder.add_edge("a", "c")
|
||||
builder.add_edge("b", "d")
|
||||
builder.add_edge("c", "d")
|
||||
builder.add_edge("d", END)
|
||||
graph = builder.compile()
|
||||
|
||||
print("\n======COMPLETE GRAPH======\n", pregel_pretty(graph))
|
||||
|
||||
print(f"\n🔍 CHANNEL CONFIGURATION:")
|
||||
print(f" 📤 output_channels: {graph.output_channels}")
|
||||
print(f" 📡 stream_channels: {graph.stream_channels}")
|
||||
print(f" 📥 input_channels: {graph.input_channels}")
|
||||
print(f" 🌊 stream_channels_asis: {graph.stream_channels_asis}")
|
||||
print(f" 📋 stream_channels_list: {graph.stream_channels_list}")
|
||||
|
||||
result = graph.invoke({"hello": "there"})
|
||||
assert result["hello"] == "world-d"
|
||||
# Only the final message from node_d should be in the result
|
||||
# because each node overwrites the messages field completely
|
||||
assert len(result["messages"]) == 1
|
||||
assert result["messages"][0].content == "hello-d"
|
||||
|
||||
def test_graph_validation() -> None:
|
||||
class State(TypedDict):
|
||||
hello: str
|
||||
|
||||
@@ -10,7 +10,6 @@ from langchain_core.runnables import RunnableConfig
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import NotRequired, Required, TypedDict
|
||||
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.graph.state import StateGraph, _get_node_name, _warn_invalid_state_schema
|
||||
|
||||
|
||||
@@ -138,7 +137,7 @@ def test_state_schema_optional_values(total_: bool):
|
||||
class InputState(SomeParentState, total=total_): # type: ignore
|
||||
val1: str
|
||||
val2: Optional[str]
|
||||
val3: Required[Annotated[dict, operator.or_]]
|
||||
val3: Required[str]
|
||||
val4: NotRequired[dict]
|
||||
val5: Annotated[Required[str], "foo"]
|
||||
val6: Annotated[NotRequired[str], "bar"]
|
||||
@@ -160,8 +159,6 @@ def test_state_schema_optional_values(total_: bool):
|
||||
graph = builder.compile()
|
||||
json_schema = graph.get_input_jsonschema()
|
||||
|
||||
assert isinstance(graph.channels["val3"], BinaryOperatorAggregate)
|
||||
|
||||
if total_ is False:
|
||||
expected_required = set()
|
||||
expected_optional = {"val2", "val1"}
|
||||
|
||||
Generated
+1
-1
@@ -1269,7 +1269,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "0.6.7"
|
||||
version = "0.6.6"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
# Understanding `versions_seen` in LangGraph Checkpoints
|
||||
|
||||
## Overview
|
||||
|
||||
`versions_seen` is a nested dictionary in the checkpoint that tracks which channel versions each node has processed. It's defined in `libs/checkpoint/langgraph/checkpoint/base/__init__.py`:
|
||||
|
||||
```python
|
||||
versions_seen: dict[str, ChannelVersions]
|
||||
"""Map from node ID to map from channel name to version seen.
|
||||
This keeps track of the versions of the channels that each node has seen.
|
||||
Used to determine which nodes to execute next.
|
||||
"""
|
||||
```
|
||||
|
||||
## Data Structure
|
||||
|
||||
```
|
||||
versions_seen = {
|
||||
"node_name": {
|
||||
"channel_name": version,
|
||||
...
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Key Point
|
||||
|
||||
**`versions_seen` only records trigger channels, NOT state channels!**
|
||||
|
||||
In StateGraph:
|
||||
- `triggers` = edge control channels like `branch:to:nodeA`
|
||||
- `channels` = state keys like `fieldA`, `fieldB`
|
||||
|
||||
So `versions_seen` records `branch:to:*` channels, **NOT** `fieldA` or `fieldB`.
|
||||
|
||||
---
|
||||
|
||||
## Example Graph
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ nodeA │
|
||||
└────┬─────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ nodeB │ │ nodeC │
|
||||
└────┬─────┘ └────┬─────┘
|
||||
│ │
|
||||
└──────┬──────┘
|
||||
▼
|
||||
┌──────────┐
|
||||
│ nodeD │
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
### State Definition
|
||||
|
||||
```python
|
||||
class State(TypedDict):
|
||||
fieldA: str
|
||||
fieldB: str
|
||||
|
||||
class StateOnlyA(TypedDict):
|
||||
"""Input schema for nodeB - only reads fieldA"""
|
||||
fieldA: str
|
||||
|
||||
class StateOnlyB(TypedDict):
|
||||
"""Input schema for nodeC - only reads fieldB"""
|
||||
fieldB: str
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compiled Graph Structure
|
||||
|
||||
### Channels Created
|
||||
|
||||
| Channel | Type | Purpose |
|
||||
|---------|------|---------|
|
||||
| `fieldA` | LastValue | State data |
|
||||
| `fieldB` | LastValue | State data |
|
||||
| `__start__` | EphemeralValue | Input channel |
|
||||
| `branch:to:nodeA` | EphemeralValue | Edge control channel |
|
||||
| `branch:to:nodeB` | EphemeralValue | Edge control channel |
|
||||
| `branch:to:nodeC` | EphemeralValue | Edge control channel |
|
||||
| `branch:to:nodeD` | EphemeralValue | Edge control channel |
|
||||
| `join:nodeB+nodeC:nodeD` | NamedBarrierValue | Parallel join channel |
|
||||
|
||||
### Nodes Configuration
|
||||
|
||||
| Node | triggers | channels | Note |
|
||||
|------|----------|----------|------|
|
||||
| `__start__` | `["__start__"]` | `"__start__"` | Input node |
|
||||
| `nodeA` | `["branch:to:nodeA"]` | `["fieldA", "fieldB"]` | Reads full state |
|
||||
| `nodeB` | `["branch:to:nodeB"]` | `["fieldA"]` | Only reads fieldA (via `input_schema=StateOnlyA`) |
|
||||
| `nodeC` | `["branch:to:nodeC"]` | `["fieldB"]` | Only reads fieldB (via `input_schema=StateOnlyB`) |
|
||||
| `nodeD` | `["branch:to:nodeD", "join:nodeB+nodeC:nodeD"]` | `["fieldA", "fieldB"]` | Reads full state |
|
||||
|
||||
**Note**: `triggers` are edge control channels, `channels` are state fields the node reads. Use `input_schema` to control which fields a node reads.
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Execution
|
||||
|
||||
### Input
|
||||
|
||||
```python
|
||||
{"fieldA": "Hello", "fieldB": "World"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step -1: Input Phase (source: input)
|
||||
|
||||
```
|
||||
State values: {}
|
||||
|
||||
channel_versions:
|
||||
__start__: v01
|
||||
|
||||
versions_seen:
|
||||
__input__: {}
|
||||
```
|
||||
|
||||
Initial checkpoint when input is received.
|
||||
|
||||
---
|
||||
|
||||
### Step 0: `__start__` executes (source: loop)
|
||||
|
||||
```
|
||||
State values: {'fieldA': 'Hello', 'fieldB': 'World'}
|
||||
|
||||
channel_versions:
|
||||
__start__: v02
|
||||
branch:to:nodeA: v02
|
||||
fieldA: v02
|
||||
fieldB: v02
|
||||
|
||||
versions_seen:
|
||||
__input__: {}
|
||||
__start__:
|
||||
__start__: v01 ← __start__ node saw __start__ channel
|
||||
```
|
||||
|
||||
**Note**: `fieldA` and `fieldB` are NOT in `versions_seen`!
|
||||
|
||||
---
|
||||
|
||||
### Step 1: nodeA executes (source: loop)
|
||||
|
||||
```
|
||||
nodeA reads: fieldA='Hello', fieldB='World'
|
||||
State values: {'fieldA': 'Hello->A', 'fieldB': 'World->A'}
|
||||
|
||||
channel_versions:
|
||||
__start__: v02
|
||||
branch:to:nodeA: v03
|
||||
branch:to:nodeB: v03
|
||||
branch:to:nodeC: v03
|
||||
fieldA: v03
|
||||
fieldB: v03
|
||||
|
||||
versions_seen:
|
||||
__input__: {}
|
||||
__start__:
|
||||
__start__: v01
|
||||
nodeA:
|
||||
branch:to:nodeA: v02 ← nodeA saw its trigger
|
||||
```
|
||||
|
||||
**Key observation**:
|
||||
- `nodeA`'s `versions_seen` only records `branch:to:nodeA`
|
||||
- **NO** `fieldA` or `fieldB` because they are NOT triggers!
|
||||
|
||||
---
|
||||
|
||||
### Step 2: nodeB and nodeC execute in parallel (source: loop)
|
||||
|
||||
```
|
||||
nodeB reads: fieldA='Hello->A'
|
||||
nodeC reads: fieldB='World->A'
|
||||
State values: {'fieldA': 'Hello->A->B', 'fieldB': 'World->A->C'}
|
||||
|
||||
channel_versions:
|
||||
__start__: v02
|
||||
branch:to:nodeA: v03
|
||||
branch:to:nodeB: v04
|
||||
branch:to:nodeC: v04
|
||||
fieldA: v04
|
||||
fieldB: v04
|
||||
join:nodeB+nodeC:nodeD: v04
|
||||
|
||||
versions_seen:
|
||||
__input__: {}
|
||||
__start__:
|
||||
__start__: v01
|
||||
nodeA:
|
||||
branch:to:nodeA: v02
|
||||
nodeB:
|
||||
branch:to:nodeB: v03 ← nodeB saw its trigger
|
||||
nodeC:
|
||||
branch:to:nodeC: v03 ← nodeC saw its trigger
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3: nodeD executes (source: loop)
|
||||
|
||||
```
|
||||
nodeD reads: fieldA='Hello->A->B', fieldB='World->A->C'
|
||||
State values: {'fieldA': 'Hello->A->B->D', 'fieldB': 'World->A->C->D'}
|
||||
|
||||
channel_versions:
|
||||
__start__: v02
|
||||
branch:to:nodeA: v03
|
||||
branch:to:nodeB: v04
|
||||
branch:to:nodeC: v04
|
||||
fieldA: v05
|
||||
fieldB: v05
|
||||
join:nodeB+nodeC:nodeD: v05
|
||||
|
||||
versions_seen:
|
||||
__input__: {}
|
||||
__start__:
|
||||
__start__: v01
|
||||
nodeA:
|
||||
branch:to:nodeA: v02
|
||||
nodeB:
|
||||
branch:to:nodeB: v03
|
||||
nodeC:
|
||||
branch:to:nodeC: v03
|
||||
nodeD:
|
||||
join:nodeB+nodeC:nodeD: v04 ← nodeD saw join channel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Node | versions_seen records | Why? |
|
||||
|------|----------------------|------|
|
||||
| `__start__` | `__start__` | Its trigger is `__start__` |
|
||||
| `nodeA` | `branch:to:nodeA` | Its trigger is `branch:to:nodeA` |
|
||||
| `nodeB` | `branch:to:nodeB` | Its trigger is `branch:to:nodeB` |
|
||||
| `nodeC` | `branch:to:nodeC` | Its trigger is `branch:to:nodeC` |
|
||||
| `nodeD` | `join:nodeB+nodeC:nodeD` | One of its triggers (join channel) |
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
- `versions_seen` **only records triggers**
|
||||
- `fieldA` and `fieldB` **never appear** in `versions_seen`
|
||||
- In StateGraph, triggers are edge control channels (`branch:to:*`), not state fields
|
||||
- The purpose of `versions_seen` is to **prevent duplicate triggering**, so it only needs to track trigger channel versions
|
||||
|
||||
---
|
||||
|
||||
## Deep Dive: How `versions_seen` Determines the Last Node
|
||||
|
||||
### The Problem
|
||||
|
||||
When calling `update_state()` without specifying `as_node`, LangGraph needs to figure out which node "last updated" the state. This is done using `versions_seen`.
|
||||
|
||||
### The Algorithm
|
||||
|
||||
```python
|
||||
last_seen_by_node = sorted(
|
||||
(v, n)
|
||||
for n, seen in checkpoint["versions_seen"].items()
|
||||
if n in self.nodes
|
||||
for v in seen.values()
|
||||
)
|
||||
```
|
||||
|
||||
This creates a sorted list of `(version, node_name)` tuples.
|
||||
|
||||
### Key Insight: Version = Superstep
|
||||
|
||||
**Nodes that execute in the same superstep (parallel execution) will have the same trigger channel version.**
|
||||
|
||||
This is because:
|
||||
1. Each superstep increments the version counter
|
||||
2. All nodes triggered in the same superstep see the same version
|
||||
3. So `version` effectively identifies which superstep a node executed in
|
||||
|
||||
### Analysis by Step (Using Our Example)
|
||||
|
||||
#### Step 1: After nodeA executes
|
||||
|
||||
```
|
||||
versions_seen:
|
||||
__start__: { __start__: v01 }
|
||||
nodeA: { branch:to:nodeA: v02 }
|
||||
|
||||
last_seen_by_node = [(v01, "__start__"), (v02, "nodeA")]
|
||||
|
||||
Check: last[-1][0] != last[-2][0]?
|
||||
v02 != v01? ✅ YES
|
||||
|
||||
Result: as_node = "nodeA" (last node in the latest superstep)
|
||||
```
|
||||
|
||||
#### Step 2: After nodeB and nodeC execute (parallel)
|
||||
|
||||
```
|
||||
versions_seen:
|
||||
__start__: { __start__: v01 }
|
||||
nodeA: { branch:to:nodeA: v02 }
|
||||
nodeB: { branch:to:nodeB: v03 } ← same version!
|
||||
nodeC: { branch:to:nodeC: v03 } ← same version!
|
||||
|
||||
last_seen_by_node = [(v01, "__start__"), (v02, "nodeA"), (v03, "nodeB"), (v03, "nodeC")]
|
||||
|
||||
Check: last[-1][0] != last[-2][0]?
|
||||
v03 != v03? ❌ NO (same version = same superstep)
|
||||
|
||||
Result: AMBIGUOUS! Multiple nodes executed in the last superstep.
|
||||
→ Raises InvalidUpdateError("Ambiguous update, specify as_node")
|
||||
```
|
||||
|
||||
#### Step 3: After nodeD executes
|
||||
|
||||
```
|
||||
versions_seen:
|
||||
__start__: { __start__: v01 }
|
||||
nodeA: { branch:to:nodeA: v02 }
|
||||
nodeB: { branch:to:nodeB: v03 }
|
||||
nodeC: { branch:to:nodeC: v03 }
|
||||
nodeD: { join:nodeB+nodeC:nodeD: v04 }
|
||||
|
||||
last_seen_by_node = [(v01, "__start__"), (v02, "nodeA"), (v03, "nodeB"), (v03, "nodeC"), (v04, "nodeD")]
|
||||
|
||||
Check: last[-1][0] != last[-2][0]?
|
||||
v04 != v03? ✅ YES
|
||||
|
||||
Result: as_node = "nodeD" (only node in the latest superstep)
|
||||
```
|
||||
|
||||
### Summary Table
|
||||
|
||||
| Step | Last Two Versions | Same Superstep? | as_node |
|
||||
|------|-------------------|-----------------|---------|
|
||||
| Step 1 | v02, v01 | No | ✅ nodeA |
|
||||
| Step 2 | v03, v03 | **Yes (parallel!)** | ❌ Ambiguous |
|
||||
| Step 3 | v04, v03 | No | ✅ nodeD |
|
||||
|
||||
### Visual Representation
|
||||
|
||||
```
|
||||
Superstep Timeline:
|
||||
|
||||
Superstep 0 Superstep 1 Superstep 2 Superstep 3
|
||||
(v01, v02) (v03) (v04) (v05)
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────┐ ┌──────────┐ ┌─────────┐ ┌────────┐
|
||||
│__start__│ │ nodeA │ │ nodeB │ │ nodeD │
|
||||
└────────┘ └──────────┘ │ nodeC │ └────────┘
|
||||
│(parallel)│
|
||||
└─────────┘
|
||||
|
||||
When version[-1] == version[-2]:
|
||||
→ Multiple nodes in the same superstep
|
||||
→ Cannot determine which one was "last"
|
||||
→ Ambiguous!
|
||||
```
|
||||
|
||||
### The Logic Explained
|
||||
|
||||
```python
|
||||
if last_seen_by_node:
|
||||
if len(last_seen_by_node) == 1:
|
||||
# Only one node ever executed
|
||||
as_node = last_seen_by_node[0][1]
|
||||
elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]:
|
||||
# Last two have different versions
|
||||
# → Last superstep had only ONE node
|
||||
# → That node is unambiguously the "last" one
|
||||
as_node = last_seen_by_node[-1][1]
|
||||
# else: versions are equal
|
||||
# → Multiple nodes in the last superstep
|
||||
# → Ambiguous, will raise error later
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How Scheduling Works
|
||||
|
||||
```python
|
||||
def _triggers(channels, versions, seen, null_version, proc) -> bool:
|
||||
for chan in proc.triggers: # Only checks triggers!
|
||||
if channels[chan].is_available() and \ # Condition 1: channel has value
|
||||
versions.get(chan, null_version) > seen.get(chan, null_version): # Condition 2: version updated
|
||||
return True
|
||||
return False
|
||||
```
|
||||
|
||||
Translation:
|
||||
> Trigger the node if ANY trigger channel satisfies **BOTH** conditions:
|
||||
> 1. `is_available()` - the channel has a value
|
||||
> 2. `current_version > seen_version` - the version is newer than what the node has seen
|
||||
|
||||
**Important**: Both conditions must be met! This is why `EphemeralValue` channels (like `branch:to:*`)
|
||||
can have their version increase after being consumed, but won't re-trigger the node because
|
||||
`is_available()` returns `False` after consumption.
|
||||
|
||||
Since only triggers are checked, only trigger versions need to be recorded in `versions_seen`.
|
||||
|
||||
---
|
||||
|
||||
## Running the Test
|
||||
|
||||
To run the test script yourself:
|
||||
|
||||
```bash
|
||||
cd libs/langgraph
|
||||
uv run python test_versions_seen.py
|
||||
```
|
||||
|
||||
Generated
+1
-1
@@ -316,7 +316,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "0.6.7"
|
||||
version = "0.6.6"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from langgraph_sdk.auth import Auth
|
||||
from langgraph_sdk.client import get_client, get_sync_client
|
||||
|
||||
__version__ = "0.2.6"
|
||||
__version__ = "0.2.4"
|
||||
|
||||
__all__ = ["Auth", "get_client", "get_sync_client"]
|
||||
|
||||
@@ -400,20 +400,6 @@ class AuthContext(BaseAuthContext):
|
||||
"""
|
||||
|
||||
|
||||
class ThreadTTL(typing.TypedDict, total=False):
|
||||
"""Time-to-live configuration for a thread.
|
||||
|
||||
Matches the OpenAPI schema where TTL is represented as an object with
|
||||
an optional strategy and a time value in minutes.
|
||||
"""
|
||||
|
||||
strategy: typing.Literal["delete"]
|
||||
"""TTL strategy. Currently only 'delete' is supported."""
|
||||
|
||||
ttl: int
|
||||
"""Time-to-live in minutes from now until the thread should be swept."""
|
||||
|
||||
|
||||
class ThreadsCreate(typing.TypedDict, total=False):
|
||||
"""Parameters for creating a new thread.
|
||||
|
||||
@@ -436,9 +422,6 @@ class ThreadsCreate(typing.TypedDict, total=False):
|
||||
if_exists: OnConflictBehavior
|
||||
"""Behavior when a thread with the same ID already exists."""
|
||||
|
||||
ttl: ThreadTTL
|
||||
"""Optional TTL configuration for the thread."""
|
||||
|
||||
|
||||
class ThreadsRead(typing.TypedDict, total=False):
|
||||
"""Parameters for reading thread state or run information.
|
||||
@@ -506,9 +489,6 @@ class ThreadsSearch(typing.TypedDict, total=False):
|
||||
offset: int
|
||||
"""Offset for pagination."""
|
||||
|
||||
ids: Sequence[UUID] | None
|
||||
"""typing.Optional list of thread IDs to filter by."""
|
||||
|
||||
thread_id: UUID | None
|
||||
"""typing.Optional thread ID to filter by."""
|
||||
|
||||
|
||||
@@ -157,38 +157,27 @@ def get_client(
|
||||
headers: Mapping[str, str] | None = None,
|
||||
timeout: TimeoutTypes | None = None,
|
||||
) -> LangGraphClient:
|
||||
"""Create and configure a LangGraphClient.
|
||||
|
||||
The client provides programmatic access to a LangGraph Platform deployment. It supports
|
||||
both remote servers and local in-process connections (when running inside a LangGraph server).
|
||||
"""Get a LangGraphClient instance.
|
||||
|
||||
Args:
|
||||
url:
|
||||
Base URL of the LangGraph API.
|
||||
– If `None`, the client first attempts an in-process connection via ASGI transport.
|
||||
If that fails, it falls back to `http://localhost:8123`.
|
||||
api_key:
|
||||
API key for authentication. If omitted, the client reads from environment
|
||||
variables in the following order:
|
||||
1. Function argument
|
||||
2. `LANGGRAPH_API_KEY`
|
||||
3. `LANGSMITH_API_KEY`
|
||||
4. `LANGCHAIN_API_KEY`
|
||||
headers:
|
||||
Additional HTTP headers to include in requests. Merged with authentication headers.
|
||||
timeout:
|
||||
HTTP timeout configuration. May be:
|
||||
– `httpx.Timeout` instance
|
||||
– float (total seconds)
|
||||
– tuple `(connect, read, write, pool)` in seconds
|
||||
Defaults: connect=5, read=300, write=300, pool=5.
|
||||
url: The URL of the LangGraph API.
|
||||
api_key: The API key. If not provided, it will be read from the environment.
|
||||
Precedence:
|
||||
1. explicit argument
|
||||
2. LANGGRAPH_API_KEY
|
||||
3. LANGSMITH_API_KEY
|
||||
4. LANGCHAIN_API_KEY
|
||||
headers: Optional custom headers
|
||||
timeout: Optional timeout configuration for the HTTP client.
|
||||
Accepts an httpx.Timeout instance, a float (seconds), or a tuple of timeouts.
|
||||
Tuple format is (connect, read, write, pool)
|
||||
If not provided, defaults to connect=5s, read=300s, write=300s, and pool=5s.
|
||||
|
||||
Returns:
|
||||
LangGraphClient:
|
||||
A top-level client exposing sub-clients for assistants, threads,
|
||||
runs, and cron operations.
|
||||
LangGraphClient: The top-level client for accessing AssistantsClient,
|
||||
ThreadsClient, RunsClient, and CronClient.
|
||||
|
||||
???+ example "Connect to a remote server:"
|
||||
???+ example "Example"
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
@@ -199,21 +188,6 @@ def get_client(
|
||||
# example usage: client.<model>.<method_name>()
|
||||
assistants = await client.assistants.get(assistant_id="some_uuid")
|
||||
```
|
||||
|
||||
???+ example "Connect in-process to a running LangGraph server:"
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
client = get_client(url=None)
|
||||
|
||||
async def my_node(...):
|
||||
subagent_result = await client.runs.wait(
|
||||
thread_id=None,
|
||||
assistant_id="agent",
|
||||
input={"messages": [{"role": "user", "content": "Foo"}]},
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
transport: httpx.AsyncBaseTransport | None = None
|
||||
@@ -1205,7 +1179,6 @@ class ThreadsClient:
|
||||
if_exists: OnConflictBehavior | None = None,
|
||||
supersteps: Sequence[dict[str, Sequence[dict[str, Any]]]] | None = None,
|
||||
graph_id: str | None = None,
|
||||
ttl: int | Mapping[str, Any] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> Thread:
|
||||
@@ -1220,9 +1193,6 @@ class ThreadsClient:
|
||||
supersteps: Apply a list of supersteps when creating a thread, each containing a sequence of updates.
|
||||
Each update has `values` or `command` and `as_node`. Used for copying a thread between deployments.
|
||||
graph_id: Optional graph ID to associate with the thread.
|
||||
ttl: Optional time-to-live in minutes for the thread. You can pass an
|
||||
integer (minutes) or a mapping with keys `ttl` and optional
|
||||
`strategy` (defaults to "delete").
|
||||
headers: Optional custom headers to include with the request.
|
||||
params: Optional query parameters to include with the request.
|
||||
|
||||
@@ -1264,11 +1234,6 @@ class ThreadsClient:
|
||||
}
|
||||
for s in supersteps
|
||||
]
|
||||
if ttl is not None:
|
||||
if isinstance(ttl, (int, float)):
|
||||
payload["ttl"] = {"ttl": ttl, "strategy": "delete"}
|
||||
else:
|
||||
payload["ttl"] = ttl
|
||||
|
||||
return await self.http.post(
|
||||
"/threads", json=payload, headers=headers, params=params
|
||||
@@ -1279,7 +1244,6 @@ class ThreadsClient:
|
||||
thread_id: str,
|
||||
*,
|
||||
metadata: Mapping[str, Any],
|
||||
ttl: int | Mapping[str, Any] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> Thread:
|
||||
@@ -1288,9 +1252,6 @@ class ThreadsClient:
|
||||
Args:
|
||||
thread_id: ID of thread to update.
|
||||
metadata: Metadata to merge with existing thread metadata.
|
||||
ttl: Optional time-to-live in minutes for the thread. You can pass an
|
||||
integer (minutes) or a mapping with keys `ttl` and optional
|
||||
`strategy` (defaults to "delete").
|
||||
headers: Optional custom headers to include with the request.
|
||||
params: Optional query parameters to include with the request.
|
||||
|
||||
@@ -1304,19 +1265,12 @@ class ThreadsClient:
|
||||
thread = await client.threads.update(
|
||||
thread_id="my-thread-id",
|
||||
metadata={"number":1},
|
||||
ttl=43_200,
|
||||
)
|
||||
```
|
||||
""" # noqa: E501
|
||||
payload: dict[str, Any] = {"metadata": metadata}
|
||||
if ttl is not None:
|
||||
if isinstance(ttl, (int, float)):
|
||||
payload["ttl"] = {"ttl": ttl, "strategy": "delete"}
|
||||
else:
|
||||
payload["ttl"] = ttl
|
||||
return await self.http.patch(
|
||||
f"/threads/{thread_id}",
|
||||
json=payload,
|
||||
json={"metadata": metadata},
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
@@ -1355,7 +1309,6 @@ class ThreadsClient:
|
||||
*,
|
||||
metadata: Json = None,
|
||||
values: Json = None,
|
||||
ids: Sequence[str] | None = None,
|
||||
status: ThreadStatus | None = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
@@ -1370,7 +1323,6 @@ class ThreadsClient:
|
||||
Args:
|
||||
metadata: Thread metadata to filter on.
|
||||
values: State values to filter on.
|
||||
ids: List of thread IDs to filter by.
|
||||
status: Thread status to filter on.
|
||||
Must be one of 'idle', 'busy', 'interrupted' or 'error'.
|
||||
limit: Limit on number of threads to return.
|
||||
@@ -1404,8 +1356,6 @@ class ThreadsClient:
|
||||
payload["metadata"] = metadata
|
||||
if values:
|
||||
payload["values"] = values
|
||||
if ids:
|
||||
payload["ids"] = ids
|
||||
if status:
|
||||
payload["status"] = status
|
||||
if sort_by:
|
||||
@@ -4378,7 +4328,6 @@ class SyncThreadsClient:
|
||||
if_exists: OnConflictBehavior | None = None,
|
||||
supersteps: Sequence[dict[str, Sequence[dict[str, Any]]]] | None = None,
|
||||
graph_id: str | None = None,
|
||||
ttl: int | Mapping[str, Any] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> Thread:
|
||||
@@ -4393,9 +4342,6 @@ class SyncThreadsClient:
|
||||
supersteps: Apply a list of supersteps when creating a thread, each containing a sequence of updates.
|
||||
Each update has `values` or `command` and `as_node`. Used for copying a thread between deployments.
|
||||
graph_id: Optional graph ID to associate with the thread.
|
||||
ttl: Optional time-to-live in minutes for the thread. You can pass an
|
||||
integer (minutes) or a mapping with keys `ttl` and optional
|
||||
`strategy` (defaults to "delete").
|
||||
headers: Optional custom headers to include with the request.
|
||||
|
||||
Returns:
|
||||
@@ -4437,11 +4383,6 @@ class SyncThreadsClient:
|
||||
}
|
||||
for s in supersteps
|
||||
]
|
||||
if ttl is not None:
|
||||
if isinstance(ttl, (int, float)):
|
||||
payload["ttl"] = {"ttl": ttl, "strategy": "delete"}
|
||||
else:
|
||||
payload["ttl"] = ttl
|
||||
|
||||
return self.http.post("/threads", json=payload, headers=headers, params=params)
|
||||
|
||||
@@ -4450,7 +4391,6 @@ class SyncThreadsClient:
|
||||
thread_id: str,
|
||||
*,
|
||||
metadata: Mapping[str, Any],
|
||||
ttl: int | Mapping[str, Any] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> Thread:
|
||||
@@ -4459,11 +4399,7 @@ class SyncThreadsClient:
|
||||
Args:
|
||||
thread_id: ID of thread to update.
|
||||
metadata: Metadata to merge with existing thread metadata.
|
||||
ttl: Optional time-to-live in minutes for the thread. You can pass an
|
||||
integer (minutes) or a mapping with keys `ttl` and optional
|
||||
`strategy` (defaults to "delete").
|
||||
headers: Optional custom headers to include with the request.
|
||||
params: Optional query parameters to include with the request.
|
||||
|
||||
Returns:
|
||||
Thread: The created thread.
|
||||
@@ -4475,19 +4411,12 @@ class SyncThreadsClient:
|
||||
thread = client.threads.update(
|
||||
thread_id="my-thread-id",
|
||||
metadata={"number":1},
|
||||
ttl=43_200,
|
||||
)
|
||||
```
|
||||
""" # noqa: E501
|
||||
payload: dict[str, Any] = {"metadata": metadata}
|
||||
if ttl is not None:
|
||||
if isinstance(ttl, (int, float)):
|
||||
payload["ttl"] = {"ttl": ttl, "strategy": "delete"}
|
||||
else:
|
||||
payload["ttl"] = ttl
|
||||
return self.http.patch(
|
||||
f"/threads/{thread_id}",
|
||||
json=payload,
|
||||
json={"metadata": metadata},
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
@@ -4525,7 +4454,6 @@ class SyncThreadsClient:
|
||||
*,
|
||||
metadata: Json = None,
|
||||
values: Json = None,
|
||||
ids: Sequence[str] | None = None,
|
||||
status: ThreadStatus | None = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
@@ -4540,7 +4468,6 @@ class SyncThreadsClient:
|
||||
Args:
|
||||
metadata: Thread metadata to filter on.
|
||||
values: State values to filter on.
|
||||
ids: List of thread IDs to filter by.
|
||||
status: Thread status to filter on.
|
||||
Must be one of 'idle', 'busy', 'interrupted' or 'error'.
|
||||
limit: Limit on number of threads to return.
|
||||
@@ -4570,8 +4497,6 @@ class SyncThreadsClient:
|
||||
payload["metadata"] = metadata
|
||||
if values:
|
||||
payload["values"] = values
|
||||
if ids:
|
||||
payload["ids"] = ids
|
||||
if status:
|
||||
payload["status"] = status
|
||||
if sort_by:
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
# LangGraph Channel Types Usage Analysis
|
||||
|
||||
本文档分析了 LangGraph 中各种通道类型在 StateGraph 中的使用情况,基于对整个仓库的深入分析。
|
||||
|
||||
## 通道类型使用总结
|
||||
|
||||
### 1. **LastValue** - 最常用的默认通道
|
||||
**使用场景:** 普通状态字段的默认通道类型
|
||||
**创建方式:** 自动创建(fallback)
|
||||
**仓库例子:**
|
||||
```python
|
||||
class State(TypedDict):
|
||||
hello: str # 自动创建 LastValue(str) 通道
|
||||
count: int # 自动创建 LastValue(int) 通道
|
||||
```
|
||||
|
||||
### 2. **BinaryOperatorAggregate** - 状态聚合通道
|
||||
**使用场景:** 使用 reducer 函数进行状态聚合
|
||||
**创建方式:** 通过 `Annotated[Type, reducer_function]`
|
||||
**仓库例子:**
|
||||
```python
|
||||
# 例子1: 使用 add_messages
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list[str], add_messages] # 创建 BinaryOperatorAggregate
|
||||
|
||||
# 例子2: 使用 operator.add
|
||||
StateGraph(Annotated[str, operator.add]) # 整个状态使用聚合
|
||||
StateGraph(Annotated[list, operator.add]) # 列表聚合
|
||||
|
||||
# 例子3: 使用 operator.or_
|
||||
class State(TypedDict):
|
||||
val3: Required[Annotated[dict, operator.or_]] # 字典合并
|
||||
```
|
||||
|
||||
### 3. **EphemeralValue** - 临时通道
|
||||
**使用场景:** 系统内部使用,节点间的临时通信
|
||||
**创建方式:** 系统自动创建
|
||||
**仓库例子:**
|
||||
```python
|
||||
# StateGraph.compile() 中自动创建
|
||||
START: EphemeralValue(self.input_schema) # 输入通道
|
||||
|
||||
# CompiledStateGraph.attach_node() 中创建
|
||||
EphemeralValue(Any, guard=False) # 节点分支通道
|
||||
```
|
||||
|
||||
### 4. **LastValueAfterFinish** - 延迟通道
|
||||
**使用场景:** 节点设置了 `defer=True` 时使用
|
||||
**创建方式:** 系统根据节点配置自动创建
|
||||
**仓库例子:**
|
||||
```python
|
||||
# CompiledStateGraph.attach_node() 中
|
||||
if node.defer:
|
||||
LastValueAfterFinish(Any) # 延迟节点的分支通道
|
||||
```
|
||||
|
||||
### 5. **NamedBarrierValue** - 同步屏障通道
|
||||
**使用场景:** 多个节点汇聚到一个节点时的同步
|
||||
**创建方式:** 系统在处理 waiting_edges 时自动创建
|
||||
**仓库例子:**
|
||||
```python
|
||||
# CompiledStateGraph.attach_edge() 中
|
||||
channel_name = f"join:{'+'.join(starts)}:{end}"
|
||||
self.channels[channel_name] = NamedBarrierValue(str, set(starts))
|
||||
```
|
||||
|
||||
### 6. **NamedBarrierValueAfterFinish** - 延迟同步屏障通道
|
||||
**使用场景:** 延迟节点的多节点汇聚同步
|
||||
**创建方式:** 系统在处理延迟节点的 waiting_edges 时自动创建
|
||||
**仓库例子:**
|
||||
```python
|
||||
# CompiledStateGraph.attach_edge() 中
|
||||
if self.builder.nodes[end].defer:
|
||||
self.channels[channel_name] = NamedBarrierValueAfterFinish(str, set(starts))
|
||||
```
|
||||
|
||||
### 7. **Topic** - 发布订阅通道
|
||||
**使用场景:** 仅在直接使用 Pregel 时手动创建,StateGraph 中无实际使用
|
||||
**创建方式:** 手动创建或系统内部 TASKS 通道
|
||||
**仓库例子:**
|
||||
```python
|
||||
# Pregel.__init__() 中系统创建
|
||||
self.channels[TASKS] = Topic(Send, accumulate=False)
|
||||
|
||||
# 直接 Pregel 使用(非 StateGraph)
|
||||
app = Pregel(
|
||||
channels={"c": Topic(str, accumulate=True)}, # 手动创建
|
||||
# ...
|
||||
)
|
||||
```
|
||||
|
||||
### 8. **UntrackedValue** - 未追踪通道
|
||||
**使用场景:** 在仓库中未找到实际使用例子
|
||||
**创建方式:** 需要手动创建
|
||||
**状态:** 理论存在但实际未使用
|
||||
|
||||
### 9. **AnyValue** - 任意值通道
|
||||
**使用场景:** 在仓库中未找到实际使用例子
|
||||
**创建方式:** 需要手动创建
|
||||
**状态:** 理论存在但实际未使用
|
||||
|
||||
## 使用模式总结
|
||||
|
||||
### **用户显式创建的通道:**
|
||||
1. **BinaryOperatorAggregate** - 通过 `Annotated[Type, reducer]`
|
||||
2. **Topic** - 仅在直接 Pregel API 中手动创建
|
||||
|
||||
### **系统自动创建的通道:**
|
||||
1. **LastValue** - 默认通道类型
|
||||
2. **EphemeralValue** - 输入和分支通道
|
||||
3. **LastValueAfterFinish** - 延迟节点分支
|
||||
4. **NamedBarrierValue** - 多节点汇聚同步
|
||||
5. **NamedBarrierValueAfterFinish** - 延迟节点汇聚同步
|
||||
|
||||
### **实际使用频率:**
|
||||
1. **高频使用:** LastValue, BinaryOperatorAggregate, EphemeralValue
|
||||
2. **中频使用:** NamedBarrierValue, LastValueAfterFinish
|
||||
3. **低频使用:** Topic(仅系统内部)
|
||||
4. **未使用:** UntrackedValue, AnyValue
|
||||
|
||||
## 通道创建机制
|
||||
|
||||
### 自动创建流程
|
||||
1. **StateGraph._add_schema()** - 解析状态类型
|
||||
2. **_get_channels()** - 提取类型注解
|
||||
3. **_get_channel()** - 判断通道类型:
|
||||
- 检查 Managed Value
|
||||
- 检查 Channel(如 Topic)
|
||||
- 检查 BinaryOperator(如 add_messages)
|
||||
- 默认创建 LastValue
|
||||
|
||||
### 编译时创建
|
||||
- **START 通道:** `EphemeralValue(input_schema)`
|
||||
- **分支通道:** `EphemeralValue(Any, guard=False)` 或 `LastValueAfterFinish(Any)`
|
||||
- **汇聚通道:** `NamedBarrierValue` 或 `NamedBarrierValueAfterFinish`
|
||||
|
||||
## 设计哲学
|
||||
|
||||
**StateGraph 主要关注状态管理,大部分通道类型都是系统自动管理的,用户只需要关心状态结构和聚合逻辑**。只有在需要特殊聚合行为时,用户才需要显式使用 `Annotated` 注解来指定 reducer 函数。
|
||||
|
||||
## 关键发现
|
||||
|
||||
1. **Topic 通道在 StateGraph 中几乎不使用** - 仓库中没有通过状态 Schema 注解创建 Topic 的实际例子
|
||||
2. **BinaryOperatorAggregate 是用户最常显式创建的通道** - 通过 `add_messages` 等 reducer 函数
|
||||
3. **大部分通道都是系统内部自动管理** - 用户无需关心底层通道实现
|
||||
4. **StateGraph 和直接 Pregel 的使用场景不同** - StateGraph 专注状态管理,Pregel 专注消息传递
|
||||
|
||||
## 实际代码位置
|
||||
|
||||
- **通道创建逻辑:** `libs/langgraph/langgraph/graph/state.py:1299-1388`
|
||||
- **编译时通道管理:** `libs/langgraph/langgraph/graph/state.py:856-894`
|
||||
- **节点附加逻辑:** `libs/langgraph/langgraph/graph/state.py:935-1067`
|
||||
- **边处理逻辑:** `libs/langgraph/langgraph/graph/state.py:1038-1062`
|
||||
Reference in New Issue
Block a user