Initial scaffold

This commit is contained in:
Eugene Yurtsev
2025-03-06 13:43:51 -05:00
commit ca48cff804
24 changed files with 5037 additions and 0 deletions
View File
+44
View File
@@ -0,0 +1,44 @@
# This workflow will run integration tests for the current project once per day
name: Integration Tests
on:
schedule:
- cron: "37 14 * * *" # Run at 7:37 AM Pacific Time (14:37 UTC) every day
workflow_dispatch: # Allows triggering the workflow manually in GitHub UI
# If another scheduled run starts while this workflow is still running,
# cancel the earlier run in favor of the next run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
integration-tests:
name: Integration Tests
strategy:
matrix:
os: [ubuntu-latest]
python-version: ["3.11", "3.12"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv
uv pip install -r pyproject.toml
uv pip install -U pytest-asyncio vcrpy
- name: Run integration tests
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }}
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
LANGSMITH_TRACING: true
LANGSMITH_TEST_CACHE: tests/cassettes
run: |
uv run pytest tests/integration_tests
+57
View File
@@ -0,0 +1,57 @@
# This workflow will run unit tests for the current project
name: CI
on:
push:
branches: ["main"]
pull_request:
workflow_dispatch: # Allows triggering the workflow manually in GitHub UI
# If another push to the same PR or branch happens while this workflow is still running,
# cancel the earlier run in favor of the next run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
unit-tests:
name: Unit Tests
strategy:
matrix:
os: [ubuntu-latest]
python-version: ["3.11", "3.12"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv
uv pip install -r pyproject.toml
- name: Lint with ruff
run: |
uv pip install ruff
uv run ruff check .
- name: Lint with mypy
run: |
uv pip install mypy
uv run mypy --strict src/
- name: Check README spelling
uses: codespell-project/actions-codespell@v2
with:
ignore_words_file: .codespellignore
path: README.md
- name: Check code spelling
uses: codespell-project/actions-codespell@v2
with:
ignore_words_file: .codespellignore
path: src/
- name: Run tests with pytest
run: |
uv pip install pytest
uv run pytest tests/unit_tests
+182
View File
@@ -0,0 +1,182 @@
.vs/
.vscode/
.idea/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
docs/docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
notebooks/
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
.venvs
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# macOS display setting files
.DS_Store
# Wandb directory
wandb/
# asdf tool versions
.tool-versions
/.ruff_cache/
*.pkl
*.bin
# integration test artifacts
data_map*
\[('_type', 'fake'), ('stop', None)]
# Replit files
*replit*
node_modules
docs/.yarn/
docs/node_modules/
docs/.docusaurus/
docs/.cache-loader/
docs/_dist
docs/api_reference/api_reference.rst
docs/api_reference/experimental_api_reference.rst
docs/api_reference/_build
docs/api_reference/*/
!docs/api_reference/_static/
!docs/api_reference/templates/
!docs/api_reference/themes/
docs/docs_skeleton/build
docs/docs_skeleton/node_modules
docs/docs_skeleton/yarn.lock
# Any new jupyter notebooks
# not intended for the repo
Untitled*.ipynb
Chinook.db
.vercel
.turbo
+21
View File
@@ -0,0 +1,21 @@
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.
+64
View File
@@ -0,0 +1,64 @@
.PHONY: all format lint test tests test_watch integration_tests docker_tests help extended_tests
# Default target executed when no arguments are given to make.
all: help
# Define a variable for the test file path.
TEST_FILE ?= tests/unit_tests/
test:
python -m pytest $(TEST_FILE)
test_watch:
python -m ptw --snapshot-update --now . -- -vv tests/unit_tests
test_profile:
python -m pytest -vv tests/unit_tests/ --profile-svg
extended_tests:
python -m pytest --only-extended $(TEST_FILE)
######################
# LINTING AND FORMATTING
######################
# Define a variable for Python and notebook files.
PYTHON_FILES=src/
MYPY_CACHE=.mypy_cache
lint format: PYTHON_FILES=.
lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --diff-filter=d main | grep -E '\.py$$|\.ipynb$$')
lint_package: PYTHON_FILES=src
lint_tests: PYTHON_FILES=tests
lint_tests: MYPY_CACHE=.mypy_cache_test
lint lint_diff lint_package lint_tests:
python -m ruff check .
[ "$(PYTHON_FILES)" = "" ] || python -m ruff format $(PYTHON_FILES) --diff
[ "$(PYTHON_FILES)" = "" ] || python -m ruff check --select I $(PYTHON_FILES)
[ "$(PYTHON_FILES)" = "" ] || python -m mypy --strict $(PYTHON_FILES)
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) && python -m mypy --strict $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
format format_diff:
ruff format $(PYTHON_FILES)
ruff check --select I --fix $(PYTHON_FILES)
spell_check:
codespell --toml pyproject.toml
spell_fix:
codespell --toml pyproject.toml -w
######################
# HELP
######################
help:
@echo '----'
@echo 'format - run code formatters'
@echo 'lint - run linters'
@echo 'test - run unit tests'
@echo 'tests - run unit tests'
@echo 'test TEST_FILE=<test_file> - run all tests in file'
@echo 'test_watch - run unit tests in watch mode'
+18
View File
@@ -0,0 +1,18 @@
# LangGraph ReAct Agent Template (WIP)
A template that deploys a ReAct agent with access to a LangChain Tool Server.
## Getting Started
1. Deploy the LangChain Tool Server with configured tools.
2. Launch the ReAct agent with the `TOOL_SERVER_URL` environment variable set to the URL of the Tool Server.
### Development
Make sure that you also include any necessary environment variables related to models
```shell
TOOL_SERVER_URL=http://localhost:8000 uv run langgraph dev
```
+10
View File
@@ -0,0 +1,10 @@
{
"dependencies": ["."],
"graphs": {
"agent": "./src/react_agent/graph.py:make_graph"
},
"env": ".env",
"http": {
"app": "./src/react_agent/lifespan.py:app"
}
}
+70
View File
@@ -0,0 +1,70 @@
[project]
name = "react-agent-tool-server"
version = "0.0.1"
description = "Starter template for making a custom Reasoning and Action agent (using tool calling) in LangGraph."
authors = [
{ name = "Eugene Yurtsev", email = "13333726+hinthornw@users.noreply.github.com" },
]
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.11,<4.0"
dependencies = [
"langchain-openai>=0.1.22",
"langchain-anthropic>=0.1.23",
"langchain>=0.2.14",
"langchain-fireworks>=0.1.7",
"python-dotenv>=1.0.1",
"langchain-community>=0.2.17",
"ipython>=9.0.0",
"langchain-tool-server",
"fastapi>=0.115.11",
]
[build-system]
requires = ["setuptools>=73.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["langgraph.templates.react_agent", "react_agent"]
[tool.setuptools.package-dir]
"langgraph.templates.react_agent" = "src/react_agent"
"react_agent" = "src/react_agent"
[tool.setuptools.package-data]
"*" = ["py.typed"]
[tool.ruff]
lint.select = [
"E", # pycodestyle
"F", # pyflakes
"I", # isort
"D", # pydocstyle
"D401", # First line should be in imperative mood
"T201",
"UP",
]
lint.ignore = [
"UP006",
"UP007",
# We actually do want to import from typing_extensions
"UP035",
# Relax the convention by _not_ requiring documentation for every function parameter.
"D417",
"E501",
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["D", "UP"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.uv.sources]
langchain-tool-server = { path = "../langchain-tool-server" }
[dependency-groups]
dev = [
"langgraph-cli[inmem]>=0.1.71",
"ruff>=0.9.9",
]
+5
View File
@@ -0,0 +1,5 @@
"""React Agent.
This module defines a custom reasoning and action agent graph.
It invokes tools in a simple loop.
"""
+80
View File
@@ -0,0 +1,80 @@
"""Define the configurable parameters for the agent."""
from __future__ import annotations
from dataclasses import dataclass, field, fields
from typing import Annotated, Literal, Optional
from langchain_core.runnables import RunnableConfig, ensure_config
from react_agent import prompts, tools, utils
# A singleton object to store the agent's configuration.
# We use this to store the schema for the agent's configuration, which
# needs to be generated dynamically based on the available tools.
APP_STATE = utils.State()
def create_configurable(toolbox: tools.Toolbox) -> None:
"""Dynamically create a configuration schema for the agent.
This function will create a dataclass that represents the configuration schema.
It will automatically include the names of the available tools in the schema.
"""
# We need to save the tool names in the APP_STATE configuration schema
# to make the type information available when generating
# the json schema for the configuration.
APP_STATE.tool_names = toolbox.get_tool_names()
@dataclass(kw_only=True)
class Config:
"""The configuration for the agent."""
system_prompt: str = field(
default=prompts.SYSTEM_PROMPT,
metadata={
"description": "The system prompt to use for the agent's interactions. "
"This prompt sets the context and behavior for the agent."
},
)
model: Annotated[str, {"__template_metadata__": {"kind": "llm"}}] = field(
default="anthropic/claude-3-5-sonnet-20240620",
metadata={
"description": (
"The name of the language model to use for the agent's "
"main interactions."
"Should be in the form: provider/model-name."
)
},
)
max_search_results: int = field(
default=10,
metadata={
"description": (
"The maximum number of search results to "
"return for each search query."
)
},
)
selected_tools: list[Literal[*APP_STATE.tool_names]] = field(
default_factory=list,
metadata={
"description": "The list of tools to use for the agent's interactions. "
"This list should contain the names of the tools to use."
},
)
@classmethod
def from_runnable_config(
cls, config: Optional[RunnableConfig] = None
) -> Config:
"""Create a Configuration instance from a RunnableConfig object."""
config = ensure_config(config)
configurable = config.get("configurable") or {}
_fields = {f.name for f in fields(cls) if f.init}
return cls(**{k: v for k, v in configurable.items() if k in _fields})
APP_STATE.configurable = Config
+130
View File
@@ -0,0 +1,130 @@
"""Define a custom Reasoning and Action agent.
Works with a chat model with tool calling support.
"""
from datetime import UTC, datetime
from typing import Dict, List, Literal, cast
from langchain_core.messages import AIMessage
from langchain_core.runnables import RunnableConfig
from langgraph.graph import StateGraph
from langgraph.graph.state import CompiledStateGraph
from langgraph.prebuilt import ToolNode
from react_agent.configuration import APP_STATE
from react_agent.state import InputState, State
from react_agent.tools import TOOLBOX
from react_agent.utils import load_chat_model
# Define the function that calls the model
async def call_model(
state: State, config: RunnableConfig
) -> Dict[str, List[AIMessage]]:
"""Call the LLM powering our "agent".
This function prepares the prompt, initializes the model, and processes the response.
Args:
state (State): The current state of the conversation.
config (RunnableConfig): Configuration for the model run.
Returns:
dict: A dictionary containing the model's response message.
"""
configuration = APP_STATE.configurable.from_runnable_config(config)
# Add logic to select tools
if configuration.selected_tools:
selected_tools = [
tool
for tool in TOOLBOX.get_tools()
if tool.name in configuration.selected_tools
]
else:
selected_tools = TOOLBOX.get_tools()
# Initialize the model with tool binding. Change the model or add more tools here.
model = load_chat_model(configuration.model).bind_tools(selected_tools)
# Format the system prompt. Customize this to change the agent's behavior.
system_message = configuration.system_prompt.format(
system_time=datetime.now(tz=UTC).isoformat()
)
# Get the model's response
response = cast(
AIMessage,
await model.ainvoke(
[{"role": "system", "content": system_message}, *state.messages], config
),
)
# Handle the case when it's the last step and the model still wants to use a tool
if state.is_last_step and response.tool_calls:
return {
"messages": [
AIMessage(
id=response.id,
content="Sorry, I could not find an answer to your question in the specified number of steps.",
)
]
}
# Return the model's response as a list to be added to existing messages
return {"messages": [response]}
def route_model_output(state: State) -> Literal["__end__", "tools"]:
"""Determine the next node based on the model's output.
This function checks if the model's last message contains tool calls.
Args:
state (State): The current state of the conversation.
Returns:
str: The name of the next node to call ("__end__" or "tools").
"""
last_message = state.messages[-1]
if not isinstance(last_message, AIMessage):
raise ValueError(
f"Expected AIMessage in output edges, but got {type(last_message).__name__}"
)
# If there is no tool call, then we finish
if not last_message.tool_calls:
return "__end__"
# Otherwise we execute the requested actions
return "tools"
async def make_graph(config: RunnableConfig) -> CompiledStateGraph:
"""Create a custom state graph for the Reasoning and Action agent."""
tools = TOOLBOX.get_tools()
builder = StateGraph(State, input=InputState, config_schema=APP_STATE.configurable)
# Define the two nodes we will cycle between
builder.add_node(call_model)
builder.add_node("tools", ToolNode(tools))
# Set the entrypoint as `call_model`
# This means that this node is the first one called
builder.add_edge("__start__", "call_model")
# Add a conditional edge to determine the next step after `call_model`
builder.add_conditional_edges(
"call_model",
# After call_model finishes running, the next node(s) are scheduled
# based on the output from route_model_output
route_model_output,
)
# Add a normal edge from `tools` to `call_model`
# This creates a cycle: after using tools, we always return to the model
builder.add_edge("tools", "call_model")
# Compile the builder into an executable graph
# You can customize this by adding interrupt points for state updates
graph = builder.compile()
graph.name = "ReAct Agent" # This customizes the name in LangSmith
return graph
+21
View File
@@ -0,0 +1,21 @@
"""Define the configurable parameters for the agent."""
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from react_agent.configuration import create_configurable
from react_agent.tools import TOOLBOX
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize the tools."""
await TOOLBOX.initialize()
create_configurable(TOOLBOX)
yield
app = FastAPI(lifespan=lifespan)
+5
View File
@@ -0,0 +1,5 @@
"""Default prompts used by the agent."""
SYSTEM_PROMPT = """You are a helpful AI assistant.
System time: {system_time}"""
+60
View File
@@ -0,0 +1,60 @@
"""Define the state structures for the agent."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Sequence
from langchain_core.messages import AnyMessage
from langgraph.graph import add_messages
from langgraph.managed import IsLastStep
from typing_extensions import Annotated
@dataclass
class InputState:
"""Defines the input state for the agent, representing a narrower interface to the outside world.
This class is used to define the initial state and structure of incoming data.
"""
messages: Annotated[Sequence[AnyMessage], add_messages] = field(
default_factory=list
)
"""
Messages tracking 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
4. AIMessage without .tool_calls - agent responding in unstructured format to the user
5. HumanMessage - user responds with the next conversational turn
Steps 2-5 may repeat as needed.
The `add_messages` annotation ensures that new messages are merged with existing ones,
updating by ID to maintain an "append-only" state unless a message with the same ID is provided.
"""
@dataclass
class State(InputState):
"""Represents the complete state of the agent, extending InputState with additional attributes.
This class can be used to store any information needed throughout the agent's lifecycle.
"""
is_last_step: IsLastStep = field(default=False)
"""
Indicates whether the current step is the last one before the graph raises an error.
This is a 'managed' variable, controlled by the state machine rather than user code.
It is set to 'True' when the step count reaches recursion_limit - 1.
"""
# Additional attributes can be added here as needed.
# Common examples include:
# retrieved_documents: List[Document] = field(default_factory=list)
# extracted_entities: Dict[str, Any] = field(default_factory=dict)
# api_connections: Dict[str, Any] = field(default_factory=dict)
+36
View File
@@ -0,0 +1,36 @@
"""This module provides example tools for web scraping and search functionality.
It includes a basic Tavily search function (as an example)
These tools are intended as free examples to get started. For production use,
consider implementing more robust and specialized tools tailored to your needs.
"""
import os
from langchain_core.tools import BaseTool
from langchain_tool_server.client import AsyncClient, get_async_client
class Toolbox:
def __init__(self, client: AsyncClient) -> None:
self.tools = []
self.client = client
async def initialize(self) -> None:
self.tools = await self.client.tools.as_langchain_tools()
def get_tool_names(self) -> list[str]:
return [tool.name for tool in self.tools]
def get_tools(self) -> list[BaseTool]:
"""Get tools."""
return self.tools
TOOL_SERVER_URL = os.getenv("TOOL_SERVER_URL")
if not TOOL_SERVER_URL:
raise ValueError("TOOL_SERVER_URL environment variable must be set")
TOOLBOX = Toolbox(client=get_async_client(url=TOOL_SERVER_URL))
+55
View File
@@ -0,0 +1,55 @@
"""Utility & helper functions."""
from __future__ import annotations
import typing
from langchain.chat_models import init_chat_model
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import BaseMessage
def get_message_text(msg: BaseMessage) -> str:
"""Get the text content of a message."""
content = msg.content
if isinstance(content, str):
return content
elif isinstance(content, dict):
return content.get("text", "")
else:
txts = [c if isinstance(c, str) else (c.get("text") or "") for c in content]
return "".join(txts).strip()
def load_chat_model(fully_specified_name: str) -> BaseChatModel:
"""Load a chat model from a fully specified name.
Args:
fully_specified_name (str): String in the format 'provider/model'.
"""
provider, model = fully_specified_name.split("/", maxsplit=1)
return init_chat_model(model, model_provider=provider)
class State:
"""An object that can be used to store arbitrary state."""
_state: dict[str, typing.Any]
def __init__(self, state: dict[str, typing.Any] | None = None):
if state is None:
state = {}
super().__setattr__("_state", state)
def __setattr__(self, key: typing.Any, value: typing.Any) -> None:
self._state[key] = value
def __getattr__(self, key: typing.Any) -> typing.Any:
try:
return self._state[key]
except KeyError:
message = "'{}' object has no attribute '{}'"
raise AttributeError(message.format(self.__class__.__name__, key))
def __delattr__(self, key: typing.Any) -> None:
del self._state[key]
Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
"""Define any integration tests you want in this directory."""
+15
View File
@@ -0,0 +1,15 @@
import pytest
from langsmith import unit
from react_agent import graph
@pytest.mark.asyncio
@unit
async def test_react_agent_simple_passthrough() -> None:
res = await graph.ainvoke(
{"messages": [("user", "Who is the founder of LangChain?")]},
{"configurable": {"system_prompt": "You are a helpful AI assistant."}},
)
assert "harrison" in str(res["messages"][-1].content).lower()
+1
View File
@@ -0,0 +1 @@
"""Define any unit tests you may want in this directory."""
+5
View File
@@ -0,0 +1,5 @@
from react_agent.configuration import Configuration
def test_configuration_empty() -> None:
Configuration.from_runnable_config({})
Generated
+2104
View File
File diff suppressed because it is too large Load Diff