mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-24 16:42:24 +02:00
Remove cli
This commit is contained in:
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 LangChain, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,33 +0,0 @@
|
||||
.PHONY: test lint format test-integration
|
||||
|
||||
######################
|
||||
# TESTING AND COVERAGE
|
||||
######################
|
||||
|
||||
test:
|
||||
poetry run pytest tests/unit_tests
|
||||
test-integration:
|
||||
poetry run pytest tests/integration_tests
|
||||
|
||||
######################
|
||||
# LINTING AND FORMATTING
|
||||
######################
|
||||
|
||||
# Define a variable for Python and notebook files.
|
||||
PYTHON_FILES=.
|
||||
MYPY_CACHE=.mypy_cache
|
||||
lint format: PYTHON_FILES=.
|
||||
lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$')
|
||||
lint_package: PYTHON_FILES=langgraph_cli
|
||||
lint_tests: PYTHON_FILES=tests
|
||||
lint_tests: MYPY_CACHE=.mypy_cache_test
|
||||
|
||||
lint lint_diff lint_package lint_tests:
|
||||
poetry run ruff check .
|
||||
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff format $(PYTHON_FILES) --diff
|
||||
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff check --select I $(PYTHON_FILES)
|
||||
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) || poetry run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
format format_diff:
|
||||
poetry run ruff format $(PYTHON_FILES)
|
||||
poetry run ruff check --select I --fix $(PYTHON_FILES)
|
||||
@@ -1,105 +0,0 @@
|
||||
# LangGraph CLI
|
||||
|
||||
The official command-line interface for LangGraph, providing tools to create, develop, and deploy LangGraph applications.
|
||||
|
||||
## Installation
|
||||
|
||||
Install via pip:
|
||||
```bash
|
||||
pip install langgraph-cli
|
||||
```
|
||||
|
||||
For development mode with hot reloading:
|
||||
```bash
|
||||
pip install "langgraph-cli[inmem]"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `langgraph new` 🌱
|
||||
Create a new LangGraph project from a template
|
||||
```bash
|
||||
langgraph new [PATH] --template TEMPLATE_NAME
|
||||
```
|
||||
|
||||
### `langgraph dev` 🏃♀️
|
||||
Run LangGraph API server in development mode with hot reloading
|
||||
```bash
|
||||
langgraph dev [OPTIONS]
|
||||
--host TEXT Host to bind to (default: 127.0.0.1)
|
||||
--port INTEGER Port to bind to (default: 2024)
|
||||
--no-reload Disable auto-reload
|
||||
--debug-port INTEGER Enable remote debugging
|
||||
--no-browser Skip opening browser window
|
||||
-c, --config FILE Config file path (default: langgraph.json)
|
||||
```
|
||||
|
||||
### `langgraph up` 🚀
|
||||
Launch LangGraph API server in Docker
|
||||
```bash
|
||||
langgraph up [OPTIONS]
|
||||
-p, --port INTEGER Port to expose (default: 8123)
|
||||
--wait Wait for services to start
|
||||
--watch Restart on file changes
|
||||
--verbose Show detailed logs
|
||||
-c, --config FILE Config file path
|
||||
-d, --docker-compose Additional services file
|
||||
```
|
||||
|
||||
### `langgraph build`
|
||||
Build a Docker image for your LangGraph application
|
||||
```bash
|
||||
langgraph build -t IMAGE_TAG [OPTIONS]
|
||||
--platform TEXT Target platforms (e.g., linux/amd64,linux/arm64)
|
||||
--pull / --no-pull Use latest/local base image
|
||||
-c, --config FILE Config file path
|
||||
```
|
||||
|
||||
### `langgraph dockerfile`
|
||||
Generate a Dockerfile for custom deployments
|
||||
```bash
|
||||
langgraph dockerfile SAVE_PATH [OPTIONS]
|
||||
-c, --config FILE Config file path
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The CLI uses a `langgraph.json` configuration file with these key settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": ["langchain_openai", "./your_package"], // Required: Package dependencies
|
||||
"graphs": {
|
||||
"my_graph": "./your_package/file.py:graph" // Required: Graph definitions
|
||||
},
|
||||
"env": "./.env", // Optional: Environment variables
|
||||
"python_version": "3.11", // Optional: Python version (3.11/3.12)
|
||||
"pip_config_file": "./pip.conf", // Optional: pip configuration
|
||||
"dockerfile_lines": [] // Optional: Additional Dockerfile commands
|
||||
}
|
||||
```
|
||||
|
||||
See the [full documentation](https://langchain-ai.github.io/langgraph/docs/cloud/reference/cli.html) for detailed configuration options.
|
||||
|
||||
## Development
|
||||
|
||||
To develop the CLI itself:
|
||||
|
||||
1. Clone the repository
|
||||
2. Navigate to the CLI directory: `cd libs/cli`
|
||||
3. Install development dependencies: `poetry install`
|
||||
4. Make your changes to the CLI code
|
||||
5. Test your changes:
|
||||
```bash
|
||||
# Run CLI commands directly
|
||||
poetry run langgraph --help
|
||||
|
||||
# Or use the examples
|
||||
cd examples
|
||||
poetry install
|
||||
poetry run langgraph dev # or other commands
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the terms specified in the repository's LICENSE file.
|
||||
@@ -1,10 +0,0 @@
|
||||
OPENAI_API_KEY=placeholder
|
||||
ANTHROPIC_API_KEY=placeholder
|
||||
TAVILY_API_KEY=placeholder
|
||||
LANGCHAIN_TRACING_V2=false
|
||||
LANGCHAIN_ENDPOINT=placeholder
|
||||
LANGCHAIN_API_KEY=placeholder
|
||||
LANGCHAIN_PROJECT=placeholder
|
||||
LANGGRAPH_AUTH_TYPE=noop
|
||||
LANGSMITH_AUTH_ENDPOINT=placeholder
|
||||
LANGSMITH_TENANT_ID=placeholder
|
||||
@@ -1 +0,0 @@
|
||||
.langgraph-data
|
||||
@@ -1,13 +0,0 @@
|
||||
.PHONY: run_w_override
|
||||
|
||||
run:
|
||||
poetry run langgraph up --watch --no-pull
|
||||
|
||||
run_faux:
|
||||
cd graphs && poetry run langgraph up --no-pull
|
||||
|
||||
run_graphs_reqs_a:
|
||||
cd graphs_reqs_a && poetry run langgraph up --no-pull
|
||||
|
||||
run_graphs_reqs_b:
|
||||
cd graphs_reqs_b && poetry run langgraph up --no-pull
|
||||
@@ -1,94 +0,0 @@
|
||||
from typing import Annotated, Literal, Sequence, TypedDict
|
||||
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
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 = ChatAnthropic(temperature=0, model_name="claude-3-sonnet-20240229")
|
||||
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 ConfigSchema(TypedDict):
|
||||
model: Literal["anthropic", "openai"]
|
||||
|
||||
|
||||
# Define a new graph
|
||||
workflow = StateGraph(AgentState, config_schema=ConfigSchema)
|
||||
|
||||
# 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,16 +0,0 @@
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": [
|
||||
"langchain_community",
|
||||
"langchain_anthropic",
|
||||
"langchain_openai",
|
||||
"wikipedia",
|
||||
"scikit-learn",
|
||||
"."
|
||||
],
|
||||
"graphs": {
|
||||
"agent": "./agent.py:graph",
|
||||
"storm": "./storm.py:graph"
|
||||
},
|
||||
"env": "../.env"
|
||||
}
|
||||
@@ -1,636 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Annotated, List, Optional
|
||||
|
||||
from langchain_community.retrievers import WikipediaRetriever
|
||||
from langchain_community.tools.tavily_search import TavilySearchResults
|
||||
from langchain_community.vectorstores import SKLearnVectorStore
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
AnyMessage,
|
||||
HumanMessage,
|
||||
ToolMessage,
|
||||
)
|
||||
from langchain_core.output_parsers import StrOutputParser
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from langchain_core.runnables import RunnableConfig, RunnableLambda
|
||||
from langchain_core.runnables import chain as as_runnable
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
|
||||
from langgraph.graph import END, StateGraph
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
fast_llm = ChatOpenAI(model="gpt-3.5-turbo")
|
||||
# Uncomment for a Fireworks model
|
||||
# fast_llm = ChatFireworks(model="accounts/fireworks/models/firefunction-v1", max_tokens=32_000)
|
||||
long_context_llm = ChatOpenAI(model="gpt-4-turbo-preview")
|
||||
|
||||
|
||||
direct_gen_outline_prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
(
|
||||
"system",
|
||||
"You are a Wikipedia writer. Write an outline for a Wikipedia page about a user-provided topic. Be comprehensive and specific.",
|
||||
),
|
||||
("user", "{topic}"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class Subsection(BaseModel):
|
||||
subsection_title: str = Field(..., title="Title of the subsection")
|
||||
description: str = Field(..., title="Content of the subsection")
|
||||
|
||||
@property
|
||||
def as_str(self) -> str:
|
||||
return f"### {self.subsection_title}\n\n{self.description}".strip()
|
||||
|
||||
|
||||
class Section(BaseModel):
|
||||
section_title: str = Field(..., title="Title of the section")
|
||||
description: str = Field(..., title="Content of the section")
|
||||
subsections: Optional[List[Subsection]] = Field(
|
||||
default=None,
|
||||
title="Titles and descriptions for each subsection of the Wikipedia page.",
|
||||
)
|
||||
|
||||
@property
|
||||
def as_str(self) -> str:
|
||||
subsections = "\n\n".join(
|
||||
f"### {subsection.subsection_title}\n\n{subsection.description}"
|
||||
for subsection in self.subsections or []
|
||||
)
|
||||
return f"## {self.section_title}\n\n{self.description}\n\n{subsections}".strip()
|
||||
|
||||
|
||||
class Outline(BaseModel):
|
||||
page_title: str = Field(..., title="Title of the Wikipedia page")
|
||||
sections: List[Section] = Field(
|
||||
default_factory=list,
|
||||
title="Titles and descriptions for each section of the Wikipedia page.",
|
||||
)
|
||||
|
||||
@property
|
||||
def as_str(self) -> str:
|
||||
sections = "\n\n".join(section.as_str for section in self.sections)
|
||||
return f"# {self.page_title}\n\n{sections}".strip()
|
||||
|
||||
|
||||
generate_outline_direct = direct_gen_outline_prompt | fast_llm.with_structured_output(
|
||||
Outline
|
||||
)
|
||||
|
||||
gen_related_topics_prompt = ChatPromptTemplate.from_template(
|
||||
"""I'm writing a Wikipedia page for a topic mentioned below. Please identify and recommend some Wikipedia pages on closely related subjects. I'm looking for examples that provide insights into interesting aspects commonly associated with this topic, or examples that help me understand the typical content and structure included in Wikipedia pages for similar topics.
|
||||
|
||||
Please list the as many subjects and urls as you can.
|
||||
|
||||
Topic of interest: {topic}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
class RelatedSubjects(BaseModel):
|
||||
topics: List[str] = Field(
|
||||
description="Comprehensive list of related subjects as background research.",
|
||||
)
|
||||
|
||||
|
||||
expand_chain = gen_related_topics_prompt | fast_llm.with_structured_output(
|
||||
RelatedSubjects
|
||||
)
|
||||
|
||||
|
||||
class Editor(BaseModel):
|
||||
affiliation: str = Field(
|
||||
description="Primary affiliation of the editor.",
|
||||
)
|
||||
name: str = Field(
|
||||
description="Name of the editor.",
|
||||
)
|
||||
role: str = Field(
|
||||
description="Role of the editor in the context of the topic.",
|
||||
)
|
||||
description: str = Field(
|
||||
description="Description of the editor's focus, concerns, and motives.",
|
||||
)
|
||||
|
||||
@property
|
||||
def persona(self) -> str:
|
||||
return f"Name: {self.name}\nRole: {self.role}\nAffiliation: {self.affiliation}\nDescription: {self.description}\n"
|
||||
|
||||
|
||||
class Perspectives(BaseModel):
|
||||
editors: List[Editor] = Field(
|
||||
description="Comprehensive list of editors with their roles and affiliations.",
|
||||
# Add a pydantic validation/restriction to be at most M editors
|
||||
)
|
||||
|
||||
|
||||
gen_perspectives_prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
(
|
||||
"system",
|
||||
"""You need to select a diverse (and distinct) group of Wikipedia editors who will work together to create a comprehensive article on the topic. Each of them represents a different perspective, role, or affiliation related to this topic.\
|
||||
You can use other Wikipedia pages of related topics for inspiration. For each editor, add a description of what they will focus on.
|
||||
|
||||
Wiki page outlines of related topics for inspiration:
|
||||
{examples}""",
|
||||
),
|
||||
("user", "Topic of interest: {topic}"),
|
||||
]
|
||||
)
|
||||
|
||||
gen_perspectives_chain = gen_perspectives_prompt | ChatOpenAI(
|
||||
model="gpt-3.5-turbo"
|
||||
).with_structured_output(Perspectives)
|
||||
|
||||
|
||||
wikipedia_retriever = WikipediaRetriever(load_all_available_meta=True, top_k_results=1)
|
||||
|
||||
|
||||
def format_doc(doc, max_length=1000):
|
||||
related = "- ".join(doc.metadata["categories"])
|
||||
return f"### {doc.metadata['title']}\n\nSummary: {doc.page_content}\n\nRelated\n{related}"[
|
||||
:max_length
|
||||
]
|
||||
|
||||
|
||||
def format_docs(docs):
|
||||
return "\n\n".join(format_doc(doc) for doc in docs)
|
||||
|
||||
|
||||
@as_runnable
|
||||
async def survey_subjects(topic: str):
|
||||
related_subjects = await expand_chain.ainvoke({"topic": topic})
|
||||
retrieved_docs = await wikipedia_retriever.abatch(
|
||||
related_subjects.topics, return_exceptions=True
|
||||
)
|
||||
all_docs = []
|
||||
for docs in retrieved_docs:
|
||||
if isinstance(docs, BaseException):
|
||||
continue
|
||||
all_docs.extend(docs)
|
||||
formatted = format_docs(all_docs)
|
||||
return await gen_perspectives_chain.ainvoke({"examples": formatted, "topic": topic})
|
||||
|
||||
|
||||
def add_messages(left, right):
|
||||
if not isinstance(left, list):
|
||||
left = [left]
|
||||
if not isinstance(right, list):
|
||||
right = [right]
|
||||
return left + right
|
||||
|
||||
|
||||
def update_references(references, new_references):
|
||||
if not references:
|
||||
references = {}
|
||||
references.update(new_references)
|
||||
return references
|
||||
|
||||
|
||||
def update_editor(editor, new_editor):
|
||||
# Can only set at the outset
|
||||
if not editor:
|
||||
return new_editor
|
||||
return editor
|
||||
|
||||
|
||||
class InterviewState(TypedDict):
|
||||
messages: Annotated[List[AnyMessage], add_messages]
|
||||
references: Annotated[Optional[dict], update_references]
|
||||
editor: Annotated[Optional[Editor], update_editor]
|
||||
|
||||
|
||||
gen_qn_prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
(
|
||||
"system",
|
||||
"""You are an experienced Wikipedia writer and want to edit a specific page. \
|
||||
Besides your identity as a Wikipedia writer, you have a specific focus when researching the topic. \
|
||||
Now, you are chatting with an expert to get information. Ask good questions to get more useful information.
|
||||
|
||||
When you have no more questions to ask, say "Thank you so much for your help!" to end the conversation.\
|
||||
Please only ask one question at a time and don't ask what you have asked before.\
|
||||
Your questions should be related to the topic you want to write.
|
||||
Be comprehensive and curious, gaining as much unique insight from the expert as possible.\
|
||||
|
||||
Stay true to your specific perspective:
|
||||
|
||||
{persona}""",
|
||||
),
|
||||
MessagesPlaceholder(variable_name="messages", optional=True),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def tag_with_name(ai_message: AIMessage, name: str):
|
||||
ai_message.name = name.replace(" ", "_").replace(".", "_")
|
||||
return ai_message
|
||||
|
||||
|
||||
def swap_roles(state: InterviewState, name: str):
|
||||
converted = []
|
||||
for message in state["messages"]:
|
||||
if isinstance(message, AIMessage) and message.name != name:
|
||||
message = HumanMessage(**message.dict(exclude={"type"}))
|
||||
converted.append(message)
|
||||
return {"messages": converted}
|
||||
|
||||
|
||||
@as_runnable
|
||||
async def generate_question(state: InterviewState):
|
||||
editor = state["editor"]
|
||||
gn_chain = (
|
||||
RunnableLambda(swap_roles).bind(name=editor.name)
|
||||
| gen_qn_prompt.partial(persona=editor.persona)
|
||||
| fast_llm
|
||||
| RunnableLambda(tag_with_name).bind(name=editor.name)
|
||||
)
|
||||
result = await gn_chain.ainvoke(state)
|
||||
return {"messages": [result]}
|
||||
|
||||
|
||||
class Queries(BaseModel):
|
||||
queries: List[str] = Field(
|
||||
description="Comprehensive list of search engine queries to answer the user's questions.",
|
||||
)
|
||||
|
||||
|
||||
gen_queries_prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
(
|
||||
"system",
|
||||
"You are a helpful research assistant. Query the search engine to answer the user's questions.",
|
||||
),
|
||||
MessagesPlaceholder(variable_name="messages", optional=True),
|
||||
]
|
||||
)
|
||||
gen_queries_chain = gen_queries_prompt | ChatOpenAI(
|
||||
model="gpt-3.5-turbo"
|
||||
).with_structured_output(Queries, include_raw=True)
|
||||
|
||||
|
||||
class AnswerWithCitations(BaseModel):
|
||||
answer: str = Field(
|
||||
description="Comprehensive answer to the user's question with citations.",
|
||||
)
|
||||
cited_urls: List[str] = Field(
|
||||
description="List of urls cited in the answer.",
|
||||
)
|
||||
|
||||
@property
|
||||
def as_str(self) -> str:
|
||||
return f"{self.answer}\n\nCitations:\n\n" + "\n".join(
|
||||
f"[{i+1}]: {url}" for i, url in enumerate(self.cited_urls)
|
||||
)
|
||||
|
||||
|
||||
gen_answer_prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
(
|
||||
"system",
|
||||
"""You are an expert who can use information effectively. You are chatting with a Wikipedia writer who wants\
|
||||
to write a Wikipedia page on the topic you know. You have gathered the related information and will now use the information to form a response.
|
||||
|
||||
Make your response as informative as possible and make sure every sentence is supported by the gathered information.
|
||||
Each response must be backed up by a citation from a reliable source, formatted as a footnote, reproducing the URLS after your response.""",
|
||||
),
|
||||
MessagesPlaceholder(variable_name="messages", optional=True),
|
||||
]
|
||||
)
|
||||
|
||||
gen_answer_chain = gen_answer_prompt | fast_llm.with_structured_output(
|
||||
AnswerWithCitations, include_raw=True
|
||||
).with_config(run_name="GenerateAnswer")
|
||||
|
||||
|
||||
# Tavily is typically a better search engine, but your free queries are limited
|
||||
tavily_search = TavilySearchResults(max_results=4)
|
||||
|
||||
|
||||
@tool
|
||||
async def search_engine(query: str):
|
||||
"""Search engine to the internet."""
|
||||
results = tavily_search.invoke(query)
|
||||
return [{"content": r["content"], "url": r["url"]} for r in results]
|
||||
|
||||
|
||||
async def gen_answer(
|
||||
state: InterviewState,
|
||||
config: Optional[RunnableConfig] = None,
|
||||
name: str = "Subject_Matter_Expert",
|
||||
max_str_len: int = 15000,
|
||||
):
|
||||
swapped_state = swap_roles(state, name) # Convert all other AI messages
|
||||
queries = await gen_queries_chain.ainvoke(swapped_state)
|
||||
query_results = await search_engine.abatch(
|
||||
queries["parsed"].queries, config, return_exceptions=True
|
||||
)
|
||||
successful_results = [
|
||||
res for res in query_results if not isinstance(res, Exception)
|
||||
]
|
||||
all_query_results = {
|
||||
res["url"]: res["content"] for results in successful_results for res in results
|
||||
}
|
||||
# We could be more precise about handling max token length if we wanted to here
|
||||
dumped = json.dumps(all_query_results)[:max_str_len]
|
||||
ai_message: AIMessage = queries["raw"]
|
||||
tool_call = queries["raw"].tool_calls[0]
|
||||
tool_id = tool_call["id"]
|
||||
tool_message = ToolMessage(tool_call_id=tool_id, content=dumped)
|
||||
swapped_state["messages"].extend([ai_message, tool_message])
|
||||
# Only update the shared state with the final answer to avoid
|
||||
# polluting the dialogue history with intermediate messages
|
||||
generated = await gen_answer_chain.ainvoke(swapped_state)
|
||||
cited_urls = set(generated["parsed"].cited_urls)
|
||||
# Save the retrieved information to a the shared state for future reference
|
||||
cited_references = {k: v for k, v in all_query_results.items() if k in cited_urls}
|
||||
formatted_message = AIMessage(name=name, content=generated["parsed"].as_str)
|
||||
return {"messages": [formatted_message], "references": cited_references}
|
||||
|
||||
|
||||
max_num_turns = 5
|
||||
|
||||
|
||||
def route_messages(state: InterviewState, name: str = "Subject_Matter_Expert"):
|
||||
messages = state["messages"]
|
||||
num_responses = len(
|
||||
[m for m in messages if isinstance(m, AIMessage) and m.name == name]
|
||||
)
|
||||
if num_responses >= max_num_turns:
|
||||
return END
|
||||
last_question = messages[-2]
|
||||
if last_question.content.endswith("Thank you so much for your help!"):
|
||||
return END
|
||||
return "ask_question"
|
||||
|
||||
|
||||
builder = StateGraph(InterviewState)
|
||||
|
||||
builder.add_node("ask_question", generate_question)
|
||||
builder.add_node("answer_question", gen_answer)
|
||||
builder.add_conditional_edges("answer_question", route_messages)
|
||||
builder.add_edge("ask_question", "answer_question")
|
||||
|
||||
builder.set_entry_point("ask_question")
|
||||
interview_graph = builder.compile().with_config(run_name="Conduct Interviews")
|
||||
|
||||
refine_outline_prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
(
|
||||
"system",
|
||||
"""You are a Wikipedia writer. You have gathered information from experts and search engines. Now, you are refining the outline of the Wikipedia page. \
|
||||
You need to make sure that the outline is comprehensive and specific. \
|
||||
Topic you are writing about: {topic}
|
||||
|
||||
Old outline:
|
||||
|
||||
{old_outline}""",
|
||||
),
|
||||
(
|
||||
"user",
|
||||
"Refine the outline based on your conversations with subject-matter experts:\n\nConversations:\n\n{conversations}\n\nWrite the refined Wikipedia outline:",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Using turbo preview since the context can get quite long
|
||||
refine_outline_chain = refine_outline_prompt | long_context_llm.with_structured_output(
|
||||
Outline
|
||||
)
|
||||
|
||||
|
||||
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
|
||||
# reference_docs = [
|
||||
# Document(page_content=v, metadata={"source": k})
|
||||
# for k, v in final_state["references"].items()
|
||||
# ]
|
||||
# # This really doesn't need to be a vectorstore for this size of data.
|
||||
# # It could just be a numpy matrix. Or you could store documents
|
||||
# # across requests if you want.
|
||||
# vectorstore = SKLearnVectorStore.from_documents(
|
||||
# reference_docs,
|
||||
# embedding=embeddings,
|
||||
# )
|
||||
# retriever = vectorstore.as_retriever(k=10)
|
||||
|
||||
vectorstore = SKLearnVectorStore(embedding=embeddings)
|
||||
retriever = vectorstore.as_retriever(k=10)
|
||||
|
||||
|
||||
class SubSection(BaseModel):
|
||||
subsection_title: str = Field(..., title="Title of the subsection")
|
||||
content: str = Field(
|
||||
...,
|
||||
title="Full content of the subsection. Include [#] citations to the cited sources where relevant.",
|
||||
)
|
||||
|
||||
@property
|
||||
def as_str(self) -> str:
|
||||
return f"### {self.subsection_title}\n\n{self.content}".strip()
|
||||
|
||||
|
||||
class WikiSection(BaseModel):
|
||||
section_title: str = Field(..., title="Title of the section")
|
||||
content: str = Field(..., title="Full content of the section")
|
||||
subsections: Optional[List[Subsection]] = Field(
|
||||
default=None,
|
||||
title="Titles and descriptions for each subsection of the Wikipedia page.",
|
||||
)
|
||||
citations: List[str] = Field(default_factory=list)
|
||||
|
||||
@property
|
||||
def as_str(self) -> str:
|
||||
subsections = "\n\n".join(
|
||||
subsection.as_str for subsection in self.subsections or []
|
||||
)
|
||||
citations = "\n".join([f" [{i}] {cit}" for i, cit in enumerate(self.citations)])
|
||||
return (
|
||||
f"## {self.section_title}\n\n{self.content}\n\n{subsections}".strip()
|
||||
+ f"\n\n{citations}".strip()
|
||||
)
|
||||
|
||||
|
||||
section_writer_prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
(
|
||||
"system",
|
||||
"You are an expert Wikipedia writer. Complete your assigned WikiSection from the following outline:\n\n"
|
||||
"{outline}\n\nCite your sources, using the following references:\n\n<Documents>\n{docs}\n<Documents>",
|
||||
),
|
||||
("user", "Write the full WikiSection for the {section} section."),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def retrieve(inputs: dict):
|
||||
docs = await retriever.ainvoke(inputs["topic"] + ": " + inputs["section"])
|
||||
formatted = "\n".join(
|
||||
[
|
||||
f'<Document href="{doc.metadata["source"]}"/>\n{doc.page_content}\n</Document>'
|
||||
for doc in docs
|
||||
]
|
||||
)
|
||||
return {"docs": formatted, **inputs}
|
||||
|
||||
|
||||
section_writer = (
|
||||
retrieve
|
||||
| section_writer_prompt
|
||||
| long_context_llm.with_structured_output(WikiSection)
|
||||
)
|
||||
|
||||
|
||||
writer_prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
(
|
||||
"system",
|
||||
"You are an expert Wikipedia author. Write the complete wiki article on {topic} using the following section drafts:\n\n"
|
||||
"{draft}\n\nStrictly follow Wikipedia format guidelines.",
|
||||
),
|
||||
(
|
||||
"user",
|
||||
'Write the complete Wiki article using markdown format. Organize citations using footnotes like "[1]",'
|
||||
" avoiding duplicates in the footer. Include URLs in the footer.",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
writer = writer_prompt | long_context_llm | StrOutputParser()
|
||||
|
||||
|
||||
class ResearchState(TypedDict):
|
||||
topic: str
|
||||
outline: Outline
|
||||
editors: List[Editor]
|
||||
interview_results: List[InterviewState]
|
||||
# The final sections output
|
||||
sections: List[WikiSection]
|
||||
article: str
|
||||
|
||||
|
||||
async def initialize_research(state: ResearchState):
|
||||
topic = state["topic"]
|
||||
coros = (
|
||||
generate_outline_direct.ainvoke({"topic": topic}),
|
||||
survey_subjects.ainvoke(topic),
|
||||
)
|
||||
results = await asyncio.gather(*coros)
|
||||
return {
|
||||
**state,
|
||||
"outline": results[0],
|
||||
"editors": results[1].editors,
|
||||
}
|
||||
|
||||
|
||||
async def conduct_interviews(state: ResearchState):
|
||||
topic = state["topic"]
|
||||
initial_states = [
|
||||
{
|
||||
"editor": editor,
|
||||
"messages": [
|
||||
AIMessage(
|
||||
content=f"So you said you were writing an article on {topic}?",
|
||||
name="Subject_Matter_Expert",
|
||||
)
|
||||
],
|
||||
}
|
||||
for editor in state["editors"]
|
||||
]
|
||||
# We call in to the sub-graph here to parallelize the interviews
|
||||
interview_results = await interview_graph.abatch(initial_states)
|
||||
|
||||
return {
|
||||
**state,
|
||||
"interview_results": interview_results,
|
||||
}
|
||||
|
||||
|
||||
def format_conversation(interview_state):
|
||||
messages = interview_state["messages"]
|
||||
convo = "\n".join(f"{m.name}: {m.content}" for m in messages)
|
||||
return f'Conversation with {interview_state["editor"].name}\n\n' + convo
|
||||
|
||||
|
||||
async def refine_outline(state: ResearchState):
|
||||
convos = "\n\n".join(
|
||||
[
|
||||
format_conversation(interview_state)
|
||||
for interview_state in state["interview_results"]
|
||||
]
|
||||
)
|
||||
|
||||
updated_outline = await refine_outline_chain.ainvoke(
|
||||
{
|
||||
"topic": state["topic"],
|
||||
"old_outline": state["outline"].as_str,
|
||||
"conversations": convos,
|
||||
}
|
||||
)
|
||||
return {**state, "outline": updated_outline}
|
||||
|
||||
|
||||
async def index_references(state: ResearchState):
|
||||
all_docs = []
|
||||
for interview_state in state["interview_results"]:
|
||||
reference_docs = [
|
||||
Document(page_content=v, metadata={"source": k})
|
||||
for k, v in interview_state["references"].items()
|
||||
]
|
||||
all_docs.extend(reference_docs)
|
||||
await vectorstore.aadd_documents(all_docs)
|
||||
return state
|
||||
|
||||
|
||||
async def write_sections(state: ResearchState):
|
||||
outline = state["outline"]
|
||||
sections = await section_writer.abatch(
|
||||
[
|
||||
{
|
||||
"outline": outline.as_str,
|
||||
"section": section.section_title,
|
||||
"topic": state["topic"],
|
||||
}
|
||||
for section in outline.sections
|
||||
]
|
||||
)
|
||||
return {
|
||||
**state,
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
|
||||
async def write_article(state: ResearchState):
|
||||
topic = state["topic"]
|
||||
sections = state["sections"]
|
||||
draft = "\n\n".join([section.as_str for section in sections])
|
||||
article = await writer.ainvoke({"topic": topic, "draft": draft})
|
||||
return {
|
||||
**state,
|
||||
"article": article,
|
||||
}
|
||||
|
||||
|
||||
builder_of_storm = StateGraph(ResearchState)
|
||||
|
||||
nodes = [
|
||||
("init_research", initialize_research),
|
||||
("conduct_interviews", conduct_interviews),
|
||||
("refine_outline", refine_outline),
|
||||
("index_references", index_references),
|
||||
("write_sections", write_sections),
|
||||
("write_article", write_article),
|
||||
]
|
||||
for i in range(len(nodes)):
|
||||
name, node = nodes[i]
|
||||
builder_of_storm.add_node(name, node)
|
||||
if i > 0:
|
||||
builder_of_storm.add_edge(nodes[i - 1][0], name)
|
||||
|
||||
builder_of_storm.set_entry_point(nodes[0][0])
|
||||
builder_of_storm.set_finish_point(nodes[-1][0])
|
||||
graph = builder_of_storm.compile()
|
||||
@@ -1,94 +0,0 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Sequence, TypedDict
|
||||
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
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 = ChatAnthropic(temperature=0, model_name="claude-3-sonnet-20240229")
|
||||
model_oai = ChatOpenAI(temperature=0)
|
||||
|
||||
model_anth = model_anth.bind_tools(tools)
|
||||
model_oai = model_oai.bind_tools(tools)
|
||||
|
||||
prompt = open("prompt.txt").read()
|
||||
subprompt = open(Path(__file__).parent / "subprompt.txt").read()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# Define a new graph
|
||||
workflow = StateGraph(AgentState)
|
||||
|
||||
# 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 +0,0 @@
|
||||
from graphs_reqs_a.graphs_submod.agent import graph # noqa
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"dependencies": [
|
||||
"."
|
||||
],
|
||||
"env": "../.env",
|
||||
"graphs": {
|
||||
"graph": "./hello.py:graph"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
requests
|
||||
langchain_anthropic
|
||||
langchain_openai
|
||||
langchain_community
|
||||
@@ -1,94 +0,0 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Sequence, TypedDict
|
||||
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
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 = ChatAnthropic(temperature=0, model_name="claude-3-sonnet-20240229")
|
||||
model_oai = ChatOpenAI(temperature=0)
|
||||
|
||||
model_anth = model_anth.bind_tools(tools)
|
||||
model_oai = model_oai.bind_tools(tools)
|
||||
|
||||
prompt = open("prompt.txt").read()
|
||||
subprompt = open(Path(__file__).parent / "subprompt.txt").read()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# Define a new graph
|
||||
workflow = StateGraph(AgentState)
|
||||
|
||||
# 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,4 +0,0 @@
|
||||
from graphs_submod.agent import graph # noqa
|
||||
from utils.greeter import greet
|
||||
|
||||
greet()
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"dependencies": [
|
||||
"."
|
||||
],
|
||||
"env": "../.env",
|
||||
"graphs": {
|
||||
"graph": "./hello.py:graph"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
requests
|
||||
langchain_anthropic
|
||||
langchain_openai
|
||||
langchain_community
|
||||
@@ -1,2 +0,0 @@
|
||||
def greet():
|
||||
print("Hello, world!")
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"pip_config_file": "./pipconf.txt",
|
||||
"dependencies": [
|
||||
"langchain_community",
|
||||
"langchain_anthropic",
|
||||
"langchain_openai",
|
||||
"wikipedia",
|
||||
"scikit-learn",
|
||||
"./graphs"
|
||||
],
|
||||
"graphs": {
|
||||
"agent": "./graphs/agent.py:graph",
|
||||
"storm": "./graphs/storm.py:graph"
|
||||
},
|
||||
"env": ".env"
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
my_context_var: ContextVar[str] = ContextVar("my_context_var", default="")
|
||||
LIFESPAN_VAL = ""
|
||||
other_context_var = ContextVar("other_context_var", default="")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def my_lifespan(app):
|
||||
global LIFESPAN_VAL
|
||||
LIFESPAN_VAL = "foobar-lifespan"
|
||||
yield
|
||||
assert LIFESPAN_VAL == "foobar-lifespan"
|
||||
LIFESPAN_VAL = ""
|
||||
|
||||
|
||||
class MyContextMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Any, call_next: Any) -> Any:
|
||||
token = my_context_var.set("Foobar")
|
||||
try:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
finally:
|
||||
my_context_var.reset(token)
|
||||
|
||||
|
||||
async def custom_my_route(request):
|
||||
"""A great route."""
|
||||
assert my_context_var.get() == "Foobar"
|
||||
assert LIFESPAN_VAL == "foobar-lifespan"
|
||||
return JSONResponse({"foo": "bar"})
|
||||
|
||||
|
||||
async def runs_afakeroute(request):
|
||||
"""Another great route."""
|
||||
assert my_context_var.get() == "Foobar"
|
||||
assert LIFESPAN_VAL == "foobar-lifespan"
|
||||
return JSONResponse({"foo": "afakeroute"})
|
||||
|
||||
|
||||
async def other_middleware(request: Any, call_next: Any) -> Any:
|
||||
other_context_var.set("foobar")
|
||||
response = await call_next(request)
|
||||
other_context_var.reset()
|
||||
return response
|
||||
|
||||
|
||||
app = Starlette(
|
||||
middleware=[(MyContextMiddleware, {}, {})],
|
||||
routes=[
|
||||
Route("/custom/my-route", custom_my_route),
|
||||
Route("/runs/afakeroute", runs_afakeroute),
|
||||
],
|
||||
lifespan=my_lifespan,
|
||||
)
|
||||
@@ -1,2 +0,0 @@
|
||||
[global]
|
||||
timeout = 60
|
||||
Generated
-267
@@ -1,267 +0,0 @@
|
||||
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.4.0"
|
||||
description = "High level compatibility layer for multiple asynchronous event loop implementations"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"},
|
||||
{file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
|
||||
idna = ">=2.8"
|
||||
sniffio = ">=1.1"
|
||||
typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
|
||||
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
|
||||
trio = ["trio (>=0.23)"]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2024.7.4"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
|
||||
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.7"
|
||||
description = "Composable command line interface toolkit"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
|
||||
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
description = "Cross-platform colored terminal text."
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.2.1"
|
||||
description = "Backport of PEP 654 (exception groups)"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"},
|
||||
{file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
test = ["pytest (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.14.0"
|
||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
|
||||
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.5"
|
||||
description = "A minimal low-level HTTP client."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
|
||||
{file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = "*"
|
||||
h11 = ">=0.13,<0.15"
|
||||
|
||||
[package.extras]
|
||||
asyncio = ["anyio (>=4.0,<5.0)"]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (==1.*)"]
|
||||
trio = ["trio (>=0.22.0,<0.26.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.27.0"
|
||||
description = "The next generation HTTP client."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
|
||||
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = "*"
|
||||
certifi = "*"
|
||||
httpcore = "==1.*"
|
||||
idna = "*"
|
||||
sniffio = "*"
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotli", "brotlicffi"]
|
||||
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (==1.*)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.0"
|
||||
description = "Consume Server-Sent Event (SSE) messages with HTTPX."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"},
|
||||
{file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.7"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
files = [
|
||||
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
|
||||
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-cli"
|
||||
version = "0.1.52"
|
||||
description = "CLI for interacting with LangGraph API"
|
||||
optional = false
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
files = []
|
||||
develop = true
|
||||
|
||||
[package.dependencies]
|
||||
click = "^8.1.7"
|
||||
|
||||
[package.source]
|
||||
type = "directory"
|
||||
url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.1.29"
|
||||
description = "SDK for interacting with LangGraph API"
|
||||
optional = false
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
files = []
|
||||
develop = true
|
||||
|
||||
[package.dependencies]
|
||||
httpx = ">=0.25.2"
|
||||
httpx-sse = ">=0.4.0"
|
||||
orjson = ">=3.10.1"
|
||||
|
||||
[package.source]
|
||||
type = "directory"
|
||||
url = "../../sdk-py"
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.10.5"
|
||||
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "orjson-3.10.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:545d493c1f560d5ccfc134803ceb8955a14c3fcb47bbb4b2fee0232646d0b932"},
|
||||
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4324929c2dd917598212bfd554757feca3e5e0fa60da08be11b4aa8b90013c1"},
|
||||
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c13ca5e2ddded0ce6a927ea5a9f27cae77eee4c75547b4297252cb20c4d30e6"},
|
||||
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6c8e30adfa52c025f042a87f450a6b9ea29649d828e0fec4858ed5e6caecf63"},
|
||||
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338fd4f071b242f26e9ca802f443edc588fa4ab60bfa81f38beaedf42eda226c"},
|
||||
{file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6970ed7a3126cfed873c5d21ece1cd5d6f83ca6c9afb71bbae21a0b034588d96"},
|
||||
{file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:235dadefb793ad12f7fa11e98a480db1f7c6469ff9e3da5e73c7809c700d746b"},
|
||||
{file = "orjson-3.10.5-cp310-none-win32.whl", hash = "sha256:be79e2393679eda6a590638abda16d167754393f5d0850dcbca2d0c3735cebe2"},
|
||||
{file = "orjson-3.10.5-cp310-none-win_amd64.whl", hash = "sha256:c4a65310ccb5c9910c47b078ba78e2787cb3878cdded1702ac3d0da71ddc5228"},
|
||||
{file = "orjson-3.10.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cdf7365063e80899ae3a697def1277c17a7df7ccfc979990a403dfe77bb54d40"},
|
||||
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b68742c469745d0e6ca5724506858f75e2f1e5b59a4315861f9e2b1df77775a"},
|
||||
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d10cc1b594951522e35a3463da19e899abe6ca95f3c84c69e9e901e0bd93d38"},
|
||||
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcbe82b35d1ac43b0d84072408330fd3295c2896973112d495e7234f7e3da2e1"},
|
||||
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c0eb7e0c75e1e486c7563fe231b40fdd658a035ae125c6ba651ca3b07936f5"},
|
||||
{file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:53ed1c879b10de56f35daf06dbc4a0d9a5db98f6ee853c2dbd3ee9d13e6f302f"},
|
||||
{file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:099e81a5975237fda3100f918839af95f42f981447ba8f47adb7b6a3cdb078fa"},
|
||||
{file = "orjson-3.10.5-cp311-none-win32.whl", hash = "sha256:1146bf85ea37ac421594107195db8bc77104f74bc83e8ee21a2e58596bfb2f04"},
|
||||
{file = "orjson-3.10.5-cp311-none-win_amd64.whl", hash = "sha256:36a10f43c5f3a55c2f680efe07aa93ef4a342d2960dd2b1b7ea2dd764fe4a37c"},
|
||||
{file = "orjson-3.10.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:68f85ecae7af14a585a563ac741b0547a3f291de81cd1e20903e79f25170458f"},
|
||||
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28afa96f496474ce60d3340fe8d9a263aa93ea01201cd2bad844c45cd21f5268"},
|
||||
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cd684927af3e11b6e754df80b9ffafd9fb6adcaa9d3e8fdd5891be5a5cad51e"},
|
||||
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d21b9983da032505f7050795e98b5d9eee0df903258951566ecc358f6696969"},
|
||||
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ad1de7fef79736dde8c3554e75361ec351158a906d747bd901a52a5c9c8d24b"},
|
||||
{file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d97531cdfe9bdd76d492e69800afd97e5930cb0da6a825646667b2c6c6c0211"},
|
||||
{file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d69858c32f09c3e1ce44b617b3ebba1aba030e777000ebdf72b0d8e365d0b2b3"},
|
||||
{file = "orjson-3.10.5-cp312-none-win32.whl", hash = "sha256:64c9cc089f127e5875901ac05e5c25aa13cfa5dbbbd9602bda51e5c611d6e3e2"},
|
||||
{file = "orjson-3.10.5-cp312-none-win_amd64.whl", hash = "sha256:b2efbd67feff8c1f7728937c0d7f6ca8c25ec81373dc8db4ef394c1d93d13dc5"},
|
||||
{file = "orjson-3.10.5-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:03b565c3b93f5d6e001db48b747d31ea3819b89abf041ee10ac6988886d18e01"},
|
||||
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:584c902ec19ab7928fd5add1783c909094cc53f31ac7acfada817b0847975f26"},
|
||||
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a35455cc0b0b3a1eaf67224035f5388591ec72b9b6136d66b49a553ce9eb1e6"},
|
||||
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1670fe88b116c2745a3a30b0f099b699a02bb3482c2591514baf5433819e4f4d"},
|
||||
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:185c394ef45b18b9a7d8e8f333606e2e8194a50c6e3c664215aae8cf42c5385e"},
|
||||
{file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ca0b3a94ac8d3886c9581b9f9de3ce858263865fdaa383fbc31c310b9eac07c9"},
|
||||
{file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dfc91d4720d48e2a709e9c368d5125b4b5899dced34b5400c3837dadc7d6271b"},
|
||||
{file = "orjson-3.10.5-cp38-none-win32.whl", hash = "sha256:c05f16701ab2a4ca146d0bca950af254cb7c02f3c01fca8efbbad82d23b3d9d4"},
|
||||
{file = "orjson-3.10.5-cp38-none-win_amd64.whl", hash = "sha256:8a11d459338f96a9aa7f232ba95679fc0c7cedbd1b990d736467894210205c09"},
|
||||
{file = "orjson-3.10.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:85c89131d7b3218db1b24c4abecea92fd6c7f9fab87441cfc342d3acc725d807"},
|
||||
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66215277a230c456f9038d5e2d84778141643207f85336ef8d2a9da26bd7ca"},
|
||||
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51bbcdea96cdefa4a9b4461e690c75ad4e33796530d182bdd5c38980202c134a"},
|
||||
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbead71dbe65f959b7bd8cf91e0e11d5338033eba34c114f69078d59827ee139"},
|
||||
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df58d206e78c40da118a8c14fc189207fffdcb1f21b3b4c9c0c18e839b5a214"},
|
||||
{file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c4057c3b511bb8aef605616bd3f1f002a697c7e4da6adf095ca5b84c0fd43595"},
|
||||
{file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b39e006b00c57125ab974362e740c14a0c6a66ff695bff44615dcf4a70ce2b86"},
|
||||
{file = "orjson-3.10.5-cp39-none-win32.whl", hash = "sha256:eded5138cc565a9d618e111c6d5c2547bbdd951114eb822f7f6309e04db0fb47"},
|
||||
{file = "orjson-3.10.5-cp39-none-win_amd64.whl", hash = "sha256:cc28e90a7cae7fcba2493953cff61da5a52950e78dc2dacfe931a317ee3d8de7"},
|
||||
{file = "orjson-3.10.5.tar.gz", hash = "sha256:7a5baef8a4284405d96c90c7c62b755e9ef1ada84c2406c24a9ebec86b89f46d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
description = "Sniff out which async library your code is running under"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
|
||||
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.12.2"
|
||||
description = "Backported and Experimental Type Hints for Python 3.8+"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
|
||||
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
|
||||
]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
content-hash = "ec5109729f30d2033a10a10e8f8d3ed94c7d96d5d31025b4815b0123664bb063"
|
||||
@@ -1,17 +0,0 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-examples"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
readme = "README.md"
|
||||
packages = []
|
||||
package-mode = false
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9.0,<4.0"
|
||||
langgraph-cli = {path = "../../cli", develop = true}
|
||||
langgraph-sdk = {path = "../../sdk-py", develop = true}
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -1,10 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{js,json,yml}]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
@@ -1,3 +0,0 @@
|
||||
# Copy this over:
|
||||
# cp .env.example .env
|
||||
# Then modify to suit your needs
|
||||
@@ -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,19 +0,0 @@
|
||||
index.cjs
|
||||
index.js
|
||||
index.d.ts
|
||||
node_modules
|
||||
dist
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
.turbo
|
||||
**/.turbo
|
||||
**/.eslintcache
|
||||
|
||||
.env
|
||||
.ipynb_checkpoints
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 LangChain
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,79 +0,0 @@
|
||||
# New LangGraph.js Project
|
||||
|
||||
[](https://github.com/langchain-ai/new-langgraphjs-project/actions/workflows/unit-tests.yml)
|
||||
[](https://github.com/langchain-ai/new-langgraphjs-project/actions/workflows/integration-tests.yml)
|
||||
[](https://langgraph-studio.vercel.app/templates/open?githubUrl=https://github.com/langchain-ai/new-langgraphjs-project)
|
||||
|
||||
This template demonstrates a simple chatbot implemented using [LangGraph.js](https://github.com/langchain-ai/langgraphjs), designed for [LangGraph Studio](https://github.com/langchain-ai/langgraph-studio). The chatbot maintains persistent chat memory, allowing for coherent conversations across multiple interactions.
|
||||
|
||||

|
||||
|
||||
The core logic, defined in `src/agent/graph.ts`, showcases a straightforward chatbot that responds to user queries while maintaining context from previous messages.
|
||||
|
||||
## What it does
|
||||
|
||||
The simple chatbot:
|
||||
|
||||
1. Takes a user **message** as input
|
||||
2. Maintains a history of the conversation
|
||||
3. Returns a placeholder response, updating the conversation history
|
||||
|
||||
This template provides a foundation that can be easily customized and extended to create more complex conversational agents.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Assuming you have already [installed LangGraph Studio](https://github.com/langchain-ai/langgraph-studio?tab=readme-ov-file#download), to set up:
|
||||
|
||||
1. Create a `.env` file. This template does not require any environment variables by default, but you will likely want to add some when customizing.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
<!--
|
||||
Setup instruction auto-generated by `langgraph template lock`. DO NOT EDIT MANUALLY.
|
||||
-->
|
||||
|
||||
<!--
|
||||
End setup instructions
|
||||
-->
|
||||
|
||||
2. Open the folder in LangGraph Studio!
|
||||
3. Customize the code as needed.
|
||||
|
||||
## How to customize
|
||||
|
||||
1. **Add an LLM call**: You can select and install a chat model wrapper from [the LangChain.js ecosystem](https://js.langchain.com/docs/integrations/chat/), or use LangGraph.js without LangChain.js.
|
||||
2. **Extend the graph**: The core logic of the chatbot is defined in [graph.ts](./src/agent/graph.ts). You can modify this file to add new nodes, edges, or change the flow of the conversation.
|
||||
|
||||
You can also extend this template by:
|
||||
|
||||
- Adding [custom tools or functions](https://js.langchain.com/docs/how_to/tool_calling) to enhance the chatbot's capabilities.
|
||||
- Implementing additional logic for handling specific types of user queries or tasks.
|
||||
- Add retrieval-augmented generation (RAG) capabilities by integrating [external APIs or databases](https://langchain-ai.github.io/langgraphjs/tutorials/rag/langgraph_agentic_rag/) to provide more customized responses.
|
||||
|
||||
## Development
|
||||
|
||||
While iterating on your graph, you can edit past state and rerun your app from previous states to debug specific nodes. Local changes will be automatically applied via hot reload. Try experimenting with:
|
||||
|
||||
- Modifying the system prompt to give your chatbot a unique personality.
|
||||
- Adding new nodes to the graph for more complex conversation flows.
|
||||
- Implementing conditional logic to handle different types of user inputs.
|
||||
|
||||
Follow-up requests will be appended to the same thread. You can create an entirely new thread, clearing previous history, using the `+` button in the top right.
|
||||
|
||||
For more advanced features and examples, refer to the [LangGraph.js documentation](https://github.com/langchain-ai/langgraphjs). These resources can help you adapt this template for your specific use case and build more sophisticated conversational agents.
|
||||
|
||||
LangGraph Studio also integrates with [LangSmith](https://smith.langchain.com/) for more in-depth tracing and collaboration with teammates, allowing you to analyze and optimize your chatbot's performance.
|
||||
|
||||
<!--
|
||||
Configuration auto-generated by `langgraph template lock`. DO NOT EDIT MANUALLY.
|
||||
{
|
||||
"config_schemas": {
|
||||
"agent": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
-->
|
||||
@@ -1,18 +0,0 @@
|
||||
export default {
|
||||
preset: "ts-jest/presets/default-esm",
|
||||
moduleNameMapper: {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1",
|
||||
},
|
||||
transform: {
|
||||
"^.+\\.tsx?$": [
|
||||
"ts-jest",
|
||||
{
|
||||
useESM: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
extensionsToTreatAsEsm: [".ts"],
|
||||
setupFiles: ["dotenv/config"],
|
||||
passWithNoTests: true,
|
||||
testTimeout: 20_000,
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"node_version": "20",
|
||||
"graphs": {
|
||||
"agent": "./src/agent/graph.ts:graph"
|
||||
},
|
||||
"env": ".env",
|
||||
"dependencies": ["."]
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"name": "example-graph",
|
||||
"version": "0.0.1",
|
||||
"description": "A starter template for creating a LangGraph workflow.",
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"main": "my_app/graph.ts",
|
||||
"author": "Your Name",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=\\.test\\.ts$ --testPathIgnorePatterns=\\.int\\.test\\.ts$",
|
||||
"test:int": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=\\.int\\.test\\.ts$",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint src",
|
||||
"format:check": "prettier --check .",
|
||||
"lint:langgraph-json": "node scripts/checkLanggraphPaths.js",
|
||||
"lint:all": "yarn lint & yarn lint:langgraph-json & yarn format:check",
|
||||
"test:all": "yarn test && yarn test:int && yarn lint:langgraph"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/core": "^0.3.2",
|
||||
"@langchain/langgraph": "^0.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@tsconfig/recommended": "^1.0.7",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.8",
|
||||
"@typescript-eslint/parser": "^5.59.8",
|
||||
"dotenv": "^16.4.5",
|
||||
"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",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.3.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Starter LangGraph.js Template
|
||||
* Make this code your own!
|
||||
*/
|
||||
import { StateGraph } from "@langchain/langgraph";
|
||||
import { RunnableConfig } from "@langchain/core/runnables";
|
||||
import { StateAnnotation } from "./state.js";
|
||||
|
||||
/**
|
||||
* Define a node, these do the work of the graph and should have most of the logic.
|
||||
* Must return a subset of the properties set in StateAnnotation.
|
||||
* @param state The current state of the graph.
|
||||
* @param config Extra parameters passed into the state graph.
|
||||
* @returns Some subset of parameters of the graph state, used to update the state
|
||||
* for the edges and nodes executed next.
|
||||
*/
|
||||
const callModel = async (
|
||||
state: typeof StateAnnotation.State,
|
||||
_config: RunnableConfig,
|
||||
): Promise<typeof StateAnnotation.Update> => {
|
||||
/**
|
||||
* Do some work... (e.g. call an LLM)
|
||||
* For example, with LangChain you could do something like:
|
||||
*
|
||||
* ```bash
|
||||
* $ npm i @langchain/anthropic
|
||||
* ```
|
||||
*
|
||||
* ```ts
|
||||
* import { ChatAnthropic } from "@langchain/anthropic";
|
||||
* const model = new ChatAnthropic({
|
||||
* model: "claude-3-5-sonnet-20240620",
|
||||
* apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
* });
|
||||
* const res = await model.invoke(state.messages);
|
||||
* ```
|
||||
*
|
||||
* Or, with an SDK directly:
|
||||
*
|
||||
* ```bash
|
||||
* $ npm i openai
|
||||
* ```
|
||||
*
|
||||
* ```ts
|
||||
* import OpenAI from "openai";
|
||||
* const openai = new OpenAI({
|
||||
* apiKey: process.env.OPENAI_API_KEY,
|
||||
* });
|
||||
*
|
||||
* const chatCompletion = await openai.chat.completions.create({
|
||||
* messages: [{
|
||||
* role: state.messages[0]._getType(),
|
||||
* content: state.messages[0].content,
|
||||
* }],
|
||||
* model: "gpt-4o-mini",
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
console.log("Current state:", state);
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: `Hi there! How are you?`,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Routing function: Determines whether to continue research or end the builder.
|
||||
* This function decides if the gathered information is satisfactory or if more research is needed.
|
||||
*
|
||||
* @param state - The current state of the research builder
|
||||
* @returns Either "callModel" to continue research or END to finish the builder
|
||||
*/
|
||||
export const route = (
|
||||
state: typeof StateAnnotation.State,
|
||||
): "__end__" | "callModel" => {
|
||||
if (state.messages.length > 0) {
|
||||
return "__end__";
|
||||
}
|
||||
// Loop back
|
||||
return "callModel";
|
||||
};
|
||||
|
||||
// Finally, create the graph itself.
|
||||
const builder = new StateGraph(StateAnnotation)
|
||||
// Add the nodes to do the work.
|
||||
// Chaining the nodes together in this way
|
||||
// updates the types of the StateGraph instance
|
||||
// so you have static type checking when it comes time
|
||||
// to add the edges.
|
||||
.addNode("callModel", callModel)
|
||||
// Regular edges mean "always transition to node B after node A is done"
|
||||
// The "__start__" and "__end__" nodes are "virtual" nodes that are always present
|
||||
// and represent the beginning and end of the builder.
|
||||
.addEdge("__start__", "callModel")
|
||||
// Conditional edges optionally route to different nodes (or end)
|
||||
.addConditionalEdges("callModel", route);
|
||||
|
||||
export const graph = builder.compile();
|
||||
|
||||
graph.name = "New Agent";
|
||||
@@ -1,59 +0,0 @@
|
||||
import { BaseMessage, BaseMessageLike } from "@langchain/core/messages";
|
||||
import { Annotation, messagesStateReducer } from "@langchain/langgraph";
|
||||
|
||||
/**
|
||||
* A graph's StateAnnotation defines three main things:
|
||||
* 1. The structure of the data to be passed between nodes (which "channels" to read from/write to and their types)
|
||||
* 2. Default values for each field
|
||||
* 3. Reducers for the state's. Reducers are functions that determine how to apply updates to the state.
|
||||
* See [Reducers](https://langchain-ai.github.io/langgraphjs/concepts/low_level/#reducers) for more information.
|
||||
*/
|
||||
|
||||
// This is the primary state of your agent, where you can store any information
|
||||
export const StateAnnotation = Annotation.Root({
|
||||
/**
|
||||
* Messages track the primary execution state of the agent.
|
||||
*
|
||||
* Typically accumulates a pattern of:
|
||||
*
|
||||
* 1. HumanMessage - user input
|
||||
* 2. AIMessage with .tool_calls - agent picking tool(s) to use to collect
|
||||
* information
|
||||
* 3. ToolMessage(s) - the responses (or errors) from the executed tools
|
||||
*
|
||||
* (... repeat steps 2 and 3 as needed ...)
|
||||
* 4. AIMessage without .tool_calls - agent responding in unstructured
|
||||
* format to the user.
|
||||
*
|
||||
* 5. HumanMessage - user responds with the next conversational turn.
|
||||
*
|
||||
* (... repeat steps 2-5 as needed ... )
|
||||
*
|
||||
* Merges two lists of messages or message-like objects with role and content,
|
||||
* updating existing messages by ID.
|
||||
*
|
||||
* Message-like objects are automatically coerced by `messagesStateReducer` into
|
||||
* LangChain message classes. If a message does not have a given id,
|
||||
* LangGraph will automatically assign one.
|
||||
*
|
||||
* By default, this ensures the state is "append-only", unless the
|
||||
* new message has the same ID as an existing message.
|
||||
*
|
||||
* Returns:
|
||||
* A new list of messages with the messages from \`right\` merged into \`left\`.
|
||||
* If a message in \`right\` has the same ID as a message in \`left\`, the
|
||||
* message from \`right\` will replace the message from \`left\`.`
|
||||
*/
|
||||
messages: Annotation<BaseMessage[], BaseMessageLike[]>({
|
||||
reducer: messagesStateReducer,
|
||||
default: () => [],
|
||||
}),
|
||||
/**
|
||||
* Feel free to add additional attributes to your state as needed.
|
||||
* Common examples include retrieved documents, extracted entities, API connections, etc.
|
||||
*
|
||||
* For simple fields whose value should be overwritten by the return value of a node,
|
||||
* you don't need to define a reducer or default.
|
||||
*/
|
||||
// additionalField: Annotation<string>,
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 583 KiB |
@@ -1,8 +0,0 @@
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
import { route } from "../src/agent/graph.js";
|
||||
describe("Routers", () => {
|
||||
it("Test route", async () => {
|
||||
const res = route({ messages: [] });
|
||||
expect(res).toEqual("callModel");
|
||||
}, 100_000);
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
import { graph } from "../src/agent/graph.js";
|
||||
|
||||
describe("Graph", () => {
|
||||
it("should process input through the graph", async () => {
|
||||
const input = "What is the capital of France?";
|
||||
const result = await graph.invoke({ input });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe("object");
|
||||
expect(result.messages).toBeDefined();
|
||||
expect(Array.isArray(result.messages)).toBe(true);
|
||||
expect(result.messages.length).toBeGreaterThan(0);
|
||||
|
||||
const lastMessage = result.messages[result.messages.length - 1];
|
||||
expect(lastMessage.content.toString().toLowerCase()).toContain("hi");
|
||||
}, 30000); // Increased timeout to 30 seconds
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"extends": "@tsconfig/recommended",
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"lib": ["ES2021", "ES2022.Object", "DOM"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "nodenext",
|
||||
"esModuleInterop": true,
|
||||
"noImplicitReturns": true,
|
||||
"declaration": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"useDefineForClassFields": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"strictFunctionTypes": false,
|
||||
"outDir": "dist",
|
||||
"types": ["jest", "node"],
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.js"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,98 +0,0 @@
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from langgraph_cli.constants import (
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_PORT,
|
||||
SUPABASE_PUBLIC_API_KEY,
|
||||
SUPABASE_URL,
|
||||
)
|
||||
from langgraph_cli.version import __version__
|
||||
|
||||
|
||||
class LogData(TypedDict):
|
||||
os: str
|
||||
os_version: str
|
||||
python_version: str
|
||||
cli_version: str
|
||||
cli_command: str
|
||||
params: dict[str, Any]
|
||||
|
||||
|
||||
def get_anonymized_params(kwargs: dict[str, Any]) -> dict[str, bool]:
|
||||
params = {}
|
||||
|
||||
# anonymize params with values
|
||||
if config := kwargs.get("config"):
|
||||
if config != pathlib.Path(DEFAULT_CONFIG).resolve():
|
||||
params["config"] = True
|
||||
|
||||
if port := kwargs.get("port"):
|
||||
if port != DEFAULT_PORT:
|
||||
params["port"] = True
|
||||
|
||||
if kwargs.get("docker_compose"):
|
||||
params["docker_compose"] = True
|
||||
|
||||
if kwargs.get("debugger_port"):
|
||||
params["debugger_port"] = True
|
||||
|
||||
if kwargs.get("postgres_uri"):
|
||||
params["postgres_uri"] = True
|
||||
|
||||
# pick up exact values for boolean flags
|
||||
for boolean_param in ["recreate", "pull", "watch", "wait", "verbose"]:
|
||||
if kwargs.get(boolean_param):
|
||||
params[boolean_param] = kwargs[boolean_param]
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def log_data(data: LogData) -> None:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"apikey": SUPABASE_PUBLIC_API_KEY,
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
}
|
||||
supabase_url = SUPABASE_URL
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{supabase_url}/rest/v1/logs",
|
||||
data=json.dumps(data).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
except urllib.error.URLError:
|
||||
pass
|
||||
|
||||
|
||||
def log_command(func):
|
||||
@functools.wraps(func)
|
||||
def decorator(*args, **kwargs):
|
||||
if os.getenv("LANGGRAPH_CLI_NO_ANALYTICS") == "1":
|
||||
return func(*args, **kwargs)
|
||||
|
||||
data = {
|
||||
"os": platform.system(),
|
||||
"os_version": platform.version(),
|
||||
"python_version": platform.python_version(),
|
||||
"cli_version": __version__,
|
||||
"cli_command": func.__name__,
|
||||
"params": get_anonymized_params(kwargs),
|
||||
}
|
||||
|
||||
background_thread = threading.Thread(target=log_data, args=(data,))
|
||||
background_thread.start()
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return decorator
|
||||
@@ -1,755 +0,0 @@
|
||||
"""CLI entrypoint for LangGraph API server."""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Callable, List, Optional, Sequence, Tuple
|
||||
|
||||
import click
|
||||
import click.exceptions
|
||||
from click import secho
|
||||
|
||||
import langgraph_cli.config
|
||||
import langgraph_cli.docker
|
||||
from langgraph_cli.analytics import log_command
|
||||
from langgraph_cli.config import Config
|
||||
from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT
|
||||
from langgraph_cli.docker import DockerCapabilities
|
||||
from langgraph_cli.exec import Runner, subp_exec
|
||||
from langgraph_cli.progress import Progress
|
||||
from langgraph_cli.templates import TEMPLATE_HELP_STRING, create_new
|
||||
from langgraph_cli.version import __version__
|
||||
|
||||
OPT_DOCKER_COMPOSE = click.option(
|
||||
"--docker-compose",
|
||||
"-d",
|
||||
help="Advanced: Path to docker-compose.yml file with additional services to launch.",
|
||||
type=click.Path(
|
||||
exists=True,
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
resolve_path=True,
|
||||
path_type=pathlib.Path,
|
||||
),
|
||||
)
|
||||
OPT_CONFIG = click.option(
|
||||
"--config",
|
||||
"-c",
|
||||
help="""Path to configuration file declaring dependencies, graphs and environment variables.
|
||||
|
||||
\b
|
||||
Config file must be a JSON file that has the following keys:
|
||||
- "dependencies": array of dependencies for langgraph API server. Dependencies can be one of the following:
|
||||
- ".", which would look for local python packages, as well as pyproject.toml, setup.py or requirements.txt in the app directory
|
||||
- "./local_package"
|
||||
- "<package_name>
|
||||
- "graphs": mapping from graph ID to path where the compiled graph is defined, i.e. ./your_package/your_file.py:variable, where
|
||||
"variable" is an instance of langgraph.graph.graph.CompiledGraph
|
||||
- "env": (optional) path to .env file or a mapping from environment variable to its value
|
||||
- "python_version": (optional) 3.11, 3.12, or 3.13. Defaults to 3.11
|
||||
- "pip_config_file": (optional) path to pip config file
|
||||
- "dockerfile_lines": (optional) array of additional lines to add to Dockerfile following the import from parent image
|
||||
|
||||
\b
|
||||
Example:
|
||||
langgraph up -c langgraph.json
|
||||
|
||||
\b
|
||||
Example:
|
||||
{
|
||||
"dependencies": [
|
||||
"langchain_openai",
|
||||
"./your_package"
|
||||
],
|
||||
"graphs": {
|
||||
"my_graph_id": "./your_package/your_file.py:variable"
|
||||
},
|
||||
"env": "./.env"
|
||||
}
|
||||
|
||||
\b
|
||||
Example:
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"dependencies": [
|
||||
"langchain_openai",
|
||||
"."
|
||||
],
|
||||
"graphs": {
|
||||
"my_graph_id": "./your_package/your_file.py:variable"
|
||||
},
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "secret-key"
|
||||
}
|
||||
}
|
||||
|
||||
Defaults to looking for langgraph.json in the current directory.""",
|
||||
default=DEFAULT_CONFIG,
|
||||
type=click.Path(
|
||||
exists=True,
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
resolve_path=True,
|
||||
path_type=pathlib.Path,
|
||||
),
|
||||
)
|
||||
OPT_PORT = click.option(
|
||||
"--port",
|
||||
"-p",
|
||||
type=int,
|
||||
default=DEFAULT_PORT,
|
||||
show_default=True,
|
||||
help="""
|
||||
Port to expose.
|
||||
|
||||
\b
|
||||
Example:
|
||||
langgraph up --port 8000
|
||||
\b
|
||||
""",
|
||||
)
|
||||
OPT_RECREATE = click.option(
|
||||
"--recreate/--no-recreate",
|
||||
default=False,
|
||||
show_default=True,
|
||||
help="Recreate containers even if their configuration and image haven't changed",
|
||||
)
|
||||
OPT_PULL = click.option(
|
||||
"--pull/--no-pull",
|
||||
default=True,
|
||||
show_default=True,
|
||||
help="""
|
||||
Pull latest images. Use --no-pull for running the server with locally-built images.
|
||||
|
||||
\b
|
||||
Example:
|
||||
langgraph up --no-pull
|
||||
\b
|
||||
""",
|
||||
)
|
||||
OPT_VERBOSE = click.option(
|
||||
"--verbose",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Show more output from the server logs",
|
||||
)
|
||||
OPT_WATCH = click.option("--watch", is_flag=True, help="Restart on file changes")
|
||||
OPT_DEBUGGER_PORT = click.option(
|
||||
"--debugger-port",
|
||||
type=int,
|
||||
help="Pull the debugger image locally and serve the UI on specified port",
|
||||
)
|
||||
OPT_DEBUGGER_BASE_URL = click.option(
|
||||
"--debugger-base-url",
|
||||
type=str,
|
||||
help="URL used by the debugger to access LangGraph API. Defaults to http://127.0.0.1:[PORT]",
|
||||
)
|
||||
|
||||
OPT_POSTGRES_URI = click.option(
|
||||
"--postgres-uri",
|
||||
help="Postgres URI to use for the database. Defaults to launching a local database",
|
||||
)
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version=__version__, prog_name="LangGraph CLI")
|
||||
def cli():
|
||||
pass
|
||||
|
||||
|
||||
@OPT_RECREATE
|
||||
@OPT_PULL
|
||||
@OPT_PORT
|
||||
@OPT_DOCKER_COMPOSE
|
||||
@OPT_CONFIG
|
||||
@OPT_VERBOSE
|
||||
@OPT_DEBUGGER_PORT
|
||||
@OPT_DEBUGGER_BASE_URL
|
||||
@OPT_WATCH
|
||||
@OPT_POSTGRES_URI
|
||||
@click.option(
|
||||
"--wait",
|
||||
is_flag=True,
|
||||
help="Wait for services to start before returning. Implies --detach",
|
||||
)
|
||||
@cli.command(help="🚀 Launch LangGraph API server.")
|
||||
@log_command
|
||||
def up(
|
||||
config: pathlib.Path,
|
||||
docker_compose: Optional[pathlib.Path],
|
||||
port: int,
|
||||
recreate: bool,
|
||||
pull: bool,
|
||||
watch: bool,
|
||||
wait: bool,
|
||||
verbose: bool,
|
||||
debugger_port: Optional[int],
|
||||
debugger_base_url: Optional[str],
|
||||
postgres_uri: Optional[str],
|
||||
):
|
||||
click.secho("Starting LangGraph API server...", fg="green")
|
||||
click.secho(
|
||||
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangGraph Cloud closed beta.
|
||||
For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KEY.""",
|
||||
)
|
||||
with Runner() as runner, Progress(message="Pulling...") as set:
|
||||
capabilities = langgraph_cli.docker.check_capabilities(runner)
|
||||
args, stdin = prepare(
|
||||
runner,
|
||||
capabilities=capabilities,
|
||||
config_path=config,
|
||||
docker_compose=docker_compose,
|
||||
port=port,
|
||||
pull=pull,
|
||||
watch=watch,
|
||||
verbose=verbose,
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_base_url,
|
||||
postgres_uri=postgres_uri,
|
||||
)
|
||||
# add up + options
|
||||
args.extend(["up", "--remove-orphans"])
|
||||
if recreate:
|
||||
args.extend(["--force-recreate", "--renew-anon-volumes"])
|
||||
try:
|
||||
runner.run(subp_exec("docker", "volume", "rm", "langgraph-data"))
|
||||
except click.exceptions.Exit:
|
||||
pass
|
||||
if watch:
|
||||
args.append("--watch")
|
||||
if wait:
|
||||
args.append("--wait")
|
||||
else:
|
||||
args.append("--abort-on-container-exit")
|
||||
# run docker compose
|
||||
set("Building...")
|
||||
|
||||
def on_stdout(line: str):
|
||||
if "unpacking to docker.io" in line:
|
||||
set("Starting...")
|
||||
elif "Application startup complete" in line:
|
||||
debugger_origin = (
|
||||
f"http://localhost:{debugger_port}"
|
||||
if debugger_port
|
||||
else "https://smith.langchain.com"
|
||||
)
|
||||
debugger_base_url_query = (
|
||||
debugger_base_url or f"http://127.0.0.1:{port}"
|
||||
)
|
||||
set("")
|
||||
sys.stdout.write(
|
||||
f"""Ready!
|
||||
- API: http://localhost:{port}
|
||||
- Docs: http://localhost:{port}/docs
|
||||
- LangGraph Studio: {debugger_origin}/studio/?baseUrl={debugger_base_url_query}
|
||||
"""
|
||||
)
|
||||
sys.stdout.flush()
|
||||
return True
|
||||
|
||||
if capabilities.compose_type == "plugin":
|
||||
compose_cmd = ["docker", "compose"]
|
||||
elif capabilities.compose_type == "standalone":
|
||||
compose_cmd = ["docker-compose"]
|
||||
|
||||
runner.run(
|
||||
subp_exec(
|
||||
*compose_cmd,
|
||||
*args,
|
||||
input=stdin,
|
||||
verbose=verbose,
|
||||
on_stdout=on_stdout,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _build(
|
||||
runner,
|
||||
set: Callable[[str], None],
|
||||
config: pathlib.Path,
|
||||
config_json: dict,
|
||||
base_image: Optional[str],
|
||||
pull: bool,
|
||||
tag: str,
|
||||
passthrough: Sequence[str] = (),
|
||||
):
|
||||
base_image = base_image or (
|
||||
"langchain/langgraphjs-api"
|
||||
if config_json.get("node_version")
|
||||
else "langchain/langgraph-api"
|
||||
)
|
||||
|
||||
# pull latest images
|
||||
if pull:
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"pull",
|
||||
(
|
||||
f"{base_image}:{config_json['node_version']}"
|
||||
if config_json.get("node_version")
|
||||
else f"{base_image}:{config_json['python_version']}"
|
||||
),
|
||||
verbose=True,
|
||||
)
|
||||
)
|
||||
set("Building...")
|
||||
# apply options
|
||||
args = [
|
||||
"-f",
|
||||
"-", # stdin
|
||||
"-t",
|
||||
tag,
|
||||
]
|
||||
# apply config
|
||||
stdin, additional_contexts = langgraph_cli.config.config_to_docker(
|
||||
config, config_json, base_image
|
||||
)
|
||||
# add additional_contexts
|
||||
if additional_contexts:
|
||||
additional_contexts_str = ",".join(
|
||||
f"{k}={v}" for k, v in additional_contexts.items()
|
||||
)
|
||||
args.extend(["--build-context", additional_contexts_str])
|
||||
# run docker build
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"build",
|
||||
*args,
|
||||
*passthrough,
|
||||
str(config.parent),
|
||||
input=stdin,
|
||||
verbose=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@OPT_CONFIG
|
||||
@OPT_PULL
|
||||
@click.option(
|
||||
"--tag",
|
||||
"-t",
|
||||
help="""Tag for the docker image.
|
||||
|
||||
\b
|
||||
Example:
|
||||
langgraph build -t my-image
|
||||
|
||||
\b
|
||||
""",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--base-image",
|
||||
hidden=True,
|
||||
)
|
||||
@click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED)
|
||||
@cli.command(
|
||||
help="📦 Build LangGraph API server Docker image.",
|
||||
context_settings=dict(
|
||||
ignore_unknown_options=True,
|
||||
),
|
||||
)
|
||||
@log_command
|
||||
def build(
|
||||
config: pathlib.Path,
|
||||
docker_build_args: Sequence[str],
|
||||
base_image: Optional[str],
|
||||
pull: bool,
|
||||
tag: str,
|
||||
):
|
||||
with Runner() as runner, Progress(message="Pulling...") as set:
|
||||
if shutil.which("docker") is None:
|
||||
raise click.UsageError("Docker not installed") from None
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
_build(
|
||||
runner, set, config, config_json, base_image, pull, tag, docker_build_args
|
||||
)
|
||||
|
||||
|
||||
def _get_docker_ignore_content() -> str:
|
||||
"""Return the content of a .dockerignore file.
|
||||
|
||||
This file is used to exclude files and directories from the Docker build context.
|
||||
|
||||
It may be overly broad, but it's better to be safe than sorry.
|
||||
|
||||
The main goal is to exclude .env files by default.
|
||||
"""
|
||||
return """\
|
||||
# Ignore node_modules and other dependency directories
|
||||
node_modules
|
||||
bower_components
|
||||
vendor
|
||||
|
||||
# Ignore logs and temporary files
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
# Ignore .env files and other environment files
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
|
||||
# Ignore git-related files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Ignore Docker-related files and configs
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
# Ignore build and cache directories
|
||||
dist
|
||||
build
|
||||
.cache
|
||||
__pycache__
|
||||
|
||||
# Ignore IDE and editor configurations
|
||||
.vscode
|
||||
.idea
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
.DS_Store # macOS-specific
|
||||
|
||||
# Ignore test and coverage files
|
||||
coverage
|
||||
*.coverage
|
||||
*.test.js
|
||||
*.spec.js
|
||||
tests
|
||||
"""
|
||||
|
||||
|
||||
@OPT_CONFIG
|
||||
@click.argument("save_path", type=click.Path(resolve_path=True))
|
||||
@cli.command(
|
||||
help="🐳 Generate a Dockerfile for the LangGraph API server, with Docker Compose options."
|
||||
)
|
||||
@click.option(
|
||||
# Add a flag for adding a docker-compose.yml file as part of the output
|
||||
"--add-docker-compose",
|
||||
help=(
|
||||
"Add additional files for running the LangGraph API server with "
|
||||
"docker-compose. These files include a docker-compose.yml, .env file, "
|
||||
"and a .dockerignore file."
|
||||
),
|
||||
is_flag=True,
|
||||
)
|
||||
@log_command
|
||||
def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -> None:
|
||||
save_path = pathlib.Path(save_path).absolute()
|
||||
secho(f"🔍 Validating configuration at path: {config}", fg="yellow")
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
secho("✅ Configuration validated!", fg="green")
|
||||
|
||||
secho(f"📝 Generating Dockerfile at {save_path}", fg="yellow")
|
||||
dockerfile, additional_contexts = langgraph_cli.config.config_to_docker(
|
||||
config,
|
||||
config_json,
|
||||
(
|
||||
"langchain/langgraphjs-api"
|
||||
if config_json.get("node_version")
|
||||
else "langchain/langgraph-api"
|
||||
),
|
||||
)
|
||||
with open(str(save_path), "w", encoding="utf-8") as f:
|
||||
f.write(dockerfile)
|
||||
secho("✅ Created: Dockerfile", fg="green")
|
||||
|
||||
if additional_contexts:
|
||||
additional_contexts_str = ",".join(
|
||||
f"{k}={v}" for k, v in additional_contexts.items()
|
||||
)
|
||||
secho(
|
||||
f"""📝 Run docker build with these additional build contexts `--build-context {additional_contexts_str}`""",
|
||||
fg="yellow",
|
||||
)
|
||||
|
||||
if add_docker_compose:
|
||||
# Add docker compose and related files
|
||||
# Add .dockerignore file in the same directory as the Dockerfile
|
||||
with open(str(save_path.parent / ".dockerignore"), "w", encoding="utf-8") as f:
|
||||
f.write(_get_docker_ignore_content())
|
||||
secho("✅ Created: .dockerignore", fg="green")
|
||||
|
||||
# Generate a docker-compose.yml file
|
||||
path = str(save_path.parent / "docker-compose.yml")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
with Runner() as runner:
|
||||
capabilities = langgraph_cli.docker.check_capabilities(runner)
|
||||
|
||||
compose_dict = langgraph_cli.docker.compose_as_dict(
|
||||
capabilities,
|
||||
port=8123,
|
||||
)
|
||||
# Add .env file to the docker-compose.yml for the langgraph-api service
|
||||
compose_dict["services"]["langgraph-api"]["env_file"] = [".env"]
|
||||
# Add the Dockerfile to the build context
|
||||
compose_dict["services"]["langgraph-api"]["build"] = {
|
||||
"context": ".",
|
||||
"dockerfile": save_path.name,
|
||||
}
|
||||
f.write(langgraph_cli.docker.dict_to_yaml(compose_dict))
|
||||
secho("✅ Created: docker-compose.yml", fg="green")
|
||||
|
||||
# Check if the .env file exists in the same directory as the Dockerfile
|
||||
if not (save_path.parent / ".env").exists():
|
||||
# Also add an empty .env file
|
||||
with open(str(save_path.parent / ".env"), "w", encoding="utf-8") as f:
|
||||
f.writelines(
|
||||
[
|
||||
"# Uncomment the following line to add your LangSmith API key",
|
||||
"\n",
|
||||
"# LANGSMITH_API_KEY=your-api-key",
|
||||
"\n",
|
||||
"# Or if you have a LangGraph Cloud license key, "
|
||||
"then uncomment the following line: ",
|
||||
"\n",
|
||||
"# LANGGRAPH_CLOUD_LICENSE_KEY=your-license-key",
|
||||
"\n",
|
||||
"# Add any other environment variables go below...",
|
||||
]
|
||||
)
|
||||
|
||||
secho("✅ Created: .env", fg="green")
|
||||
else:
|
||||
# Do nothing since the .env file already exists. Not a great
|
||||
# idea to overwrite in case the user has added custom env vars set
|
||||
# in the .env file already.
|
||||
secho("➖ Skipped: .env. It already exists!", fg="yellow")
|
||||
|
||||
secho(
|
||||
f"🎉 Files generated successfully at path {save_path.parent}!",
|
||||
fg="cyan",
|
||||
bold=True,
|
||||
)
|
||||
|
||||
|
||||
@click.option(
|
||||
"--host",
|
||||
default="127.0.0.1",
|
||||
help="Network interface to bind the development server to. Default 127.0.0.1 is recommended for security. Only use 0.0.0.0 in trusted networks",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
default=2024,
|
||||
type=int,
|
||||
help="Port number to bind the development server to. Example: langgraph dev --port 8000",
|
||||
)
|
||||
@click.option(
|
||||
"--no-reload",
|
||||
is_flag=True,
|
||||
help="Disable automatic reloading when code changes are detected",
|
||||
)
|
||||
@click.option(
|
||||
"--config",
|
||||
type=click.Path(exists=True),
|
||||
default="langgraph.json",
|
||||
help="Path to configuration file declaring dependencies, graphs and environment variables",
|
||||
)
|
||||
@click.option(
|
||||
"--n-jobs-per-worker",
|
||||
default=None,
|
||||
type=int,
|
||||
help="Maximum number of concurrent jobs each worker process can handle. Default: 10",
|
||||
)
|
||||
@click.option(
|
||||
"--no-browser",
|
||||
is_flag=True,
|
||||
help="Skip automatically opening the browser when the server starts",
|
||||
)
|
||||
@click.option(
|
||||
"--debug-port",
|
||||
default=None,
|
||||
type=int,
|
||||
help="Enable remote debugging by listening on specified port. Requires debugpy to be installed",
|
||||
)
|
||||
@click.option(
|
||||
"--wait-for-client",
|
||||
is_flag=True,
|
||||
help="Wait for a debugger client to connect to the debug port before starting the server",
|
||||
default=False,
|
||||
)
|
||||
@cli.command(
|
||||
"dev",
|
||||
help="🏃♀️➡️ Run LangGraph API server in development mode with hot reloading and debugging support",
|
||||
)
|
||||
@log_command
|
||||
def dev(
|
||||
host: str,
|
||||
port: int,
|
||||
no_reload: bool,
|
||||
config: str,
|
||||
n_jobs_per_worker: Optional[int],
|
||||
no_browser: bool,
|
||||
debug_port: Optional[int],
|
||||
wait_for_client: bool,
|
||||
):
|
||||
"""CLI entrypoint for running the LangGraph API server."""
|
||||
try:
|
||||
from langgraph_api.cli import run_server # type: ignore
|
||||
except ImportError:
|
||||
py_version_msg = ""
|
||||
if sys.version_info < (3, 11):
|
||||
py_version_msg = (
|
||||
"\n\nNote: The in-mem server requires Python 3.11 or higher to be installed."
|
||||
f" You are currently using Python {sys.version_info.major}.{sys.version_info.minor}."
|
||||
' Please upgrade your Python version before installing "langgraph-cli[inmem]".'
|
||||
)
|
||||
try:
|
||||
from importlib import util
|
||||
|
||||
if not util.find_spec("langgraph_api"):
|
||||
raise click.UsageError(
|
||||
"Required package 'langgraph-api' is not installed.\n"
|
||||
"Please install it with:\n\n"
|
||||
' pip install -U "langgraph-cli[inmem]"'
|
||||
f"{py_version_msg}"
|
||||
) from None
|
||||
except ImportError:
|
||||
raise click.UsageError(
|
||||
"Could not verify package installation. Please ensure Python is up to date and\n"
|
||||
"langgraph-cli is installed with the 'inmem' extra: pip install -U \"langgraph-cli[inmem]\""
|
||||
f"{py_version_msg}"
|
||||
) from None
|
||||
raise click.UsageError(
|
||||
"Could not import run_server. This likely means your installation is incomplete.\n"
|
||||
"Please ensure langgraph-cli is installed with the 'inmem' extra: pip install -U \"langgraph-cli[inmem]\""
|
||||
f"{py_version_msg}"
|
||||
) from None
|
||||
|
||||
config_json = langgraph_cli.config.validate_config_file(pathlib.Path(config))
|
||||
if config_json.get("node_version"):
|
||||
raise click.UsageError(
|
||||
"In-mem server for JS graphs is not supported in this version of the LangGraph CLI. Please use `npx @langchain/langgraph-cli` instead."
|
||||
) from None
|
||||
|
||||
cwd = os.getcwd()
|
||||
sys.path.append(cwd)
|
||||
dependencies = config_json.get("dependencies", [])
|
||||
for dep in dependencies:
|
||||
dep_path = pathlib.Path(cwd) / dep
|
||||
if dep_path.is_dir() and dep_path.exists():
|
||||
sys.path.append(str(dep_path))
|
||||
|
||||
graphs = config_json.get("graphs", {})
|
||||
|
||||
run_server(
|
||||
host,
|
||||
port,
|
||||
not no_reload,
|
||||
graphs,
|
||||
n_jobs_per_worker=n_jobs_per_worker,
|
||||
open_browser=not no_browser,
|
||||
debug_port=debug_port,
|
||||
env=config_json.get("env"),
|
||||
store=config_json.get("store"),
|
||||
wait_for_client=wait_for_client,
|
||||
auth=config_json.get("auth"),
|
||||
http=config_json.get("http"),
|
||||
)
|
||||
|
||||
|
||||
@click.argument("path", required=False)
|
||||
@click.option(
|
||||
"--template",
|
||||
type=str,
|
||||
help=TEMPLATE_HELP_STRING,
|
||||
)
|
||||
@cli.command("new", help="🌱 Create a new LangGraph project from a template.")
|
||||
@log_command
|
||||
def new(path: Optional[str], template: Optional[str]) -> None:
|
||||
"""Create a new LangGraph project from a template."""
|
||||
return create_new(path, template)
|
||||
|
||||
|
||||
def prepare_args_and_stdin(
|
||||
*,
|
||||
capabilities: DockerCapabilities,
|
||||
config_path: pathlib.Path,
|
||||
config: Config,
|
||||
docker_compose: Optional[pathlib.Path],
|
||||
port: int,
|
||||
watch: bool,
|
||||
debugger_port: Optional[int] = None,
|
||||
debugger_base_url: Optional[str] = None,
|
||||
postgres_uri: Optional[str] = None,
|
||||
) -> Tuple[List[str], str]:
|
||||
assert config_path.exists(), f"Config file not found: {config_path}"
|
||||
# prepare args
|
||||
stdin = langgraph_cli.docker.compose(
|
||||
capabilities,
|
||||
port=port,
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_base_url,
|
||||
postgres_uri=postgres_uri,
|
||||
)
|
||||
args = [
|
||||
"--project-directory",
|
||||
str(config_path.parent),
|
||||
]
|
||||
# apply options
|
||||
if docker_compose:
|
||||
args.extend(["-f", str(docker_compose)])
|
||||
args.extend(["-f", "-"]) # stdin
|
||||
# apply config
|
||||
stdin += langgraph_cli.config.config_to_compose(
|
||||
config_path,
|
||||
config,
|
||||
watch=watch,
|
||||
base_image=(
|
||||
"langchain/langgraphjs-api"
|
||||
if config.get("node_version")
|
||||
else "langchain/langgraph-api"
|
||||
),
|
||||
)
|
||||
return args, stdin
|
||||
|
||||
|
||||
def prepare(
|
||||
runner,
|
||||
*,
|
||||
capabilities: DockerCapabilities,
|
||||
config_path: pathlib.Path,
|
||||
docker_compose: Optional[pathlib.Path],
|
||||
port: int,
|
||||
pull: bool,
|
||||
watch: bool,
|
||||
verbose: bool,
|
||||
debugger_port: Optional[int] = None,
|
||||
debugger_base_url: Optional[str] = None,
|
||||
postgres_uri: Optional[str] = None,
|
||||
) -> Tuple[List[str], str]:
|
||||
"""Prepare the arguments and stdin for running the LangGraph API server."""
|
||||
config_json = langgraph_cli.config.validate_config_file(config_path)
|
||||
# pull latest images
|
||||
if pull:
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"pull",
|
||||
(
|
||||
f"langchain/langgraphjs-api:{config_json['node_version']}"
|
||||
if config_json.get("node_version")
|
||||
else f"langchain/langgraph-api:{config_json['python_version']}"
|
||||
),
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
|
||||
args, stdin = prepare_args_and_stdin(
|
||||
capabilities=capabilities,
|
||||
config_path=config_path,
|
||||
config=config_json,
|
||||
docker_compose=docker_compose,
|
||||
port=port,
|
||||
watch=watch,
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_base_url or f"http://127.0.0.1:{port}",
|
||||
postgres_uri=postgres_uri,
|
||||
)
|
||||
return args, stdin
|
||||
@@ -1,914 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import textwrap
|
||||
from collections import Counter
|
||||
from typing import NamedTuple, Optional, TypedDict, Union
|
||||
|
||||
import click
|
||||
|
||||
MIN_NODE_VERSION = "20"
|
||||
MIN_PYTHON_VERSION = "3.11"
|
||||
|
||||
|
||||
class IndexConfig(TypedDict, total=False):
|
||||
"""Configuration for indexing documents for semantic search in the store."""
|
||||
|
||||
dims: int
|
||||
"""Number of dimensions in the embedding vectors.
|
||||
|
||||
Common embedding models have the following dimensions:
|
||||
- openai:text-embedding-3-large: 3072
|
||||
- openai:text-embedding-3-small: 1536
|
||||
- openai:text-embedding-ada-002: 1536
|
||||
- cohere:embed-english-v3.0: 1024
|
||||
- cohere:embed-english-light-v3.0: 384
|
||||
- cohere:embed-multilingual-v3.0: 1024
|
||||
- cohere:embed-multilingual-light-v3.0: 384
|
||||
"""
|
||||
|
||||
embed: str
|
||||
"""Optional model (string) to generate embeddings from text or path to model or function.
|
||||
|
||||
Examples:
|
||||
- "openai:text-embedding-3-large"
|
||||
- "cohere:embed-multilingual-v3.0"
|
||||
- "src/app.py:embeddings
|
||||
"""
|
||||
|
||||
fields: Optional[list[str]]
|
||||
"""Fields to extract text from for embedding generation.
|
||||
|
||||
Defaults to the root ["$"], which embeds the json object as a whole.
|
||||
"""
|
||||
|
||||
|
||||
class StoreConfig(TypedDict, total=False):
|
||||
embed: Optional[IndexConfig]
|
||||
"""Configuration for vector embeddings in store."""
|
||||
|
||||
|
||||
class SecurityConfig(TypedDict, total=False):
|
||||
securitySchemes: dict
|
||||
security: list
|
||||
# path => {method => security}
|
||||
paths: dict[str, dict[str, list]]
|
||||
|
||||
|
||||
class AuthConfig(TypedDict, total=False):
|
||||
path: str
|
||||
"""Path to the authentication function in a Python file."""
|
||||
disable_studio_auth: bool
|
||||
"""Whether to disable auth when connecting from the LangSmith Studio."""
|
||||
openapi: SecurityConfig
|
||||
"""The schema to use for updating the openapi spec.
|
||||
|
||||
Example:
|
||||
{
|
||||
"securitySchemes": {
|
||||
"OAuth2": {
|
||||
"type": "oauth2",
|
||||
"flows": {
|
||||
"password": {
|
||||
"tokenUrl": "/token",
|
||||
"scopes": {
|
||||
"me": "Read information about the current user",
|
||||
"items": "Access to create and manage items"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{"OAuth2": ["me"]} # Default security requirement for all endpoints
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class CorsConfig(TypedDict, total=False):
|
||||
allow_origins: list[str]
|
||||
allow_methods: list[str]
|
||||
allow_headers: list[str]
|
||||
allow_credentials: bool
|
||||
allow_origin_regex: str
|
||||
expose_headers: list[str]
|
||||
max_age: int
|
||||
|
||||
|
||||
class HttpConfig(TypedDict, total=False):
|
||||
app: str
|
||||
"""Import path for a custom Starlette/FastAPI app to mount"""
|
||||
disable_assistants: bool
|
||||
"""Disable /assistants routes"""
|
||||
disable_threads: bool
|
||||
"""Disable /threads routes"""
|
||||
disable_runs: bool
|
||||
"""Disable /runs routes"""
|
||||
disable_store: bool
|
||||
"""Disable /store routes"""
|
||||
disable_meta: bool
|
||||
"""Disable /ok, /info, /metrics, and /docs routes"""
|
||||
cors: Optional[CorsConfig]
|
||||
"""Cross-Origin Resource Sharing (CORS) configuration"""
|
||||
|
||||
|
||||
class Config(TypedDict, total=False):
|
||||
"""Configuration for langgraph-cli."""
|
||||
|
||||
python_version: str
|
||||
"""Python version to use."""
|
||||
|
||||
node_version: Optional[str]
|
||||
"""Node.js version to use."""
|
||||
|
||||
pip_config_file: Optional[str]
|
||||
"""Path to a pip configuration file."""
|
||||
|
||||
dockerfile_lines: list[str]
|
||||
"""Additional lines to add to the Dockerfile."""
|
||||
|
||||
dependencies: list[str]
|
||||
"""Additional Python dependencies to install."""
|
||||
|
||||
graphs: dict[str, str]
|
||||
"""Mapping of graph names to their definitions."""
|
||||
|
||||
env: Union[dict[str, str], str]
|
||||
"""Environment variables to set.
|
||||
|
||||
If a dictionary is provided, the keys are environment variable names
|
||||
and the values are the corresponding environment variable values.
|
||||
|
||||
If a string is provided, it is interpreted as a path to a file containing
|
||||
environment variables in the format KEY=VALUE, with one environment variable
|
||||
per line.
|
||||
"""
|
||||
|
||||
store: Optional[StoreConfig]
|
||||
"""Configuration for vector embeddings in store."""
|
||||
|
||||
auth: Optional[AuthConfig]
|
||||
"""Configuration for authentication."""
|
||||
|
||||
http: Optional[HttpConfig]
|
||||
"""Configuration for HTTP server."""
|
||||
|
||||
|
||||
def _parse_version(version_str: str) -> tuple[int, int]:
|
||||
"""Parse a version string into a tuple of (major, minor)."""
|
||||
try:
|
||||
major, minor = map(int, version_str.split("-")[0].split("."))
|
||||
return (major, minor)
|
||||
except ValueError:
|
||||
raise click.UsageError(f"Invalid version format: {version_str}") from None
|
||||
|
||||
|
||||
def _parse_node_version(version_str: str) -> int:
|
||||
"""Parse a Node.js version string into a major version number."""
|
||||
try:
|
||||
if "." in version_str:
|
||||
raise ValueError("Node.js version must be major version only")
|
||||
return int(version_str)
|
||||
except ValueError:
|
||||
raise click.UsageError(
|
||||
f"Invalid Node.js version format: {version_str}. "
|
||||
"Use major version only (e.g., '20')."
|
||||
) from None
|
||||
|
||||
|
||||
def validate_config(config: Config) -> Config:
|
||||
"""Validate a configuration dictionary."""
|
||||
config = (
|
||||
{
|
||||
"node_version": config.get("node_version"),
|
||||
"dockerfile_lines": config.get("dockerfile_lines", []),
|
||||
"dependencies": config.get("dependencies", []),
|
||||
"graphs": config.get("graphs", {}),
|
||||
"env": config.get("env", {}),
|
||||
"store": config.get("store"),
|
||||
"auth": config.get("auth"),
|
||||
"http": config.get("http"),
|
||||
}
|
||||
if config.get("node_version")
|
||||
else {
|
||||
"python_version": config.get("python_version", "3.11"),
|
||||
"pip_config_file": config.get("pip_config_file"),
|
||||
"dockerfile_lines": config.get("dockerfile_lines", []),
|
||||
"dependencies": config.get("dependencies", []),
|
||||
"graphs": config.get("graphs", {}),
|
||||
"env": config.get("env", {}),
|
||||
"store": config.get("store"),
|
||||
"auth": config.get("auth"),
|
||||
"http": config.get("http"),
|
||||
}
|
||||
)
|
||||
|
||||
if config.get("node_version"):
|
||||
node_version = config["node_version"]
|
||||
try:
|
||||
major = _parse_node_version(node_version)
|
||||
min_major = _parse_node_version(MIN_NODE_VERSION)
|
||||
if major < min_major:
|
||||
raise click.UsageError(
|
||||
f"Node.js version {node_version} is not supported. "
|
||||
f"Minimum required version is {MIN_NODE_VERSION}."
|
||||
)
|
||||
except ValueError as e:
|
||||
raise click.UsageError(str(e)) from None
|
||||
|
||||
if config.get("python_version"):
|
||||
pyversion = config["python_version"]
|
||||
if not pyversion.count(".") == 1 or not all(
|
||||
part.isdigit() for part in pyversion.split("-")[0].split(".")
|
||||
):
|
||||
raise click.UsageError(
|
||||
f"Invalid Python version format: {pyversion}. "
|
||||
"Use 'major.minor' format (e.g., '3.11'). "
|
||||
"Patch version cannot be specified."
|
||||
)
|
||||
if _parse_version(pyversion) < _parse_version(MIN_PYTHON_VERSION):
|
||||
raise click.UsageError(
|
||||
f"Python version {pyversion} is not supported. "
|
||||
f"Minimum required version is {MIN_PYTHON_VERSION}."
|
||||
)
|
||||
|
||||
if not config["dependencies"]:
|
||||
raise click.UsageError(
|
||||
"No dependencies found in config. "
|
||||
"Add at least one dependency to 'dependencies' list."
|
||||
)
|
||||
|
||||
if not config["graphs"]:
|
||||
raise click.UsageError(
|
||||
"No graphs found in config. "
|
||||
"Add at least one graph to 'graphs' dictionary."
|
||||
)
|
||||
|
||||
# Validate auth config
|
||||
if auth_conf := config.get("auth"):
|
||||
if "path" in auth_conf:
|
||||
if ":" not in auth_conf["path"]:
|
||||
raise ValueError(
|
||||
f"Invalid auth.path format: '{auth_conf['path']}'. "
|
||||
"Must be in format './path/to/file.py:attribute_name'"
|
||||
)
|
||||
if http_conf := config.get("http"):
|
||||
if "app" in http_conf:
|
||||
if ":" not in http_conf["app"]:
|
||||
raise ValueError(
|
||||
f"Invalid http.app format: '{http_conf['app']}'. "
|
||||
"Must be in format './path/to/file.py:attribute_name'"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def validate_config_file(config_path: pathlib.Path) -> Config:
|
||||
"""Load and validate a configuration file."""
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
validated = validate_config(config)
|
||||
# Enforce the package.json doesn't enforce an
|
||||
# incompatible Node.js version
|
||||
if validated.get("node_version"):
|
||||
package_json_path = config_path.parent / "package.json"
|
||||
if package_json_path.is_file():
|
||||
try:
|
||||
with open(package_json_path) as f:
|
||||
package_json = json.load(f)
|
||||
if "engines" in package_json:
|
||||
engines = package_json["engines"]
|
||||
if any(engine != "node" for engine in engines.keys()):
|
||||
raise click.UsageError(
|
||||
"Only 'node' engine is supported in package.json engines."
|
||||
f" Got engines: {list(engines.keys())}"
|
||||
)
|
||||
if engines:
|
||||
node_version = engines["node"]
|
||||
try:
|
||||
major = _parse_node_version(node_version)
|
||||
min_major = _parse_node_version(MIN_NODE_VERSION)
|
||||
if major < min_major:
|
||||
raise click.UsageError(
|
||||
f"Node.js version in package.json engines must be >= {MIN_NODE_VERSION} "
|
||||
f"(major version only), got '{node_version}'. Minor/patch versions "
|
||||
"(like '20.x.y') are not supported to prevent deployment issues "
|
||||
"when new Node.js versions are released."
|
||||
)
|
||||
except ValueError as e:
|
||||
raise click.UsageError(str(e)) from None
|
||||
|
||||
except json.JSONDecodeError:
|
||||
raise click.UsageError(
|
||||
"Invalid package.json found in langgraph "
|
||||
f"config directory {package_json_path}: file is not valid JSON"
|
||||
) from None
|
||||
return validated
|
||||
|
||||
|
||||
class LocalDeps(NamedTuple):
|
||||
"""A container for referencing and managing local Python dependencies.
|
||||
|
||||
A "local dependency" is any entry in the config's `dependencies` list
|
||||
that starts with "." (dot), denoting a relative path
|
||||
to a local directory containing Python code.
|
||||
|
||||
For each local dependency, the system inspects its directory to
|
||||
determine how it should be installed inside the Docker container.
|
||||
|
||||
Specifically, we detect:
|
||||
|
||||
- **Real packages**: Directories containing a `pyproject.toml` or a `setup.py`.
|
||||
These can be installed with pip as a regular Python package.
|
||||
- **Faux packages**: Directories that do not include a `pyproject.toml` or
|
||||
`setup.py` but do contain Python files and possibly an `__init__.py`. For
|
||||
these, the code dynamically generates a minimal `pyproject.toml` in the
|
||||
Docker image so that they can still be installed with pip.
|
||||
- **Requirements files**: If a local dependency directory
|
||||
has a `requirements.txt`, it is tracked so that those dependencies
|
||||
can be installed within the Docker container before installing the local package.
|
||||
|
||||
Attributes:
|
||||
pip_reqs: A list of (host_requirements_path, container_requirements_path)
|
||||
tuples. Each entry points to a local `requirements.txt` file and where
|
||||
it should be placed inside the Docker container before running `pip install`.
|
||||
|
||||
real_pkgs: A dictionary mapping a local directory path (host side) to a
|
||||
tuple of (dependency_string, container_package_path). These directories
|
||||
contain the necessary files (e.g., `pyproject.toml` or `setup.py`) to be
|
||||
installed as a standard Python package with pip.
|
||||
|
||||
faux_pkgs: A dictionary mapping a local directory path (host side) to a
|
||||
tuple of (dependency_string, container_package_path). For these
|
||||
directories—called "faux packages"—the code will generate a minimal
|
||||
`pyproject.toml` inside the Docker image. This ensures that pip
|
||||
recognizes them as installable packages, even though they do not
|
||||
natively include packaging metadata.
|
||||
|
||||
working_dir: The path inside the Docker container to use as the working
|
||||
directory. If the local dependency `"."` is present in the config, this
|
||||
field captures the path where that dependency will appear in the
|
||||
container (e.g., `/deps/<name>` or similar). Otherwise, it may be `None`.
|
||||
|
||||
additional_contexts: A list of paths to directories that contain local
|
||||
dependencies in parent directories. These directories are added to the
|
||||
Docker build context to ensure that the Dockerfile can access them.
|
||||
"""
|
||||
|
||||
pip_reqs: list[tuple[pathlib.Path, str]]
|
||||
real_pkgs: dict[pathlib.Path, tuple[str, str]]
|
||||
faux_pkgs: dict[pathlib.Path, tuple[str, str]]
|
||||
# if . is in dependencies, use it as working_dir
|
||||
working_dir: Optional[str] = None
|
||||
# if there are local dependencies in parent directories, use additional_contexts
|
||||
additional_contexts: list[pathlib.Path] = None
|
||||
|
||||
|
||||
def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps:
|
||||
config_path = config_path.resolve()
|
||||
# ensure reserved package names are not used
|
||||
reserved = {
|
||||
"src",
|
||||
"langgraph-api",
|
||||
"langgraph_api",
|
||||
"langgraph",
|
||||
"langchain-core",
|
||||
"langchain_core",
|
||||
"pydantic",
|
||||
"orjson",
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"psycopg",
|
||||
"httpx",
|
||||
"langsmith",
|
||||
}
|
||||
counter = Counter()
|
||||
|
||||
def check_reserved(name: str, ref: str):
|
||||
if name in reserved:
|
||||
raise ValueError(
|
||||
f"Package name '{name}' used in local dep '{ref}' is reserved. "
|
||||
"Rename the directory."
|
||||
)
|
||||
reserved.add(name)
|
||||
|
||||
pip_reqs = []
|
||||
real_pkgs = {}
|
||||
faux_pkgs = {}
|
||||
working_dir: Optional[str] = None
|
||||
additional_contexts: list[pathlib.Path] = []
|
||||
|
||||
for local_dep in config["dependencies"]:
|
||||
if not local_dep.startswith("."):
|
||||
# If the dependency is not a local path, skip it
|
||||
continue
|
||||
|
||||
# Verify that the local dependency can be resolved
|
||||
# (e.g., this would raise an informative error if a user mistyped a path).
|
||||
resolved = (config_path.parent / local_dep).resolve()
|
||||
|
||||
# validate local dependency
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(f"Could not find local dependency: {resolved}")
|
||||
elif not resolved.is_dir():
|
||||
raise NotADirectoryError(
|
||||
f"Local dependency must be a directory: {resolved}"
|
||||
)
|
||||
elif resolved == config_path.parent:
|
||||
pass
|
||||
elif config_path.parent not in resolved.parents:
|
||||
additional_contexts.append(resolved)
|
||||
|
||||
# Check for pyproject.toml or setup.py
|
||||
# If found, treat as a real package, if not treat as a faux package.
|
||||
# For faux packages, we'll also check for presence of requirements.txt.
|
||||
files = os.listdir(resolved)
|
||||
if "pyproject.toml" in files or "setup.py" in files:
|
||||
# real package
|
||||
|
||||
# assign a unique folder name
|
||||
container_name = resolved.name
|
||||
if counter[container_name] > 0:
|
||||
container_name += f"_{counter[container_name]}"
|
||||
counter[container_name] += 1
|
||||
# add to deps
|
||||
real_pkgs[resolved] = (local_dep, container_name)
|
||||
# set working_dir
|
||||
if local_dep == ".":
|
||||
working_dir = f"/deps/{container_name}"
|
||||
else:
|
||||
# We could not find a pyproject.toml or setup.py, so treat as a faux package
|
||||
if any(file == "__init__.py" for file in files):
|
||||
# flat layout
|
||||
if "-" in resolved.name:
|
||||
raise ValueError(
|
||||
f"Package name '{resolved.name}' contains a hyphen. "
|
||||
"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}"
|
||||
else:
|
||||
# src layout
|
||||
container_path = f"/deps/__outer_{resolved.name}/src"
|
||||
for file in files:
|
||||
rfile = resolved / file
|
||||
if (
|
||||
rfile.is_dir()
|
||||
and file != "__pycache__"
|
||||
and not file.startswith(".")
|
||||
):
|
||||
try:
|
||||
for subfile in os.listdir(rfile):
|
||||
if subfile.endswith(".py"):
|
||||
check_reserved(file, local_dep)
|
||||
break
|
||||
except PermissionError:
|
||||
pass
|
||||
faux_pkgs[resolved] = (local_dep, container_path)
|
||||
if local_dep == ".":
|
||||
working_dir = container_path
|
||||
|
||||
# If the faux package has a requirements.txt, we'll add
|
||||
# the path to the list of requirements to install.
|
||||
if "requirements.txt" in files:
|
||||
rfile = resolved / "requirements.txt"
|
||||
pip_reqs.append(
|
||||
(
|
||||
rfile,
|
||||
f"{container_path}/requirements.txt",
|
||||
)
|
||||
)
|
||||
|
||||
return LocalDeps(pip_reqs, real_pkgs, faux_pkgs, working_dir, additional_contexts)
|
||||
|
||||
|
||||
def _update_graph_paths(
|
||||
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
|
||||
) -> None:
|
||||
"""Remap each graph's import path to the correct in-container path.
|
||||
|
||||
The config may contain entries in `graphs` that look like this:
|
||||
{
|
||||
"my_graph": "./mygraphs/main.py:graph_function"
|
||||
}
|
||||
or
|
||||
{
|
||||
"my_graph": "./src/some_subdir/my_file.py:my_graph"
|
||||
}
|
||||
which indicate a local file (on the host) followed by a colon and a
|
||||
callable/object attribute within that file.
|
||||
|
||||
During the Docker build, local directories are copied into special
|
||||
`/deps/` subdirectories, so they can be installed or referenced in
|
||||
the container. This function updates each graph's import path to
|
||||
reflect its new location **inside** the Docker container.
|
||||
|
||||
Paths inside the container must be POSIX-style paths (even if
|
||||
the host system is Windows).
|
||||
|
||||
Args:
|
||||
config_path: The path to the config file (e.g. `langgraph.json`).
|
||||
config: The validated configuration dictionary.
|
||||
local_deps: An object containing references to local dependencies:
|
||||
- real Python packages (with a `pyproject.toml` or `setup.py`)
|
||||
- “faux” packages that need minimal metadata to be installable
|
||||
- potential `requirements.txt` for local dependencies
|
||||
- container work directory (if "." is in `dependencies`)
|
||||
|
||||
Raises:
|
||||
ValueError: If the import string is not in the format `<module>:<attribute>`
|
||||
or if the referenced local file is not found in `dependencies`.
|
||||
FileNotFoundError: If the local file (module) does not actually exist on disk.
|
||||
IsADirectoryError: If `module_str` points to a directory instead of a file.
|
||||
"""
|
||||
for graph_id, import_str in config["graphs"].items():
|
||||
module_str, _, attr_str = import_str.partition(":")
|
||||
if not module_str or not attr_str:
|
||||
message = (
|
||||
'Import string "{import_str}" must be in format "<module>:<attribute>".'
|
||||
)
|
||||
raise ValueError(message.format(import_str=import_str))
|
||||
|
||||
# Check for either forward slash or backslash in the module string
|
||||
# to determine if it's a file path.
|
||||
if "/" in module_str or "\\" in module_str:
|
||||
# Resolve the local path properly on the current OS
|
||||
resolved = (config_path.parent / module_str).resolve()
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(f"Could not find local module: {resolved}")
|
||||
elif not resolved.is_file():
|
||||
raise IsADirectoryError(f"Local module must be a file: {resolved}")
|
||||
else:
|
||||
for path in local_deps.real_pkgs:
|
||||
if resolved.is_relative_to(path):
|
||||
container_path = (
|
||||
pathlib.Path("/deps")
|
||||
/ path.name
|
||||
/ resolved.relative_to(path)
|
||||
)
|
||||
module_str = container_path.as_posix()
|
||||
break
|
||||
else:
|
||||
for faux_pkg, (_, destpath) in local_deps.faux_pkgs.items():
|
||||
if resolved.is_relative_to(faux_pkg):
|
||||
container_subpath = resolved.relative_to(faux_pkg)
|
||||
# Construct the final path, ensuring POSIX style
|
||||
module_str = f"{destpath}/{container_subpath.as_posix()}"
|
||||
break
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Module '{import_str}' not found in 'dependencies' list. "
|
||||
"Add its containing package to 'dependencies' list."
|
||||
)
|
||||
# update the config
|
||||
config["graphs"][graph_id] = f"{module_str}:{attr_str}"
|
||||
|
||||
|
||||
def _update_auth_path(
|
||||
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
|
||||
) -> None:
|
||||
"""Update auth.path to use Docker container paths."""
|
||||
auth_conf = config.get("auth")
|
||||
if not auth_conf or not (path_str := auth_conf.get("path")):
|
||||
return
|
||||
|
||||
module_str, sep, attr_str = path_str.partition(":")
|
||||
if not sep or not module_str.startswith("."):
|
||||
return # Already validated or absolute path
|
||||
|
||||
resolved = config_path.parent / module_str
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(f"Auth file not found: {resolved} (from {path_str})")
|
||||
if not resolved.is_file():
|
||||
raise IsADirectoryError(f"Auth path must be a file: {resolved}")
|
||||
|
||||
# Check faux packages first (higher priority)
|
||||
for faux_path, (_, destpath) in local_deps.faux_pkgs.items():
|
||||
if resolved.is_relative_to(faux_path):
|
||||
new_path = f"{destpath}/{resolved.relative_to(faux_path)}:{attr_str}"
|
||||
auth_conf["path"] = new_path
|
||||
return
|
||||
|
||||
# Check real packages
|
||||
for real_path in local_deps.real_pkgs:
|
||||
if resolved.is_relative_to(real_path):
|
||||
new_path = (
|
||||
f"/deps/{real_path.name}/{resolved.relative_to(real_path)}:{attr_str}"
|
||||
)
|
||||
auth_conf["path"] = new_path
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
f"Auth file '{resolved}' not covered by dependencies.\n"
|
||||
"Add its parent directory to the 'dependencies' array in your config.\n"
|
||||
f"Current dependencies: {config['dependencies']}"
|
||||
)
|
||||
|
||||
|
||||
def _update_http_app_path(
|
||||
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
|
||||
) -> None:
|
||||
"""Update the HTTP app path to point to the correct location in the Docker container.
|
||||
|
||||
Similar to _update_graph_paths, this ensures that if a custom app is specified via
|
||||
a local file path, that file is included in the Docker build context and its path
|
||||
is updated to point to the correct location in the container.
|
||||
"""
|
||||
if not (http_config := config.get("http")) or not (
|
||||
app_str := http_config.get("app")
|
||||
):
|
||||
return
|
||||
|
||||
module_str, _, attr_str = app_str.partition(":")
|
||||
if not module_str or not attr_str:
|
||||
message = (
|
||||
'Import string "{import_str}" must be in format "<module>:<attribute>".'
|
||||
)
|
||||
raise ValueError(message.format(import_str=app_str))
|
||||
|
||||
# Check if it's a file path
|
||||
if "/" in module_str or "\\" in module_str:
|
||||
# Resolve the local path properly on the current OS
|
||||
resolved = (config_path.parent / module_str).resolve()
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(f"Could not find HTTP app module: {resolved}")
|
||||
elif not resolved.is_file():
|
||||
raise IsADirectoryError(f"HTTP app module must be a file: {resolved}")
|
||||
else:
|
||||
for path in local_deps.real_pkgs:
|
||||
if resolved.is_relative_to(path):
|
||||
container_path = (
|
||||
pathlib.Path("/deps") / path.name / resolved.relative_to(path)
|
||||
)
|
||||
module_str = container_path.as_posix()
|
||||
break
|
||||
else:
|
||||
for faux_pkg, (_, destpath) in local_deps.faux_pkgs.items():
|
||||
if resolved.is_relative_to(faux_pkg):
|
||||
container_subpath = resolved.relative_to(faux_pkg)
|
||||
# Construct the final path, ensuring POSIX style
|
||||
module_str = f"{destpath}/{container_subpath.as_posix()}"
|
||||
break
|
||||
else:
|
||||
raise ValueError(
|
||||
f"HTTP app module '{app_str}' not found in 'dependencies' list. "
|
||||
"Add its containing package to 'dependencies' list."
|
||||
)
|
||||
# update the config
|
||||
http_config["app"] = f"{module_str}:{attr_str}"
|
||||
|
||||
|
||||
def python_config_to_docker(
|
||||
config_path: pathlib.Path, config: Config, base_image: str
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
"""Generate a Dockerfile from the configuration."""
|
||||
# configure pip
|
||||
pip_install = (
|
||||
"PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt"
|
||||
)
|
||||
if config.get("pip_config_file"):
|
||||
pip_install = f"PIP_CONFIG_FILE=/pipconfig.txt {pip_install}"
|
||||
pip_config_file_str = (
|
||||
f"ADD {config['pip_config_file']} /pipconfig.txt"
|
||||
if config.get("pip_config_file")
|
||||
else ""
|
||||
)
|
||||
|
||||
# collect dependencies
|
||||
pypi_deps = [dep for dep in config["dependencies"] if not dep.startswith(".")]
|
||||
local_deps = _assemble_local_deps(config_path, config)
|
||||
# Rewrite graph paths, so they point to the correct location in the Docker container
|
||||
_update_graph_paths(config_path, config, local_deps)
|
||||
# Rewrite auth path, so it points to the correct location in the Docker container
|
||||
_update_auth_path(config_path, config, local_deps)
|
||||
# Rewrite HTTP app path, so it points to the correct location in the Docker container
|
||||
_update_http_app_path(config_path, config, local_deps)
|
||||
|
||||
pip_pkgs_str = f"RUN {pip_install} {' '.join(pypi_deps)}" if pypi_deps else ""
|
||||
if local_deps.pip_reqs:
|
||||
pip_reqs_str = os.linesep.join(
|
||||
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}"
|
||||
for reqpath, destpath in local_deps.pip_reqs
|
||||
)
|
||||
pip_reqs_str += f'{os.linesep}RUN {pip_install} {" ".join("-r " + r for _,r in local_deps.pip_reqs)}'
|
||||
pip_reqs_str = f"""# -- Installing local requirements --
|
||||
{pip_reqs_str}
|
||||
# -- End of local requirements install --"""
|
||||
|
||||
else:
|
||||
pip_reqs_str = ""
|
||||
|
||||
# https://setuptools.pypa.io/en/latest/userguide/datafiles.html#package-data
|
||||
# https://til.simonwillison.net/python/pyproject
|
||||
faux_pkgs_str = f"{os.linesep}{os.linesep}".join(
|
||||
(
|
||||
f"""# -- Adding non-package dependency {fullpath.name} --
|
||||
COPY --from=__outer_{fullpath.name} . {destpath}"""
|
||||
if fullpath in local_deps.additional_contexts
|
||||
else f"""# -- Adding non-package dependency {fullpath.name} --
|
||||
ADD {relpath} {destpath}"""
|
||||
)
|
||||
+ f"""
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "{fullpath.name}"' \\
|
||||
'version = "0.1"' \\
|
||||
'[tool.setuptools.package-data]' \\
|
||||
'"*" = ["**/*"]'; do \\
|
||||
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()
|
||||
)
|
||||
|
||||
local_pkgs_str = os.linesep.join(
|
||||
f"""# -- Adding local package {relpath} --
|
||||
COPY --from={name} . /deps/{name}
|
||||
# -- End of local package {relpath} --"""
|
||||
if fullpath in local_deps.additional_contexts
|
||||
else f"""# -- Adding local package {relpath} --
|
||||
ADD {relpath} /deps/{name}
|
||||
# -- End of local package {relpath} --"""
|
||||
for fullpath, (relpath, name) in local_deps.real_pkgs.items()
|
||||
)
|
||||
|
||||
installs = f"{os.linesep}{os.linesep}".join(
|
||||
filter(
|
||||
None,
|
||||
[
|
||||
pip_config_file_str,
|
||||
pip_pkgs_str,
|
||||
pip_reqs_str,
|
||||
local_pkgs_str,
|
||||
faux_pkgs_str,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
env_vars = []
|
||||
|
||||
if (store_config := config.get("store")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_STORE='{json.dumps(store_config)}'")
|
||||
|
||||
if (auth_config := config.get("auth")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
|
||||
|
||||
if (http_config := config.get("http")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
|
||||
|
||||
graphs = config["graphs"]
|
||||
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(graphs)}'")
|
||||
|
||||
docker_file_contents = [
|
||||
f"FROM {base_image}:{config['python_version']}",
|
||||
"",
|
||||
os.linesep.join(config["dockerfile_lines"]),
|
||||
"",
|
||||
installs,
|
||||
"",
|
||||
"# -- Installing all local dependencies --",
|
||||
f"RUN {pip_install} -e /deps/*",
|
||||
"# -- End of local dependencies install --",
|
||||
os.linesep.join(env_vars),
|
||||
"",
|
||||
f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else "",
|
||||
]
|
||||
|
||||
additional_contexts: dict[str, str] = {}
|
||||
for p in local_deps.additional_contexts:
|
||||
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}"
|
||||
else:
|
||||
raise RuntimeError(f"Unknown additional context: {p}")
|
||||
additional_contexts[name] = str(p)
|
||||
|
||||
return os.linesep.join(docker_file_contents), additional_contexts
|
||||
|
||||
|
||||
def node_config_to_docker(
|
||||
config_path: pathlib.Path, config: Config, base_image: str
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
faux_path = f"/deps/{config_path.parent.name}"
|
||||
|
||||
def test_file(file_name):
|
||||
full_path = config_path.parent / file_name
|
||||
try:
|
||||
return full_path.is_file()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
npm, yarn, pnpm, bun = [
|
||||
test_file("package-lock.json"),
|
||||
test_file("yarn.lock"),
|
||||
test_file("pnpm-lock.yaml"),
|
||||
test_file("bun.lockb"),
|
||||
]
|
||||
|
||||
if yarn:
|
||||
install_cmd = "yarn install --frozen-lockfile"
|
||||
elif pnpm:
|
||||
install_cmd = "pnpm i --frozen-lockfile"
|
||||
elif npm:
|
||||
install_cmd = "npm ci"
|
||||
elif bun:
|
||||
install_cmd = "bun i"
|
||||
else:
|
||||
install_cmd = "npm i"
|
||||
store_config = config.get("store")
|
||||
env_additional_config = (
|
||||
""
|
||||
if not store_config
|
||||
else f"""
|
||||
ENV LANGGRAPH_STORE='{json.dumps(store_config)}'
|
||||
"""
|
||||
)
|
||||
if (auth_config := config.get("auth")) is not None:
|
||||
env_additional_config += f"""
|
||||
ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'
|
||||
"""
|
||||
if (http_config := config.get("http")) is not None:
|
||||
env_additional_config += f"""
|
||||
ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'
|
||||
"""
|
||||
|
||||
return (
|
||||
f"""FROM {base_image}:{config['node_version']}
|
||||
|
||||
{os.linesep.join(config["dockerfile_lines"])}
|
||||
|
||||
ADD . {faux_path}
|
||||
|
||||
RUN cd {faux_path} && {install_cmd}
|
||||
{env_additional_config}
|
||||
ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}'
|
||||
|
||||
WORKDIR {faux_path}
|
||||
|
||||
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts""",
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
def config_to_docker(
|
||||
config_path: pathlib.Path, config: Config, base_image: str
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
if config.get("node_version"):
|
||||
return node_config_to_docker(config_path, config, base_image)
|
||||
|
||||
return python_config_to_docker(config_path, config, base_image)
|
||||
|
||||
|
||||
def config_to_compose(
|
||||
config_path: pathlib.Path,
|
||||
config: Config,
|
||||
base_image: str,
|
||||
watch: bool = False,
|
||||
) -> str:
|
||||
env_vars = config["env"].items() if isinstance(config["env"], dict) else {}
|
||||
env_vars_str = "\n".join(f' {k}: "{v}"' for k, v in env_vars)
|
||||
env_file_str = (
|
||||
f"env_file: {config['env']}" if isinstance(config["env"], str) else ""
|
||||
)
|
||||
if watch:
|
||||
dependencies = config.get("dependencies") or ["."]
|
||||
watch_paths = [config_path.name] + [
|
||||
dep for dep in dependencies if dep.startswith(".")
|
||||
]
|
||||
watch_actions = "\n".join(
|
||||
f"""- path: {path}
|
||||
action: rebuild"""
|
||||
for path in watch_paths
|
||||
)
|
||||
watch_str = f"""
|
||||
develop:
|
||||
watch:
|
||||
{textwrap.indent(watch_actions, " ")}
|
||||
"""
|
||||
else:
|
||||
watch_str = ""
|
||||
|
||||
dockerfile, additional_contexts = config_to_docker(config_path, config, base_image)
|
||||
|
||||
additional_contexts_str = "\n".join(
|
||||
f" - {name}: {path}"
|
||||
for name, path in additional_contexts.items()
|
||||
)
|
||||
if additional_contexts_str:
|
||||
additional_contexts_str = f"""
|
||||
additional_contexts:
|
||||
{additional_contexts_str}"""
|
||||
|
||||
return f"""
|
||||
{textwrap.indent(env_vars_str, " ")}
|
||||
{env_file_str}
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .{additional_contexts_str}
|
||||
dockerfile_inline: |
|
||||
{textwrap.indent(dockerfile, " ")}
|
||||
{watch_str}
|
||||
"""
|
||||
@@ -1,6 +0,0 @@
|
||||
DEFAULT_CONFIG = "langgraph.json"
|
||||
DEFAULT_PORT = 8123
|
||||
|
||||
# analytics
|
||||
SUPABASE_PUBLIC_API_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt6cmxwcG9qaW5wY3l5YWlweG5iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTkyNTc1NzksImV4cCI6MjAzNDgzMzU3OX0.kkVOlLz3BxemA5nP-vat3K4qRtrDuO4SwZSR_htcX9c"
|
||||
SUPABASE_URL = "https://kzrlppojinpcyyaipxnb.supabase.co"
|
||||
@@ -1,257 +0,0 @@
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
from typing import Literal, NamedTuple, Optional
|
||||
|
||||
import click.exceptions
|
||||
|
||||
from langgraph_cli.exec import subp_exec
|
||||
|
||||
ROOT = pathlib.Path(__file__).parent.resolve()
|
||||
DEFAULT_POSTGRES_URI = (
|
||||
"postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
|
||||
)
|
||||
|
||||
|
||||
class Version(NamedTuple):
|
||||
major: int
|
||||
minor: int
|
||||
patch: int
|
||||
|
||||
|
||||
DockerComposeType = Literal["plugin", "standalone"]
|
||||
|
||||
|
||||
class DockerCapabilities(NamedTuple):
|
||||
version_docker: Version
|
||||
version_compose: Version
|
||||
healthcheck_start_interval: bool
|
||||
compose_type: DockerComposeType = "plugin"
|
||||
|
||||
|
||||
def _parse_version(version: str) -> Version:
|
||||
parts = version.split(".", 2)
|
||||
if len(parts) == 1:
|
||||
major = parts[0]
|
||||
minor = "0"
|
||||
patch = "0"
|
||||
elif len(parts) == 2:
|
||||
major, minor = parts
|
||||
patch = "0"
|
||||
else:
|
||||
major, minor, patch = parts
|
||||
return Version(int(major.lstrip("v")), int(minor), int(patch.split("-")[0]))
|
||||
|
||||
|
||||
def check_capabilities(runner) -> DockerCapabilities:
|
||||
# check docker available
|
||||
if shutil.which("docker") is None:
|
||||
raise click.UsageError("Docker not installed") from None
|
||||
|
||||
try:
|
||||
stdout, _ = runner.run(
|
||||
subp_exec("docker", "info", "-f", "{{json .}}", collect=True)
|
||||
)
|
||||
info = json.loads(stdout)
|
||||
except (click.exceptions.Exit, json.JSONDecodeError):
|
||||
raise click.UsageError("Docker not installed or not running") from None
|
||||
|
||||
if not info["ServerVersion"]:
|
||||
raise click.UsageError("Docker not running") from None
|
||||
|
||||
compose_type: DockerComposeType
|
||||
try:
|
||||
compose = next(
|
||||
p for p in info["ClientInfo"]["Plugins"] if p["Name"] == "compose"
|
||||
)
|
||||
compose_version_str = compose["Version"]
|
||||
compose_type = "plugin"
|
||||
except (KeyError, StopIteration):
|
||||
if shutil.which("docker-compose") is None:
|
||||
raise click.UsageError("Docker Compose not installed") from None
|
||||
|
||||
compose_version_str, _ = runner.run(
|
||||
subp_exec("docker-compose", "--version", "--short", collect=True)
|
||||
)
|
||||
compose_type = "standalone"
|
||||
|
||||
# parse versions
|
||||
docker_version = _parse_version(info["ServerVersion"])
|
||||
compose_version = _parse_version(compose_version_str)
|
||||
|
||||
# check capabilities
|
||||
return DockerCapabilities(
|
||||
version_docker=docker_version,
|
||||
version_compose=compose_version,
|
||||
healthcheck_start_interval=docker_version >= Version(25, 0, 0),
|
||||
compose_type=compose_type,
|
||||
)
|
||||
|
||||
|
||||
def debugger_compose(
|
||||
*, port: Optional[int] = None, base_url: Optional[str] = None
|
||||
) -> dict:
|
||||
if port is None:
|
||||
return ""
|
||||
|
||||
config = {
|
||||
"langgraph-debugger": {
|
||||
"image": "langchain/langgraph-debugger",
|
||||
"restart": "on-failure",
|
||||
"depends_on": {
|
||||
"langgraph-postgres": {"condition": "service_healthy"},
|
||||
},
|
||||
"ports": [f'"{port}:3968"'],
|
||||
}
|
||||
}
|
||||
|
||||
if base_url:
|
||||
config["langgraph-debugger"]["environment"] = {
|
||||
"VITE_STUDIO_LOCAL_GRAPH_URL": base_url
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Function to convert dictionary to YAML
|
||||
def dict_to_yaml(d: dict, *, indent: int = 0) -> str:
|
||||
"""Convert a dictionary to a YAML string."""
|
||||
yaml_str = ""
|
||||
|
||||
for idx, (key, value) in enumerate(d.items()):
|
||||
# Format things in a visually appealing way
|
||||
# Use an extra newline for top-level keys only
|
||||
if idx >= 1 and indent < 2:
|
||||
yaml_str += "\n"
|
||||
space = " " * indent
|
||||
if isinstance(value, dict):
|
||||
yaml_str += f"{space}{key}:\n" + dict_to_yaml(value, indent=indent + 1)
|
||||
elif isinstance(value, list):
|
||||
yaml_str += f"{space}{key}:\n"
|
||||
for item in value:
|
||||
yaml_str += f"{space} - {item}\n"
|
||||
else:
|
||||
yaml_str += f"{space}{key}: {value}\n"
|
||||
return yaml_str
|
||||
|
||||
|
||||
def compose_as_dict(
|
||||
capabilities: DockerCapabilities,
|
||||
*,
|
||||
port: int,
|
||||
debugger_port: Optional[int] = None,
|
||||
debugger_base_url: Optional[str] = None,
|
||||
# postgres://user:password@host:port/database?option=value
|
||||
postgres_uri: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Create a docker compose file as a dictionary in YML style."""
|
||||
if postgres_uri is None:
|
||||
include_db = True
|
||||
postgres_uri = DEFAULT_POSTGRES_URI
|
||||
else:
|
||||
include_db = False
|
||||
|
||||
# The services below are defined in a non-intuitive order to match
|
||||
# the existing unit tests for this function.
|
||||
# It's fine to re-order just requires updating the unit tests, so it should
|
||||
# be done with caution.
|
||||
|
||||
# Define the Redis service first as per the test order
|
||||
services = {
|
||||
"langgraph-redis": {
|
||||
"image": "redis:6",
|
||||
"healthcheck": {
|
||||
"test": "redis-cli ping",
|
||||
"interval": "5s",
|
||||
"timeout": "1s",
|
||||
"retries": 5,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# Add Postgres service before langgraph-api if it is needed
|
||||
if include_db:
|
||||
services["langgraph-postgres"] = {
|
||||
"image": "pgvector/pgvector:pg16",
|
||||
"ports": ['"5433:5432"'],
|
||||
"environment": {
|
||||
"POSTGRES_DB": "postgres",
|
||||
"POSTGRES_USER": "postgres",
|
||||
"POSTGRES_PASSWORD": "postgres",
|
||||
},
|
||||
"command": ["postgres", "-c", "shared_preload_libraries=vector"],
|
||||
"volumes": ["langgraph-data:/var/lib/postgresql/data"],
|
||||
"healthcheck": {
|
||||
"test": "pg_isready -U postgres",
|
||||
"start_period": "10s",
|
||||
"timeout": "1s",
|
||||
"retries": 5,
|
||||
},
|
||||
}
|
||||
if capabilities.healthcheck_start_interval:
|
||||
services["langgraph-postgres"]["healthcheck"]["interval"] = "60s"
|
||||
services["langgraph-postgres"]["healthcheck"]["start_interval"] = "1s"
|
||||
else:
|
||||
services["langgraph-postgres"]["healthcheck"]["interval"] = "5s"
|
||||
|
||||
# Add optional debugger service if debugger_port is specified
|
||||
if debugger_port:
|
||||
services["langgraph-debugger"] = debugger_compose(
|
||||
port=debugger_port, base_url=debugger_base_url
|
||||
)["langgraph-debugger"]
|
||||
|
||||
# Add langgraph-api service
|
||||
services["langgraph-api"] = {
|
||||
"ports": [f'"{port}:8000"'],
|
||||
"depends_on": {
|
||||
"langgraph-redis": {"condition": "service_healthy"},
|
||||
},
|
||||
"environment": {
|
||||
"REDIS_URI": "redis://langgraph-redis:6379",
|
||||
"POSTGRES_URI": postgres_uri,
|
||||
},
|
||||
}
|
||||
|
||||
# If Postgres is included, add it to the dependencies of langgraph-api
|
||||
if include_db:
|
||||
services["langgraph-api"]["depends_on"]["langgraph-postgres"] = {
|
||||
"condition": "service_healthy"
|
||||
}
|
||||
|
||||
# Additional healthcheck for langgraph-api if required
|
||||
if capabilities.healthcheck_start_interval:
|
||||
services["langgraph-api"]["healthcheck"] = {
|
||||
"test": "python /api/healthcheck.py",
|
||||
"interval": "60s",
|
||||
"start_interval": "1s",
|
||||
"start_period": "10s",
|
||||
}
|
||||
|
||||
# Final compose dictionary with volumes included if needed
|
||||
compose_dict = {}
|
||||
if include_db:
|
||||
compose_dict["volumes"] = {"langgraph-data": {"driver": "local"}}
|
||||
compose_dict["services"] = services
|
||||
|
||||
return compose_dict
|
||||
|
||||
|
||||
def compose(
|
||||
capabilities: DockerCapabilities,
|
||||
*,
|
||||
port: int,
|
||||
debugger_port: Optional[int] = None,
|
||||
debugger_base_url: Optional[str] = None,
|
||||
# postgres://user:password@host:port/database?option=value
|
||||
postgres_uri: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Create a docker compose file as a string."""
|
||||
compose_content = compose_as_dict(
|
||||
capabilities,
|
||||
port=port,
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_base_url,
|
||||
postgres_uri=postgres_uri,
|
||||
)
|
||||
compose_str = dict_to_yaml(compose_content)
|
||||
return compose_str
|
||||
@@ -1,173 +0,0 @@
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from typing import Callable, Optional, cast
|
||||
|
||||
import click.exceptions
|
||||
|
||||
|
||||
@contextmanager
|
||||
def Runner():
|
||||
if hasattr(asyncio, "Runner"):
|
||||
with asyncio.Runner() as runner:
|
||||
yield runner
|
||||
else:
|
||||
|
||||
class _Runner:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
def run(self, coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
yield _Runner()
|
||||
|
||||
|
||||
async def subp_exec(
|
||||
cmd: str,
|
||||
*args: str,
|
||||
input: Optional[str] = None,
|
||||
wait: Optional[float] = None,
|
||||
verbose: bool = False,
|
||||
collect: bool = False,
|
||||
on_stdout: Optional[Callable[[str], Optional[bool]]] = None,
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
if verbose:
|
||||
cmd_str = f"+ {cmd} {' '.join(map(str, args))}"
|
||||
if input:
|
||||
print(cmd_str, " <\n", "\n".join(filter(None, input.splitlines())), sep="")
|
||||
else:
|
||||
print(cmd_str)
|
||||
if wait:
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
cmd,
|
||||
*args,
|
||||
stdin=asyncio.subprocess.PIPE if input else None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
def signal_handler():
|
||||
# make sure process exists, then terminate it
|
||||
if proc.returncode is None:
|
||||
proc.terminate()
|
||||
|
||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
if sys.platform == "win32":
|
||||
|
||||
def handle_windows_signal(signum, frame):
|
||||
signal_handler()
|
||||
original_sigint_handler(signum, frame)
|
||||
|
||||
signal.signal(signal.SIGINT, handle_windows_signal)
|
||||
# NOTE: we're not adding a handler for SIGTERM since it's ignored on Windows
|
||||
else:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.add_signal_handler(signal.SIGINT, signal_handler)
|
||||
loop.add_signal_handler(signal.SIGTERM, signal_handler)
|
||||
|
||||
empty_fut: asyncio.Future = asyncio.Future()
|
||||
empty_fut.set_result(None)
|
||||
stdout, stderr, _ = await asyncio.gather(
|
||||
monitor_stream(
|
||||
cast(asyncio.StreamReader, proc.stdout),
|
||||
collect=True,
|
||||
display=verbose,
|
||||
on_line=on_stdout,
|
||||
),
|
||||
monitor_stream(
|
||||
cast(asyncio.StreamReader, proc.stderr),
|
||||
collect=True,
|
||||
display=verbose,
|
||||
),
|
||||
proc._feed_stdin(input.encode()) if input else empty_fut, # type: ignore[attr-defined]
|
||||
)
|
||||
returncode = await proc.wait()
|
||||
if (
|
||||
returncode is not None
|
||||
and returncode != 0 # success
|
||||
and returncode != 130 # user interrupt
|
||||
):
|
||||
sys.stdout.write(stdout.decode() if stdout else "")
|
||||
sys.stderr.write(stderr.decode() if stderr else "")
|
||||
raise click.exceptions.Exit(returncode)
|
||||
if collect:
|
||||
return (
|
||||
stdout.decode() if stdout else None,
|
||||
stderr.decode() if stderr else None,
|
||||
)
|
||||
else:
|
||||
return None, None
|
||||
finally:
|
||||
try:
|
||||
if proc.returncode is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
except (ProcessLookupError, KeyboardInterrupt):
|
||||
pass
|
||||
|
||||
if sys.platform == "win32":
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
else:
|
||||
loop.remove_signal_handler(signal.SIGINT)
|
||||
loop.remove_signal_handler(signal.SIGTERM)
|
||||
except UnboundLocalError:
|
||||
pass
|
||||
|
||||
|
||||
async def monitor_stream(
|
||||
stream: asyncio.StreamReader,
|
||||
collect: bool = False,
|
||||
display: bool = False,
|
||||
on_line: Optional[Callable[[str], Optional[bool]]] = None,
|
||||
) -> Optional[bytearray]:
|
||||
if collect:
|
||||
ba = bytearray()
|
||||
|
||||
def handle(line: bytes, overrun: bool):
|
||||
nonlocal on_line
|
||||
nonlocal display
|
||||
|
||||
if display:
|
||||
sys.stdout.buffer.write(line)
|
||||
if overrun:
|
||||
return
|
||||
if collect:
|
||||
ba.extend(line)
|
||||
if on_line:
|
||||
if on_line(line.decode()):
|
||||
on_line = None
|
||||
display = True
|
||||
|
||||
"""Adapted from asyncio.StreamReader.readline() to handle LimitOverrunError."""
|
||||
sep = b"\n"
|
||||
seplen = len(sep)
|
||||
while True:
|
||||
try:
|
||||
line = await stream.readuntil(sep)
|
||||
overrun = False
|
||||
except asyncio.IncompleteReadError as e:
|
||||
line = e.partial
|
||||
overrun = False
|
||||
except asyncio.LimitOverrunError as e:
|
||||
if stream._buffer.startswith(sep, e.consumed):
|
||||
line = stream._buffer[: e.consumed + seplen]
|
||||
else:
|
||||
line = stream._buffer.clear()
|
||||
overrun = True
|
||||
stream._maybe_resume_transport()
|
||||
await asyncio.to_thread(handle, line, overrun)
|
||||
if line == b"":
|
||||
break
|
||||
|
||||
if collect:
|
||||
return ba
|
||||
else:
|
||||
return None
|
||||
@@ -1,64 +0,0 @@
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class Progress:
|
||||
delay: float = 0.1
|
||||
|
||||
@staticmethod
|
||||
def spinning_cursor():
|
||||
while True:
|
||||
yield from "|/-\\"
|
||||
|
||||
def __init__(self, *, message=""):
|
||||
self.message = message
|
||||
self.spinner_generator = self.spinning_cursor()
|
||||
|
||||
def spinner_iteration(self):
|
||||
message = self.message
|
||||
sys.stdout.write(next(self.spinner_generator) + " " + message)
|
||||
sys.stdout.flush()
|
||||
time.sleep(self.delay)
|
||||
# clear the spinner and message
|
||||
sys.stdout.write(
|
||||
"\b" * (len(message) + 2)
|
||||
+ " " * (len(message) + 2)
|
||||
+ "\b" * (len(message) + 2)
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
def spinner_task(self):
|
||||
while self.message:
|
||||
message = self.message
|
||||
sys.stdout.write(next(self.spinner_generator) + " " + message)
|
||||
sys.stdout.flush()
|
||||
time.sleep(self.delay)
|
||||
# clear the spinner and message
|
||||
sys.stdout.write(
|
||||
"\b" * (len(message) + 2)
|
||||
+ " " * (len(message) + 2)
|
||||
+ "\b" * (len(message) + 2)
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
def __enter__(self) -> Callable[[str], None]:
|
||||
self.thread = threading.Thread(target=self.spinner_task)
|
||||
self.thread.start()
|
||||
|
||||
def set_message(message):
|
||||
self.message = message
|
||||
if not message:
|
||||
self.thread.join()
|
||||
|
||||
return set_message
|
||||
|
||||
def __exit__(self, exception, value, tb):
|
||||
self.message = ""
|
||||
try:
|
||||
self.thread.join()
|
||||
finally:
|
||||
del self.thread
|
||||
if exception is not None:
|
||||
return False
|
||||
@@ -1,223 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from io import BytesIO
|
||||
from typing import Dict, Optional
|
||||
from urllib import error, request
|
||||
from zipfile import ZipFile
|
||||
|
||||
import click
|
||||
|
||||
TEMPLATES: Dict[str, Dict[str, str]] = {
|
||||
"New LangGraph Project": {
|
||||
"description": "A simple, minimal chatbot with memory.",
|
||||
"python": "https://github.com/langchain-ai/new-langgraph-project/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/new-langgraphjs-project/archive/refs/heads/main.zip",
|
||||
},
|
||||
"ReAct Agent": {
|
||||
"description": "A simple agent that can be flexibly extended to many tools.",
|
||||
"python": "https://github.com/langchain-ai/react-agent/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/react-agent-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
"Memory Agent": {
|
||||
"description": "A ReAct-style agent with an additional tool to store memories for use across conversational threads.",
|
||||
"python": "https://github.com/langchain-ai/memory-agent/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/memory-agent-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
"Retrieval Agent": {
|
||||
"description": "An agent that includes a retrieval-based question-answering system.",
|
||||
"python": "https://github.com/langchain-ai/retrieval-agent-template/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/retrieval-agent-template-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
"Data-enrichment Agent": {
|
||||
"description": "An agent that performs web searches and organizes its findings into a structured format.",
|
||||
"python": "https://github.com/langchain-ai/data-enrichment/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/data-enrichment-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
}
|
||||
|
||||
# Generate TEMPLATE_IDS programmatically
|
||||
TEMPLATE_ID_TO_CONFIG = {
|
||||
f"{name.lower().replace(' ', '-')}-{lang}": (name, lang, url)
|
||||
for name, versions in TEMPLATES.items()
|
||||
for lang, url in versions.items()
|
||||
if lang in {"python", "js"}
|
||||
}
|
||||
|
||||
TEMPLATE_IDS = list(TEMPLATE_ID_TO_CONFIG.keys())
|
||||
|
||||
TEMPLATE_HELP_STRING = (
|
||||
"The name of the template to use. Available options:\n"
|
||||
+ "\n".join(f"{id_}" for id_ in TEMPLATE_ID_TO_CONFIG)
|
||||
)
|
||||
|
||||
|
||||
def _choose_template() -> str:
|
||||
"""Presents a list of templates to the user and prompts them to select one.
|
||||
|
||||
Returns:
|
||||
str: The URL of the selected template.
|
||||
"""
|
||||
click.secho("🌟 Please select a template:", bold=True, fg="yellow")
|
||||
for idx, (template_name, template_info) in enumerate(TEMPLATES.items(), 1):
|
||||
click.secho(f"{idx}. ", nl=False, fg="cyan")
|
||||
click.secho(template_name, fg="cyan", nl=False)
|
||||
click.secho(f" - {template_info['description']}", fg="white")
|
||||
|
||||
# Get the template choice from the user, defaulting to the first template if blank
|
||||
template_choice: Optional[int] = click.prompt(
|
||||
"Enter the number of your template choice (default is 1)",
|
||||
type=int,
|
||||
default=1,
|
||||
show_default=False,
|
||||
)
|
||||
|
||||
template_keys = list(TEMPLATES.keys())
|
||||
if 1 <= template_choice <= len(template_keys):
|
||||
selected_template: str = template_keys[template_choice - 1]
|
||||
else:
|
||||
click.secho("❌ Invalid choice. Please try again.", fg="red")
|
||||
return _choose_template()
|
||||
|
||||
# Prompt the user to choose between Python or JS/TS version
|
||||
click.secho(
|
||||
f"\nYou selected: {selected_template} - {TEMPLATES[selected_template]['description']}",
|
||||
fg="green",
|
||||
)
|
||||
version_choice: int = click.prompt(
|
||||
"Choose language (1 for Python 🐍, 2 for JS/TS 🌐)", type=int
|
||||
)
|
||||
|
||||
if version_choice == 1:
|
||||
return TEMPLATES[selected_template]["python"]
|
||||
elif version_choice == 2:
|
||||
return TEMPLATES[selected_template]["js"]
|
||||
else:
|
||||
click.secho("❌ Invalid choice. Please try again.", fg="red")
|
||||
return _choose_template()
|
||||
|
||||
|
||||
def _download_repo_with_requests(repo_url: str, path: str) -> None:
|
||||
"""Download a ZIP archive from the given URL and extracts it to the specified path.
|
||||
|
||||
Args:
|
||||
repo_url (str): The URL of the repository to download.
|
||||
path (str): The path where the repository should be extracted.
|
||||
"""
|
||||
click.secho("📥 Attempting to download repository as a ZIP archive...", fg="yellow")
|
||||
click.secho(f"URL: {repo_url}", fg="yellow")
|
||||
try:
|
||||
with request.urlopen(repo_url) as response:
|
||||
if response.status == 200:
|
||||
with ZipFile(BytesIO(response.read())) as zip_file:
|
||||
zip_file.extractall(path)
|
||||
# Move extracted contents to path
|
||||
for item in os.listdir(path):
|
||||
if item.endswith("-main"):
|
||||
extracted_dir = os.path.join(path, item)
|
||||
for filename in os.listdir(extracted_dir):
|
||||
shutil.move(os.path.join(extracted_dir, filename), path)
|
||||
shutil.rmtree(extracted_dir)
|
||||
click.secho(
|
||||
f"✅ Downloaded and extracted repository to {path}", fg="green"
|
||||
)
|
||||
except error.HTTPError as e:
|
||||
click.secho(
|
||||
f"❌ Error: Failed to download repository.\n" f"Details: {e}\n",
|
||||
fg="red",
|
||||
bold=True,
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _get_template_url(template_name: str) -> Optional[str]:
|
||||
"""
|
||||
Retrieves the template URL based on the provided template name.
|
||||
|
||||
Args:
|
||||
template_name (str): The name of the template.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The URL of the template if found, else None.
|
||||
"""
|
||||
if template_name in TEMPLATES:
|
||||
click.secho(f"Template selected: {template_name}", fg="green")
|
||||
version_choice: int = click.prompt(
|
||||
"Choose version (1 for Python 🐍, 2 for JS/TS 🌐)", type=int
|
||||
)
|
||||
|
||||
if version_choice == 1:
|
||||
return TEMPLATES[template_name]["python"]
|
||||
elif version_choice == 2:
|
||||
return TEMPLATES[template_name]["js"]
|
||||
else:
|
||||
click.secho("❌ Invalid choice. Please try again.", fg="red")
|
||||
return None
|
||||
else:
|
||||
click.secho(
|
||||
f"Template '{template_name}' not found. Please select from the available options.",
|
||||
fg="red",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def create_new(path: Optional[str], template: Optional[str]) -> None:
|
||||
"""Create a new LangGraph project at the specified PATH using the chosen TEMPLATE.
|
||||
|
||||
Args:
|
||||
path (Optional[str]): The path where the new project will be created.
|
||||
template (Optional[str]): The name of the template to use.
|
||||
"""
|
||||
# Prompt for path if not provided
|
||||
if not path:
|
||||
path = click.prompt(
|
||||
"📂 Please specify the path to create the application", default="."
|
||||
)
|
||||
|
||||
path = os.path.abspath(path) # Ensure path is absolute
|
||||
|
||||
# Check if path exists and is not empty
|
||||
if os.path.exists(path) and os.listdir(path):
|
||||
click.secho(
|
||||
"❌ The specified directory already exists and is not empty. "
|
||||
"Aborting to prevent overwriting files.",
|
||||
fg="red",
|
||||
bold=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Get template URL either from command-line argument or
|
||||
# through interactive selection
|
||||
if template:
|
||||
if template not in TEMPLATE_ID_TO_CONFIG:
|
||||
# Format available options in a readable way with descriptions
|
||||
template_options = ""
|
||||
for id_ in TEMPLATE_IDS:
|
||||
name, lang, _ = TEMPLATE_ID_TO_CONFIG[id_]
|
||||
description = TEMPLATES[name]["description"]
|
||||
|
||||
# Add each template option with color formatting
|
||||
template_options += (
|
||||
click.style("- ", fg="yellow", bold=True)
|
||||
+ click.style(f"{id_}", fg="cyan")
|
||||
+ click.style(f": {description}", fg="white")
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
# Display error message with colors and formatting
|
||||
click.secho("❌ Error:", fg="red", bold=True, nl=False)
|
||||
click.secho(f" Template '{template}' not found.", fg="red")
|
||||
click.secho(
|
||||
"Please select from the available options:\n", fg="yellow", bold=True
|
||||
)
|
||||
click.secho(template_options, fg="cyan")
|
||||
sys.exit(1)
|
||||
_, _, template_url = TEMPLATE_ID_TO_CONFIG[template]
|
||||
else:
|
||||
template_url = _choose_template()
|
||||
|
||||
# Download and extract the template
|
||||
_download_repo_with_requests(template_url, path)
|
||||
|
||||
click.secho(f"🎉 New project created at {path}", fg="green", bold=True)
|
||||
@@ -1,2 +0,0 @@
|
||||
def clean_empty_lines(input_str: str):
|
||||
return "\n".join(filter(None, input_str.splitlines()))
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Main entrypoint into package."""
|
||||
|
||||
from importlib import metadata
|
||||
|
||||
try:
|
||||
__version__ = metadata.version(__package__)
|
||||
except metadata.PackageNotFoundError:
|
||||
# Case where package metadata is not available.
|
||||
__version__ = ""
|
||||
del metadata # optional, avoids polluting the results of dir(__package__)
|
||||
Generated
-1668
File diff suppressed because it is too large
Load Diff
@@ -1,60 +0,0 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-cli"
|
||||
version = "0.1.74"
|
||||
description = "CLI for interacting with LangGraph API"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
repository = "https://www.github.com/langchain-ai/langgraph"
|
||||
packages = [{ include = "langgraph_cli" }]
|
||||
|
||||
[tool.poetry.scripts]
|
||||
langgraph = "langgraph_cli.cli:cli"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9.0,<4.0"
|
||||
click = "^8.1.7"
|
||||
langgraph-api = { version = ">=0.0.27,<0.1.0", optional = true, python = ">=3.11,<4.0" }
|
||||
python-dotenv = { version = ">=0.8.0", optional = true }
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "^0.6.2"
|
||||
codespell = "^2.2.0"
|
||||
pytest = "^7.2.1"
|
||||
pytest-asyncio = "^0.21.1"
|
||||
pytest-mock = "^3.11.1"
|
||||
pytest-watch = "^4.2.0"
|
||||
mypy = "^1.10.0"
|
||||
|
||||
[tool.poetry.extras]
|
||||
inmem = ["langgraph-api", "python-dotenv"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
# --strict-markers will raise errors on unknown marks.
|
||||
# https://docs.pytest.org/en/7.1.x/how-to/mark.html#raising-errors-on-unknown-marks
|
||||
#
|
||||
# https://docs.pytest.org/en/7.1.x/reference/reference.html
|
||||
# --strict-config any warnings encountered while parsing the `pytest`
|
||||
# section of the configuration file raise errors.
|
||||
addopts = "--strict-markers --strict-config --durations=5 -vv"
|
||||
asyncio_mode = "auto"
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.ruff]
|
||||
lint.select = [
|
||||
# pycodestyle
|
||||
"E",
|
||||
# Pyflakes
|
||||
"F",
|
||||
# pyupgrade
|
||||
"UP",
|
||||
# flake8-bugbear
|
||||
"B",
|
||||
# isort
|
||||
"I",
|
||||
]
|
||||
lint.ignore = ["E501", "B008"]
|
||||
@@ -1,13 +0,0 @@
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from langgraph_cli.templates import TEMPLATE_ID_TO_CONFIG
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template_key", TEMPLATE_ID_TO_CONFIG.keys())
|
||||
def test_template_urls_work(template_key: str) -> None:
|
||||
"""Integration test to verify that all template URLs are reachable."""
|
||||
_, _, template_url = TEMPLATE_ID_TO_CONFIG[template_key]
|
||||
response = requests.head(template_url)
|
||||
# Returns 302 on a successful HEAD request
|
||||
assert response.status_code == 302, f"URL {template_url} is not reachable."
|
||||
@@ -1,81 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Annotated, Sequence, TypedDict
|
||||
|
||||
from langchain_core.language_models.fake_chat_models import FakeListChatModel
|
||||
from langchain_core.messages import BaseMessage, HumanMessage, ToolMessage
|
||||
from langgraph.graph import END, StateGraph, add_messages
|
||||
|
||||
# check that env var is present
|
||||
os.environ["SOME_ENV_VAR"]
|
||||
|
||||
|
||||
class AgentState(TypedDict):
|
||||
some_bytes: bytes
|
||||
some_byte_array: bytearray
|
||||
dict_with_bytes: dict[str, bytes]
|
||||
messages: Annotated[Sequence[BaseMessage], add_messages]
|
||||
sleep: int
|
||||
|
||||
|
||||
async def call_model(state, config):
|
||||
if sleep := state.get("sleep"):
|
||||
await asyncio.sleep(sleep)
|
||||
|
||||
messages = state["messages"]
|
||||
|
||||
if len(messages) > 1:
|
||||
assert state["some_bytes"] == b"some_bytes"
|
||||
assert state["some_byte_array"] == bytearray(b"some_byte_array")
|
||||
assert state["dict_with_bytes"] == {"more_bytes": b"more_bytes"}
|
||||
|
||||
# hacky way to reset model to the "first" response
|
||||
if isinstance(messages[-1], HumanMessage):
|
||||
model.i = 0
|
||||
|
||||
response = await model.ainvoke(messages)
|
||||
return {
|
||||
"messages": [response],
|
||||
"some_bytes": b"some_bytes",
|
||||
"some_byte_array": bytearray(b"some_byte_array"),
|
||||
"dict_with_bytes": {"more_bytes": b"more_bytes"},
|
||||
}
|
||||
|
||||
|
||||
def call_tool(state):
|
||||
last_message_content = state["messages"][-1].content
|
||||
return {
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
f"tool_call__{last_message_content}", tool_call_id="tool_call_id"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def should_continue(state):
|
||||
messages = state["messages"]
|
||||
last_message = messages[-1]
|
||||
if last_message.content == "end":
|
||||
return END
|
||||
else:
|
||||
return "tool"
|
||||
|
||||
|
||||
# NOTE: the model cycles through responses infinitely here
|
||||
model = FakeListChatModel(responses=["begin", "end"])
|
||||
workflow = StateGraph(AgentState)
|
||||
|
||||
workflow.add_node("agent", call_model)
|
||||
workflow.add_node("tool", call_tool)
|
||||
|
||||
workflow.set_entry_point("agent")
|
||||
|
||||
workflow.add_conditional_edges(
|
||||
"agent",
|
||||
should_continue,
|
||||
)
|
||||
|
||||
workflow.add_edge("tool", "agent")
|
||||
|
||||
graph = workflow.compile()
|
||||
@@ -1,259 +0,0 @@
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from langgraph_cli.cli import cli, prepare_args_and_stdin
|
||||
from langgraph_cli.config import Config, validate_config
|
||||
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
|
||||
from langgraph_cli.util import clean_empty_lines
|
||||
|
||||
DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
|
||||
version_docker=Version(26, 1, 1),
|
||||
version_compose=Version(2, 27, 0),
|
||||
healthcheck_start_interval=True,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def temporary_config_folder(config_content: dict):
|
||||
# Create a temporary directory
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
# Define the path for the config.json file
|
||||
config_path = Path(temp_dir) / "config.json"
|
||||
|
||||
# Write the provided dictionary content to config.json
|
||||
with open(config_path, "w", encoding="utf-8") as config_file:
|
||||
json.dump(config_content, config_file)
|
||||
|
||||
# Yield the temporary directory path for use within the context
|
||||
yield config_path.parent
|
||||
finally:
|
||||
# Cleanup the temporary directory and its contents
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
def test_prepare_args_and_stdin() -> None:
|
||||
# this basically serves as an end-to-end test for using config and docker helpers
|
||||
config_path = pathlib.Path(__file__).parent / "langgraph.json"
|
||||
config = validate_config(
|
||||
Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
|
||||
)
|
||||
port = 8000
|
||||
debugger_port = 8001
|
||||
debugger_graph_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
actual_args, actual_stdin = prepare_args_and_stdin(
|
||||
capabilities=DEFAULT_DOCKER_CAPABILITIES,
|
||||
config_path=config_path,
|
||||
config=config,
|
||||
docker_compose=pathlib.Path("custom-docker-compose.yml"),
|
||||
port=port,
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_graph_url,
|
||||
watch=True,
|
||||
)
|
||||
|
||||
expected_args = [
|
||||
"--project-directory",
|
||||
str(pathlib.Path(__file__).parent.absolute()),
|
||||
"-f",
|
||||
"custom-docker-compose.yml",
|
||||
"-f",
|
||||
"-",
|
||||
]
|
||||
expected_stdin = f"""volumes:
|
||||
langgraph-data:
|
||||
driver: local
|
||||
services:
|
||||
langgraph-redis:
|
||||
image: redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
langgraph-postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
ports:
|
||||
- "5433:5432"
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- shared_preload_libraries=vector
|
||||
volumes:
|
||||
- langgraph-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres
|
||||
start_period: 10s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
langgraph-debugger:
|
||||
image: langchain/langgraph-debugger
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "{debugger_port}:3968"
|
||||
environment:
|
||||
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: {DEFAULT_POSTGRES_URI}
|
||||
healthcheck:
|
||||
test: python /api/healthcheck.py
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
start_period: 10s
|
||||
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
additional_contexts:
|
||||
- cli_1: {str(pathlib.Path(__file__).parent.parent.parent.parent.absolute())}
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
# -- Adding local package . --
|
||||
ADD . /deps/cli
|
||||
# -- End of local package . --
|
||||
# -- Adding local package ../../.. --
|
||||
COPY --from=cli_1 . /deps/cli_1
|
||||
# -- End of local package ../../.. --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "agent.py:graph"}}'
|
||||
WORKDIR /deps/cli
|
||||
|
||||
develop:
|
||||
watch:
|
||||
- path: langgraph.json
|
||||
action: rebuild
|
||||
- path: .
|
||||
action: rebuild
|
||||
- path: ../../..
|
||||
action: rebuild\
|
||||
"""
|
||||
assert actual_args == expected_args
|
||||
assert clean_empty_lines(actual_stdin) == expected_stdin
|
||||
|
||||
|
||||
def test_version_option() -> None:
|
||||
"""Test the --version option of the CLI."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--version"])
|
||||
|
||||
# Verify that the command executed successfully
|
||||
assert result.exit_code == 0, "Expected exit code 0 for --version option"
|
||||
|
||||
# Check that the output contains the correct version information
|
||||
assert (
|
||||
"LangGraph CLI, version" in result.output
|
||||
), "Expected version information in output"
|
||||
|
||||
|
||||
def test_dockerfile_command_basic() -> None:
|
||||
"""Test the 'dockerfile' command with basic configuration."""
|
||||
runner = CliRunner()
|
||||
config_content = {
|
||||
"node_version": "20", # Add any other necessary configuration fields
|
||||
"graphs": {"agent": "agent.py:graph"},
|
||||
}
|
||||
|
||||
with temporary_config_folder(config_content) as temp_dir:
|
||||
save_path = temp_dir / "Dockerfile"
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["dockerfile", str(save_path), "--config", str(temp_dir / "config.json")],
|
||||
)
|
||||
|
||||
# Assert command was successful
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "✅ Created: Dockerfile" in result.output
|
||||
|
||||
# Check if Dockerfile was created
|
||||
assert save_path.exists()
|
||||
|
||||
|
||||
def test_dockerfile_command_with_docker_compose() -> None:
|
||||
"""Test the 'dockerfile' command with Docker Compose configuration."""
|
||||
runner = CliRunner()
|
||||
config_content = {
|
||||
"dependencies": ["./my_agent"],
|
||||
"graphs": {"agent": "./my_agent/agent.py:graph"},
|
||||
"env": ".env",
|
||||
}
|
||||
with temporary_config_folder(config_content) as temp_dir:
|
||||
save_path = temp_dir / "Dockerfile"
|
||||
# Add agent.py file
|
||||
agent_path = temp_dir / "my_agent" / "agent.py"
|
||||
agent_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
agent_path.touch()
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"dockerfile",
|
||||
str(save_path),
|
||||
"--config",
|
||||
str(temp_dir / "config.json"),
|
||||
"--add-docker-compose",
|
||||
],
|
||||
)
|
||||
|
||||
# Assert command was successful
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Created: Dockerfile" in result.output
|
||||
assert "✅ Created: .dockerignore" in result.output
|
||||
assert "✅ Created: docker-compose.yml" in result.output
|
||||
assert (
|
||||
"✅ Created: .env" in result.output or "➖ Skipped: .env" in result.output
|
||||
)
|
||||
assert "🎉 Files generated successfully" in result.output
|
||||
|
||||
# Check if Dockerfile, .dockerignore, docker-compose.yml, and .env were created
|
||||
assert save_path.exists()
|
||||
assert (temp_dir / ".dockerignore").exists()
|
||||
assert (temp_dir / "docker-compose.yml").exists()
|
||||
assert (temp_dir / ".env").exists() or "➖ Skipped: .env" in result.output
|
||||
|
||||
|
||||
def test_dockerfile_command_with_bad_config() -> None:
|
||||
"""Test the 'dockerfile' command with basic configuration."""
|
||||
runner = CliRunner()
|
||||
config_content = {
|
||||
"node_version": "20" # Add any other necessary configuration fields
|
||||
}
|
||||
|
||||
with temporary_config_folder(config_content) as temp_dir:
|
||||
save_path = temp_dir / "Dockerfile"
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["dockerfile", str(save_path), "--config", str(temp_dir / "conf.json")],
|
||||
)
|
||||
|
||||
# Assert command was successful
|
||||
assert result.exit_code == 2
|
||||
assert "conf.json' does not exist" in result.output
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Unit tests for the 'new' CLI command.
|
||||
|
||||
This command creates a new LangGraph project using a specified template.
|
||||
"""
|
||||
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib import request
|
||||
from zipfile import ZipFile
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from langgraph_cli.cli import cli
|
||||
from langgraph_cli.templates import TEMPLATE_ID_TO_CONFIG
|
||||
|
||||
|
||||
@patch.object(request, "urlopen")
|
||||
def test_create_new_with_mocked_download(mock_urlopen: MagicMock) -> None:
|
||||
"""Test the 'new' CLI command with a mocked download response using urllib."""
|
||||
# Mock the response content to simulate a ZIP file
|
||||
mock_zip_content = BytesIO()
|
||||
with ZipFile(mock_zip_content, "w") as mock_zip:
|
||||
mock_zip.writestr("test-file.txt", "Test content.")
|
||||
|
||||
# Create a mock response that behaves like a context manager
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = mock_zip_content.getvalue()
|
||||
mock_response.__enter__.return_value = mock_response # Setup enter context
|
||||
mock_response.status = 200
|
||||
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
runner = CliRunner()
|
||||
template = next(
|
||||
iter(TEMPLATE_ID_TO_CONFIG)
|
||||
) # Select the first template for the test
|
||||
result = runner.invoke(cli, ["new", temp_dir, "--template", template])
|
||||
|
||||
# Verify CLI command execution and success
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (
|
||||
"New project created" in result.output
|
||||
), "Expected success message in output."
|
||||
|
||||
# Verify that the directory is not empty
|
||||
assert os.listdir(temp_dir), "Expected files to be created in temp directory."
|
||||
|
||||
# Check for a known file in the extracted content
|
||||
extracted_files = [f.name for f in Path(temp_dir).glob("*")]
|
||||
assert (
|
||||
"test-file.txt" in extracted_files
|
||||
), "Expected 'test-file.txt' in the extracted content."
|
||||
|
||||
|
||||
def test_invalid_template_id() -> None:
|
||||
"""Test that an invalid template ID passed via CLI results in a graceful error."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["new", "dummy_path", "--template", "invalid-template-id"]
|
||||
)
|
||||
|
||||
# Verify the command failed and proper message is displayed
|
||||
assert result.exit_code != 0, "Expected non-zero exit code for invalid template."
|
||||
assert (
|
||||
"Template 'invalid-template-id' not found" in result.output
|
||||
), "Expected error message in output."
|
||||
@@ -1,16 +0,0 @@
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_analytics_env() -> None:
|
||||
"""Disable analytics for unit tests LANGGRAPH_CLI_NO_ANALYTICS."""
|
||||
# First check if the environment variable is already set, if so, log a warning prior
|
||||
# to overriding it.
|
||||
if "LANGGRAPH_CLI_NO_ANALYTICS" in os.environ:
|
||||
print("⚠️ LANGGRAPH_CLI_NO_ANALYTICS is set. Overriding it for the test.")
|
||||
|
||||
with patch.dict(os.environ, {"LANGGRAPH_CLI_NO_ANALYTICS": "0"}):
|
||||
yield
|
||||
@@ -1,6 +0,0 @@
|
||||
from langgraph.func import entrypoint
|
||||
|
||||
|
||||
@entrypoint()
|
||||
def graph(state):
|
||||
return None
|
||||
@@ -1,2 +0,0 @@
|
||||
def clean_empty_lines(input_str: str):
|
||||
return "\n".join(filter(None, input_str.splitlines()))
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"pip_config_file": "pipconfig.txt",
|
||||
"dockerfile_lines": [
|
||||
"ARG meow=woof"
|
||||
],
|
||||
"dependencies": [
|
||||
"langchain_openai",
|
||||
"starlette",
|
||||
"."
|
||||
],
|
||||
"graphs": {
|
||||
"agent": "graphs/agent.py:graph"
|
||||
},
|
||||
"env": ".env",
|
||||
"http": {
|
||||
"app": "../../examples/my_app.py:app"
|
||||
}
|
||||
}
|
||||
@@ -1,679 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from langgraph_cli.config import (
|
||||
config_to_compose,
|
||||
config_to_docker,
|
||||
validate_config,
|
||||
validate_config_file,
|
||||
)
|
||||
from langgraph_cli.util import clean_empty_lines
|
||||
|
||||
PATH_TO_CONFIG = pathlib.Path(__file__).parent / "test_config.json"
|
||||
|
||||
|
||||
def test_validate_config():
|
||||
# minimal config
|
||||
expected_config = {
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./agent.py:graph",
|
||||
},
|
||||
}
|
||||
expected_config = {
|
||||
"python_version": "3.11",
|
||||
"pip_config_file": None,
|
||||
"dockerfile_lines": [],
|
||||
"env": {},
|
||||
"store": None,
|
||||
"auth": None,
|
||||
"http": None,
|
||||
**expected_config,
|
||||
}
|
||||
actual_config = validate_config(expected_config)
|
||||
assert actual_config == expected_config
|
||||
|
||||
# full config
|
||||
env = ".env"
|
||||
expected_config = {
|
||||
"python_version": "3.12",
|
||||
"pip_config_file": "pipconfig.txt",
|
||||
"dockerfile_lines": ["ARG meow"],
|
||||
"dependencies": [".", "langchain"],
|
||||
"graphs": {
|
||||
"agent": "./agent.py:graph",
|
||||
},
|
||||
"env": env,
|
||||
"store": None,
|
||||
"auth": None,
|
||||
"http": None,
|
||||
}
|
||||
actual_config = validate_config(expected_config)
|
||||
assert actual_config == expected_config
|
||||
expected_config["python_version"] = "3.13"
|
||||
actual_config = validate_config(expected_config)
|
||||
assert actual_config == expected_config
|
||||
|
||||
# check wrong python version raises
|
||||
with pytest.raises(click.UsageError):
|
||||
validate_config(
|
||||
{
|
||||
"python_version": "3.9",
|
||||
}
|
||||
)
|
||||
|
||||
# check missing dependencies key raises
|
||||
with pytest.raises(click.UsageError):
|
||||
validate_config(
|
||||
{"python_version": "3.9", "graphs": {"agent": "./agent.py:graph"}},
|
||||
)
|
||||
|
||||
# check missing graphs key raises
|
||||
with pytest.raises(click.UsageError):
|
||||
validate_config({"python_version": "3.9", "dependencies": ["."]})
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
validate_config({"python_version": "3.11.0"})
|
||||
assert "Invalid Python version format" in str(exc_info.value)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
validate_config({"python_version": "3"})
|
||||
assert "Invalid Python version format" in str(exc_info.value)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
validate_config({"python_version": "abc.def"})
|
||||
assert "Invalid Python version format" in str(exc_info.value)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
validate_config({"python_version": "3.10"})
|
||||
assert "Minimum required version" in str(exc_info.value)
|
||||
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11-bullseye",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
)
|
||||
assert config["python_version"] == "3.11-bullseye"
|
||||
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.12-slim",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
)
|
||||
assert config["python_version"] == "3.12-slim"
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Invalid http.app format",
|
||||
):
|
||||
validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {"app": "../../examples/my_app.py"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_validate_config_file():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = pathlib.Path(tmpdir)
|
||||
|
||||
config_path = tmpdir_path / "langgraph.json"
|
||||
|
||||
node_config = {"node_version": "20", "graphs": {"agent": "./agent.js:graph"}}
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(node_config, f)
|
||||
|
||||
validate_config_file(config_path)
|
||||
|
||||
package_json = {"name": "test", "engines": {"node": "20"}}
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
json.dump(package_json, f)
|
||||
validate_config_file(config_path)
|
||||
|
||||
package_json["engines"]["node"] = "20.18"
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
json.dump(package_json, f)
|
||||
with pytest.raises(click.UsageError, match="Use major version only"):
|
||||
validate_config_file(config_path)
|
||||
|
||||
package_json["engines"] = {"node": "18"}
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
json.dump(package_json, f)
|
||||
with pytest.raises(click.UsageError, match="must be >= 20"):
|
||||
validate_config_file(config_path)
|
||||
|
||||
package_json["engines"] = {"node": "20", "deno": "1.0"}
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
json.dump(package_json, f)
|
||||
with pytest.raises(click.UsageError, match="Only 'node' engine is supported"):
|
||||
validate_config_file(config_path)
|
||||
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
f.write("{invalid json")
|
||||
with pytest.raises(click.UsageError, match="Invalid package.json"):
|
||||
validate_config_file(config_path)
|
||||
|
||||
python_config = {
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(python_config, f)
|
||||
|
||||
validate_config_file(config_path)
|
||||
|
||||
for package_content in [
|
||||
{"name": "test"},
|
||||
{"engines": {"node": "18"}},
|
||||
{"engines": {"node": "20", "deno": "1.0"}},
|
||||
"{invalid json",
|
||||
]:
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
if isinstance(package_content, dict):
|
||||
json.dump(package_content, f)
|
||||
else:
|
||||
f.write(package_content)
|
||||
validate_config_file(config_path)
|
||||
|
||||
|
||||
# config_to_docker
|
||||
def test_config_to_docker_simple():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_docker_stdin, additional_contexts = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": [".", "../../examples/graphs_reqs_a", "../../examples"],
|
||||
"graphs": graphs,
|
||||
"http": {"app": "../../examples/my_app.py:app"},
|
||||
}
|
||||
),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
expected_docker_stdin = """\
|
||||
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 pip install --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
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
'version = "0.1"' \\
|
||||
'[tool.setuptools.package-data]' \\
|
||||
'"*" = ["**/*"]'; do \\
|
||||
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
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "graphs_reqs_a"' \\
|
||||
'version = "0.1"' \\
|
||||
'[tool.setuptools.package-data]' \\
|
||||
'"*" = ["**/*"]'; do \\
|
||||
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 pip install --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"}'
|
||||
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(
|
||||
(pathlib.Path(__file__).parent / "../../examples/graphs_reqs_a").resolve()
|
||||
),
|
||||
"examples": str((pathlib.Path(__file__).parent / "../../examples").resolve()),
|
||||
}
|
||||
|
||||
|
||||
def test_config_to_docker_outside_path():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_docker_stdin, additional_contexts = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": [".", ".."], "graphs": graphs}),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
expected_docker_stdin = """\
|
||||
FROM langchain/langgraph-api:3.11
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
'version = "0.1"' \\
|
||||
'[tool.setuptools.package-data]' \\
|
||||
'"*" = ["**/*"]'; do \\
|
||||
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
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "tests"' \\
|
||||
'version = "0.1"' \\
|
||||
'[tool.setuptools.package-data]' \\
|
||||
'"*" = ["**/*"]'; do \\
|
||||
echo "$line" >> /deps/__outer_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --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"}'
|
||||
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()),
|
||||
}
|
||||
|
||||
|
||||
def test_config_to_docker_pipconfig():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_docker_stdin, additional_contexts = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": graphs,
|
||||
"pip_config_file": "pipconfig.txt",
|
||||
}
|
||||
),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
expected_docker_stdin = """\
|
||||
FROM langchain/langgraph-api:3.11
|
||||
ADD pipconfig.txt /pipconfig.txt
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
'version = "0.1"' \\
|
||||
'[tool.setuptools.package-data]' \\
|
||||
'"*" = ["**/*"]'; do \\
|
||||
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 pip install --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"}'
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests\
|
||||
"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
|
||||
|
||||
def test_config_to_docker_invalid_inputs():
|
||||
# test missing local dependencies
|
||||
with pytest.raises(FileNotFoundError):
|
||||
graphs = {"agent": "tests/unit_tests/agent.py:graph"}
|
||||
config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["./missing"], "graphs": graphs}),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
|
||||
# test missing local module
|
||||
with pytest.raises(FileNotFoundError):
|
||||
graphs = {"agent": "./missing_agent.py:graph"}
|
||||
config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs}),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
|
||||
|
||||
def test_config_to_docker_local_deps():
|
||||
graphs = {"agent": "./graphs/agent.py:graph"}
|
||||
actual_docker_stdin, additional_contexts = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": ["./graphs"],
|
||||
"graphs": graphs,
|
||||
}
|
||||
),
|
||||
"langchain/langgraph-api-custom",
|
||||
)
|
||||
expected_docker_stdin = """\
|
||||
FROM langchain/langgraph-api-custom:3.11
|
||||
# -- Adding non-package dependency graphs --
|
||||
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; \\
|
||||
done
|
||||
# -- End of non-package dependency graphs --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --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"}'\
|
||||
"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
|
||||
|
||||
def test_config_to_docker_pyproject():
|
||||
pyproject_str = """[project]
|
||||
name = "custom"
|
||||
version = "0.1"
|
||||
dependencies = ["langchain"]"""
|
||||
pyproject_path = "tests/unit_tests/pyproject.toml"
|
||||
with open(pyproject_path, "w") as f:
|
||||
f.write(pyproject_str)
|
||||
|
||||
graphs = {"agent": "./graphs/agent.py:graph"}
|
||||
actual_docker_stdin, additional_contexts = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": graphs,
|
||||
}
|
||||
),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
os.remove(pyproject_path)
|
||||
expected_docker_stdin = """FROM langchain/langgraph-api:3.11
|
||||
# -- Adding local package . --
|
||||
ADD . /deps/unit_tests
|
||||
# -- End of local package . --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --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"}'
|
||||
WORKDIR /deps/unit_tests"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
|
||||
|
||||
def test_config_to_docker_end_to_end():
|
||||
graphs = {"agent": "./graphs/agent.py:graph"}
|
||||
actual_docker_stdin, additional_contexts = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["./graphs/", "langchain", "langchain_openai"],
|
||||
"graphs": graphs,
|
||||
"pip_config_file": "pipconfig.txt",
|
||||
"dockerfile_lines": ["ARG meow", "ARG foo"],
|
||||
}
|
||||
),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
expected_docker_stdin = """FROM langchain/langgraph-api:3.12
|
||||
ARG meow
|
||||
ARG foo
|
||||
ADD pipconfig.txt /pipconfig.txt
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt langchain langchain_openai
|
||||
# -- Adding non-package dependency graphs --
|
||||
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; \\
|
||||
done
|
||||
# -- End of non-package dependency graphs --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --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"}'"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
|
||||
|
||||
# node.js build used for LangGraph Cloud
|
||||
def test_config_to_docker_nodejs():
|
||||
graphs = {"agent": "./graphs/agent.js:graph"}
|
||||
actual_docker_stdin, additional_contexts = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"node_version": "20",
|
||||
"graphs": graphs,
|
||||
"dockerfile_lines": ["ARG meow", "ARG foo"],
|
||||
}
|
||||
),
|
||||
"langchain/langgraphjs-api",
|
||||
)
|
||||
expected_docker_stdin = """FROM langchain/langgraphjs-api:20
|
||||
ARG meow
|
||||
ARG foo
|
||||
ADD . /deps/unit_tests
|
||||
RUN cd /deps/unit_tests && npm i
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
|
||||
WORKDIR /deps/unit_tests
|
||||
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts"""
|
||||
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
|
||||
|
||||
# config_to_compose
|
||||
def test_config_to_compose_simple_config():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """\
|
||||
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
'version = "0.1"' \\
|
||||
'[tool.setuptools.package-data]' \\
|
||||
'"*" = ["**/*"]'; do \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --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"}'
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
"""
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs}),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
|
||||
|
||||
|
||||
def test_config_to_compose_env_vars():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """ OPENAI_API_KEY: "key"
|
||||
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api-custom:3.11
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
'version = "0.1"' \\
|
||||
'[tool.setuptools.package-data]' \\
|
||||
'"*" = ["**/*"]'; do \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --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"}'
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
"""
|
||||
openai_api_key = "key"
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": graphs,
|
||||
"env": {"OPENAI_API_KEY": openai_api_key},
|
||||
}
|
||||
),
|
||||
"langchain/langgraph-api-custom",
|
||||
)
|
||||
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
|
||||
|
||||
|
||||
def test_config_to_compose_env_file():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """\
|
||||
env_file: .env
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
'version = "0.1"' \\
|
||||
'[tool.setuptools.package-data]' \\
|
||||
'"*" = ["**/*"]'; do \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --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"}'
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
"""
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
|
||||
|
||||
|
||||
def test_config_to_compose_watch():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """\
|
||||
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
'version = "0.1"' \\
|
||||
'[tool.setuptools.package-data]' \\
|
||||
'"*" = ["**/*"]'; do \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --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"}'
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
|
||||
develop:
|
||||
watch:
|
||||
- path: test_config.json
|
||||
action: rebuild
|
||||
- path: .
|
||||
action: rebuild\
|
||||
"""
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs}),
|
||||
"langchain/langgraph-api",
|
||||
watch=True,
|
||||
)
|
||||
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
|
||||
|
||||
|
||||
def test_config_to_compose_end_to_end():
|
||||
# test all of the above + langgraph API path
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """\
|
||||
env_file: .env
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
'version = "0.1"' \\
|
||||
'[tool.setuptools.package-data]' \\
|
||||
'"*" = ["**/*"]'; do \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --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"}'
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
|
||||
develop:
|
||||
watch:
|
||||
- path: test_config.json
|
||||
action: rebuild
|
||||
- path: .
|
||||
action: rebuild\
|
||||
"""
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
|
||||
"langchain/langgraph-api",
|
||||
watch=True,
|
||||
)
|
||||
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
|
||||
@@ -1,148 +0,0 @@
|
||||
from langgraph_cli.docker import (
|
||||
DEFAULT_POSTGRES_URI,
|
||||
DockerCapabilities,
|
||||
Version,
|
||||
compose,
|
||||
)
|
||||
from langgraph_cli.util import clean_empty_lines
|
||||
|
||||
DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
|
||||
version_docker=Version(26, 1, 1),
|
||||
version_compose=Version(2, 27, 0),
|
||||
healthcheck_start_interval=False,
|
||||
)
|
||||
|
||||
|
||||
def test_compose_with_no_debugger_and_custom_db():
|
||||
port = 8123
|
||||
custom_postgres_uri = "custom_postgres_uri"
|
||||
actual_compose_str = compose(
|
||||
DEFAULT_DOCKER_CAPABILITIES, port=port, postgres_uri=custom_postgres_uri
|
||||
)
|
||||
expected_compose_str = f"""services:
|
||||
langgraph-redis:
|
||||
image: redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "{port}:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: {custom_postgres_uri}"""
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
|
||||
|
||||
def test_compose_with_no_debugger_and_custom_db_with_healthcheck():
|
||||
port = 8123
|
||||
custom_postgres_uri = "custom_postgres_uri"
|
||||
actual_compose_str = compose(
|
||||
DEFAULT_DOCKER_CAPABILITIES._replace(healthcheck_start_interval=True),
|
||||
port=port,
|
||||
postgres_uri=custom_postgres_uri,
|
||||
)
|
||||
expected_compose_str = f"""services:
|
||||
langgraph-redis:
|
||||
image: redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "{port}:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: {custom_postgres_uri}
|
||||
healthcheck:
|
||||
test: python /api/healthcheck.py
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
start_period: 10s"""
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
|
||||
|
||||
def test_compose_with_debugger_and_custom_db():
|
||||
port = 8123
|
||||
custom_postgres_uri = "custom_postgres_uri"
|
||||
actual_compose_str = compose(
|
||||
DEFAULT_DOCKER_CAPABILITIES,
|
||||
port=port,
|
||||
postgres_uri=custom_postgres_uri,
|
||||
)
|
||||
expected_compose_str = f"""services:
|
||||
langgraph-redis:
|
||||
image: redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "{port}:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: {custom_postgres_uri}"""
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
|
||||
|
||||
def test_compose_with_debugger_and_default_db():
|
||||
port = 8123
|
||||
actual_compose_str = compose(DEFAULT_DOCKER_CAPABILITIES, port=port)
|
||||
expected_compose_str = f"""volumes:
|
||||
langgraph-data:
|
||||
driver: local
|
||||
services:
|
||||
langgraph-redis:
|
||||
image: redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
langgraph-postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
ports:
|
||||
- "5433:5432"
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- shared_preload_libraries=vector
|
||||
volumes:
|
||||
- langgraph-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres
|
||||
start_period: 10s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
interval: 5s
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "{port}:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
Reference in New Issue
Block a user