mirror of
https://github.com/langchain-ai/react-agent-tool-server.git
synced 2026-07-01 18:28:34 -04:00
Initial scaffold
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./src/react_agent/graph.py:make_graph"
|
||||
},
|
||||
"env": ".env",
|
||||
"http": {
|
||||
"app": "./src/react_agent/lifespan.py:app"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""React Agent.
|
||||
|
||||
This module defines a custom reasoning and action agent graph.
|
||||
It invokes tools in a simple loop.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Default prompts used by the agent."""
|
||||
|
||||
SYSTEM_PROMPT = """You are a helpful AI assistant.
|
||||
|
||||
System time: {system_time}"""
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
"""Define any integration tests you want in this directory."""
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Define any unit tests you may want in this directory."""
|
||||
@@ -0,0 +1,5 @@
|
||||
from react_agent.configuration import Configuration
|
||||
|
||||
|
||||
def test_configuration_empty() -> None:
|
||||
Configuration.from_runnable_config({})
|
||||
Reference in New Issue
Block a user