mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-07 02:07:52 +02:00
Merge branch 'main' into wfh/llm_compiler
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
name: "\U0001F41B Bug Report"
|
||||
description: Report a bug in LangChain. To report a security issue, please instead use the security option below. For questions, please use the GitHub Discussions.
|
||||
labels: ["02 Bug Report"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >
|
||||
Thank you for taking the time to file a bug report.
|
||||
|
||||
Use this to report bugs in LangChain.
|
||||
|
||||
If you're not certain that your issue is due to a bug in LangChain, please use [GitHub Discussions](https://github.com/langchain-ai/langchain/discussions)
|
||||
to ask for help with your issue.
|
||||
|
||||
Relevant links to check before filing a bug report to see if your issue has already been reported, fixed or
|
||||
if there's another way to solve your problem:
|
||||
|
||||
[LangChain documentation with the integrated search](https://python.langchain.com/docs/get_started/introduction),
|
||||
[API Reference](https://api.python.langchain.com/en/stable/),
|
||||
[GitHub search](https://github.com/langchain-ai/langchain),
|
||||
[LangChain Github Discussions](https://github.com/langchain-ai/langchain/discussions),
|
||||
[LangChain Github Issues](https://github.com/langchain-ai/langchain/issues?q=is%3Aissue),
|
||||
[LangChain ChatBot](https://chat.langchain.com/)
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Checked other resources
|
||||
description: Please confirm and check all the following options.
|
||||
options:
|
||||
- label: I added a very descriptive title to this issue.
|
||||
required: true
|
||||
- label: I searched the LangChain documentation with the integrated search.
|
||||
required: true
|
||||
- label: I used the GitHub search to find a similar question and didn't find it.
|
||||
required: true
|
||||
- label: I am sure that this is a bug in LangChain rather than my code.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Example Code
|
||||
description: |
|
||||
Please add a self-contained, [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) with your use case.
|
||||
|
||||
If a maintainer can copy it, run it, and see it right away, there's a much higher chance that you'll be able to get help.
|
||||
|
||||
**Important!**
|
||||
|
||||
* Use code tags (e.g., ```python ... ```) to correctly [format your code](https://help.github.com/en/github/writing-on-github/creating-and-highlighting-code-blocks#syntax-highlighting).
|
||||
* INCLUDE the language label (e.g. `python`) after the first three backticks to enable syntax highlighting. (e.g., ```python rather than ```).
|
||||
* Reduce your code to the minimum required to reproduce the issue if possible. This makes it much easier for others to help you.
|
||||
* Avoid screenshots when possible, as they are hard to read and (more importantly) don't allow others to copy-and-paste your code.
|
||||
|
||||
placeholder: |
|
||||
The following code:
|
||||
|
||||
```python
|
||||
from langchain_core.runnables import RunnableLambda
|
||||
|
||||
def bad_code(inputs) -> int:
|
||||
raise NotImplementedError('For demo purpose')
|
||||
|
||||
chain = RunnableLambda(bad_code)
|
||||
chain.invoke('Hello!')
|
||||
```
|
||||
- type: textarea
|
||||
id: error
|
||||
validations:
|
||||
required: false
|
||||
attributes:
|
||||
label: Error Message and Stack Trace (if applicable)
|
||||
description: |
|
||||
If you are reporting an error, please include the full error message and stack trace.
|
||||
placeholder: |
|
||||
Exception + full stack trace
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
What is the problem, question, or error?
|
||||
|
||||
Write a short description telling what you are doing, what you expect to happen, and what is currently happening.
|
||||
placeholder: |
|
||||
* I'm trying to use the `langchain` library to do X.
|
||||
* I expect to see Y.
|
||||
* Instead, it does Z.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: system-info
|
||||
attributes:
|
||||
label: System Info
|
||||
description: |
|
||||
Please share your system info with us.
|
||||
|
||||
"pip freeze | grep langchain"
|
||||
platform (windows / linux / mac)
|
||||
python version
|
||||
|
||||
OR if you're on a recent version of langchain-core you can paste the output of:
|
||||
|
||||
python -m langchain_core.sys_info
|
||||
placeholder: |
|
||||
"pip freeze | grep langchain"
|
||||
platform
|
||||
python version
|
||||
|
||||
Alternatively, if you're on a recent version of langchain-core you can paste the output of:
|
||||
|
||||
python -m langchain_core.sys_info
|
||||
|
||||
These will only surface LangChain packages, don't forget to include any other relevant
|
||||
packages you're using (if you're not sure what's relevant, you can paste the entire output of `pip freeze`).
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,15 @@
|
||||
blank_issues_enabled: false
|
||||
version: 2.1
|
||||
contact_links:
|
||||
- name: 🤔 Question or Problem
|
||||
about: Ask a question or ask about a problem in GitHub Discussions.
|
||||
url: https://www.github.com/langchain-ai/langchain/discussions/categories/q-a
|
||||
- name: Discord
|
||||
url: https://discord.gg/6adMQxSpJS
|
||||
about: General community discussions
|
||||
- name: Feature Request
|
||||
url: https://www.github.com/langchain-ai/langchain/discussions/categories/ideas
|
||||
about: Suggest a feature or an idea
|
||||
- name: Show and tell
|
||||
about: Show what you built with LangChain
|
||||
url: https://www.github.com/langchain-ai/langchain/discussions/categories/show-and-tell
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Documentation
|
||||
description: Report an issue related to the LangChain documentation.
|
||||
title: "DOC: <Please write a comprehensive title after the 'DOC: ' prefix>"
|
||||
labels: [03 - Documentation]
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Issue with current documentation:"
|
||||
description: >
|
||||
Please make sure to leave a reference to the document/code you're
|
||||
referring to.
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Idea or request for content:"
|
||||
description: >
|
||||
Please describe as clearly as possible what topics you think are missing
|
||||
from the current documentation.
|
||||
@@ -0,0 +1,25 @@
|
||||
name: 🔒 Privileged
|
||||
description: You are a LangChain maintainer, or was asked directly by a maintainer to create an issue here. If not, check the other options.
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for your interest in LangChain! 🚀
|
||||
|
||||
If you are not a LangChain maintainer or were not asked directly by a maintainer to create an issue, then please start the conversation in a [Question in GitHub Discussions](https://github.com/langchain-ai/langchain/discussions/categories/q-a) instead.
|
||||
|
||||
You are a LangChain maintainer if you maintain any of the packages inside of the LangChain repository
|
||||
or are a regular contributor to LangChain with previous merged merged pull requests.
|
||||
- type: checkboxes
|
||||
id: privileged
|
||||
attributes:
|
||||
label: Privileged issue
|
||||
description: Confirm that you are allowed to create an issue here.
|
||||
options:
|
||||
- label: I am a LangChain maintainer, or was asked directly by a LangChain maintainer to create an issue here.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: content
|
||||
attributes:
|
||||
label: Issue Content
|
||||
description: Add the content of the issue here.
|
||||
@@ -0,0 +1,88 @@
|
||||
# An action for setting up poetry install with caching.
|
||||
# Using a custom action since the default action does not
|
||||
# take poetry install groups into account.
|
||||
# Action code from:
|
||||
# https://github.com/actions/setup-python/issues/505#issuecomment-1273013236
|
||||
name: poetry-install-with-caching
|
||||
description: Poetry install with support for caching of dependency groups.
|
||||
|
||||
inputs:
|
||||
python-version:
|
||||
description: Python version, supporting MAJOR.MINOR only
|
||||
required: true
|
||||
|
||||
poetry-version:
|
||||
description: Poetry version
|
||||
required: true
|
||||
|
||||
cache-key:
|
||||
description: Cache key to use for manual handling of caching
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-python@v5
|
||||
name: Setup python ${{ inputs.python-version }}
|
||||
id: setup-python
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- uses: actions/cache@v3
|
||||
id: cache-bin-poetry
|
||||
name: Cache Poetry binary - Python ${{ inputs.python-version }}
|
||||
env:
|
||||
SEGMENT_DOWNLOAD_TIMEOUT_MIN: "1"
|
||||
with:
|
||||
path: |
|
||||
/opt/pipx/venvs/poetry
|
||||
# This step caches the poetry installation, so make sure it's keyed on the poetry version as well.
|
||||
key: bin-poetry-${{ runner.os }}-${{ runner.arch }}-py-${{ inputs.python-version }}-${{ inputs.poetry-version }}
|
||||
|
||||
- name: Refresh shell hashtable and fixup softlinks
|
||||
if: steps.cache-bin-poetry.outputs.cache-hit == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
POETRY_VERSION: ${{ inputs.poetry-version }}
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
run: |
|
||||
set -eux
|
||||
|
||||
# Refresh the shell hashtable, to ensure correct `which` output.
|
||||
hash -r
|
||||
|
||||
# `actions/cache@v3` doesn't always seem able to correctly unpack softlinks.
|
||||
# Delete and recreate the softlinks pipx expects to have.
|
||||
rm /opt/pipx/venvs/poetry/bin/python
|
||||
cd /opt/pipx/venvs/poetry/bin
|
||||
ln -s "$(which "python$PYTHON_VERSION")" python
|
||||
chmod +x python
|
||||
cd /opt/pipx_bin/
|
||||
ln -s /opt/pipx/venvs/poetry/bin/poetry poetry
|
||||
chmod +x poetry
|
||||
|
||||
# Ensure everything got set up correctly.
|
||||
/opt/pipx/venvs/poetry/bin/python --version
|
||||
/opt/pipx_bin/poetry --version
|
||||
|
||||
- name: Install poetry
|
||||
if: steps.cache-bin-poetry.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
POETRY_VERSION: ${{ inputs.poetry-version }}
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
# Install poetry using the python version installed by setup-python step.
|
||||
run: pipx install "poetry==$POETRY_VERSION" --python '${{ steps.setup-python.outputs.python-path }}' --verbose
|
||||
|
||||
- name: Restore pip and poetry cached dependencies
|
||||
uses: actions/cache@v3
|
||||
env:
|
||||
SEGMENT_DOWNLOAD_TIMEOUT_MIN: "4"
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pip
|
||||
~/.cache/pypoetry/virtualenvs
|
||||
~/.cache/pypoetry/cache
|
||||
~/.cache/pypoetry/artifacts
|
||||
./.venv
|
||||
key: py-deps-${{ runner.os }}-${{ runner.arch }}-py-${{ inputs.python-version }}-poetry-${{ inputs.poetry-version }}-${{ inputs.cache-key }}-${{ hashFiles('./poetry.lock') }}
|
||||
@@ -0,0 +1,87 @@
|
||||
name: test-release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.7.1"
|
||||
PYTHON_VERSION: "3.10"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
pkg-name: ${{ steps.check-version.outputs.pkg-name }}
|
||||
version: ${{ steps.check-version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python + Poetry ${{ env.POETRY_VERSION }}
|
||||
uses: "./.github/actions/poetry_setup"
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
cache-key: release
|
||||
|
||||
# We want to keep this build stage *separate* from the release stage,
|
||||
# so that there's no sharing of permissions between them.
|
||||
# The release stage has trusted publishing and GitHub repo contents write access,
|
||||
# and we want to keep the scope of that access limited just to the release job.
|
||||
# Otherwise, a malicious `build` step (e.g. via a compromised dependency)
|
||||
# could get access to our GitHub or PyPI credentials.
|
||||
#
|
||||
# Per the trusted publishing GitHub Action:
|
||||
# > It is strongly advised to separate jobs for building [...]
|
||||
# > from the publish job.
|
||||
# https://github.com/pypa/gh-action-pypi-publish#non-goals
|
||||
- name: Build project for distribution
|
||||
run: poetry build
|
||||
|
||||
- name: Upload build
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-dist
|
||||
path: ./langgraph/dist/
|
||||
|
||||
- name: Check Version
|
||||
id: check-version
|
||||
shell: bash
|
||||
run: |
|
||||
echo pkg-name="$(poetry version | cut -d ' ' -f 1)" >> $GITHUB_OUTPUT
|
||||
echo version="$(poetry version --short)" >> $GITHUB_OUTPUT
|
||||
|
||||
publish:
|
||||
needs:
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# This permission is used for trusted publishing:
|
||||
# https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/
|
||||
#
|
||||
# Trusted publishing has to also be configured on PyPI for each package:
|
||||
# https://docs.pypi.org/trusted-publishers/adding-a-publisher/
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: test-dist
|
||||
path: ./langgraph/dist/
|
||||
|
||||
- name: Publish to test PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./langgraph/dist/
|
||||
verbose: true
|
||||
print-hash: true
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
|
||||
# We overwrite any existing distributions with the same name and version.
|
||||
# This is *only for CI use* and is *extremely dangerous* otherwise!
|
||||
# https://github.com/pypa/gh-action-pypi-publish#tolerating-release-package-file-duplicates
|
||||
skip-existing: true
|
||||
@@ -0,0 +1,94 @@
|
||||
name: lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.7.1"
|
||||
|
||||
# This env var allows us to get inline annotations when ruff has complaints.
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
# Only lint on the min and max supported Python versions.
|
||||
# It's extremely unlikely that there's a lint issue on any version in between
|
||||
# that doesn't show up on the min or max versions.
|
||||
#
|
||||
# GitHub rate-limits how many jobs can be running at any one time.
|
||||
# Starting new jobs is also relatively slow,
|
||||
# so linting on fewer versions makes CI faster.
|
||||
python-version:
|
||||
- "3.9"
|
||||
- "3.11"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }} + Poetry ${{ env.POETRY_VERSION }}
|
||||
uses: "./.github/actions/poetry_setup"
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
cache-key: lint-with-extras
|
||||
|
||||
- name: Check Poetry File
|
||||
shell: bash
|
||||
run: poetry check
|
||||
|
||||
- name: Check lock file
|
||||
shell: bash
|
||||
run: poetry lock --check
|
||||
|
||||
- name: Install dependencies
|
||||
# Also installs dev/lint/test/typing dependencies, to ensure we have
|
||||
# type hints for as many of our libraries as possible.
|
||||
# This helps catch errors that require dependencies to be spotted, for example:
|
||||
# https://github.com/langchain-ai/langchain/pull/10249/files#diff-935185cd488d015f026dcd9e19616ff62863e8cde8c0bee70318d3ccbca98341
|
||||
#
|
||||
# If you change this configuration, make sure to change the `cache-key`
|
||||
# in the `poetry_setup` action above to stop using the old cache.
|
||||
# It doesn't matter how you change it, any change will cause a cache-bust.
|
||||
run: poetry install --with lint,typing
|
||||
|
||||
- name: Get .mypy_cache to speed up mypy
|
||||
uses: actions/cache@v3
|
||||
env:
|
||||
SEGMENT_DOWNLOAD_TIMEOUT_MIN: "2"
|
||||
with:
|
||||
path: |
|
||||
./.mypy_cache
|
||||
key: mypy-lint-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ hashFiles('./poetry.lock') }}
|
||||
|
||||
|
||||
- name: Analysing the code with our lint
|
||||
run: |
|
||||
make lint_package
|
||||
|
||||
- name: Install test dependencies
|
||||
# Also installs dev/lint/test/typing dependencies, to ensure we have
|
||||
# type hints for as many of our libraries as possible.
|
||||
# This helps catch errors that require dependencies to be spotted, for example:
|
||||
# https://github.com/langchain-ai/langchain/pull/10249/files#diff-935185cd488d015f026dcd9e19616ff62863e8cde8c0bee70318d3ccbca98341
|
||||
#
|
||||
# If you change this configuration, make sure to change the `cache-key`
|
||||
# in the `poetry_setup` action above to stop using the old cache.
|
||||
# It doesn't matter how you change it, any change will cause a cache-bust.
|
||||
run: |
|
||||
poetry install --with test
|
||||
|
||||
- name: Get .mypy_cache_test to speed up mypy
|
||||
uses: actions/cache@v3
|
||||
env:
|
||||
SEGMENT_DOWNLOAD_TIMEOUT_MIN: "2"
|
||||
with:
|
||||
path: ./.mypy_cache_test
|
||||
key: mypy-test-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ hashFiles('./poetry.lock') }}
|
||||
|
||||
- name: Analysing the code with our lint
|
||||
run: |
|
||||
make lint_tests
|
||||
@@ -0,0 +1,209 @@
|
||||
name: release
|
||||
run-name: Release by @${{ github.actor }}
|
||||
on:
|
||||
workflow_dispatch
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.10"
|
||||
POETRY_VERSION: "1.7.1"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
pkg-name: ${{ steps.check-version.outputs.pkg-name }}
|
||||
version: ${{ steps.check-version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python + Poetry ${{ env.POETRY_VERSION }}
|
||||
uses: "./.github/actions/poetry_setup"
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
cache-key: release
|
||||
|
||||
# We want to keep this build stage *separate* from the release stage,
|
||||
# so that there's no sharing of permissions between them.
|
||||
# The release stage has trusted publishing and GitHub repo contents write access,
|
||||
# and we want to keep the scope of that access limited just to the release job.
|
||||
# Otherwise, a malicious `build` step (e.g. via a compromised dependency)
|
||||
# could get access to our GitHub or PyPI credentials.
|
||||
#
|
||||
# Per the trusted publishing GitHub Action:
|
||||
# > It is strongly advised to separate jobs for building [...]
|
||||
# > from the publish job.
|
||||
# https://github.com/pypa/gh-action-pypi-publish#non-goals
|
||||
- name: Build project for distribution
|
||||
run: poetry build
|
||||
|
||||
- name: Upload build
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: dist
|
||||
path: ./dist/
|
||||
|
||||
- name: Check Version
|
||||
id: check-version
|
||||
shell: bash
|
||||
run: |
|
||||
echo pkg-name="$(poetry version | cut -d ' ' -f 1)" >> $GITHUB_OUTPUT
|
||||
echo version="$(poetry version --short)" >> $GITHUB_OUTPUT
|
||||
|
||||
test-pypi-publish:
|
||||
needs:
|
||||
- build
|
||||
uses:
|
||||
./.github/workflows/_test_release.yml
|
||||
secrets: inherit
|
||||
|
||||
pre-release-checks:
|
||||
needs:
|
||||
- build
|
||||
- test-pypi-publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# We explicitly *don't* set up caching here. This ensures our tests are
|
||||
# maximally sensitive to catching breakage.
|
||||
#
|
||||
# For example, here's a way that caching can cause a falsely-passing test:
|
||||
# - Make the langchain package manifest no longer list a dependency package
|
||||
# as a requirement. This means it won't be installed by `pip install`,
|
||||
# and attempting to use it would cause a crash.
|
||||
# - That dependency used to be required, so it may have been cached.
|
||||
# When restoring the venv packages from cache, that dependency gets included.
|
||||
# - Tests pass, because the dependency is present even though it wasn't specified.
|
||||
# - The package is published, and it breaks on the missing dependency when
|
||||
# used in the real world.
|
||||
|
||||
- name: Set up Python + Poetry ${{ env.POETRY_VERSION }}
|
||||
uses: "./.github/actions/poetry_setup"
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
|
||||
- name: Import published package
|
||||
shell: bash
|
||||
env:
|
||||
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
# Here we use:
|
||||
# - The default regular PyPI index as the *primary* index, meaning
|
||||
# that it takes priority (https://pypi.org/simple)
|
||||
# - The test PyPI index as an extra index, so that any dependencies that
|
||||
# are not found on test PyPI can be resolved and installed anyway.
|
||||
# (https://test.pypi.org/simple). This will include the PKG_NAME==VERSION
|
||||
# package because VERSION will not have been uploaded to regular PyPI yet.
|
||||
# - attempt install again after 5 seconds if it fails because there is
|
||||
# sometimes a delay in availability on test pypi
|
||||
run: |
|
||||
poetry run pip install \
|
||||
--extra-index-url https://test.pypi.org/simple/ \
|
||||
"$PKG_NAME==$VERSION" || \
|
||||
( \
|
||||
sleep 5 && \
|
||||
poetry run pip install \
|
||||
--extra-index-url https://test.pypi.org/simple/ \
|
||||
"$PKG_NAME==$VERSION" \
|
||||
)
|
||||
|
||||
# Replace all dashes in the package name with underscores,
|
||||
# since that's how Python imports packages with dashes in the name.
|
||||
IMPORT_NAME="$(echo "$PKG_NAME" | sed s/-/_/g)"
|
||||
|
||||
poetry run python -c "import $IMPORT_NAME; print(dir($IMPORT_NAME))"
|
||||
|
||||
- name: Import test dependencies
|
||||
run: poetry install --with test
|
||||
|
||||
# Overwrite the local version of the package with the test PyPI version.
|
||||
- name: Import published package (again)
|
||||
shell: bash
|
||||
env:
|
||||
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
run: |
|
||||
poetry run pip install \
|
||||
--extra-index-url https://test.pypi.org/simple/ \
|
||||
"$PKG_NAME==$VERSION"
|
||||
|
||||
- name: Run unit tests
|
||||
run: make tests
|
||||
|
||||
publish:
|
||||
needs:
|
||||
- build
|
||||
- test-pypi-publish
|
||||
- pre-release-checks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# This permission is used for trusted publishing:
|
||||
# https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/
|
||||
#
|
||||
# Trusted publishing has to also be configured on PyPI for each package:
|
||||
# https://docs.pypi.org/trusted-publishers/adding-a-publisher/
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python + Poetry ${{ env.POETRY_VERSION }}
|
||||
uses: "./.github/actions/poetry_setup"
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
cache-key: release
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: dist
|
||||
path: ./dist/
|
||||
|
||||
- name: Publish package distributions to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./dist/
|
||||
verbose: true
|
||||
print-hash: true
|
||||
|
||||
mark-release:
|
||||
needs:
|
||||
- build
|
||||
- test-pypi-publish
|
||||
- pre-release-checks
|
||||
- publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# This permission is needed by `ncipollo/release-action` to
|
||||
# create the GitHub release.
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python + Poetry ${{ env.POETRY_VERSION }}
|
||||
uses: "./.github/actions/poetry_setup"
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
cache-key: release
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: dist
|
||||
path: ./dist/
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: "dist/*"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
draft: false
|
||||
generateReleaseNotes: true
|
||||
tag: v${{ needs.build.outputs.version }}
|
||||
commit: master
|
||||
@@ -0,0 +1,50 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.7.1"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.9"
|
||||
- "3.10"
|
||||
- "3.11"
|
||||
name: Python ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }} + Poetry ${{ env.POETRY_VERSION }}
|
||||
uses: "./.github/actions/poetry_setup"
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
cache-key: core
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: poetry install --with test
|
||||
|
||||
- name: Run core tests
|
||||
shell: bash
|
||||
run: |
|
||||
make test
|
||||
|
||||
- name: Ensure the tests did not create any additional files
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
STATUS="$(git status)"
|
||||
echo "$STATUS"
|
||||
|
||||
# grep will exit non-zero if the target message isn't found,
|
||||
# and `set -e` above will cause the step to fail.
|
||||
echo "$STATUS" | grep 'nothing to commit, working tree clean'
|
||||
@@ -18,7 +18,7 @@ test:
|
||||
poetry run pytest
|
||||
|
||||
test_watch:
|
||||
poetry run ptw --snapshot-update --now . -- -vv -x tests
|
||||
poetry run ptw --snapshot-update --now . -- -vv -x --ff tests
|
||||
|
||||
######################
|
||||
# LINTING AND FORMATTING
|
||||
@@ -26,13 +26,18 @@ test_watch:
|
||||
|
||||
# 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 --diff-filter=d master | grep -E '\.py$$|\.ipynb$$')
|
||||
lint_package: PYTHON_FILES=langgraph
|
||||
lint_tests: PYTHON_FILES=tests
|
||||
lint_tests: MYPY_CACHE=.mypy_cache_test
|
||||
|
||||
lint lint_diff:
|
||||
lint lint_diff lint_package lint_tests:
|
||||
poetry run ruff .
|
||||
poetry run ruff format $(PYTHON_FILES) --check
|
||||
poetry run mypy $(PYTHON_FILES)
|
||||
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff format $(PYTHON_FILES) --diff
|
||||
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff --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)
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
## Overview
|
||||
|
||||
LangGraph is a library for building stateful, multi-actor applications with LLMs, built on top of (and intended to be used with) [LangChain](https://github.com/langchain-ai/langchain).
|
||||
[LangGraph](https://github.com/langchain-ai/langgraph) is a library for building stateful, multi-actor applications with LLMs, built on top of (and intended to be used with) [LangChain](https://github.com/langchain-ai/langchain).
|
||||
It extends the [LangChain Expression Language](https://python.langchain.com/docs/expression_language/) with the ability to coordinate multiple chains (or actors) across multiple steps of computation in a cyclic manner.
|
||||
It is inspired by [Pregel](https://research.google/pubs/pub37252/) and [Apache Beam](https://beam.apache.org/).
|
||||
The current interface exposed is one inspired by [NetworkX](https://networkx.org/documentation/latest/).
|
||||
|
||||
The main use is for adding **cycles** to your LLM application.
|
||||
Crucially, this is NOT a **DAG** framework.
|
||||
If you want to build a DAG, you should use just use [LangChain Expression Language](https://python.langchain.com/docs/expression_language/).
|
||||
If you want to build a DAG, you should just use [LangChain Expression Language](https://python.langchain.com/docs/expression_language/).
|
||||
|
||||
Cycles are important for agent-like behaviors, where you call an LLM in a loop, asking it what action to take next.
|
||||
|
||||
@@ -24,7 +24,7 @@ pip install langgraph
|
||||
## Quick Start
|
||||
|
||||
Here we will go over an example of creating a simple agent that uses chat models and function calling.
|
||||
This agent will represent all state as a list of messages.
|
||||
This agent will represent all its state as a list of messages.
|
||||
|
||||
We will need to install some LangChain packages, as well as [Tavily](https://app.tavily.com/sign-in) to use as an example tool.
|
||||
|
||||
@@ -32,7 +32,7 @@ We will need to install some LangChain packages, as well as [Tavily](https://app
|
||||
pip install -U langchain langchain_openai tavily-python
|
||||
```
|
||||
|
||||
We also need to export some environment variables needed for our agent.
|
||||
We also need to export some environment variables for OpenAI and Tavily API access.
|
||||
|
||||
```shell
|
||||
export OPENAI_API_KEY=sk-...
|
||||
@@ -44,7 +44,6 @@ Optionally, we can set up [LangSmith](https://docs.smith.langchain.com/) for bes
|
||||
```shell
|
||||
export LANGCHAIN_TRACING_V2="true"
|
||||
export LANGCHAIN_API_KEY=ls__...
|
||||
export LANGCHAIN_ENDPOINT=https://api.langchain.plus
|
||||
```
|
||||
|
||||
### Set up the tools
|
||||
@@ -59,9 +58,9 @@ from langchain_community.tools.tavily_search import TavilySearchResults
|
||||
tools = [TavilySearchResults(max_results=1)]
|
||||
```
|
||||
|
||||
We can now wrap these tools in a simple ToolExecutor.
|
||||
This is a real simple class that takes in a ToolInvocation and calls that tool, returning the output.
|
||||
A ToolInvocation is any class with `tool` and `tool_input` attribute.
|
||||
We can now wrap these tools in a simple LangGraph `ToolExecutor`.
|
||||
This is a simple class that receives `ToolInvocation` objects, calls that tool, and returns the output.
|
||||
`ToolInvocation` is any class with `tool` and `tool_input` attributes.
|
||||
|
||||
```python
|
||||
from langgraph.prebuilt import ToolExecutor
|
||||
@@ -74,8 +73,8 @@ tool_executor = ToolExecutor(tools)
|
||||
Now we need to load the chat model we want to use.
|
||||
Importantly, this should satisfy two criteria:
|
||||
|
||||
1. It should work with messages. We will represent all agent state in the form of messages, so it needs to be able to work well with them.
|
||||
2. It should work with OpenAI function calling. This means it should either be an OpenAI model or a model that exposes a similar interface.
|
||||
1. It should work with lists of messages. We will represent all agent state in the form of messages, so it needs to be able to work well with them.
|
||||
2. It should work with the OpenAI function calling interface. This means it should either be an OpenAI model or a model that exposes a similar interface.
|
||||
|
||||
Note: these model requirements are not requirements for using LangGraph - they are just requirements for this one example.
|
||||
|
||||
@@ -97,7 +96,6 @@ functions = [format_tool_to_openai_function(t) for t in tools]
|
||||
model = model.bind_functions(functions)
|
||||
```
|
||||
|
||||
|
||||
### Define the agent state
|
||||
|
||||
The main type of graph in `langgraph` is the `StatefulGraph`.
|
||||
@@ -135,8 +133,11 @@ The reason they are conditional is that based on the output of a node, one of se
|
||||
The path that is taken is not known until that node is run (the LLM decides).
|
||||
|
||||
1. Conditional Edge: after the agent is called, we should either:
|
||||
|
||||
a. If the agent said to take an action, then the function to invoke tools should be called
|
||||
|
||||
b. If the agent said that it was finished, then it should finish
|
||||
|
||||
2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next
|
||||
|
||||
Let's define the nodes, as well as a function to decide how what conditional edge to take.
|
||||
@@ -294,7 +295,7 @@ Output from node '__end__':
|
||||
|
||||
### Streaming LLM Tokens
|
||||
|
||||
You can also access the LLM tokens as they are produced by each node.
|
||||
You can also access the LLM tokens as they are produced by each node.
|
||||
In this case only the "agent" node produces LLM tokens.
|
||||
In order for this to work properly, you must be using an LLM that supports streaming as well as have set it when constructing the LLM (e.g. `ChatOpenAI(model="gpt-3.5-turbo-1106", streaming=True)`)
|
||||
|
||||
@@ -418,49 +419,67 @@ Langchain Expression Language allows you to easily define chains (DAGs) but does
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
### ChatAgentExecutor: with function calling
|
||||
|
||||
This agent executor takes a list of messages as input and outputs a list of messages.
|
||||
This agent executor takes a list of messages as input and outputs a list of messages.
|
||||
All agent state is represented as a list of messages.
|
||||
This specifically uses OpenAI function calling.
|
||||
This is recommended agent executor for newer chat based models that support function calling.
|
||||
|
||||
- [Getting Started Notebook](examples/chat_agent_executor_with_function_calling/base.ipynb): Walks through creating this type of executor from scratch
|
||||
- [High Level Entrypoint](examples/chat_agent_executor_with_function_calling/high-level.ipynb): Walks through how to use the high level entrypoint for the chat agent executor.
|
||||
- [Getting Started Notebook](https://github.com/langchain-ai/langgraph/blob/main/examples/chat_agent_executor_with_function_calling/base.ipynb): Walks through creating this type of executor from scratch
|
||||
- [High Level Entrypoint](https://github.com/langchain-ai/langgraph/blob/main/examples/chat_agent_executor_with_function_calling/high-level.ipynb): Walks through how to use the high level entrypoint for the chat agent executor.
|
||||
|
||||
**Modifications**
|
||||
|
||||
We also have a lot of examples highlighting how to slightly modify the base chat agent executor. These all build off the [getting started notebook](examples/chat_agent_executor_with_function_calling/base.ipynb) so it is recommended you start with that first.
|
||||
- [Human-in-the-loop](examples/chat_agent_executor_with_function_calling/human-in-the-loop.ipynb): How to add a human-in-the-loop component
|
||||
- [Force calling a tool first](examples/chat_agent_executor_with_function_calling/force-calling-a-tool-first.ipynb): How to always call a specific tool first
|
||||
- [Respond in a specific format](examples/chat_agent_executor_with_function_calling/respond-in-format.ipynb): How to force the agent to respond in a specific format
|
||||
- [Dynamically returning tool output directly](examples/chat_agent_executor_with_function_calling/dynamically-returning-directly.ipynb): How to dynamically let the agent choose whether to return the result of a tool directly to the user
|
||||
- [Managing agent steps](examples/chat_agent_executor_with_function_calling/managing-agent-steps.ipynb): How to more explicitly manage intermediate steps that an agent takes
|
||||
We also have a lot of examples highlighting how to slightly modify the base chat agent executor. These all build off the [getting started notebook](https://github.com/langchain-ai/langgraph/blob/main/examples/chat_agent_executor_with_function_calling/base.ipynb) so it is recommended you start with that first.
|
||||
|
||||
- [Human-in-the-loop](https://github.com/langchain-ai/langgraph/blob/main/examples/chat_agent_executor_with_function_calling/human-in-the-loop.ipynb): How to add a human-in-the-loop component
|
||||
- [Force calling a tool first](https://github.com/langchain-ai/langgraph/blob/main/examples/chat_agent_executor_with_function_calling/force-calling-a-tool-first.ipynb): How to always call a specific tool first
|
||||
- [Respond in a specific format](https://github.com/langchain-ai/langgraph/blob/main/examples/chat_agent_executor_with_function_calling/respond-in-format.ipynb): How to force the agent to respond in a specific format
|
||||
- [Dynamically returning tool output directly](https://github.com/langchain-ai/langgraph/blob/main/examples/chat_agent_executor_with_function_calling/dynamically-returning-directly.ipynb): How to dynamically let the agent choose whether to return the result of a tool directly to the user
|
||||
- [Managing agent steps](https://github.com/langchain-ai/langgraph/blob/main/examples/chat_agent_executor_with_function_calling/managing-agent-steps.ipynb): How to more explicitly manage intermediate steps that an agent takes
|
||||
|
||||
### AgentExecutor
|
||||
|
||||
This agent executor uses existing LangChain agents.
|
||||
|
||||
- [Getting Started Notebook](examples/agent_executor/base.ipynb): Walks through creating this type of executor from scratch
|
||||
- [High Level Entrypoint](examples/agent_executor/high-level.ipynb): Walks through how to use the high level entrypoint for the chat agent executor.
|
||||
- [Getting Started Notebook](https://github.com/langchain-ai/langgraph/blob/main/examples/agent_executor/base.ipynb): Walks through creating this type of executor from scratch
|
||||
- [High Level Entrypoint](https://github.com/langchain-ai/langgraph/blob/main/examples/agent_executor/high-level.ipynb): Walks through how to use the high level entrypoint for the chat agent executor.
|
||||
|
||||
**Modifications**
|
||||
|
||||
We also have a lot of examples highlighting how to slightly modify the base chat agent executor. These all build off the [getting started notebook](examples/agent_executor/base.ipynb) so it is recommended you start with that first.
|
||||
- [Human-in-the-loop](examples/agent_executor/human-in-the-loop.ipynb): How to add a human-in-the-loop component
|
||||
- [Force calling a tool first](examples/agent_executor/force-calling-a-tool-first.ipynb): How to always call a specific tool first
|
||||
- [Managing agent steps](examples/agent_executor/managing-agent-steps.ipynb): How to more explicitly manage intermediate steps that an agent takes
|
||||
We also have a lot of examples highlighting how to slightly modify the base chat agent executor. These all build off the [getting started notebook](https://github.com/langchain-ai/langgraph/blob/main/examples/agent_executor/base.ipynb) so it is recommended you start with that first.
|
||||
|
||||
- [Human-in-the-loop](https://github.com/langchain-ai/langgraph/blob/main/examples/agent_executor/human-in-the-loop.ipynb): How to add a human-in-the-loop component
|
||||
- [Force calling a tool first](https://github.com/langchain-ai/langgraph/blob/main/examples/agent_executor/force-calling-a-tool-first.ipynb): How to always call a specific tool first
|
||||
- [Managing agent steps](https://github.com/langchain-ai/langgraph/blob/main/examples/agent_executor/managing-agent-steps.ipynb): How to more explicitly manage intermediate steps that an agent takes
|
||||
|
||||
### Multi-agent Examples
|
||||
|
||||
- [Multi-agent collaboration](https://github.com/langchain-ai/langgraph/blob/main/examples/multi_agent/multi-agent-collaboration.ipynb): how to create two agents that work together to accomplish a task
|
||||
- [Multi-agent with supervisor](https://github.com/langchain-ai/langgraph/blob/main/examples/multi_agent/agent_supervisor.ipynb): how to orchestrate individual agents by using an LLM as a "supervisor" to distribute work
|
||||
- [Hierarchical agent teams](https://github.com/langchain-ai/langgraph/blob/main/examples/multi_agent/hierarchical_agent_teams.ipynb): how to orchestrate "teams" of agents as nested graphs that can collaborate to solve a problem
|
||||
|
||||
### Chatbot Evaluation via Simulation
|
||||
|
||||
It can often be tough to evaluation chat bots in multi-turn situations. One way to do this is with simulations.
|
||||
|
||||
- [Chat bot evaluation as multi-agent simulation](https://github.com/langchain-ai/langgraph/blob/main/examples/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb): How to simulate a dialogue between a "virtual user" and your chat bot
|
||||
|
||||
### Async
|
||||
|
||||
If you are running LangGraph in async workflows, you may want to create the nodes to be async by default.
|
||||
In order for a walkthrough on how to do that, see [this documentation](examples/async.ipynb)
|
||||
In order for a walkthrough on how to do that, see [this documentation](https://github.com/langchain-ai/langgraph/blob/main/examples/async.ipynb)
|
||||
|
||||
### Streaming Tokens
|
||||
|
||||
Sometimes language models take a while to respond and you may want to stream tokens to end users.
|
||||
For a guide on how to do this, see [this documentation](examples/streaming-tokens.ipynb)
|
||||
For a guide on how to do this, see [this documentation](https://github.com/langchain-ai/langgraph/blob/main/examples/streaming-tokens.ipynb)
|
||||
|
||||
### Persistence
|
||||
|
||||
LangGraph comes with built-in persistence, allowing you to save the state of the graph at point and resume from there.
|
||||
In order for a walkthrough on how to do that, see [this documentation](https://github.com/langchain-ai/langgraph/blob/main/examples/persistence.ipynb)
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -478,7 +497,6 @@ This class is responsible for constructing the graph.
|
||||
It exposes an interface inspired by [NetworkX](https://networkx.org/documentation/latest/).
|
||||
This graph is parameterized by a state object that it passes around to each node.
|
||||
|
||||
|
||||
#### `__init__`
|
||||
|
||||
```python
|
||||
@@ -634,7 +652,6 @@ It can be used in two places:
|
||||
- As the `end_key` in `add_edge`
|
||||
- As a value in `conditional_edge_mapping` as passed to `add_conditional_edges`
|
||||
|
||||
|
||||
## Prebuilt Examples
|
||||
|
||||
There are also a few methods we've added to make it easy to use common, prebuilt graphs and components.
|
||||
|
||||
@@ -44,10 +44,19 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 2,
|
||||
"id": "c903a1cf-2977-4e2d-ad7d-8b3946821d89",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdin",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"OpenAI API Key: ········\n",
|
||||
"Tavily API Key: ········\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import os\n",
|
||||
"import getpass\n",
|
||||
@@ -66,10 +75,18 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 3,
|
||||
"id": "95e25aec-7c9f-4a63-b143-225d0e9a79c3",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdin",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"LangSmith API Key: ········\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n",
|
||||
"os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")"
|
||||
@@ -89,7 +106,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"execution_count": 4,
|
||||
"id": "d7ef57dd-5d6e-4ad3-9377-a92201c1310e",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
@@ -111,7 +128,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"execution_count": 5,
|
||||
"id": "5cf3331e-ccb3-41c8-aeb9-a840a94d41e7",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
@@ -139,7 +156,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"execution_count": 6,
|
||||
"id": "892b54b9-75f0-4804-9ed0-88b5e5532989",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
@@ -163,7 +180,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"execution_count": 7,
|
||||
"id": "cd3cbae5-d92c-4559-a4aa-44721b80d107",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
@@ -194,7 +211,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"execution_count": 8,
|
||||
"id": "ea793afa-2eab-4901-910d-6eed90cd6564",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
@@ -237,7 +254,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"execution_count": 9,
|
||||
"id": "b547109f-f9e8-4e77-a7e7-ed2bae7a72ab",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
@@ -277,7 +294,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 7,
|
||||
"execution_count": 10,
|
||||
"id": "73fd6432-42e8-472a-89ca-bb5ddbbcc35a",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
@@ -293,7 +310,7 @@
|
||||
" tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n",
|
||||
" tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n",
|
||||
" )\n",
|
||||
" response = input(prompt=f\"[y/n] continue with: {action}?\")\n",
|
||||
" response = input(f\"[y/n] continue with: {action}?\")\n",
|
||||
" if response == \"n\":\n",
|
||||
" raise ValueError\n",
|
||||
" # We call the tool_executor and get back a response\n",
|
||||
@@ -316,7 +333,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 8,
|
||||
"execution_count": 11,
|
||||
"id": "813ae66c-3b58-4283-a02a-36da72a2ab90",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
@@ -377,7 +394,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 10,
|
||||
"execution_count": 12,
|
||||
"id": "f544977e-31f7-41f0-88c4-ec9c27b8cecb",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -397,31 +414,31 @@
|
||||
"name": "stdin",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[y/n] continue with: tool='tavily_search_results_json' tool_input={'query': 'weather in San Francisco'}? n\n"
|
||||
"[y/n] continue with: tool='tavily_search_results_json' tool_input={'query': 'weather in San Francisco'}? y\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ename": "ValueError",
|
||||
"evalue": "",
|
||||
"output_type": "error",
|
||||
"traceback": [
|
||||
"\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
|
||||
"\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)",
|
||||
"Cell \u001b[0;32mIn[10], line 4\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[38;5;28;01mfrom\u001b[39;00m \u001b[38;5;21;01mlangchain_core\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mmessages\u001b[39;00m \u001b[38;5;28;01mimport\u001b[39;00m HumanMessage\n\u001b[1;32m 3\u001b[0m inputs \u001b[38;5;241m=\u001b[39m {\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mmessages\u001b[39m\u001b[38;5;124m\"\u001b[39m: [HumanMessage(content\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mwhat is the weather in sf\u001b[39m\u001b[38;5;124m\"\u001b[39m)]}\n\u001b[0;32m----> 4\u001b[0m \u001b[38;5;28;43;01mfor\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43moutput\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;129;43;01min\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mapp\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mstream\u001b[49m\u001b[43m(\u001b[49m\u001b[43minputs\u001b[49m\u001b[43m)\u001b[49m\u001b[43m:\u001b[49m\n\u001b[1;32m 5\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;66;43;03m# stream() yields dictionaries with output keyed by node name\u001b[39;49;00m\n\u001b[1;32m 6\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43;01mfor\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mkey\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mvalue\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;129;43;01min\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43moutput\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mitems\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\u001b[43m:\u001b[49m\n\u001b[1;32m 7\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43mprint\u001b[39;49m\u001b[43m(\u001b[49m\u001b[38;5;124;43mf\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mOutput from node \u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;132;43;01m{\u001b[39;49;00m\u001b[43mkey\u001b[49m\u001b[38;5;132;43;01m}\u001b[39;49;00m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43m:\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n",
|
||||
"File \u001b[0;32m~/workplace/permchain/langgraph/pregel/__init__.py:528\u001b[0m, in \u001b[0;36mPregel.transform\u001b[0;34m(self, input, config, output_keys, input_keys, **kwargs)\u001b[0m\n\u001b[1;32m 519\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mtransform\u001b[39m(\n\u001b[1;32m 520\u001b[0m \u001b[38;5;28mself\u001b[39m,\n\u001b[1;32m 521\u001b[0m \u001b[38;5;28minput\u001b[39m: Iterator[Union[\u001b[38;5;28mdict\u001b[39m[\u001b[38;5;28mstr\u001b[39m, Any], Any]],\n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 526\u001b[0m \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs: Any,\n\u001b[1;32m 527\u001b[0m ) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m Iterator[Union[\u001b[38;5;28mdict\u001b[39m[\u001b[38;5;28mstr\u001b[39m, Any], Any]]:\n\u001b[0;32m--> 528\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43;01mfor\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mchunk\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;129;43;01min\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_transform_stream_with_config\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 529\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\n\u001b[1;32m 530\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_transform\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 531\u001b[0m \u001b[43m \u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 532\u001b[0m \u001b[43m \u001b[49m\u001b[43moutput_keys\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43moutput_keys\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 533\u001b[0m \u001b[43m \u001b[49m\u001b[43minput_keys\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43minput_keys\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 534\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 535\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\u001b[43m:\u001b[49m\n\u001b[1;32m 536\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43;01myield\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mchunk\u001b[49m\n",
|
||||
"File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/base.py:1226\u001b[0m, in \u001b[0;36mRunnable._transform_stream_with_config\u001b[0;34m(self, input, transformer, config, run_type, **kwargs)\u001b[0m\n\u001b[1;32m 1224\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[1;32m 1225\u001b[0m \u001b[38;5;28;01mwhile\u001b[39;00m \u001b[38;5;28;01mTrue\u001b[39;00m:\n\u001b[0;32m-> 1226\u001b[0m chunk: Output \u001b[38;5;241m=\u001b[39m context\u001b[38;5;241m.\u001b[39mrun(\u001b[38;5;28mnext\u001b[39m, iterator) \u001b[38;5;66;03m# type: ignore\u001b[39;00m\n\u001b[1;32m 1227\u001b[0m \u001b[38;5;28;01myield\u001b[39;00m chunk\n\u001b[1;32m 1228\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m final_output_supported:\n",
|
||||
"File \u001b[0;32m~/workplace/permchain/langgraph/pregel/__init__.py:313\u001b[0m, in \u001b[0;36mPregel._transform\u001b[0;34m(self, input, run_manager, config, input_keys, output_keys)\u001b[0m\n\u001b[1;32m 303\u001b[0m done, inflight \u001b[38;5;241m=\u001b[39m concurrent\u001b[38;5;241m.\u001b[39mfutures\u001b[38;5;241m.\u001b[39mwait(\n\u001b[1;32m 304\u001b[0m [\n\u001b[1;32m 305\u001b[0m executor\u001b[38;5;241m.\u001b[39msubmit(proc\u001b[38;5;241m.\u001b[39minvoke, \u001b[38;5;28minput\u001b[39m, config)\n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 309\u001b[0m timeout\u001b[38;5;241m=\u001b[39m\u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mstep_timeout,\n\u001b[1;32m 310\u001b[0m )\n\u001b[1;32m 312\u001b[0m \u001b[38;5;66;03m# interrupt on failure or timeout\u001b[39;00m\n\u001b[0;32m--> 313\u001b[0m \u001b[43m_interrupt_or_proceed\u001b[49m\u001b[43m(\u001b[49m\u001b[43mdone\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43minflight\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mstep\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 315\u001b[0m \u001b[38;5;66;03m# apply writes to channels\u001b[39;00m\n\u001b[1;32m 316\u001b[0m _apply_writes(checkpoint, channels, pending_writes, config, step \u001b[38;5;241m+\u001b[39m \u001b[38;5;241m1\u001b[39m)\n",
|
||||
"File \u001b[0;32m~/workplace/permchain/langgraph/pregel/__init__.py:611\u001b[0m, in \u001b[0;36m_interrupt_or_proceed\u001b[0;34m(done, inflight, step)\u001b[0m\n\u001b[1;32m 609\u001b[0m inflight\u001b[38;5;241m.\u001b[39mpop()\u001b[38;5;241m.\u001b[39mcancel()\n\u001b[1;32m 610\u001b[0m \u001b[38;5;66;03m# raise the exception\u001b[39;00m\n\u001b[0;32m--> 611\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m exc\n\u001b[1;32m 612\u001b[0m \u001b[38;5;66;03m# TODO this is where retry of an entire step would happen\u001b[39;00m\n\u001b[1;32m 614\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m inflight:\n\u001b[1;32m 615\u001b[0m \u001b[38;5;66;03m# if we got here means we timed out\u001b[39;00m\n",
|
||||
"File \u001b[0;32m~/.pyenv/versions/3.11.1/lib/python3.11/concurrent/futures/thread.py:58\u001b[0m, in \u001b[0;36m_WorkItem.run\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 55\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m\n\u001b[1;32m 57\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[0;32m---> 58\u001b[0m result \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mfn\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43margs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 59\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mBaseException\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m exc:\n\u001b[1;32m 60\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mfuture\u001b[38;5;241m.\u001b[39mset_exception(exc)\n",
|
||||
"File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/base.py:3596\u001b[0m, in \u001b[0;36mRunnableBindingBase.invoke\u001b[0;34m(self, input, config, **kwargs)\u001b[0m\n\u001b[1;32m 3590\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21minvoke\u001b[39m(\n\u001b[1;32m 3591\u001b[0m \u001b[38;5;28mself\u001b[39m,\n\u001b[1;32m 3592\u001b[0m \u001b[38;5;28minput\u001b[39m: Input,\n\u001b[1;32m 3593\u001b[0m config: Optional[RunnableConfig] \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mNone\u001b[39;00m,\n\u001b[1;32m 3594\u001b[0m \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs: Optional[Any],\n\u001b[1;32m 3595\u001b[0m ) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m Output:\n\u001b[0;32m-> 3596\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mbound\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43minvoke\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 3597\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\n\u001b[1;32m 3598\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_merge_configs\u001b[49m\u001b[43m(\u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 3599\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43m{\u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m}\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 3600\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\n",
|
||||
"File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/base.py:1774\u001b[0m, in \u001b[0;36mRunnableSequence.invoke\u001b[0;34m(self, input, config)\u001b[0m\n\u001b[1;32m 1772\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[1;32m 1773\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m i, step \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28menumerate\u001b[39m(\u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39msteps):\n\u001b[0;32m-> 1774\u001b[0m \u001b[38;5;28minput\u001b[39m \u001b[38;5;241m=\u001b[39m \u001b[43mstep\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43minvoke\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 1775\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\n\u001b[1;32m 1776\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;66;43;03m# mark each step as a child run\u001b[39;49;00m\n\u001b[1;32m 1777\u001b[0m \u001b[43m \u001b[49m\u001b[43mpatch_config\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 1778\u001b[0m \u001b[43m \u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mcallbacks\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mrun_manager\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mget_child\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43mf\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mseq:step:\u001b[39;49m\u001b[38;5;132;43;01m{\u001b[39;49;00m\u001b[43mi\u001b[49m\u001b[38;5;241;43m+\u001b[39;49m\u001b[38;5;241;43m1\u001b[39;49m\u001b[38;5;132;43;01m}\u001b[39;49;00m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n\u001b[1;32m 1779\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 1780\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 1781\u001b[0m \u001b[38;5;66;03m# finish the root run\u001b[39;00m\n\u001b[1;32m 1782\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mBaseException\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m e:\n",
|
||||
"File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/base.py:3074\u001b[0m, in \u001b[0;36mRunnableLambda.invoke\u001b[0;34m(self, input, config, **kwargs)\u001b[0m\n\u001b[1;32m 3072\u001b[0m \u001b[38;5;250m\u001b[39m\u001b[38;5;124;03m\"\"\"Invoke this runnable synchronously.\"\"\"\u001b[39;00m\n\u001b[1;32m 3073\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mhasattr\u001b[39m(\u001b[38;5;28mself\u001b[39m, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mfunc\u001b[39m\u001b[38;5;124m\"\u001b[39m):\n\u001b[0;32m-> 3074\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_call_with_config\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 3075\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_invoke\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 3076\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\n\u001b[1;32m 3077\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_config\u001b[49m\u001b[43m(\u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mfunc\u001b[49m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 3078\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 3079\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 3080\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[1;32m 3081\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mTypeError\u001b[39;00m(\n\u001b[1;32m 3082\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mCannot invoke a coroutine function synchronously.\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 3083\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mUse `ainvoke` instead.\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 3084\u001b[0m )\n",
|
||||
"File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/base.py:975\u001b[0m, in \u001b[0;36mRunnable._call_with_config\u001b[0;34m(self, func, input, config, run_type, **kwargs)\u001b[0m\n\u001b[1;32m 971\u001b[0m context \u001b[38;5;241m=\u001b[39m copy_context()\n\u001b[1;32m 972\u001b[0m context\u001b[38;5;241m.\u001b[39mrun(var_child_runnable_config\u001b[38;5;241m.\u001b[39mset, child_config)\n\u001b[1;32m 973\u001b[0m output \u001b[38;5;241m=\u001b[39m cast(\n\u001b[1;32m 974\u001b[0m Output,\n\u001b[0;32m--> 975\u001b[0m \u001b[43mcontext\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mrun\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 976\u001b[0m \u001b[43m \u001b[49m\u001b[43mcall_func_with_variable_args\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 977\u001b[0m \u001b[43m \u001b[49m\u001b[43mfunc\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;66;43;03m# type: ignore[arg-type]\u001b[39;49;00m\n\u001b[1;32m 978\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;66;43;03m# type: ignore[arg-type]\u001b[39;49;00m\n\u001b[1;32m 979\u001b[0m \u001b[43m \u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 980\u001b[0m \u001b[43m \u001b[49m\u001b[43mrun_manager\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 981\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 982\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m,\n\u001b[1;32m 983\u001b[0m )\n\u001b[1;32m 984\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mBaseException\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m e:\n\u001b[1;32m 985\u001b[0m run_manager\u001b[38;5;241m.\u001b[39mon_chain_error(e)\n",
|
||||
"File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/config.py:326\u001b[0m, in \u001b[0;36mcall_func_with_variable_args\u001b[0;34m(func, input, config, run_manager, **kwargs)\u001b[0m\n\u001b[1;32m 324\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m run_manager \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;129;01mand\u001b[39;00m accepts_run_manager(func):\n\u001b[1;32m 325\u001b[0m kwargs[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mrun_manager\u001b[39m\u001b[38;5;124m\"\u001b[39m] \u001b[38;5;241m=\u001b[39m run_manager\n\u001b[0;32m--> 326\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mfunc\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n",
|
||||
"File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/base.py:2950\u001b[0m, in \u001b[0;36mRunnableLambda._invoke\u001b[0;34m(self, input, run_manager, config, **kwargs)\u001b[0m\n\u001b[1;32m 2948\u001b[0m output \u001b[38;5;241m=\u001b[39m chunk\n\u001b[1;32m 2949\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[0;32m-> 2950\u001b[0m output \u001b[38;5;241m=\u001b[39m \u001b[43mcall_func_with_variable_args\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 2951\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mfunc\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mrun_manager\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\n\u001b[1;32m 2952\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 2953\u001b[0m \u001b[38;5;66;03m# If the output is a runnable, invoke it\u001b[39;00m\n\u001b[1;32m 2954\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28misinstance\u001b[39m(output, Runnable):\n",
|
||||
"File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/config.py:326\u001b[0m, in \u001b[0;36mcall_func_with_variable_args\u001b[0;34m(func, input, config, run_manager, **kwargs)\u001b[0m\n\u001b[1;32m 324\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m run_manager \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;129;01mand\u001b[39;00m accepts_run_manager(func):\n\u001b[1;32m 325\u001b[0m kwargs[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mrun_manager\u001b[39m\u001b[38;5;124m\"\u001b[39m] \u001b[38;5;241m=\u001b[39m run_manager\n\u001b[0;32m--> 326\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mfunc\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n",
|
||||
"Cell \u001b[0;32mIn[7], line 14\u001b[0m, in \u001b[0;36mcall_tool\u001b[0;34m(state)\u001b[0m\n\u001b[1;32m 12\u001b[0m response \u001b[38;5;241m=\u001b[39m \u001b[38;5;28minput\u001b[39m(prompt\u001b[38;5;241m=\u001b[39m\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m[y/n] continue with: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00maction\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m?\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m 13\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m response \u001b[38;5;241m==\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mn\u001b[39m\u001b[38;5;124m\"\u001b[39m:\n\u001b[0;32m---> 14\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m\n\u001b[1;32m 15\u001b[0m \u001b[38;5;66;03m# We call the tool_executor and get back a response\u001b[39;00m\n\u001b[1;32m 16\u001b[0m response \u001b[38;5;241m=\u001b[39m tool_executor\u001b[38;5;241m.\u001b[39minvoke(action)\n",
|
||||
"\u001b[0;31mValueError\u001b[0m: "
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Output from node 'action':\n",
|
||||
"---\n",
|
||||
"{'messages': [FunctionMessage(content=\"[{'url': 'https://www.weather2travel.com/california/san-francisco/january/', 'content': 'San Francisco weather in January 2024 Expect 13°C daytime maximum temperatures long-term weather averages for San Francisco in January before you book your next holiday to California in 2024/2025. San Francisco January sunrise & sunset times How sunny is it in San Francisco in January?Expect 13°C daytime maximum temperatures in the shade with on average 6 hours of sunshine per day in San Francisco in January. Check more long-term weather averages for San Francisco in January before you book your next holiday to California in 2024/2025. 13. 13°C max day temperature. 6. 6 hours of sunshine per day. 10. 10 days with some ...'}]\", name='tavily_search_results_json')]}\n",
|
||||
"\n",
|
||||
"---\n",
|
||||
"\n",
|
||||
"Output from node 'agent':\n",
|
||||
"---\n",
|
||||
"{'messages': [AIMessage(content='The weather in San Francisco varies depending on the time of year. In January, the average daytime maximum temperature is around 13°C (55°F) with about 6 hours of sunshine per day. If you need more detailed information or want to check the weather for a specific date, you can visit this [website](https://www.weather2travel.com/california/san-francisco/january/).')]}\n",
|
||||
"\n",
|
||||
"---\n",
|
||||
"\n",
|
||||
"Output from node '__end__':\n",
|
||||
"---\n",
|
||||
"{'messages': [HumanMessage(content='what is the weather in sf'), AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}}), FunctionMessage(content=\"[{'url': 'https://www.weather2travel.com/california/san-francisco/january/', 'content': 'San Francisco weather in January 2024 Expect 13°C daytime maximum temperatures long-term weather averages for San Francisco in January before you book your next holiday to California in 2024/2025. San Francisco January sunrise & sunset times How sunny is it in San Francisco in January?Expect 13°C daytime maximum temperatures in the shade with on average 6 hours of sunshine per day in San Francisco in January. Check more long-term weather averages for San Francisco in January before you book your next holiday to California in 2024/2025. 13. 13°C max day temperature. 6. 6 hours of sunshine per day. 10. 10 days with some ...'}]\", name='tavily_search_results_json'), AIMessage(content='The weather in San Francisco varies depending on the time of year. In January, the average daytime maximum temperature is around 13°C (55°F) with about 6 hours of sunshine per day. If you need more detailed information or want to check the weather for a specific date, you can visit this [website](https://www.weather2travel.com/california/san-francisco/january/).')]}\n",
|
||||
"\n",
|
||||
"---\n",
|
||||
"\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -463,7 +480,7 @@
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.11.1"
|
||||
"version": "3.10.13"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a3e3ebc4-57af-4fe4-bdd3-36aff67bf276",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Chat Bot Evaluation as Multi-agent Simulation\n",
|
||||
"\n",
|
||||
"When building a chat bot, such as a customer support assistant, it can be hard to properly evalute your bot's performance. It's time-consuming to have to manually interact with it intensively for each code change.\n",
|
||||
"\n",
|
||||
"One way to make the evaluation process easier and more reproducible is to simulate a user interaction.\n",
|
||||
"\n",
|
||||
"With LangGraph, it's easy to set this up. Below is an example of how to create a \"virtual user\" to simulate a conversation.\n",
|
||||
"\n",
|
||||
"The overall simulation looks something like this:\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"First, we'll set up our environment."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "0d30b6f7-3bec-4d9f-af50-43dfdc81ae6c",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# %%capture --no-stderr\n",
|
||||
"# %pip install -U langgraph langchain langchain_openai"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "30c2f3de-c730-4aec-85a6-af2c2f058803",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import getpass\n",
|
||||
"import os\n",
|
||||
"import uuid\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def _set_if_undefined(var: str):\n",
|
||||
" if not os.environ.get(var):\n",
|
||||
" os.environ[var] = getpass(f\"Please provide your {var}\")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"_set_if_undefined(\"OPENAI_API_KEY\")\n",
|
||||
"_set_if_undefined(\"LANGCHAIN_API_KEY\")\n",
|
||||
"\n",
|
||||
"# Optional, add tracing in LangSmith.\n",
|
||||
"# This will help you visualize and debug the control flow\n",
|
||||
"os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n",
|
||||
"os.environ[\"LANGCHAIN_PROJECT\"] = \"Agent Simulation Evaluation\""
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "6ef4528d-6b2a-47c7-98b5-50f14984a304",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Define Chat Bot\n",
|
||||
"\n",
|
||||
"Next, we will define our chat bot. For this notebook, we assume the bot's API accepts a list of messages and responds with a message. If you want to update this, all you'll have to change is this section and the \"get_messages_for_agent\" function in \n",
|
||||
"the simulator below.\n",
|
||||
"\n",
|
||||
"The implementation within `my_chat_bot` is configurable and can even be run on another system (e.g., if your system isn't running in python)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"id": "828479af-cf9c-4888-a365-599643a96b55",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from typing import List\n",
|
||||
"\n",
|
||||
"import openai\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# This is flexible, but you can define your agent here, or call your agent API here.\n",
|
||||
"def my_chat_bot(messages: List[dict]) -> dict:\n",
|
||||
" system_message = {\"role\": \"system\", \"content\": \"You are a customer support agent for an airline.\"}\n",
|
||||
" messages = [system_message] + messages\n",
|
||||
" completion = openai.chat.completions.create(\n",
|
||||
" messages=messages, model=\"gpt-3.5-turbo\"\n",
|
||||
" )\n",
|
||||
" return completion.choices[0].message.model_dump()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "f58959bf-2ab5-4330-9ac2-c00f45237e24",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"{'content': 'Hello! How can I assist you today?',\n",
|
||||
" 'role': 'assistant',\n",
|
||||
" 'function_call': None,\n",
|
||||
" 'tool_calls': None}"
|
||||
]
|
||||
},
|
||||
"execution_count": 4,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"my_chat_bot([{\"role\": \"user\", \"content\": \"hi!\"}])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "419340a3-5ecf-48e7-9028-4f2fad750502",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. Define Simulated User\n",
|
||||
"\n",
|
||||
"We're now going to define the simulated user. \n",
|
||||
"This can be anything we want, but we're going to build it as a LangChain bot."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "32c147df-7f90-4b0d-9a6b-671677020353",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\n",
|
||||
"from langchain_core.runnables import chain\n",
|
||||
"from langchain_openai import ChatOpenAI\n",
|
||||
"\n",
|
||||
"system_prompt_template = \"\"\"You are a customer of an airline company. \\\n",
|
||||
"You are interacting with a user who is a customer support person. \\\n",
|
||||
"\n",
|
||||
"{instructions}\n",
|
||||
"\n",
|
||||
"When you are finished with the conversation, respond with a single word 'FINISHED'\"\"\"\n",
|
||||
"\n",
|
||||
"prompt = ChatPromptTemplate.from_messages(\n",
|
||||
" [\n",
|
||||
" (\"system\", system_prompt_template),\n",
|
||||
" MessagesPlaceholder(variable_name=\"messages\"),\n",
|
||||
" ]\n",
|
||||
")\n",
|
||||
"instructions = \"\"\"Your name is Harrison. You are tyring to get a refund for the trip you took to Alaska. \\\n",
|
||||
"You want them to give you ALL the money back. \\\n",
|
||||
"This trip happened 5 years ago.\"\"\"\n",
|
||||
"\n",
|
||||
"prompt = prompt.partial(name=\"Harrison\", instructions=instructions)\n",
|
||||
"\n",
|
||||
"model = ChatOpenAI()\n",
|
||||
"\n",
|
||||
"simulated_user = prompt | model"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"id": "6f80669e-aa78-4666-b67c-a539366d5aab",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"AIMessage(content='Hi, I would like to request a refund for a trip I took with your airline company to Alaska. Is it possible to get a refund for that trip?')"
|
||||
]
|
||||
},
|
||||
"execution_count": 6,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from langchain_core.messages import HumanMessage\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"messages = [HumanMessage(content=\"Hi! How can I help you?\")]\n",
|
||||
"simulated_user.invoke({\"messages\": messages})"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "321312b4-a1f0-4454-a481-fdac4e37cb7d",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3. Define the Agent Simulation\n",
|
||||
"\n",
|
||||
"The code below creates a LangGraph workflow to run the simulation. The main components are:\n",
|
||||
"\n",
|
||||
"1. The two nodes: one for the simulated user, the other for the chat bot.\n",
|
||||
"2. The graph itself, with a conditional stopping criterion.\n",
|
||||
"\n",
|
||||
"Read the comments in the code below for more information.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "65bc4446-462b-4ee8-b017-2862fbbdfaf5",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**Nodes**\n",
|
||||
"\n",
|
||||
"First, we define the nodes in the graph. These should take in a list of messages and return a list of messages to ADD to the state.\n",
|
||||
"These will be thing wrappers around the chat bot and simulated user we have above.\n",
|
||||
"\n",
|
||||
"**Note:** one tricky thing here is which messages are which. Because both the chat bot AND our simulated user are both LLMs, both of them will resond with AI messages. Our state will be a list of alternating Human and AI messages. This means that for one of the nodes, there will need to be some logic that flips the AI and human roles. In this example, we will assume that HumanMessages are messages from the simulated user. This means that we need some logic in the simulated user node to swap AI and Human messages.\n",
|
||||
"\n",
|
||||
"First, let's define the chat bot node"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 7,
|
||||
"id": "69e2a3a3-40f3-4223-9136-113738440be9",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langchain.adapters.openai import convert_message_to_dict\n",
|
||||
"from langchain_core.messages import AIMessage\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def chat_bot_node(messages):\n",
|
||||
" # Convert from LangChain format to the OpenAI format, which our chatbot function expects.\n",
|
||||
" messages = [convert_message_to_dict(m) for m in messages]\n",
|
||||
" # Call the chat bot\n",
|
||||
" chat_bot_response = my_chat_bot(messages)\n",
|
||||
" # Respond with an AI Message\n",
|
||||
" return AIMessage(content=chat_bot_response[\"content\"])\n",
|
||||
" "
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "694c3c0c-56c5-4410-8fa8-ea2c0f11f506",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Next, let's define the node for our simulated user. This will involve a little logic to swap the roles of the messages."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 8,
|
||||
"id": "7cad7527-ffa5-4c30-8585-b54a7a18bd98",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def _swap_roles(messages):\n",
|
||||
" new_messages = []\n",
|
||||
" for m in messages:\n",
|
||||
" if isinstance(m, AIMessage):\n",
|
||||
" new_messages.append(HumanMessage(content=m.content))\n",
|
||||
" else:\n",
|
||||
" new_messages.append(AIMessage(content=m.content))\n",
|
||||
" return new_messages\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def simulated_user_node(messages):\n",
|
||||
" # Swap roles of messages\n",
|
||||
" new_messages = _swap_roles(messages)\n",
|
||||
" # Call the simulated user\n",
|
||||
" response = simulated_user.invoke({\"messages\": new_messages})\n",
|
||||
" # This response is an AI message - we need to flip this to be a human message\n",
|
||||
" return HumanMessage(content=response.content)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a48d8a3e-9171-4c43-a595-44d312722148",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**Edges**\n",
|
||||
"\n",
|
||||
"We now need to define the logic for the edges. The main logic occurs after the simulated user goes, and it should lead to one of two outcomes:\n",
|
||||
"\n",
|
||||
"- Either we continue and call the customer support bot\n",
|
||||
"- Or we finish and the conversation is over\n",
|
||||
"\n",
|
||||
"So what is the logic for the conversation being over? We will define that as either the Human chatbot responds with `FINISHED` (see the system prompt) OR the conversation is more than 6 messages long (this is an arbitrary number just to keep this example short)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 9,
|
||||
"id": "28004fbf-a2f3-46b7-bde7-46c7adaf97fb",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def should_continue(messages):\n",
|
||||
" if len(messages) > 6:\n",
|
||||
" return \"end\"\n",
|
||||
" elif messages[-1].content == \"FINISHED\":\n",
|
||||
" return \"end\"\n",
|
||||
" else:\n",
|
||||
" return \"continue\""
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "d0856d4f-9334-4f28-944b-06d303e913a4",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**Graph**\n",
|
||||
"\n",
|
||||
"We can now define the graph that sets up the simulation!"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 10,
|
||||
"id": "0b597e4b-4cbb-4bbc-82e5-f7e31275964c",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langgraph.graph import END, MessageGraph\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"graph_builder = MessageGraph()\n",
|
||||
"graph_builder.add_node(\"user\", simulated_user_node)\n",
|
||||
"graph_builder.add_node(\"chat_bot\", chat_bot_node)\n",
|
||||
"# Every response from your chat bot will automatically go to the\n",
|
||||
"# simulated user\n",
|
||||
"graph_builder.add_edge(\"chat_bot\", \"user\")\n",
|
||||
"graph_builder.add_conditional_edges(\n",
|
||||
" \"user\",\n",
|
||||
" should_continue,\n",
|
||||
" # If the finish criteria are met, we will stop the simulation,\n",
|
||||
" # otherwise, the virtual user's message will be sent to your chat bot\n",
|
||||
" {\n",
|
||||
" \"end\": END,\n",
|
||||
" \"continue\": \"chat_bot\",\n",
|
||||
" },\n",
|
||||
")\n",
|
||||
"# The input will first go to your chat bot\n",
|
||||
"graph_builder.set_entry_point(\"chat_bot\")\n",
|
||||
"simulation = graph_builder.compile()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "2e0bd26e-8c1d-471d-9fef-d95dc0163491",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4. Run Simulation\n",
|
||||
"\n",
|
||||
"Now we can evaluate our chat bot! We can invoke it with empty messages (this will simulate letting the chat bot start the initial conversation)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 11,
|
||||
"id": "32848c2e-be82-46f3-81db-b23fea45461c",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"{'chat_bot': AIMessage(content='How may I assist you today regarding your flight or any other concerns?')}\n",
|
||||
"----\n",
|
||||
"{'user': HumanMessage(content='Hi, my name is Harrison. I am reaching out to request a refund for a trip I took to Alaska with your airline company. The trip occurred about 5 years ago. I would like to receive a refund for the entire amount I paid for the trip. Can you please assist me with this?')}\n",
|
||||
"----\n",
|
||||
"{'chat_bot': AIMessage(content=\"Hello, Harrison. Thank you for reaching out to us. I understand you would like to request a refund for a trip you took to Alaska five years ago. I'm afraid that our refund policy typically has a specific timeframe within which refund requests must be made. Generally, refund requests need to be submitted within 24 to 48 hours after the booking is made, or in certain cases, within a specified cancellation period.\\n\\nHowever, I will do my best to assist you. Could you please provide me with some additional information? Can you recall any specific details about the booking, such as the flight dates, booking reference or confirmation number? This will help me further look into the possibility of processing a refund for you.\")}\n",
|
||||
"----\n",
|
||||
"{'user': HumanMessage(content=\"Hello, thank you for your response. I apologize for not requesting the refund earlier. Unfortunately, I don't have the specific details such as the flight dates, booking reference, or confirmation number at the moment. Is there any other way we can proceed with the refund request without these specific details? I would greatly appreciate your assistance in finding a solution.\")}\n",
|
||||
"----\n",
|
||||
"{'chat_bot': AIMessage(content=\"I understand the situation, Harrison. Without specific details like flight dates, booking reference, or confirmation number, it becomes challenging to locate and process the refund accurately. However, I can still try to help you.\\n\\nTo proceed further, could you please provide me with any additional information you might remember? This could include the approximate date of travel, the departure and arrival airports, the names of the passengers, or any other relevant details related to the booking. The more information you can provide, the better we can investigate the possibility of processing a refund for you.\\n\\nAdditionally, do you happen to have any documentation related to your trip, such as receipts, boarding passes, or emails from our airline? These documents could assist in verifying your trip and processing the refund request.\\n\\nI apologize for any inconvenience caused, and I'll do my best to assist you further based on the information you can provide.\")}\n",
|
||||
"----\n",
|
||||
"{'user': HumanMessage(content=\"I apologize for the inconvenience caused. Unfortunately, I don't have any additional information or documentation related to the trip. It seems that I am unable to provide you with the necessary details to process the refund request. I understand that this may limit your ability to assist me further, but I appreciate your efforts in trying to help. Thank you for your time. \\n\\nFINISHED\")}\n",
|
||||
"----\n",
|
||||
"{'chat_bot': AIMessage(content=\"I understand, Harrison. I apologize for any inconvenience caused, and I appreciate your understanding. If you happen to locate any additional information or documentation in the future, please don't hesitate to reach out to us again. Our team will be more than happy to assist you with your refund request or any other travel-related inquiries. Thank you for contacting us, and have a great day!\")}\n",
|
||||
"----\n",
|
||||
"{'user': HumanMessage(content='FINISHED')}\n",
|
||||
"----\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"for chunk in simulation.stream([]):\n",
|
||||
" # Print out all events aside from the final end chunk\n",
|
||||
" if END not in chunk:\n",
|
||||
" print(chunk)\n",
|
||||
" print(\"----\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "dde4f2b5-cfe8-4ff0-99ea-fe2c5fed70c0",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.11.1"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
@@ -0,0 +1,414 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a3e3ebc4-57af-4fe4-bdd3-36aff67bf276",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Agent Supervisor\n",
|
||||
"\n",
|
||||
"The [previous example](multi-agent-collaboration.ipynb) routed messages automatically based on the output of the initial researcher agent.\n",
|
||||
"\n",
|
||||
"We can also choose to use an LLM to orchestrate the different agents.\n",
|
||||
"\n",
|
||||
"Below, we will create an agent group, with an agent supervisor to help delegate tasks.\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"To simplify the code in each agent node, we will use the AgentExecutor class from LangChain. This and other \"advanced agent\" notebooks are designed to show how you can implement certain design patterns in LangGraph. If the pattern suits your needs, we recommend combining it with some of the other fundamental patterns described elsewhere in the docs for best performance.\n",
|
||||
"\n",
|
||||
"Before we build, let's configure our environment:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "0d30b6f7-3bec-4d9f-af50-43dfdc81ae6c",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# %%capture --no-stderr\n",
|
||||
"# %pip install -U langchain langchain_openai langchain_experimental langsmith pandas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "30c2f3de-c730-4aec-85a6-af2c2f058803",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import getpass\n",
|
||||
"import os\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def _set_if_undefined(var: str):\n",
|
||||
" if not os.environ.get(var):\n",
|
||||
" os.environ[var] = getpass(f\"Please provide your {var}\")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"_set_if_undefined(\"OPENAI_API_KEY\")\n",
|
||||
"_set_if_undefined(\"LANGCHAIN_API_KEY\")\n",
|
||||
"_set_if_undefined(\"TAVILY_API_KEY\")\n",
|
||||
"\n",
|
||||
"# Optional, add tracing in LangSmith\n",
|
||||
"os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n",
|
||||
"os.environ[\"LANGCHAIN_PROJECT\"] = \"Multi-agent Collaboration\""
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "1ac25624-4d83-45a4-b9ef-a10589aacfb7",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Create tools\n",
|
||||
"\n",
|
||||
"For this example, you will make an agent to do web research with a search engine, and one agent to create plots. Define the tools they'll use below:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "f04c6778-403b-4b49-9b93-678e910d5cec",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from typing import Annotated, List, Tuple, Union\n",
|
||||
"\n",
|
||||
"from langchain_community.tools.tavily_search import TavilySearchResults\n",
|
||||
"from langchain_core.tools import tool\n",
|
||||
"from langchain_experimental.tools import PythonREPLTool\n",
|
||||
"\n",
|
||||
"tavily_tool = TavilySearchResults(max_results=5)\n",
|
||||
"\n",
|
||||
"# This executes code locally, which can be unsafe\n",
|
||||
"python_repl_tool = PythonREPLTool()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "d58d1e85-22d4-4c22-9062-72a346a0d709",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Helper Utilites\n",
|
||||
"\n",
|
||||
"Define a helper function below, which make it easier to add new agent worker nodes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"id": "c4823dd9-26bd-4e1a-8117-b97b2860211a",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langchain.agents import AgentExecutor, create_openai_tools_agent\n",
|
||||
"from langchain_core.messages import BaseMessage, HumanMessage\n",
|
||||
"from langchain_openai import ChatOpenAI\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def create_agent(\n",
|
||||
" llm: ChatOpenAI, tools: list, system_prompt: str\n",
|
||||
"):\n",
|
||||
" # Each worker node will be given a name and some tools.\n",
|
||||
" prompt = ChatPromptTemplate.from_messages(\n",
|
||||
" [\n",
|
||||
" (\n",
|
||||
" \"system\",\n",
|
||||
" system_prompt,\n",
|
||||
" ),\n",
|
||||
" MessagesPlaceholder(variable_name=\"messages\"),\n",
|
||||
" MessagesPlaceholder(variable_name=\"agent_scratchpad\"),\n",
|
||||
" ]\n",
|
||||
" )\n",
|
||||
" agent = create_openai_tools_agent(llm, tools, prompt)\n",
|
||||
" executor = AgentExecutor(agent=agent, tools=tools)\n",
|
||||
" return executor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "b7c302b0-cd57-4913-986f-5dc7d6d77386",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"We can also define a function that we will use to be the nodes in the graph - it takes care of converting the agent response to a human message. This is important because that is how we will add it the global state of the graph"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "80862241-a1a7-4726-bce5-f867b233832e",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def agent_node(state, agent, name):\n",
|
||||
" result = agent.invoke(state)\n",
|
||||
" return {\"messages\": [HumanMessage(content=result[\"output\"], name=name)]}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "d32962d2-5487-496d-aefc-2a3b0d194985",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Create Agent Supervisor\n",
|
||||
"\n",
|
||||
"It will use function calling to choose the next worker node OR finish processing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "311f0a58-b425-4496-adac-dc4cd8ffb912",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langchain.output_parsers.openai_functions import JsonOutputFunctionsParser\n",
|
||||
"from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\n",
|
||||
"\n",
|
||||
"members = [\"Researcher\", \"Coder\"]\n",
|
||||
"system_prompt = (\n",
|
||||
" \"You are a supervisor tasked with managing a conversation between the\"\n",
|
||||
" \" following workers: {members}. Given the following user request,\"\n",
|
||||
" \" respond with the worker to act next. Each worker will perform a\"\n",
|
||||
" \" task and respond with their results and status. When finished,\"\n",
|
||||
" \" respond with FINISH.\"\n",
|
||||
")\n",
|
||||
"# Our team supervisor is an LLM node. It just picks the next agent to process\n",
|
||||
"# and decides when the work is completed\n",
|
||||
"options = [\"FINISH\"] + members\n",
|
||||
"# Using openai function calling can make output parsing easier for us\n",
|
||||
"function_def = {\n",
|
||||
" \"name\": \"route\",\n",
|
||||
" \"description\": \"Select the next role.\",\n",
|
||||
" \"parameters\": {\n",
|
||||
" \"title\": \"routeSchema\",\n",
|
||||
" \"type\": \"object\",\n",
|
||||
" \"properties\": {\n",
|
||||
" \"next\": {\n",
|
||||
" \"title\": \"Next\",\n",
|
||||
" \"anyOf\": [\n",
|
||||
" {\"enum\": options},\n",
|
||||
" ],\n",
|
||||
" }\n",
|
||||
" },\n",
|
||||
" \"required\": [\"next\"],\n",
|
||||
" },\n",
|
||||
"}\n",
|
||||
"prompt = ChatPromptTemplate.from_messages(\n",
|
||||
" [\n",
|
||||
" (\"system\", system_prompt),\n",
|
||||
" MessagesPlaceholder(variable_name=\"messages\"),\n",
|
||||
" (\n",
|
||||
" \"system\",\n",
|
||||
" \"Given the conversation above, who should act next?\"\n",
|
||||
" \" Or should we FINISH? Select one of: {options}\",\n",
|
||||
" ),\n",
|
||||
" ]\n",
|
||||
").partial(options=str(options), members=\", \".join(members))\n",
|
||||
"\n",
|
||||
"llm = ChatOpenAI(model=\"gpt-4-1106-preview\")\n",
|
||||
"\n",
|
||||
"supervisor_chain = (\n",
|
||||
" prompt\n",
|
||||
" | llm.bind_functions(functions=[function_def], function_call=\"route\")\n",
|
||||
" | JsonOutputFunctionsParser()\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a07d507f-34d1-4f1b-8dde-5e58d17b2166",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Construct Graph\n",
|
||||
"\n",
|
||||
"We're ready to start building the graph. Below, define the state and worker nodes using the function we just defined."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"id": "6a430af7-8fce-4e66-ba9e-d940c1bc48e8",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import operator\n",
|
||||
"from typing import Annotated, Any, Dict, List, Optional, Sequence, TypedDict\n",
|
||||
"import functools\n",
|
||||
"\n",
|
||||
"from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\n",
|
||||
"from langgraph.graph import StateGraph, END\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# The agent state is the input to each node in the graph\n",
|
||||
"class AgentState(TypedDict):\n",
|
||||
" # The annotation tells the graph that new messages will always\n",
|
||||
" # be added to the current states\n",
|
||||
" messages: Annotated[Sequence[BaseMessage], operator.add]\n",
|
||||
" # The 'next' field indicates where to route to next\n",
|
||||
" next: str\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"research_agent = create_agent(llm, [tavily_tool], \"You are a web researcher.\")\n",
|
||||
"research_node = functools.partial(agent_node, agent=research_agent, name=\"Researcher\")\n",
|
||||
"\n",
|
||||
"# NOTE: THIS PERFORMS ARBITRARY CODE EXECUTION. PROCEED WITH CAUTION\n",
|
||||
"code_agent = create_agent(llm, [python_repl_tool], \"You may generate safe python code to analyze data and generate charts using matplotlib.\")\n",
|
||||
"code_node = functools.partial(agent_node, agent=code_agent, name=\"Coder\")\n",
|
||||
"\n",
|
||||
"workflow = StateGraph(AgentState)\n",
|
||||
"workflow.add_node(\"Researcher\", research_node)\n",
|
||||
"workflow.add_node(\"Coder\", code_node)\n",
|
||||
"workflow.add_node(\"supervisor\", supervisor_chain)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "2c1593d5-39f7-4819-96d2-4ad7d7991d72",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Now connect all the edges in the graph."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 7,
|
||||
"id": "14778e86-077b-4e6a-893c-400e59b0cdbf",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"for member in members:\n",
|
||||
" # We want our workers to ALWAYS \"report back\" to the supervisor when done\n",
|
||||
" workflow.add_edge(member, \"supervisor\")\n",
|
||||
"# The supervisor populates the \"next\" field in the graph state\n",
|
||||
"# which routes to a node or finishes\n",
|
||||
"conditional_map = {k: k for k in members}\n",
|
||||
"conditional_map[\"FINISH\"] = END\n",
|
||||
"workflow.add_conditional_edges(\"supervisor\", lambda x: x[\"next\"], conditional_map)\n",
|
||||
"# Finally, add entrypoint\n",
|
||||
"workflow.set_entry_point(\"supervisor\")\n",
|
||||
"\n",
|
||||
"graph = workflow.compile()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "d36496de-7121-4c49-8cb6-58c943c66628",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Invoke the team\n",
|
||||
"\n",
|
||||
"With the graph created, we can now invoke it and see how it performs!"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 8,
|
||||
"id": "56ba78e9-d9c1-457c-a073-d606d5d3e013",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"{'supervisor': {'next': 'Coder'}}\n",
|
||||
"----\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stderr",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Python REPL can execute arbitrary code. Use with caution.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"{'Coder': {'messages': [HumanMessage(content=\"The code `print('Hello, World!')` was executed, and the output is:\\n\\n```\\nHello, World!\\n```\", name='Coder')]}}\n",
|
||||
"----\n",
|
||||
"{'supervisor': {'next': 'FINISH'}}\n",
|
||||
"----\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"for s in graph.stream(\n",
|
||||
" {\n",
|
||||
" \"messages\": [\n",
|
||||
" HumanMessage(content=\"Code hello world and print it to the terminal\")\n",
|
||||
" ]\n",
|
||||
" }\n",
|
||||
"):\n",
|
||||
" if \"__end__\" not in s:\n",
|
||||
" print(s)\n",
|
||||
" print(\"----\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 9,
|
||||
"id": "45a92dfd-0e11-47f5-aad4-b68d24990e34",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"{'supervisor': {'next': 'Researcher'}}\n",
|
||||
"----\n",
|
||||
"{'Researcher': {'messages': [HumanMessage(content='**Research Report on Pikas**\\n\\nPikas are small mammals related to rabbits, known for their distinctive chirping sounds. They inhabit some of the most challenging environments, particularly boulder fields at high elevations, such as those found along the treeless slopes of the Southern Rockies, where they can be found at altitudes of up to 14,000 feet. Pikas are well-adapted to cold climates and typically do not fare well in warmer temperatures.\\n\\nRecent studies have shown that pikas are being impacted by climate change. Research by Peter Billman, a Ph.D. student from the University of Connecticut, indicates that pikas have moved upslope by approximately 1,160 feet. This upslope retreat is a direct response to changing climatic conditions, as pikas seek cooler temperatures at higher elevations.\\n\\nPikas are also known to be industrious foragers, particularly during the summer months when they gather vegetation to create haypiles for winter sustenance. Their behavior is encapsulated in the saying, \"making hay while the sun shines,\" reflecting their proactive approach to survival in harsh conditions.\\n\\nThe effects of climate change on pikas are not limited to the Southern Rockies. Studies published in Global Change Biology suggest that climate change is influencing pikas even in areas where they were previously thought to be less vulnerable, such as the Northern Rockies. These findings point to a broader trend of pikas moving to higher elevations, a behavior that may indicate a search for cooler, more suitable habitats.\\n\\nMoreover, researchers are exploring the possibility that pikas at lower elevations may have developed warm adaptations that could be beneficial for their future survival, given the ongoing climatic shifts. This line of research could help conservationists understand how pikas might cope with a warming world.\\n\\nIn conclusion, pikas are a species that not only fascinate with their unique behaviors and adaptations but also serve as indicators of environmental changes. Their upslope migration in response to climate change highlights the urgency for understanding and mitigating the effects of global warming on mountain ecosystems and the species that inhabit them.\\n\\n**Sources:**\\n- [Colorado Sun](https://coloradosun.com/2023/08/27/colorado-pika-population-climate-change/)\\n- [Wildlife.org](https://wildlife.org/climate-change-affects-pikas-even-in-unlikely-areas/)', name='Researcher')]}}\n",
|
||||
"----\n",
|
||||
"{'supervisor': {'next': 'FINISH'}}\n",
|
||||
"----\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"for s in graph.stream(\n",
|
||||
" {\n",
|
||||
" \"messages\": [\n",
|
||||
" HumanMessage(content=\"Write a brief research report on pikas.\")\n",
|
||||
" ]\n",
|
||||
" },\n",
|
||||
" {\"recursion_limit\": 100},\n",
|
||||
"):\n",
|
||||
" if \"__end__\" not in s:\n",
|
||||
" print(s)\n",
|
||||
" print(\"----\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "1d363d2c-e0da-4cce-ba47-ad2aa9df0fef",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.11.1"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a3e3ebc4-57af-4fe4-bdd3-36aff67bf276",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Hierarchical Agent Teams\n",
|
||||
"\n",
|
||||
"In our previous example ([Agent Supervisor](./agent_supervisor.ipynb)), we introduced the concept of a single supervisor node to route work between different worker nodes.\n",
|
||||
"\n",
|
||||
"But what if the job for a single worker becomes too complex? What if the number of workers becomes too large?\n",
|
||||
"\n",
|
||||
"For some applications, the system may be more effective if work is distributed _hierarchically_.\n",
|
||||
"\n",
|
||||
"You can do this by composing different subgraphs and creating a top-level supervisor, along with mid-level supervisors.\n",
|
||||
"\n",
|
||||
"To do this, let's build a simple research assistant! The graph will look something like the following:\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"This notebook is inspired by the paper [AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation](https://arxiv.org/abs/2308.08155), by Wu, et. al. In the rest of this notebook, you will:\n",
|
||||
"\n",
|
||||
"1. Define the agents' tools to access the web and write files\n",
|
||||
"2. Define some utilities to help create the graph and agents\n",
|
||||
"3. Create and define each team (web research + doc writing)\n",
|
||||
"4. Compose everything together.\n",
|
||||
"\n",
|
||||
"But before all of that, some setup:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "0d30b6f7-3bec-4d9f-af50-43dfdc81ae6c",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# %%capture --no-stderr\n",
|
||||
"# %pip install -U langgraph langchain langchain_openai langchain_experimental"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"id": "30c2f3de-c730-4aec-85a6-af2c2f058803",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import getpass\n",
|
||||
"import os\n",
|
||||
"import uuid\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def _set_if_undefined(var: str):\n",
|
||||
" if not os.environ.get(var):\n",
|
||||
" os.environ[var] = getpass(f\"Please provide your {var}\")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"_set_if_undefined(\"OPENAI_API_KEY\")\n",
|
||||
"_set_if_undefined(\"LANGCHAIN_API_KEY\")\n",
|
||||
"_set_if_undefined(\"TAVILY_API_KEY\")\n",
|
||||
"\n",
|
||||
"# Optional, add tracing in LangSmith.\n",
|
||||
"# This will help you visualize and debug the control flow\n",
|
||||
"os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n",
|
||||
"os.environ[\"LANGCHAIN_PROJECT\"] = \"Multi-agent Collaboration\""
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "354568e2-aef0-4af9-8a79-e64d3eea752f",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Create Tools\n",
|
||||
"\n",
|
||||
"Each team will be composed of one or more agents each with one or more tools. Below, define all the tools to be used by your different teams.\n",
|
||||
"\n",
|
||||
"We'll start with the research team.\n",
|
||||
"\n",
|
||||
"**Research team tools**\n",
|
||||
"\n",
|
||||
"The research team can use a search engine and url scraper to find information on the web. Feel free to add additional functionality below to boost the team performance!"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 19,
|
||||
"id": "4024eb89-843d-4cc3-ab3f-e1eb4d031179",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from typing import Annotated, List, Tuple, Union\n",
|
||||
"\n",
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"from langchain_community.document_loaders import WebBaseLoader\n",
|
||||
"from langchain_community.tools.tavily_search import TavilySearchResults\n",
|
||||
"from langchain_core.tools import tool\n",
|
||||
"from langsmith import trace\n",
|
||||
"\n",
|
||||
"tavily_tool = TavilySearchResults(max_results=5)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"@tool\n",
|
||||
"def scrape_webpages(urls: List[str]) -> str:\n",
|
||||
" \"\"\"Use requests and bs4 to scrape the provided web pages for detailed information.\"\"\"\n",
|
||||
" loader = WebBaseLoader(urls)\n",
|
||||
" docs = loader.load()\n",
|
||||
" return \"\\n\\n\".join(\n",
|
||||
" [\n",
|
||||
" f'<Document name=\"{doc.metadata.get(\"title\", \"\")}\">\\n{doc.page_content}\\n</Document>'\n",
|
||||
" for doc in docs\n",
|
||||
" ]\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "1c427982-fadf-4721-a77e-2465df9fc6bc",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**Document writing team tools**\n",
|
||||
"\n",
|
||||
"Next up, we will give some tools for the doc writing team to use.\n",
|
||||
"We define some bare-bones file-access tools below.\n",
|
||||
"\n",
|
||||
"Note that this gives the agents access to your file-system, which can be unsafe. We also haven't optimized the tool descriptions for performance."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "f20a18ca-2709-4c12-84f3-88678591a9fa",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from pathlib import Path\n",
|
||||
"from tempfile import TemporaryDirectory\n",
|
||||
"from typing import Dict, Optional\n",
|
||||
"\n",
|
||||
"from langchain_experimental.utilities import PythonREPL\n",
|
||||
"from typing_extensions import TypedDict\n",
|
||||
"\n",
|
||||
"_TEMP_DIRECTORY = TemporaryDirectory()\n",
|
||||
"WORKING_DIRECTORY = Path(_TEMP_DIRECTORY.name)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"@tool\n",
|
||||
"def create_outline(\n",
|
||||
" points: Annotated[List[str], \"List of main points or sections.\"],\n",
|
||||
" file_name: Annotated[str, \"File path to save the outline.\"],\n",
|
||||
") -> Annotated[str, \"Path of the saved outline file.\"]:\n",
|
||||
" \"\"\"Create and save an outline.\"\"\"\n",
|
||||
" with (WORKING_DIRECTORY / file_name).open(\"w\") as file:\n",
|
||||
" for i, point in enumerate(points):\n",
|
||||
" file.write(f\"{i + 1}. {point}\\n\")\n",
|
||||
" return f\"Outline saved to {file_name}\"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"@tool\n",
|
||||
"def read_document(\n",
|
||||
" file_name: Annotated[str, \"File path to save the document.\"],\n",
|
||||
" start: Annotated[Optional[int], \"The start line. Default is 0\"] = None,\n",
|
||||
" end: Annotated[Optional[int], \"The end line. Default is None\"] = None,\n",
|
||||
") -> str:\n",
|
||||
" \"\"\"Read the specified document.\"\"\"\n",
|
||||
" with (WORKING_DIRECTORY / file_name).open(\"r\") as file:\n",
|
||||
" lines = file.readlines()\n",
|
||||
" if start is not None:\n",
|
||||
" start = 0\n",
|
||||
" return \"\\n\".join(lines[start:end])\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"@tool\n",
|
||||
"def write_document(\n",
|
||||
" content: Annotated[str, \"Text content to be written into the document.\"],\n",
|
||||
" file_name: Annotated[str, \"File path to save the document.\"],\n",
|
||||
") -> Annotated[str, \"Path of the saved document file.\"]:\n",
|
||||
" \"\"\"Create and save a text document.\"\"\"\n",
|
||||
" with (WORKING_DIRECTORY / file_name).open(\"w\") as file:\n",
|
||||
" file.write(content)\n",
|
||||
" return f\"Document saved to {file_name}\"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"@tool\n",
|
||||
"def edit_document(\n",
|
||||
" file_name: Annotated[str, \"Path of the document to be edited.\"],\n",
|
||||
" inserts: Annotated[\n",
|
||||
" Dict[int, str],\n",
|
||||
" \"Dictionary where key is the line number (1-indexed) and value is the text to be inserted at that line.\",\n",
|
||||
" ],\n",
|
||||
") -> Annotated[str, \"Path of the edited document file.\"]:\n",
|
||||
" \"\"\"Edit a document by inserting text at specific line numbers.\"\"\"\n",
|
||||
"\n",
|
||||
" with (WORKING_DIRECTORY / file_name).open(\"r\") as file:\n",
|
||||
" lines = file.readlines()\n",
|
||||
"\n",
|
||||
" sorted_inserts = sorted(inserts.items())\n",
|
||||
"\n",
|
||||
" for line_number, text in sorted_inserts:\n",
|
||||
" if 1 <= line_number <= len(lines) + 1:\n",
|
||||
" lines.insert(line_number - 1, text + \"\\n\")\n",
|
||||
" else:\n",
|
||||
" return f\"Error: Line number {line_number} is out of range.\"\n",
|
||||
"\n",
|
||||
" with (WORKING_DIRECTORY / file_name).open(\"w\") as file:\n",
|
||||
" file.writelines(lines)\n",
|
||||
"\n",
|
||||
" return f\"Document edited and saved to {file_name}\"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# Warning: This executes code locally, which can be unsafe when not sandboxed\n",
|
||||
"\n",
|
||||
"repl = PythonREPL()\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"@tool\n",
|
||||
"def python_repl(\n",
|
||||
" code: Annotated[str, \"The python code to execute to generate your chart.\"]\n",
|
||||
"):\n",
|
||||
" \"\"\"Use this to execute python code. If you want to see the output of a value,\n",
|
||||
" you should print it out with `print(...)`. This is visible to the user.\"\"\"\n",
|
||||
" try:\n",
|
||||
" result = repl.run(code)\n",
|
||||
" except BaseException as e:\n",
|
||||
" return f\"Failed to execute. Error: {repr(e)}\"\n",
|
||||
" return f\"Succesfully executed:\\n```python\\n{code}\\n```\\nStdout: {result}\""
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "504ee1c6-2b6a-439d-9046-df54e1e15698",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Helper Utilities\n",
|
||||
"\n",
|
||||
"We are going to create a few utility functions to make it more concise when we want to:\n",
|
||||
"\n",
|
||||
"1. Create a worker agent.\n",
|
||||
"2. Create a supervisor for the sub-graph.\n",
|
||||
"\n",
|
||||
"These will simplify the graph compositional code at the end for us so it's easier to see what's going on."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 20,
|
||||
"id": "e09fb60f-1aac-455b-b67d-8d2e4ccfd747",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from typing import Any, Callable, List, Optional, TypedDict, Union\n",
|
||||
"\n",
|
||||
"from langchain.agents import AgentExecutor, create_openai_functions_agent\n",
|
||||
"from langchain.output_parsers.openai_functions import JsonOutputFunctionsParser\n",
|
||||
"from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\n",
|
||||
"from langchain_core.runnables import Runnable\n",
|
||||
"from langchain_core.tools import BaseTool\n",
|
||||
"from langchain_openai import ChatOpenAI\n",
|
||||
"\n",
|
||||
"from langgraph.graph import END, StateGraph\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def create_agent(\n",
|
||||
" llm: ChatOpenAI,\n",
|
||||
" tools: list,\n",
|
||||
" system_prompt: str,\n",
|
||||
") -> str:\n",
|
||||
" \"\"\"Create a function-calling agent and add it to the graph.\"\"\"\n",
|
||||
" system_prompt += \"\\nWork autonomously according to your specialty, using the tools available to you.\"\n",
|
||||
" \" Do not ask for clarification.\"\n",
|
||||
" \" Your other team members (and other teams) will collaborate with you with their own specialties.\"\n",
|
||||
" \" You are chosen for a reason! You are one of the following team members: {team_members}.\"\n",
|
||||
" prompt = ChatPromptTemplate.from_messages(\n",
|
||||
" [\n",
|
||||
" (\n",
|
||||
" \"system\",\n",
|
||||
" system_prompt,\n",
|
||||
" ),\n",
|
||||
" MessagesPlaceholder(variable_name=\"messages\"),\n",
|
||||
" MessagesPlaceholder(variable_name=\"agent_scratchpad\"),\n",
|
||||
" ]\n",
|
||||
" )\n",
|
||||
" agent = create_openai_functions_agent(llm, tools, prompt)\n",
|
||||
" executor = AgentExecutor(agent=agent, tools=tools)\n",
|
||||
" return executor\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def agent_node(state, agent, name):\n",
|
||||
" result = agent.invoke(state)\n",
|
||||
" return {\"messages\": [HumanMessage(content=result[\"output\"], name=name)]}\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def create_team_supervisor(\n",
|
||||
" llm: ChatOpenAI, system_prompt, members\n",
|
||||
") -> str:\n",
|
||||
" \"\"\"An LLM-based router.\"\"\"\n",
|
||||
" options = [\"FINISH\"] + members\n",
|
||||
" function_def = {\n",
|
||||
" \"name\": \"route\",\n",
|
||||
" \"description\": \"Select the next role.\",\n",
|
||||
" \"parameters\": {\n",
|
||||
" \"title\": \"routeSchema\",\n",
|
||||
" \"type\": \"object\",\n",
|
||||
" \"properties\": {\n",
|
||||
" \"next\": {\n",
|
||||
" \"title\": \"Next\",\n",
|
||||
" \"anyOf\": [\n",
|
||||
" {\"enum\": options},\n",
|
||||
" ],\n",
|
||||
" },\n",
|
||||
" },\n",
|
||||
" \"required\": [\"next\"],\n",
|
||||
" },\n",
|
||||
" }\n",
|
||||
" prompt = ChatPromptTemplate.from_messages(\n",
|
||||
" [\n",
|
||||
" (\"system\", system_prompt),\n",
|
||||
" MessagesPlaceholder(variable_name=\"messages\"),\n",
|
||||
" (\n",
|
||||
" \"system\",\n",
|
||||
" \"Given the conversation above, who should act next?\"\n",
|
||||
" \" Or should we FINISH? Select one of: {options}\",\n",
|
||||
" ),\n",
|
||||
" ]\n",
|
||||
" ).partial(options=str(options), team_members=\", \".join(members))\n",
|
||||
" return (\n",
|
||||
" prompt\n",
|
||||
" | llm.bind_functions(functions=[function_def], function_call=\"route\")\n",
|
||||
" | JsonOutputFunctionsParser()\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "00282b1f-bb4d-4ee7-9bae-e8e6f586f12e",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Define Agent Teams\n",
|
||||
"\n",
|
||||
"Now we can get to define our hierachical teams. \"Choose your player!\"\n",
|
||||
"\n",
|
||||
"### Research Team\n",
|
||||
"\n",
|
||||
"The research team will have a search agent and a web scraping \"research_agent\" as the two worker nodes. Let's create those, as well as the team supervisor."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 21,
|
||||
"id": "53db0c78-e357-48ba-ae5f-3fc04735a3b7",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import functools\n",
|
||||
"import operator\n",
|
||||
"\n",
|
||||
"from langchain_core.messages import AIMessage, BaseMessage, HumanMessage\n",
|
||||
"from langchain_openai.chat_models import ChatOpenAI\n",
|
||||
"import functools\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# Research team graph state\n",
|
||||
"class ResearchTeamState(TypedDict):\n",
|
||||
" # A message is added after each team member finishes\n",
|
||||
" messages: Annotated[List[BaseMessage], operator.add]\n",
|
||||
" # The team members are tracked so they are aware of\n",
|
||||
" # the others' skill-sets\n",
|
||||
" team_members: List[str]\n",
|
||||
" # Used to route work. The supervisor calls a function\n",
|
||||
" # that will update this every time it makes a decision\n",
|
||||
" next: str\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"llm = ChatOpenAI(model=\"gpt-4-1106-preview\")\n",
|
||||
"\n",
|
||||
"search_agent = create_agent(llm, [tavily_tool], \"You are a research assistant who can search for up-to-date info using the tavily search engine.\")\n",
|
||||
"search_node = functools.partial(agent_node, agent=search_agent, name=\"Search\")\n",
|
||||
"\n",
|
||||
"research_agent = create_agent(llm, [scrape_webpages], \"You are a research assistant who can scrape specified urls for more detailed information using the scrape_webpages function.\")\n",
|
||||
"research_node = functools.partial(agent_node, agent=research_agent, name=\"Web Scraper\")\n",
|
||||
"\n",
|
||||
"supervisor_agent = create_team_supervisor(\n",
|
||||
" llm,\n",
|
||||
" \"You are a supervisor tasked with managing a conversation between the\"\n",
|
||||
" \" following workers: Search, Web Scraper. Given the following user request,\"\n",
|
||||
" \" respond with the worker to act next. Each worker will perform a\"\n",
|
||||
" \" task and respond with their results and status. When finished,\"\n",
|
||||
" \" respond with FINISH.\",\n",
|
||||
" [\"Search\", \"Web Scraper\"],\n",
|
||||
")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "b01c6ee8-a461-4081-8a97-a3a06ec0f994",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Now that we've created the necessary components, defining their interactions is easy. Add the nodes to the team graph, and define the edges, which determine the transition criteria."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 22,
|
||||
"id": "1a7a1260-d9f6-4011-b2b1-13fab5126997",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"research_graph = StateGraph(ResearchTeamState)\n",
|
||||
"research_graph.add_node(\"Search\", search_node)\n",
|
||||
"research_graph.add_node(\"Web Scraper\", research_node)\n",
|
||||
"research_graph.add_node(\"supervisor\", supervisor_agent)\n",
|
||||
"\n",
|
||||
"# Define the control flow\n",
|
||||
"research_graph.add_edge(\"Search\", \"supervisor\")\n",
|
||||
"research_graph.add_edge(\"Web Scraper\", \"supervisor\")\n",
|
||||
"research_graph.add_conditional_edges(\n",
|
||||
" \"supervisor\",\n",
|
||||
" lambda x: x[\"next\"],\n",
|
||||
" {\n",
|
||||
" \"Search\": \"Search\",\n",
|
||||
" \"Web Scraper\": \"Web Scraper\",\n",
|
||||
" \"FINISH\": END\n",
|
||||
" }\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"research_graph.set_entry_point(\"supervisor\")\n",
|
||||
"chain = research_graph.compile()\n",
|
||||
"\n",
|
||||
"# The following functions interoperate between the top level graph state\n",
|
||||
"# and the state of the research sub-graph\n",
|
||||
"# this makes it so that the states of each graph don't get intermixed\n",
|
||||
"def enter_chain(message: str):\n",
|
||||
" results = {\n",
|
||||
" \"messages\": [HumanMessage(content=message)],\n",
|
||||
" }\n",
|
||||
" return results\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"research_chain = (\n",
|
||||
" enter_chain\n",
|
||||
" | chain\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "63ee8f2c-fbde-427b-ba54-ae0c7ce5fbfb",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"We can give this team work directly. Try it out below."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 10,
|
||||
"id": "912b0604-a178-4246-a36f-2dedae606680",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"{'supervisor': {'next': 'Search'}}\n",
|
||||
"---\n",
|
||||
"{'Search': {'messages': [HumanMessage(content='Taylor Swift\\'s next tour, named \"The Eras Tour,\" has dates scheduled for both 2023 and 2024. Some of the upcoming dates mentioned in the search results include:\\n\\n- November 25-26, 2023, in São Paulo, Brazil at the Allianz Parque\\n- February 7-9, 2024, in Tokyo, Japan at the Tokyo Dome\\n\\nFor a complete and detailed list of all the dates and locations for Taylor Swift\\'s \"The Eras Tour,\" you would need to visit official sources or ticketing websites such as Ticketmaster, as the search results suggest additional dates have been added due to overwhelming demand. The tour is set to wrap up in Indianapolis on November 3, 2024. Keep in mind that the schedule may be subject to change, and it\\'s a good idea to verify the dates on an official ticketing or event site for the latest information.', name='Search')]}}\n",
|
||||
"---\n",
|
||||
"{'supervisor': {'next': 'FINISH'}}\n",
|
||||
"---\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"for s in research_chain.stream(\n",
|
||||
" \"when is Taylor Swift's next tour?\",\n",
|
||||
" {\"recursion_limit\": 100}\n",
|
||||
" ):\n",
|
||||
" if \"__end__\" not in s:\n",
|
||||
" print(s)\n",
|
||||
" print(\"---\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "749b99ab-f6f0-4c5d-a90b-10102465d186",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Document Writing Team\n",
|
||||
"\n",
|
||||
"Create the document writing team below using a similar approach. This time, we will give each agent access to different file-writing tools.\n",
|
||||
"\n",
|
||||
"Note that we are giving file-system access to our agent here, which is not safe in all cases."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 12,
|
||||
"id": "1bcdbf44-9481-430c-8429-fa142ed8a626",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import operator\n",
|
||||
"from pathlib import Path\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# Document writing team graph state\n",
|
||||
"class DocWritingState(TypedDict):\n",
|
||||
" # This tracks the team's conversation internally\n",
|
||||
" messages: Annotated[List[BaseMessage], operator.add]\n",
|
||||
" # This provides each worker with context on the others' skill sets\n",
|
||||
" team_members: str\n",
|
||||
" # This is how the supervisor tells langgraph who to work next\n",
|
||||
" next: str\n",
|
||||
" # This tracks the shared directory state\n",
|
||||
" current_files: str\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# This will be run before each worker agent begins work\n",
|
||||
"# It makes it so they are more aware of the current state\n",
|
||||
"# of the working directory.\n",
|
||||
"def prelude(state):\n",
|
||||
" written_files = []\n",
|
||||
" if not WORKING_DIRECTORY.exists():\n",
|
||||
" WORKING_DIRECTORY.mkdir()\n",
|
||||
" try:\n",
|
||||
" written_files = [\n",
|
||||
" f.relative_to(WORKING_DIRECTORY) for f in WORKING_DIRECTORY.rglob(\"*\")\n",
|
||||
" ]\n",
|
||||
" except:\n",
|
||||
" pass\n",
|
||||
" if not written_files:\n",
|
||||
" return {**state, \"current_files\": \"No files written.\"}\n",
|
||||
" return {\n",
|
||||
" **state,\n",
|
||||
" \"current_files\": \"\\nBelow are files your team has written to the directory:\\n\"\n",
|
||||
" + \"\\n\".join([f\" - {f}\" for f in written_files]),\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"llm = ChatOpenAI(model=\"gpt-4-1106-preview\")\n",
|
||||
"\n",
|
||||
"doc_writer_agent = create_agent(\n",
|
||||
" llm,\n",
|
||||
" [write_document, edit_document, read_document],\n",
|
||||
" \"You are an expert writing a research document.\\n\"\n",
|
||||
" # The {current_files} value is populated automatically by the graph state\n",
|
||||
" \"Below are files currently in your directory:\\n{current_files}\",\n",
|
||||
")\n",
|
||||
"# Injects current directory working state before each call\n",
|
||||
"context_aware_doc_writer_agent = prelude | doc_writer_agent\n",
|
||||
"doc_writing_node = functools.partial(agent_node, agent=context_aware_doc_writer_agent, name=\"Doc Writer\")\n",
|
||||
"\n",
|
||||
"note_taking_agent = create_agent(\n",
|
||||
" llm,\n",
|
||||
" [create_outline, read_document],\n",
|
||||
" \"You are an expert senior researcher tasked with writing a paper outline and\"\n",
|
||||
" \" taking notes to craft a perfect paper.{current_files}\",\n",
|
||||
")\n",
|
||||
"context_aware_note_taking_agent = prelude | note_taking_agent\n",
|
||||
"note_taking_node = functools.partial(agent_node, agent=context_aware_note_taking_agent, name=\"Note Taker\")\n",
|
||||
"\n",
|
||||
"chart_generating_agent = create_agent(\n",
|
||||
" llm,\n",
|
||||
" [read_document, python_repl],\n",
|
||||
" \"You are a data viz expert tasked with generating charts for a research project.\"\n",
|
||||
" \"{current_files}\",\n",
|
||||
")\n",
|
||||
"context_aware_chart_generating_agent = prelude | chart_generating_agent\n",
|
||||
"chart_generating_node = functools.partial(agent_node, agent=context_aware_note_taking_agent, name=\"Chart Generator\")\n",
|
||||
"\n",
|
||||
"doc_writing_supervisor = create_team_supervisor(\n",
|
||||
" llm,\n",
|
||||
" \"You are a supervisor tasked with managing a conversation between the\"\n",
|
||||
" \" following workers: {team_members}. Given the following user request,\"\n",
|
||||
" \" respond with the worker to act next. Each worker will perform a\"\n",
|
||||
" \" task and respond with their results and status. When finished,\"\n",
|
||||
" \" respond with FINISH.\",\n",
|
||||
" [\"Doc Writer\", \"Note Taker\", \"Chart Generator\"]\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "aee2cd9b-29aa-458e-903d-4e49179e5d59",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"With the objects themselves created, we can form the graph."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 13,
|
||||
"id": "9c5c644f-8966-4d2e-98d2-80d73520e9fe",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Create the graph here:\n",
|
||||
"# Note that we have unrolled the loop for the sake of this doc\n",
|
||||
"authoring_graph = StateGraph(DocWritingState)\n",
|
||||
"authoring_graph.add_node(\"Doc Writer\", doc_writing_node)\n",
|
||||
"authoring_graph.add_node(\"Note Taker\", note_taking_node)\n",
|
||||
"authoring_graph.add_node(\"Chart Generator\", chart_generating_node)\n",
|
||||
"authoring_graph.add_node(\"supervisor\", doc_writing_supervisor)\n",
|
||||
"\n",
|
||||
"# Add the edges that always occur\n",
|
||||
"authoring_graph.add_edge(\"Doc Writer\", \"supervisor\")\n",
|
||||
"authoring_graph.add_edge(\"Note Taker\", \"supervisor\")\n",
|
||||
"authoring_graph.add_edge(\"Chart Generator\", \"supervisor\")\n",
|
||||
"\n",
|
||||
"# Add the edges where routing applies\n",
|
||||
"authoring_graph.add_conditional_edges(\n",
|
||||
" \"supervisor\",\n",
|
||||
" lambda x: x[\"next\"],\n",
|
||||
" {\n",
|
||||
" \"Doc Writer\": \"Doc Writer\",\n",
|
||||
" \"Note Taker\": \"Note Taker\",\n",
|
||||
" \"Chart Generator\": \"Chart Generator\",\n",
|
||||
" \"FINISH\": END\n",
|
||||
" }\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"authoring_graph.set_entry_point(\"supervisor\")\n",
|
||||
"chain = research_graph.compile()\n",
|
||||
"\n",
|
||||
"# The following functions interoperate between the top level graph state\n",
|
||||
"# and the state of the research sub-graph\n",
|
||||
"# this makes it so that the states of each graph don't get intermixed\n",
|
||||
"def enter_chain(message: str, members: List[str]):\n",
|
||||
" results = {\n",
|
||||
" \"messages\": [HumanMessage(content=message)],\n",
|
||||
" \"team_members\": \", \".join(members)\n",
|
||||
" }\n",
|
||||
" return results\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# We re-use the enter/exit functions to wrap the graph\n",
|
||||
"authoring_chain = (\n",
|
||||
" functools.partial(enter_chain, members=authoring_graph.nodes)\n",
|
||||
" | authoring_graph.compile()\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 14,
|
||||
"id": "9860fd46-c24d-40a5-a6ba-e8fddcd43369",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"{'supervisor': {'next': 'Note Taker'}}\n",
|
||||
"---\n",
|
||||
"{'Note Taker': {'messages': [HumanMessage(content='The outline and poem have been successfully written and saved. The poem, titled \"Whispers of the Wind,\" captures the essence of the wind\\'s journey from a gentle beginning to a powerful crescendo and then a calming end, concluding with the cyclical nature of this elemental force.\\n\\nHere is the completed poem:\\n\\n---\\n\\n**Whispers of the Wind**\\n\\nEmbracing the essence of nature\\'s breath,\\nA zephyr stirs, the dawn\\'s own cheer,\\nWhispering secrets, meant for no ear,\\nThe gentle beginnings, a soft caress.\\n\\nA gale ascends, midday\\'s fierce guest,\\nTwirling leaves in a wild, untamed dance,\\nNature\\'s raw power in its vast expanse,\\nThe crescendo of gusts, a forceful dance.\\n\\nAs dusk falls, the tempest does wane,\\nA soft sighing through the willow\\'s mane,\\nIn the quietude, peace finds its chance,\\nThe calming aftermath, a serene trance.\\n\\nThrough whispers, roars, and silent chants,\\nThe wind carries on, no pause nor glance,\\nIn its journey, a boundless, spirited lance,\\nThe enduring cycle, a timeless romance.\\n\\n---\\n\\nThe poem is a testament to the wind\\'s ever-present and ever-changing presence in the world around us.', name='Note Taker')]}}\n",
|
||||
"---\n",
|
||||
"{'supervisor': {'next': 'FINISH'}}\n",
|
||||
"---\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"for s in authoring_chain.stream(\n",
|
||||
" \"Write an outline for poem and then write the poem to disk.\",\n",
|
||||
" {\"recursion_limit\": 100}\n",
|
||||
" ):\n",
|
||||
" if \"__end__\" not in s:\n",
|
||||
" print(s)\n",
|
||||
" print(\"---\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "f4b5b08d-9a9a-474a-94b4-f7aaa8ff19e6",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Add Layers\n",
|
||||
"\n",
|
||||
"In this design, we are enforcing a top-down planning policy. We've created two graphs already, but we have to decide how to route work between the two.\n",
|
||||
"\n",
|
||||
"We'll create a _third_ graph to orchestrate the previous two, and add some connectors to define how this top-level state is shared between the different graphs."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 23,
|
||||
"id": "95ae7e52-92ed-41a3-88c4-21b6d7c8b041",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langchain_core.messages import AIMessage, BaseMessage, HumanMessage\n",
|
||||
"from langchain_openai.chat_models import ChatOpenAI\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"llm = ChatOpenAI(model=\"gpt-4-1106-preview\")\n",
|
||||
"\n",
|
||||
"supervisor_node = create_team_supervisor(\n",
|
||||
" llm,\n",
|
||||
" \"You are a supervisor tasked with managing a conversation between the\"\n",
|
||||
" \" following teams: {team_members}. Given the following user request,\"\n",
|
||||
" \" respond with the worker to act next. Each worker will perform a\"\n",
|
||||
" \" task and respond with their results and status. When finished,\"\n",
|
||||
" \" respond with FINISH.\",\n",
|
||||
" [\"Research team\", \"Paper writing team\"],\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 24,
|
||||
"id": "4880e573-612f-4d24-97c1-2079382a4a2f",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Top-level graph state\n",
|
||||
"class State(TypedDict):\n",
|
||||
" messages: Annotated[List[BaseMessage], operator.add]\n",
|
||||
" next: str\n",
|
||||
"\n",
|
||||
"def get_last_message(state: State) -> str:\n",
|
||||
" return state[\"messages\"][-1].content\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def join_graph(response: dict):\n",
|
||||
" return {\"messages\": [response[\"messages\"][-1]]}\n",
|
||||
"\n",
|
||||
"# Define the graph.\n",
|
||||
"super_graph = StateGraph(State)\n",
|
||||
"# First add the nodes, which will do the work\n",
|
||||
"super_graph.add_node(\"Research team\", get_last_message | research_chain | join_graph)\n",
|
||||
"super_graph.add_node(\n",
|
||||
" \"Paper writing team\", get_last_message | authoring_chain | join_graph\n",
|
||||
")\n",
|
||||
"super_graph.add_node(\"supervisor\", supervisor_node)\n",
|
||||
"\n",
|
||||
"# Define the graph connections, which controls how the logic\n",
|
||||
"# propagates through the program\n",
|
||||
"super_graph.add_edge(\"Research team\", \"supervisor\")\n",
|
||||
"super_graph.add_edge(\"Paper writing team\", \"supervisor\")\n",
|
||||
"super_graph.add_conditional_edges(\n",
|
||||
" \"supervisor\",\n",
|
||||
" lambda x: x[\"next\"],\n",
|
||||
" {\n",
|
||||
" \"Paper writing team\": \"Paper writing team\",\n",
|
||||
" \"Research team\": \"Research team\",\n",
|
||||
" \"FINISH\": END\n",
|
||||
" }\n",
|
||||
")\n",
|
||||
"super_graph.set_entry_point(\"supervisor\")\n",
|
||||
"super_graph = super_graph.compile()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 25,
|
||||
"id": "6b8badbf-d728-44bd-a2a7-5b4e587c92fe",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"{'supervisor': {'next': 'Research team'}}\n",
|
||||
"---\n",
|
||||
"{'Research team': {'messages': [HumanMessage(content='Based on the information gathered from the provided URLs, here is a detailed research report on the North American sturgeon along with a chart that summarizes some of the key data:\\n\\n**North American Sturgeon Research Report**\\n\\n**Overview:**\\nNorth American sturgeons are ancient fish that belong to the Acipenseridae family. These bony, bottom-dwelling fish are characterized by their elongated bodies, scutes (bony plates), and barbels near the mouth. They are anadromous, meaning they migrate from saltwater to freshwater to spawn.\\n\\n**Species and Distribution:**\\n- Atlantic Sturgeon (*Acipenser oxyrinchus*): Found along the East Coast from Canada to Florida.\\n- Green Sturgeon (*Acipenser medirostris*): Found on the West Coast from Alaska to California.\\n- Shortnose Sturgeon (*Acipenser brevirostrum*): Distributed along the East Coast in estuarine and riverine habitats.\\n\\n**Conservation Status:**\\n- Atlantic Sturgeon: Multiple distinct population segments (DPSs) listed as endangered or threatened under the Endangered Species Act (ESA).\\n- Green Sturgeon: Southern DPS listed as threatened under the ESA.\\n- Shortnose Sturgeon: Listed as endangered throughout its range under the ESA.\\n\\n**Threats:**\\n- Bycatch in commercial fisheries.\\n- Habitat degradation and loss due to dams, pollution, and development.\\n- Climate change affecting water temperatures and spawning conditions.\\n- Vessel strikes, particularly for Atlantic sturgeon in high-traffic rivers.\\n\\n**Conservation Efforts:**\\nEfforts include habitat restoration, removal of barriers to improve fish passage, protection from bycatch, and public education. Critical habitat has been designated for certain species, and regulations prohibit the retention of green sturgeon in recreational and commercial fisheries.\\n\\n**Chart Information:**\\nThe chart would include species names, conservation status, geographic distribution, and key threats.\\n\\n| Species | Conservation Status | Distribution | Key Threats |\\n|---------------------|---------------------|-------------------------------------|------------------------------------------------|\\n| Atlantic Sturgeon | Endangered/Threatened | East Coast (Canada to Florida) | Bycatch, habitat loss, vessel strikes |\\n| Green Sturgeon | Southern DPS Threatened | West Coast (Alaska to California) | Bycatch, habitat degradation, climate change |\\n| Shortnose Sturgeon | Endangered | East Coast estuarine and riverine habitats | Bycatch, habitat degradation, dams |\\n\\n**Conclusion:**\\nNorth American sturgeons face numerous threats that have led to a decline in their populations. However, ongoing conservation efforts, including habitat restoration and protective regulations, are crucial in supporting their recovery and ensuring the survival of these ancient and ecologically important species.', name='Web Scraper')]}}\n",
|
||||
"---\n",
|
||||
"{'supervisor': {'next': 'Research team'}}\n",
|
||||
"---\n",
|
||||
"{'Research team': {'messages': [HumanMessage(content='Based on the information gathered from the provided URLs, here is a detailed research report on the North American sturgeon along with a chart that summarizes some of the key data:\\n\\n**North American Sturgeon Research Report**\\n\\n**Overview:**\\nNorth American sturgeons are ancient fish that belong to the Acipenseridae family. These bony, bottom-dwelling fish are characterized by their elongated bodies, scutes (bony plates), and barbels near the mouth. They are anadromous, meaning they migrate from saltwater to freshwater to spawn.\\n\\n**Species and Distribution:**\\n- Atlantic Sturgeon (*Acipenser oxyrinchus*): Found along the East Coast from Canada to Florida.\\n- Green Sturgeon (*Acipenser medirostris*): Found on the West Coast from Alaska to California.\\n- Shortnose Sturgeon (*Acipenser brevirostrum*): Distributed along the East Coast in estuarine and riverine habitats.\\n\\n**Conservation Status:**\\n- Atlantic Sturgeon: Multiple distinct population segments (DPSs) listed as endangered or threatened under the Endangered Species Act (ESA).\\n- Green Sturgeon: Southern DPS listed as threatened under the ESA.\\n- Shortnose Sturgeon: Listed as endangered throughout its range under the ESA.\\n\\n**Threats:**\\n- Bycatch in commercial fisheries.\\n- Habitat degradation and loss due to dams, pollution, and development.\\n- Climate change affecting water temperatures and spawning conditions.\\n- Vessel strikes, particularly for Atlantic sturgeon in high-traffic rivers.\\n\\n**Conservation Efforts:**\\nEfforts include habitat restoration, removal of barriers to improve fish passage, protection from bycatch, and public education. Critical habitat has been designated for certain species, and regulations prohibit the retention of green sturgeon in recreational and commercial fisheries.\\n\\n**Chart Information:**\\nThe chart would include species names, conservation status, geographic distribution, and key threats.\\n\\n| Species | Conservation Status | Distribution | Key Threats |\\n|---------------------|---------------------|-------------------------------------|------------------------------------------------|\\n| Atlantic Sturgeon | Endangered/Threatened | East Coast (Canada to Florida) | Bycatch, habitat loss, vessel strikes |\\n| Green Sturgeon | Southern DPS Threatened | West Coast (Alaska to California) | Bycatch, habitat degradation, climate change |\\n| Shortnose Sturgeon | Endangered | East Coast estuarine and riverine habitats | Bycatch, habitat degradation, dams |\\n\\n**Conclusion:**\\nNorth American sturgeons face numerous threats that have led to a decline in their populations. However, ongoing conservation efforts, including habitat restoration and protective regulations, are crucial in supporting their recovery and ensuring the survival of these ancient and ecologically important species.')]}}\n",
|
||||
"---\n",
|
||||
"{'supervisor': {'next': 'Research team'}}\n",
|
||||
"---\n",
|
||||
"{'Research team': {'messages': [HumanMessage(content='Based on the information gathered from the provided URLs, here is a detailed research report on the North American sturgeon along with a chart that summarizes some of the key data:\\n\\n**North American Sturgeon Research Report**\\n\\n**Overview:**\\nNorth American sturgeons are ancient fish that belong to the Acipenseridae family. These bony, bottom-dwelling fish are characterized by their elongated bodies, scutes (bony plates), and barbels near the mouth. They are anadromous, meaning they migrate from saltwater to freshwater to spawn.\\n\\n**Species and Distribution:**\\n- Atlantic Sturgeon (*Acipenser oxyrinchus*): Found along the East Coast from Canada to Florida.\\n- Green Sturgeon (*Acipenser medirostris*): Found on the West Coast from Alaska to California.\\n- Shortnose Sturgeon (*Acipenser brevirostrum*): Distributed along the East Coast in estuarine and riverine habitats.\\n\\n**Conservation Status:**\\n- Atlantic Sturgeon: Multiple distinct population segments (DPSs) listed as endangered or threatened under the Endangered Species Act (ESA).\\n- Green Sturgeon: Southern DPS listed as threatened under the ESA.\\n- Shortnose Sturgeon: Listed as endangered throughout its range under the ESA.\\n\\n**Threats:**\\n- Bycatch in commercial fisheries.\\n- Habitat degradation and loss due to dams, pollution, and development.\\n- Climate change affecting water temperatures and spawning conditions.\\n- Vessel strikes, particularly for Atlantic sturgeon in high-traffic rivers.\\n\\n**Conservation Efforts:**\\nEfforts include habitat restoration, removal of barriers to improve fish passage, protection from bycatch, and public education. Critical habitat has been designated for certain species, and regulations prohibit the retention of green sturgeon in recreational and commercial fisheries.\\n\\n**Chart Information:**\\nThe chart would include species names, conservation status, geographic distribution, and key threats.\\n\\n| Species | Conservation Status | Distribution | Key Threats |\\n|---------------------|---------------------|-------------------------------------|------------------------------------------------|\\n| Atlantic Sturgeon | Endangered/Threatened | East Coast (Canada to Florida) | Bycatch, habitat loss, vessel strikes |\\n| Green Sturgeon | Southern DPS Threatened | West Coast (Alaska to California) | Bycatch, habitat degradation, climate change |\\n| Shortnose Sturgeon | Endangered | East Coast estuarine and riverine habitats | Bycatch, habitat degradation, dams |\\n\\n**Conclusion:**\\nNorth American sturgeons face numerous threats that have led to a decline in their populations. However, ongoing conservation efforts, including habitat restoration and protective regulations, are crucial in supporting their recovery and ensuring the survival of these ancient and ecologically important species.')]}}\n",
|
||||
"---\n",
|
||||
"{'supervisor': {'next': 'Research team'}}\n",
|
||||
"---\n",
|
||||
"{'Research team': {'messages': [HumanMessage(content='Based on the information gathered from the provided URLs, here is a detailed research report on the North American sturgeon along with a chart that summarizes some of the key data:\\n\\n**North American Sturgeon Research Report**\\n\\n**Overview:**\\nNorth American sturgeons are ancient fish that belong to the Acipenseridae family. These bony, bottom-dwelling fish are characterized by their elongated bodies, scutes (bony plates), and barbels near the mouth. They are anadromous, meaning they migrate from saltwater to freshwater to spawn.\\n\\n**Species and Distribution:**\\n- Atlantic Sturgeon (*Acipenser oxyrinchus*): Found along the East Coast from Canada to Florida.\\n- Green Sturgeon (*Acipenser medirostris*): Found on the West Coast from Alaska to California.\\n- Shortnose Sturgeon (*Acipenser brevirostrum*): Distributed along the East Coast in estuarine and riverine habitats.\\n\\n**Conservation Status:**\\n- Atlantic Sturgeon: Multiple distinct population segments (DPSs) listed as endangered or threatened under the Endangered Species Act (ESA).\\n- Green Sturgeon: Southern DPS listed as threatened under the ESA.\\n- Shortnose Sturgeon: Listed as endangered throughout its range under the ESA.\\n\\n**Threats:**\\n- Bycatch in commercial fisheries.\\n- Habitat degradation and loss due to dams, pollution, and development.\\n- Climate change affecting water temperatures and spawning conditions.\\n- Vessel strikes, particularly for Atlantic sturgeon in high-traffic rivers.\\n\\n**Conservation Efforts:**\\nEfforts include habitat restoration, removal of barriers to improve fish passage, protection from bycatch, and public education. Critical habitat has been designated for certain species, and regulations prohibit the retention of green sturgeon in recreational and commercial fisheries.\\n\\n**Chart Information:**\\nThe chart would include species names, conservation status, geographic distribution, and key threats.\\n\\n| Species | Conservation Status | Distribution | Key Threats |\\n|---------------------|---------------------|-------------------------------------|------------------------------------------------|\\n| Atlantic Sturgeon | Endangered/Threatened | East Coast (Canada to Florida) | Bycatch, habitat loss, vessel strikes |\\n| Green Sturgeon | Southern DPS Threatened | West Coast (Alaska to California) | Bycatch, habitat degradation, climate change |\\n| Shortnose Sturgeon | Endangered | East Coast estuarine and riverine habitats | Bycatch, habitat degradation, dams |\\n\\n**Conclusion:**\\nNorth American sturgeons face numerous threats that have led to a decline in their populations. However, ongoing conservation efforts, including habitat restoration and protective regulations, are crucial in supporting their recovery and ensuring the survival of these ancient and ecologically important species.')]}}\n",
|
||||
"---\n",
|
||||
"{'supervisor': {'next': 'Research team'}}\n",
|
||||
"---\n",
|
||||
"{'Research team': {'messages': [HumanMessage(content='Based on the information gathered from the provided URLs, here is a detailed research report on the North American sturgeon along with a chart that summarizes some of the key data:\\n\\n**North American Sturgeon Research Report**\\n\\n**Overview:**\\nNorth American sturgeons are ancient fish that belong to the Acipenseridae family. These bony, bottom-dwelling fish are characterized by their elongated bodies, scutes (bony plates), and barbels near the mouth. They are anadromous, meaning they migrate from saltwater to freshwater to spawn.\\n\\n**Species and Distribution:**\\n- Atlantic Sturgeon (*Acipenser oxyrinchus*): Found along the East Coast from Canada to Florida.\\n- Green Sturgeon (*Acipenser medirostris*): Found on the West Coast from Alaska to California.\\n- Shortnose Sturgeon (*Acipenser brevirostrum*): Distributed along the East Coast in estuarine and riverine habitats.\\n\\n**Conservation Status:**\\n- Atlantic Sturgeon: Multiple distinct population segments (DPSs) listed as endangered or threatened under the Endangered Species Act (ESA).\\n- Green Sturgeon: Southern DPS listed as threatened under the ESA.\\n- Shortnose Sturgeon: Listed as endangered throughout its range under the ESA.\\n\\n**Threats:**\\n- Bycatch in commercial fisheries.\\n- Habitat degradation and loss due to dams, pollution, and development.\\n- Climate change affecting water temperatures and spawning conditions.\\n- Vessel strikes, particularly for Atlantic sturgeon in high-traffic rivers.\\n\\n**Conservation Efforts:**\\nEfforts include habitat restoration, removal of barriers to improve fish passage, protection from bycatch, and public education. Critical habitat has been designated for certain species, and regulations prohibit the retention of green sturgeon in recreational and commercial fisheries.\\n\\n**Chart Information:**\\nThe chart would include species names, conservation status, geographic distribution, and key threats.\\n\\n| Species | Conservation Status | Distribution | Key Threats |\\n|---------------------|---------------------|-------------------------------------|------------------------------------------------|\\n| Atlantic Sturgeon | Endangered/Threatened | East Coast (Canada to Florida) | Bycatch, habitat loss, vessel strikes |\\n| Green Sturgeon | Southern DPS Threatened | West Coast (Alaska to California) | Bycatch, habitat degradation, climate change |\\n| Shortnose Sturgeon | Endangered | East Coast estuarine and riverine habitats | Bycatch, habitat degradation, dams |\\n\\n**Conclusion:**\\nNorth American sturgeons face numerous threats that have led to a decline in their populations. However, ongoing conservation efforts, including habitat restoration and protective regulations, are crucial in supporting their recovery and ensuring the survival of these ancient and ecologically important species.')]}}\n",
|
||||
"---\n",
|
||||
"{'supervisor': {'next': 'Research team'}}\n",
|
||||
"---\n",
|
||||
"{'Research team': {'messages': [HumanMessage(content='Based on the information gathered from the provided URLs, here is a detailed research report on the North American sturgeon along with a chart that summarizes some of the key data:\\n\\n**North American Sturgeon Research Report**\\n\\n**Overview:**\\nNorth American sturgeons are ancient fish that belong to the Acipenseridae family. These bony, bottom-dwelling fish are characterized by their elongated bodies, scutes (bony plates), and barbels near the mouth. They are anadromous, meaning they migrate from saltwater to freshwater to spawn.\\n\\n**Species and Distribution:**\\n- Atlantic Sturgeon (*Acipenser oxyrinchus*): Found along the East Coast from Canada to Florida.\\n- Green Sturgeon (*Acipenser medirostris*): Found on the West Coast from Alaska to California.\\n- Shortnose Sturgeon (*Acipenser brevirostrum*): Distributed along the East Coast in estuarine and riverine habitats.\\n\\n**Conservation Status:**\\n- Atlantic Sturgeon: Multiple distinct population segments (DPSs) listed as endangered or threatened under the Endangered Species Act (ESA).\\n- Green Sturgeon: Southern DPS listed as threatened under the ESA.\\n- Shortnose Sturgeon: Listed as endangered throughout its range under the ESA.\\n\\n**Threats:**\\n- Bycatch in commercial fisheries.\\n- Habitat degradation and loss due to dams, pollution, and development.\\n- Climate change affecting water temperatures and spawning conditions.\\n- Vessel strikes, particularly for Atlantic sturgeon in high-traffic rivers.\\n\\n**Conservation Efforts:**\\nEfforts include habitat restoration, removal of barriers to improve fish passage, protection from bycatch, and public education. Critical habitat has been designated for certain species, and regulations prohibit the retention of green sturgeon in recreational and commercial fisheries.\\n\\n**Chart Information:**\\nThe chart would include species names, conservation status, geographic distribution, and key threats.\\n\\n| Species | Conservation Status | Distribution | Key Threats |\\n|---------------------|---------------------|-------------------------------------|------------------------------------------------|\\n| Atlantic Sturgeon | Endangered/Threatened | East Coast (Canada to Florida) | Bycatch, habitat loss, vessel strikes |\\n| Green Sturgeon | Southern DPS Threatened | West Coast (Alaska to California) | Bycatch, habitat degradation, climate change |\\n| Shortnose Sturgeon | Endangered | East Coast estuarine and riverine habitats | Bycatch, habitat degradation, dams |\\n\\n**Conclusion:**\\nNorth American sturgeons face numerous threats that have led to a decline in their populations. However, ongoing conservation efforts, including habitat restoration and protective regulations, are crucial in supporting their recovery and ensuring the survival of these ancient and ecologically important species.')]}}\n",
|
||||
"---\n",
|
||||
"{'supervisor': {'next': 'Research team'}}\n",
|
||||
"---\n",
|
||||
"{'Research team': {'messages': [HumanMessage(content='Based on the information gathered from the provided URLs, here is a detailed research report on the North American sturgeon along with a chart that summarizes some of the key data:\\n\\n**North American Sturgeon Research Report**\\n\\n**Overview:**\\nNorth American sturgeons are ancient fish that belong to the Acipenseridae family. These bony, bottom-dwelling fish are characterized by their elongated bodies, scutes (bony plates), and barbels near the mouth. They are anadromous, meaning they migrate from saltwater to freshwater to spawn.\\n\\n**Species and Distribution:**\\n- Atlantic Sturgeon (*Acipenser oxyrinchus*): Found along the East Coast from Canada to Florida.\\n- Green Sturgeon (*Acipenser medirostris*): Found on the West Coast from Alaska to California.\\n- Shortnose Sturgeon (*Acipenser brevirostrum*): Distributed along the East Coast in estuarine and riverine habitats.\\n\\n**Conservation Status:**\\n- Atlantic Sturgeon: Multiple distinct population segments (DPSs) listed as endangered or threatened under the Endangered Species Act (ESA).\\n- Green Sturgeon: Southern DPS listed as threatened under the ESA.\\n- Shortnose Sturgeon: Listed as endangered throughout its range under the ESA.\\n\\n**Threats:**\\n- Bycatch in commercial fisheries.\\n- Habitat degradation and loss due to dams, pollution, and development.\\n- Climate change affecting water temperatures and spawning conditions.\\n- Vessel strikes, particularly for Atlantic sturgeon in high-traffic rivers.\\n\\n**Conservation Efforts:**\\nEfforts include habitat restoration, removal of barriers to improve fish passage, protection from bycatch, and public education. Critical habitat has been designated for certain species, and regulations prohibit the retention of green sturgeon in recreational and commercial fisheries.\\n\\n**Chart Information:**\\nThe chart would include species names, conservation status, geographic distribution, and key threats.\\n\\n| Species | Conservation Status | Distribution | Key Threats |\\n|---------------------|---------------------|-------------------------------------|------------------------------------------------|\\n| Atlantic Sturgeon | Endangered/Threatened | East Coast (Canada to Florida) | Bycatch, habitat loss, vessel strikes |\\n| Green Sturgeon | Southern DPS Threatened | West Coast (Alaska to California) | Bycatch, habitat degradation, climate change |\\n| Shortnose Sturgeon | Endangered | East Coast estuarine and riverine habitats | Bycatch, habitat degradation, dams |\\n\\n**Conclusion:**\\nNorth American sturgeons face numerous threats that have led to a decline in their populations. However, ongoing conservation efforts, including habitat restoration and protective regulations, are crucial in supporting their recovery and ensuring the survival of these ancient and ecologically important species.')]}}\n",
|
||||
"---\n",
|
||||
"{'supervisor': {'next': 'Research team'}}\n",
|
||||
"---\n",
|
||||
"{'Research team': {'messages': [HumanMessage(content='Based on the information gathered from the provided URLs, here is a detailed research report on the North American sturgeon along with a chart that summarizes some of the key data:\\n\\n**North American Sturgeon Research Report**\\n\\n**Overview:**\\nNorth American sturgeons are ancient fish that belong to the Acipenseridae family. These bony, bottom-dwelling fish are characterized by their elongated bodies, scutes (bony plates), and barbels near the mouth. They are anadromous, meaning they migrate from saltwater to freshwater to spawn.\\n\\n**Species and Distribution:**\\n- Atlantic Sturgeon (*Acipenser oxyrinchus*): Found along the East Coast from Canada to Florida.\\n- Green Sturgeon (*Acipenser medirostris*): Found on the West Coast from Alaska to California.\\n- Shortnose Sturgeon (*Acipenser brevirostrum*): Distributed along the East Coast in estuarine and riverine habitats.\\n\\n**Conservation Status:**\\n- Atlantic Sturgeon: Multiple distinct population segments (DPSs) listed as endangered or threatened under the Endangered Species Act (ESA).\\n- Green Sturgeon: Southern DPS listed as threatened under the ESA.\\n- Shortnose Sturgeon: Listed as endangered throughout its range under the ESA.\\n\\n**Threats:**\\n- Bycatch in commercial fisheries.\\n- Habitat degradation and loss due to dams, pollution, and development.\\n- Climate change affecting water temperatures and spawning conditions.\\n- Vessel strikes, particularly for Atlantic sturgeon in high-traffic rivers.\\n\\n**Conservation Efforts:**\\nEfforts include habitat restoration, removal of barriers to improve fish passage, protection from bycatch, and public education. Critical habitat has been designated for certain species, and regulations prohibit the retention of green sturgeon in recreational and commercial fisheries.\\n\\n**Chart Information:**\\nThe chart would include species names, conservation status, geographic distribution, and key threats.\\n\\n| Species | Conservation Status | Distribution | Key Threats |\\n|---------------------|---------------------|-------------------------------------|------------------------------------------------|\\n| Atlantic Sturgeon | Endangered/Threatened | East Coast (Canada to Florida) | Bycatch, habitat loss, vessel strikes |\\n| Green Sturgeon | Southern DPS Threatened | West Coast (Alaska to California) | Bycatch, habitat degradation, climate change |\\n| Shortnose Sturgeon | Endangered | East Coast estuarine and riverine habitats | Bycatch, habitat degradation, dams |\\n\\n**Conclusion:**\\nNorth American sturgeons face numerous threats that have led to a decline in their populations. However, ongoing conservation efforts, including habitat restoration and protective regulations, are crucial in supporting their recovery and ensuring the survival of these ancient and ecologically important species.')]}}\n",
|
||||
"---\n",
|
||||
"{'supervisor': {'next': 'Research team'}}\n",
|
||||
"---\n",
|
||||
"{'Research team': {'messages': [HumanMessage(content='Based on the information gathered from the provided URLs, here is a detailed research report on the North American sturgeon along with a chart that summarizes some of the key data:\\n\\n**North American Sturgeon Research Report**\\n\\n**Overview:**\\nNorth American sturgeons are ancient fish that belong to the Acipenseridae family. These bony, bottom-dwelling fish are characterized by their elongated bodies, scutes (bony plates), and barbels near the mouth. They are anadromous, meaning they migrate from saltwater to freshwater to spawn.\\n\\n**Species and Distribution:**\\n- Atlantic Sturgeon (*Acipenser oxyrinchus*): Found along the East Coast from Canada to Florida.\\n- Green Sturgeon (*Acipenser medirostris*): Found on the West Coast from Alaska to California.\\n- Shortnose Sturgeon (*Acipenser brevirostrum*): Distributed along the East Coast in estuarine and riverine habitats.\\n\\n**Conservation Status:**\\n- Atlantic Sturgeon: Multiple distinct population segments (DPSs) listed as endangered or threatened under the Endangered Species Act (ESA).\\n- Green Sturgeon: Southern DPS listed as threatened under the ESA.\\n- Shortnose Sturgeon: Listed as endangered throughout its range under the ESA.\\n\\n**Threats:**\\n- Bycatch in commercial fisheries.\\n- Habitat degradation and loss due to dams, pollution, and development.\\n- Climate change affecting water temperatures and spawning conditions.\\n- Vessel strikes, particularly for Atlantic sturgeon in high-traffic rivers.\\n\\n**Conservation Efforts:**\\nEfforts include habitat restoration, removal of barriers to improve fish passage, protection from bycatch, and public education. Critical habitat has been designated for certain species, and regulations prohibit the retention of green sturgeon in recreational and commercial fisheries.\\n\\n**Chart Information:**\\nThe chart would include species names, conservation status, geographic distribution, and key threats.\\n\\n| Species | Conservation Status | Distribution | Key Threats |\\n|---------------------|---------------------|-------------------------------------|------------------------------------------------|\\n| Atlantic Sturgeon | Endangered/Threatened | East Coast (Canada to Florida) | Bycatch, habitat loss, vessel strikes |\\n| Green Sturgeon | Southern DPS Threatened | West Coast (Alaska to California) | Bycatch, habitat degradation, climate change |\\n| Shortnose Sturgeon | Endangered | East Coast estuarine and riverine habitats | Bycatch, habitat degradation, dams |\\n\\n**Conclusion:**\\nNorth American sturgeons face numerous threats that have led to a decline in their populations. However, ongoing conservation efforts, including habitat restoration and protective regulations, are crucial in supporting their recovery and ensuring the survival of these ancient and ecologically important species.')]}}\n",
|
||||
"---\n",
|
||||
"{'supervisor': {'next': 'Paper writing team'}}\n",
|
||||
"---\n",
|
||||
"{'Paper writing team': {'messages': [HumanMessage(content='The contents of the \"AnalysisOfWhispersOfTheWindPoem.txt\" file have been successfully retrieved. It appears to contain the structured paper that was conceptualized earlier, including the introduction, a detailed analysis of the poem\\'s structure and content, exploration of poetic devices and imagery, discussion on the philosophical and environmental implications, and the paper\\'s conclusion.\\n\\nWith this analysis, the paper provides a comprehensive examination of the poem \"Whispers of the Wind,\" capturing the essence of the poem\\'s use of the wind as a symbol to articulate themes related to the natural world. It reflects on how the poet utilizes language and form to convey the power and beauty of nature, as well as the philosophical undertones that underscore human connection with the environment.\\n\\nThe conclusion of the paper emphasizes the significance of the poem in fostering a greater appreciation for nature and highlights the relevance of its message in the context of contemporary environmental discourse. This analysis serves as an important contribution to the understanding of the poem\\'s artistic and thematic dimensions.', name='Doc Writer')]}}\n",
|
||||
"---\n",
|
||||
"{'supervisor': {'next': 'Research team'}}\n",
|
||||
"---\n",
|
||||
"{'Research team': {'messages': [HumanMessage(content='The contents of the \"AnalysisOfWhispersOfTheWindPoem.txt\" file have been successfully retrieved. It appears to contain the structured paper that was conceptualized earlier, including the introduction, a detailed analysis of the poem\\'s structure and content, exploration of poetic devices and imagery, discussion on the philosophical and environmental implications, and the paper\\'s conclusion.\\n\\nWith this analysis, the paper provides a comprehensive examination of the poem \"Whispers of the Wind,\" capturing the essence of the poem\\'s use of the wind as a symbol to articulate themes related to the natural world. It reflects on how the poet utilizes language and form to convey the power and beauty of nature, as well as the philosophical undertones that underscore human connection with the environment.\\n\\nThe conclusion of the paper emphasizes the significance of the poem in fostering a greater appreciation for nature and highlights the relevance of its message in the context of contemporary environmental discourse. This analysis serves as an important contribution to the understanding of the poem\\'s artistic and thematic dimensions.')]}}\n",
|
||||
"---\n",
|
||||
"{'supervisor': {'next': 'FINISH'}}\n",
|
||||
"---\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"for s in super_graph.stream(\n",
|
||||
" {\n",
|
||||
" \"messages\": [\n",
|
||||
" HumanMessage(content=\"Write a brief research report on the North American sturgeon. Include a chart.\")\n",
|
||||
" ],\n",
|
||||
" },\n",
|
||||
" {\"recursion_limit\": 150},\n",
|
||||
" ):\n",
|
||||
" if \"__end__\" not in s:\n",
|
||||
" print(s)\n",
|
||||
" print(\"---\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "fb1b06f5-648c-459c-84f3-431807373782",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.11.1"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,469 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "51466c8d-8ce4-4b3d-be4e-18fdbeda5f53",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Persistence\n",
|
||||
"\n",
|
||||
"When creating LangGraph agents, you can also set them up so that they persist their state. This allows you to do things like interact with an agent multiple times and have it remember previous interactions."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "7cbd446a-808f-4394-be92-d45ab818953c",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Setup\n",
|
||||
"\n",
|
||||
"First we need to install the packages required"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "af4ce0ba-7596-4e5f-8bf8-0b0bd6e62833",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"\n",
|
||||
"\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m23.3.1\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m23.3.2\u001b[0m\n",
|
||||
"\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"!pip install --quiet -U langchain langchain_openai tavily-python"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "0abe11f4-62ed-4dc4-8875-3db21e260d1d",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "c903a1cf-2977-4e2d-ad7d-8b3946821d89",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdin",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"OpenAI API Key: ········\n",
|
||||
"Tavily API Key: ········\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import os\n",
|
||||
"import getpass\n",
|
||||
"\n",
|
||||
"os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n",
|
||||
"os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "f0ed46a8-effe-4596-b0e1-a6a29ee16f5c",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "95e25aec-7c9f-4a63-b143-225d0e9a79c3",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n",
|
||||
"os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "21ac643b-cb06-4724-a80c-2862ba4773f1",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Set up the tools\n",
|
||||
"\n",
|
||||
"We will first define the tools we want to use.\n",
|
||||
"For this simple example, we will use a built-in search tool via Tavily.\n",
|
||||
"However, it is really easy to create your own tools - see documentation [here](https://python.langchain.com/docs/modules/agents/tools/custom_tools) on how to do that.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "d7ef57dd-5d6e-4ad3-9377-a92201c1310e",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langchain_community.tools.tavily_search import TavilySearchResults\n",
|
||||
"\n",
|
||||
"tools = [TavilySearchResults(max_results=1)]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "01885785-b71a-44d1-b1d6-7b5b14d53b58",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"We can now wrap these tools in a simple ToolExecutor.\n",
|
||||
"This is a real simple class that takes in a ToolInvocation and calls that tool, returning the output.\n",
|
||||
"A ToolInvocation is any class with `tool` and `tool_input` attribute.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "5cf3331e-ccb3-41c8-aeb9-a840a94d41e7",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langgraph.prebuilt import ToolExecutor\n",
|
||||
"\n",
|
||||
"tool_executor = ToolExecutor(tools)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "5497ed70-fce3-47f1-9cad-46f912bad6a5",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Set up the model\n",
|
||||
"\n",
|
||||
"Now we need to load the chat model we want to use.\n",
|
||||
"Importantly, this should satisfy two criteria:\n",
|
||||
"\n",
|
||||
"1. It should work with messages. We will represent all agent state in the form of messages, so it needs to be able to work well with them.\n",
|
||||
"2. It should work with OpenAI function calling. This means it should either be an OpenAI model or a model that exposes a similar interface.\n",
|
||||
"\n",
|
||||
"Note: these model requirements are not requirements for using LangGraph - they are just requirements for this one example."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"id": "892b54b9-75f0-4804-9ed0-88b5e5532989",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langchain_openai import ChatOpenAI\n",
|
||||
"\n",
|
||||
"# We will set streaming=True so that we can stream tokens\n",
|
||||
"# See the streaming section for more information on this.\n",
|
||||
"model = ChatOpenAI(temperature=0, streaming=True)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a77995c0-bae2-4cee-a036-8688a90f05b9",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"\n",
|
||||
"After we've done this, we should make sure the model knows that it has these tools available to call.\n",
|
||||
"We can do this by converting the LangChain tools into the format for OpenAI function calling, and then bind them to the model class.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "cd3cbae5-d92c-4559-a4aa-44721b80d107",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langchain_core.utils.function_calling import convert_to_openai_function\n",
|
||||
"\n",
|
||||
"functions = [convert_to_openai_function(t) for t in tools]\n",
|
||||
"model = model.bind_functions(functions)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e03c5094-9297-4d19-a04e-3eedc75cefb4",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Define the nodes\n",
|
||||
"\n",
|
||||
"We now need to define a few different nodes in our graph.\n",
|
||||
"In `langgraph`, a node can be either a function or a [runnable](https://python.langchain.com/docs/expression_language/).\n",
|
||||
"There are two main nodes we need for this:\n",
|
||||
"\n",
|
||||
"1. The agent: responsible for deciding what (if any) actions to take.\n",
|
||||
"2. A function to invoke tools: if the agent decides to take an action, this node will then execute that action.\n",
|
||||
"\n",
|
||||
"We will also need to define some edges.\n",
|
||||
"Some of these edges may be conditional.\n",
|
||||
"The reason they are conditional is that based on the output of a node, one of several paths may be taken.\n",
|
||||
"The path that is taken is not known until that node is run (the LLM decides).\n",
|
||||
"\n",
|
||||
"1. Conditional Edge: after the agent is called, we should either:\n",
|
||||
" a. If the agent said to take an action, then the function to invoke tools should be called\n",
|
||||
" b. If the agent said that it was finished, then it should finish\n",
|
||||
"2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next\n",
|
||||
"\n",
|
||||
"Let's define the nodes, as well as a function to decide how what conditional edge to take."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "3b541bb9-900c-40d0-964d-7b5dfee30667",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langgraph.prebuilt import ToolInvocation\n",
|
||||
"import json\n",
|
||||
"from langchain_core.messages import FunctionMessage\n",
|
||||
"\n",
|
||||
"# Define the function that determines whether to continue or not\n",
|
||||
"def should_continue(messages):\n",
|
||||
" last_message = messages[-1]\n",
|
||||
" # If there is no function call, then we finish\n",
|
||||
" if \"function_call\" not in last_message.additional_kwargs:\n",
|
||||
" return \"end\"\n",
|
||||
" # Otherwise if there is, we continue\n",
|
||||
" else:\n",
|
||||
" return \"continue\"\n",
|
||||
"\n",
|
||||
"# Define the function that calls the model\n",
|
||||
"def call_model(messages):\n",
|
||||
" response = model.invoke(messages)\n",
|
||||
" # We return a list, because this will get added to the existing list\n",
|
||||
" return response\n",
|
||||
"\n",
|
||||
"# Define the function to execute tools\n",
|
||||
"def call_tool(messages):\n",
|
||||
" # Based on the continue condition\n",
|
||||
" # we know the last message involves a function call\n",
|
||||
" last_message = messages[-1]\n",
|
||||
" # We construct an ToolInvocation from the function_call\n",
|
||||
" action = ToolInvocation(\n",
|
||||
" tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n",
|
||||
" tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n",
|
||||
" )\n",
|
||||
" # We call the tool_executor and get back a response\n",
|
||||
" response = tool_executor.invoke(action)\n",
|
||||
" # We use the response to create a FunctionMessage\n",
|
||||
" function_message = FunctionMessage(content=str(response), name=action.tool)\n",
|
||||
" # We return a list, because this will get added to the existing list\n",
|
||||
" return function_message"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "ffd6e892-946c-4899-8cc0-7c9291c1f73b",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Define the graph\n",
|
||||
"\n",
|
||||
"We can now put it all together and define the graph!"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"id": "812b4e70-4956-4415-8880-db48b3dcbad2",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langgraph.graph import MessageGraph, END\n",
|
||||
"# Define a new graph\n",
|
||||
"workflow = MessageGraph()\n",
|
||||
"\n",
|
||||
"# Define the two nodes we will cycle between\n",
|
||||
"workflow.add_node(\"agent\", call_model)\n",
|
||||
"workflow.add_node(\"action\", call_tool)\n",
|
||||
"\n",
|
||||
"# Set the entrypoint as `agent`\n",
|
||||
"# This means that this node is the first one called\n",
|
||||
"workflow.set_entry_point(\"agent\")\n",
|
||||
"\n",
|
||||
"# We now add a conditional edge\n",
|
||||
"workflow.add_conditional_edges(\n",
|
||||
" # First, we define the start node. We use `agent`.\n",
|
||||
" # This means these are the edges taken after the `agent` node is called.\n",
|
||||
" \"agent\",\n",
|
||||
" # Next, we pass in the function that will determine which node is called next.\n",
|
||||
" should_continue,\n",
|
||||
" # Finally we pass in a mapping.\n",
|
||||
" # The keys are strings, and the values are other nodes.\n",
|
||||
" # END is a special node marking that the graph should finish.\n",
|
||||
" # What will happen is we will call `should_continue`, and then the output of that\n",
|
||||
" # will be matched against the keys in this mapping.\n",
|
||||
" # Based on which one it matches, that node will then be called.\n",
|
||||
" {\n",
|
||||
" # If `tools`, then we call the tool node.\n",
|
||||
" \"continue\": \"action\",\n",
|
||||
" # Otherwise we finish.\n",
|
||||
" \"end\": END\n",
|
||||
" }\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# We now add a normal edge from `tools` to `agent`.\n",
|
||||
"# This means that after `tools` is called, `agent` node is called next.\n",
|
||||
"workflow.add_edge('action', 'agent')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "bc9c8536-f90b-44fa-958d-5df016c66d8f",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**Persistence**\n",
|
||||
"\n",
|
||||
"To add in persistence, we pass in a checkpoint when compiling the graph"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 7,
|
||||
"id": "6845ed6a-d155-4105-9160-28849877248b",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langgraph.checkpoint.sqlite import SqliteSaver\n",
|
||||
"\n",
|
||||
"memory = SqliteSaver.from_conn_string(\":memory:\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 8,
|
||||
"id": "79d29875-8aa8-434c-9f20-1c58346a6249",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Finally, we compile it!\n",
|
||||
"# This compiles it into a LangChain Runnable,\n",
|
||||
"# meaning you can use it as you would any other runnable\n",
|
||||
"app = workflow.compile(checkpointer=memory)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "2a1b56c5-bd61-4192-8bdb-458a1e9f0159",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Interacting with the Agent\n",
|
||||
"\n",
|
||||
"We can now interact with the agent and see that it remembers previous messages!\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 11,
|
||||
"id": "cfd140f0-a5a6-4697-8115-322242f197b5",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"content='Hello Bob! How can I assist you today?'\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from langchain_core.messages import HumanMessage\n",
|
||||
"inputs = [HumanMessage(content=\"hi! I'm bob\")]\n",
|
||||
"for event in app.stream(inputs, {\"configurable\": {\"thread_id\": \"2\"}}):\n",
|
||||
" for k, v in event.items():\n",
|
||||
" if k != \"__end__\":\n",
|
||||
" print(v)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 12,
|
||||
"id": "08ae8246-11d5-40e1-8567-361e5bef8917",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"content='Your name is Bob.'\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"inputs = [HumanMessage(content=\"what is my name?\")]\n",
|
||||
"for event in app.stream(inputs, {\"configurable\": {\"thread_id\": \"2\"}}):\n",
|
||||
" for k, v in event.items():\n",
|
||||
" if k != \"__end__\":\n",
|
||||
" print(v)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "3f47bbfc-d9ef-4288-ba4a-ebbc0136fa9d",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"If we want to start a new conversation, we can pass in a different thread id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 13,
|
||||
"id": "273d56a8-f40f-4a51-a27f-7c6bb2bda0ba",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"content=\"I'm sorry, but I don't have access to personal information.\"\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"inputs = [HumanMessage(content=\"what is my name?\")]\n",
|
||||
"for event in app.stream(inputs, {\"configurable\": {\"thread_id\": \"3\"}}):\n",
|
||||
" for k, v in event.items():\n",
|
||||
" if k != \"__end__\":\n",
|
||||
" print(v)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "8578a66d-6489-4e03-8c23-fd0530278455",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.11.1"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+43
-112
@@ -198,40 +198,6 @@
|
||||
"model = model.bind_functions(functions)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "8e8b9211-93d0-4ad5-aa7a-9c09099c53ff",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Define the agent state\n",
|
||||
"\n",
|
||||
"The main type of graph in `langgraph` is the `StatefulGraph`.\n",
|
||||
"This graph is parameterized by a state object that it passes around to each node.\n",
|
||||
"Each node then returns operations to update that state.\n",
|
||||
"These operations can either SET specific attributes on the state (e.g. overwrite the existing values) or ADD to the existing attribute.\n",
|
||||
"Whether to set or add is denoted by annotating the state object you construct the graph with.\n",
|
||||
"\n",
|
||||
"For this example, the state we will track will just be a list of messages.\n",
|
||||
"We want each node to just add messages to that list.\n",
|
||||
"Therefore, we will use a `TypedDict` with one key (`messages`) and annotate it so that the `messages` attribute is always added to.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "ea793afa-2eab-4901-910d-6eed90cd6564",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from typing import TypedDict, Annotated, Sequence\n",
|
||||
"import operator\n",
|
||||
"from langchain_core.messages import BaseMessage\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"class AgentState(TypedDict):\n",
|
||||
" messages: Annotated[Sequence[BaseMessage], operator.add]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e03c5094-9297-4d19-a04e-3eedc75cefb4",
|
||||
@@ -265,7 +231,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"execution_count": 5,
|
||||
"id": "3b541bb9-900c-40d0-964d-7b5dfee30667",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
@@ -275,8 +241,7 @@
|
||||
"from langchain_core.messages import FunctionMessage\n",
|
||||
"\n",
|
||||
"# Define the function that determines whether to continue or not\n",
|
||||
"def should_continue(state):\n",
|
||||
" messages = state['messages']\n",
|
||||
"def should_continue(messages):\n",
|
||||
" last_message = messages[-1]\n",
|
||||
" # If there is no function call, then we finish\n",
|
||||
" if \"function_call\" not in last_message.additional_kwargs:\n",
|
||||
@@ -286,15 +251,13 @@
|
||||
" return \"continue\"\n",
|
||||
"\n",
|
||||
"# Define the function that calls the model\n",
|
||||
"async def call_model(state):\n",
|
||||
" messages = state['messages']\n",
|
||||
"async def call_model(messages):\n",
|
||||
" response = await model.ainvoke(messages)\n",
|
||||
" # We return a list, because this will get added to the existing list\n",
|
||||
" return {\"messages\": [response]}\n",
|
||||
" return response\n",
|
||||
"\n",
|
||||
"# Define the function to execute tools\n",
|
||||
"async def call_tool(state):\n",
|
||||
" messages = state['messages']\n",
|
||||
"async def call_tool(messages):\n",
|
||||
" # Based on the continue condition\n",
|
||||
" # we know the last message involves a function call\n",
|
||||
" last_message = messages[-1]\n",
|
||||
@@ -308,7 +271,7 @@
|
||||
" # We use the response to create a FunctionMessage\n",
|
||||
" function_message = FunctionMessage(content=str(response), name=action.tool)\n",
|
||||
" # We return a list, because this will get added to the existing list\n",
|
||||
" return {\"messages\": [function_message]}"
|
||||
" return function_message"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -323,14 +286,14 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 7,
|
||||
"execution_count": 6,
|
||||
"id": "813ae66c-3b58-4283-a02a-36da72a2ab90",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langgraph.graph import StateGraph, END\n",
|
||||
"from langgraph.graph import MessageGraph, END\n",
|
||||
"# Define a new graph\n",
|
||||
"workflow = StateGraph(AgentState)\n",
|
||||
"workflow = MessageGraph()\n",
|
||||
"\n",
|
||||
"# Define the two nodes we will cycle between\n",
|
||||
"workflow.add_node(\"agent\", call_model)\n",
|
||||
@@ -385,84 +348,52 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 10,
|
||||
"execution_count": 7,
|
||||
"id": "cfd140f0-a5a6-4697-8115-322242f197b5",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stderr",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"/Users/harrisonchase/workplace/langchain/libs/core/langchain_core/_api/beta_decorator.py:86: LangChainBetaWarning: This API is in beta and may change in the future.\n",
|
||||
" warn_beta(\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"content='' additional_kwargs={'function_call': {'arguments': '', 'name': 'tavily_search_results_json'}}\n",
|
||||
"content='' additional_kwargs={'function_call': {'arguments': '{\\n', 'name': ''}}\n",
|
||||
"content='' additional_kwargs={'function_call': {'arguments': ' ', 'name': ''}}\n",
|
||||
"content='' additional_kwargs={'function_call': {'arguments': ' \"', 'name': ''}}\n",
|
||||
"content='' additional_kwargs={'function_call': {'arguments': 'query', 'name': ''}}\n",
|
||||
"content='' additional_kwargs={'function_call': {'arguments': '\":', 'name': ''}}\n",
|
||||
"content='' additional_kwargs={'function_call': {'arguments': ' \"', 'name': ''}}\n",
|
||||
"content='' additional_kwargs={'function_call': {'arguments': 'weather', 'name': ''}}\n",
|
||||
"content='' additional_kwargs={'function_call': {'arguments': ' in', 'name': ''}}\n",
|
||||
"content='' additional_kwargs={'function_call': {'arguments': ' San', 'name': ''}}\n",
|
||||
"content='' additional_kwargs={'function_call': {'arguments': ' Francisco', 'name': ''}}\n",
|
||||
"content='' additional_kwargs={'function_call': {'arguments': '\"\\n', 'name': ''}}\n",
|
||||
"content='' additional_kwargs={'function_call': {'arguments': '}', 'name': ''}}\n",
|
||||
"content=''\n",
|
||||
"content=''\n",
|
||||
"content='I'\n",
|
||||
"content=\"'m\"\n",
|
||||
"content=' sorry'\n",
|
||||
"content=','\n",
|
||||
"content=' but'\n",
|
||||
"content=' I'\n",
|
||||
"content=' couldn'\n",
|
||||
"content=\"'t\"\n",
|
||||
"content=' find'\n",
|
||||
"content=' the'\n",
|
||||
"content=' current'\n",
|
||||
"content=' weather'\n",
|
||||
"content=' in'\n",
|
||||
"content=' San'\n",
|
||||
"content=' Francisco'\n",
|
||||
"content='.'\n",
|
||||
"content=' However'\n",
|
||||
"content=','\n",
|
||||
"content=' you'\n",
|
||||
"content=' can'\n",
|
||||
"content=' check'\n",
|
||||
"content=' the'\n",
|
||||
"content=' weather'\n",
|
||||
"content=' forecast'\n",
|
||||
"content=' for'\n",
|
||||
"content=' San'\n",
|
||||
"content=' Francisco'\n",
|
||||
"content=' on'\n",
|
||||
"content=' websites'\n",
|
||||
"content=' like'\n",
|
||||
"content=' Weather'\n",
|
||||
"content='.com'\n",
|
||||
"content=' or'\n",
|
||||
"content=' Acc'\n",
|
||||
"content='u'\n",
|
||||
"content='Weather'\n",
|
||||
"content='.'\n",
|
||||
"content=''\n"
|
||||
"--\n",
|
||||
"Starting tool: tavily_search_results_json with inputs: {'query': 'weather in San Francisco'}\n",
|
||||
"Done tool: tavily_search_results_json\n",
|
||||
"Tool output was: [{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in januaryWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 23-01-2023 47°F to 61°F. 24-01-2023 43°F to 58°F. 25-01-2023 47°F to ...'}]\n",
|
||||
"--\n",
|
||||
"I|'m| sorry|,| but| I| couldn|'t| find| the| current| weather| in| San| Francisco|.| However|,| you| can| check| the| weather| in| San| Francisco| for| the| month| of| January| on| this| website|:| [|San| Francisco| Weather| in| January|](|https|://|www|.where|and|when|.net|/|when|/n|orth|-|amer|ica|/cal|ifornia|/s|an|-fr|anc|isco|-ca|/j|an|uary|/|).|"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from langchain_core.messages import HumanMessage\n",
|
||||
"inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf\")]}\n",
|
||||
"async for output in app.astream_log(inputs, include_types=[\"llm\"]):\n",
|
||||
" # astream_log() yields the requested logs (here LLMs) in JSONPatch format\n",
|
||||
" for op in output.ops:\n",
|
||||
" if op[\"path\"] == \"/streamed_output/-\":\n",
|
||||
" # this is the output from .stream()\n",
|
||||
" ...\n",
|
||||
" elif op[\"path\"].startswith(\"/logs/\") and op[\"path\"].endswith(\n",
|
||||
" \"/streamed_output/-\"\n",
|
||||
" ):\n",
|
||||
" # because we chose to only include LLMs, these are LLM tokens\n",
|
||||
" print(op[\"value\"])"
|
||||
"inputs = [HumanMessage(content=\"what is the weather in sf\")]\n",
|
||||
"async for event in app.astream_events(inputs, version=\"v1\"):\n",
|
||||
" kind = event[\"event\"]\n",
|
||||
" if kind == \"on_chat_model_stream\":\n",
|
||||
" content = event[\"data\"][\"chunk\"].content\n",
|
||||
" if content:\n",
|
||||
" # Empty content in the context of OpenAI means\n",
|
||||
" # that the model is asking for a tool to be invoked.\n",
|
||||
" # So we only print non-empty content\n",
|
||||
" print(content, end=\"|\")\n",
|
||||
" elif kind == \"on_tool_start\":\n",
|
||||
" print(\"--\")\n",
|
||||
" print(\n",
|
||||
" f\"Starting tool: {event['name']} with inputs: {event['data'].get('input')}\"\n",
|
||||
" )\n",
|
||||
" elif kind == \"on_tool_end\":\n",
|
||||
" print(f\"Done tool: {event['name']}\")\n",
|
||||
" print(f\"Tool output was: {event['data'].get('output')}\")\n",
|
||||
" print(\"--\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
@@ -0,0 +1,157 @@
|
||||
const customCSS = `
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #27272a;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
`;
|
||||
|
||||
const styleTag = document.createElement("style");
|
||||
styleTag.textContent = customCSS;
|
||||
document.head.append(styleTag);
|
||||
|
||||
let labels = [];
|
||||
|
||||
function unmarkPage() {
|
||||
// Unmark page logic
|
||||
for (const label of labels) {
|
||||
document.body.removeChild(label);
|
||||
}
|
||||
labels = [];
|
||||
}
|
||||
|
||||
function markPage() {
|
||||
unmarkPage();
|
||||
|
||||
var bodyRect = document.body.getBoundingClientRect();
|
||||
|
||||
var items = Array.prototype.slice
|
||||
.call(document.querySelectorAll("*"))
|
||||
.map(function (element) {
|
||||
var vw = Math.max(
|
||||
document.documentElement.clientWidth || 0,
|
||||
window.innerWidth || 0
|
||||
);
|
||||
var vh = Math.max(
|
||||
document.documentElement.clientHeight || 0,
|
||||
window.innerHeight || 0
|
||||
);
|
||||
var textualContent = element.textContent.trim().replace(/\s{2,}/g, " ");
|
||||
var elementType = element.tagName.toLowerCase();
|
||||
var ariaLabel = element.getAttribute("aria-label") || "";
|
||||
|
||||
var rects = [...element.getClientRects()]
|
||||
.filter((bb) => {
|
||||
var center_x = bb.left + bb.width / 2;
|
||||
var center_y = bb.top + bb.height / 2;
|
||||
var elAtCenter = document.elementFromPoint(center_x, center_y);
|
||||
|
||||
return elAtCenter === element || element.contains(elAtCenter);
|
||||
})
|
||||
.map((bb) => {
|
||||
const rect = {
|
||||
left: Math.max(0, bb.left),
|
||||
top: Math.max(0, bb.top),
|
||||
right: Math.min(vw, bb.right),
|
||||
bottom: Math.min(vh, bb.bottom),
|
||||
};
|
||||
return {
|
||||
...rect,
|
||||
width: rect.right - rect.left,
|
||||
height: rect.bottom - rect.top,
|
||||
};
|
||||
});
|
||||
|
||||
var area = rects.reduce((acc, rect) => acc + rect.width * rect.height, 0);
|
||||
|
||||
return {
|
||||
element: element,
|
||||
include:
|
||||
element.tagName === "INPUT" ||
|
||||
element.tagName === "TEXTAREA" ||
|
||||
element.tagName === "SELECT" ||
|
||||
element.tagName === "BUTTON" ||
|
||||
element.tagName === "A" ||
|
||||
element.onclick != null ||
|
||||
window.getComputedStyle(element).cursor == "pointer" ||
|
||||
element.tagName === "IFRAME" ||
|
||||
element.tagName === "VIDEO",
|
||||
area,
|
||||
rects,
|
||||
text: textualContent,
|
||||
type: elementType,
|
||||
ariaLabel: ariaLabel,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.include && item.area >= 20);
|
||||
|
||||
// Only keep inner clickable items
|
||||
items = items.filter(
|
||||
(x) => !items.some((y) => x.element.contains(y.element) && !(x == y))
|
||||
);
|
||||
|
||||
// Function to generate random colors
|
||||
function getRandomColor() {
|
||||
var letters = "0123456789ABCDEF";
|
||||
var color = "#";
|
||||
for (var i = 0; i < 6; i++) {
|
||||
color += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
// Lets create a floating border on top of these elements that will always be visible
|
||||
items.forEach(function (item, index) {
|
||||
item.rects.forEach((bbox) => {
|
||||
newElement = document.createElement("div");
|
||||
var borderColor = getRandomColor();
|
||||
newElement.style.outline = `2px dashed ${borderColor}`;
|
||||
newElement.style.position = "fixed";
|
||||
newElement.style.left = bbox.left + "px";
|
||||
newElement.style.top = bbox.top + "px";
|
||||
newElement.style.width = bbox.width + "px";
|
||||
newElement.style.height = bbox.height + "px";
|
||||
newElement.style.pointerEvents = "none";
|
||||
newElement.style.boxSizing = "border-box";
|
||||
newElement.style.zIndex = 2147483647;
|
||||
// newElement.style.background = `${borderColor}80`;
|
||||
|
||||
// Add floating label at the corner
|
||||
var label = document.createElement("span");
|
||||
label.textContent = index;
|
||||
label.style.position = "absolute";
|
||||
// These we can tweak if we want
|
||||
label.style.top = "-19px";
|
||||
label.style.left = "0px";
|
||||
label.style.background = borderColor;
|
||||
// label.style.background = "black";
|
||||
label.style.color = "white";
|
||||
label.style.padding = "2px 4px";
|
||||
label.style.fontSize = "12px";
|
||||
label.style.borderRadius = "2px";
|
||||
newElement.appendChild(label);
|
||||
|
||||
document.body.appendChild(newElement);
|
||||
labels.push(newElement);
|
||||
// item.element.setAttribute("-ai-label", label.textContent);
|
||||
});
|
||||
});
|
||||
const coordinates = items.flatMap((item) =>
|
||||
item.rects.map(({ left, top, width, height }) => ({
|
||||
x: (left + left + width) / 2,
|
||||
y: (top + top + height) / 2,
|
||||
type: item.type,
|
||||
text: item.text,
|
||||
ariaLabel: item.ariaLabel,
|
||||
}))
|
||||
);
|
||||
return coordinates;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -44,7 +44,7 @@ class LastValue(Generic[Value], BaseChannel[Value, Value, Value]):
|
||||
if len(values) == 0:
|
||||
return
|
||||
if len(values) != 1:
|
||||
raise InvalidUpdateError()
|
||||
raise InvalidUpdateError("LastValue can only receive one value per step.")
|
||||
|
||||
self.value = values[-1]
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointAt
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver, Checkpoint, CheckpointAt
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
__all__ = [
|
||||
"Checkpoint",
|
||||
"CheckpointAt",
|
||||
"BaseCheckpointSaver",
|
||||
"MemorySaver",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import pickle
|
||||
from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
from langchain_core.pydantic_v1 import Field
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_core.runnables.utils import ConfigurableFieldSpec
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver, Checkpoint
|
||||
|
||||
|
||||
class AsyncSqliteSaver(BaseCheckpointSaver):
|
||||
conn: aiosqlite.Connection
|
||||
|
||||
is_setup: bool = Field(False, init=False, repr=False)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@classmethod
|
||||
def from_conn_string(cls, conn_string: str) -> "AsyncSqliteSaver":
|
||||
return AsyncSqliteSaver(conn=aiosqlite.connect(conn_string))
|
||||
|
||||
@property
|
||||
def config_specs(self) -> list[ConfigurableFieldSpec]:
|
||||
return [
|
||||
ConfigurableFieldSpec(
|
||||
id="thread_id",
|
||||
annotation=str,
|
||||
name="Thread ID",
|
||||
description=None,
|
||||
default="",
|
||||
is_shared=True,
|
||||
),
|
||||
]
|
||||
|
||||
async def setup(self) -> None:
|
||||
print("hello")
|
||||
if self.is_setup:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.conn
|
||||
await self.conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
thread_id TEXT PRIMARY KEY,
|
||||
checkpoint BLOB
|
||||
);
|
||||
"""
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
print("good bye")
|
||||
|
||||
self.is_setup = True
|
||||
except BaseException as e:
|
||||
print(e)
|
||||
raise e
|
||||
|
||||
def get(self, config: RunnableConfig) -> Optional[Checkpoint]:
|
||||
raise NotImplementedError
|
||||
|
||||
def put(self, config: RunnableConfig, checkpoint: Checkpoint) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def aget(self, config: RunnableConfig) -> Optional[Checkpoint]:
|
||||
await self.setup()
|
||||
async with self.conn.execute(
|
||||
"SELECT checkpoint FROM checkpoints WHERE thread_id = ?",
|
||||
(config["configurable"]["thread_id"],),
|
||||
) as cursor:
|
||||
if value := await cursor.fetchone():
|
||||
return pickle.loads(value[0])
|
||||
|
||||
async def aput(self, config: RunnableConfig, checkpoint: Checkpoint) -> None:
|
||||
await self.setup()
|
||||
await self.conn.execute(
|
||||
"INSERT OR REPLACE INTO checkpoints (thread_id, checkpoint) VALUES (?, ?)",
|
||||
(
|
||||
config["configurable"]["thread_id"],
|
||||
pickle.dumps(checkpoint),
|
||||
),
|
||||
)
|
||||
await self.conn.commit()
|
||||
@@ -19,13 +19,17 @@ class Checkpoint(TypedDict):
|
||||
versions_seen: defaultdict[str, defaultdict[str, int]]
|
||||
|
||||
|
||||
def _seen_dict():
|
||||
return defaultdict(int)
|
||||
|
||||
|
||||
def empty_checkpoint() -> Checkpoint:
|
||||
return Checkpoint(
|
||||
v=1,
|
||||
ts=datetime.now(timezone.utc).isoformat(),
|
||||
channel_values={},
|
||||
channel_versions=defaultdict(int),
|
||||
versions_seen=defaultdict(lambda: defaultdict(int)),
|
||||
versions_seen=defaultdict(_seen_dict),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import pickle
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
|
||||
from langchain_core.pydantic_v1 import Field
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_core.runnables.utils import ConfigurableFieldSpec
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver, Checkpoint
|
||||
|
||||
|
||||
class SqliteSaver(BaseCheckpointSaver):
|
||||
conn: sqlite3.Connection
|
||||
|
||||
is_setup: bool = Field(False, init=False, repr=False)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@classmethod
|
||||
def from_conn_string(cls, conn_string: str) -> "SqliteSaver":
|
||||
return SqliteSaver(conn=sqlite3.connect(conn_string))
|
||||
|
||||
@property
|
||||
def config_specs(self) -> list[ConfigurableFieldSpec]:
|
||||
return [
|
||||
ConfigurableFieldSpec(
|
||||
id="thread_id",
|
||||
annotation=str,
|
||||
name="Thread ID",
|
||||
description=None,
|
||||
default="",
|
||||
is_shared=True,
|
||||
),
|
||||
]
|
||||
|
||||
def setup(self) -> None:
|
||||
if self.is_setup:
|
||||
return
|
||||
|
||||
self.conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
thread_id TEXT PRIMARY KEY,
|
||||
checkpoint BLOB
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
self.is_setup = True
|
||||
|
||||
@contextmanager
|
||||
def cursor(self, transaction: bool = True):
|
||||
self.setup()
|
||||
cur = self.conn.cursor()
|
||||
try:
|
||||
yield cur
|
||||
finally:
|
||||
if transaction:
|
||||
self.conn.commit()
|
||||
cur.close()
|
||||
|
||||
def get(self, config: RunnableConfig) -> Optional[Checkpoint]:
|
||||
with self.cursor(transaction=False) as cur:
|
||||
cur.execute(
|
||||
"SELECT checkpoint FROM checkpoints WHERE thread_id = ?",
|
||||
(config["configurable"]["thread_id"],),
|
||||
)
|
||||
if value := cur.fetchone():
|
||||
return pickle.loads(value[0])
|
||||
|
||||
def put(self, config: RunnableConfig, checkpoint: Checkpoint) -> None:
|
||||
with self.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT OR REPLACE INTO checkpoints (thread_id, checkpoint) VALUES (?, ?)",
|
||||
(
|
||||
config["configurable"]["thread_id"],
|
||||
pickle.dumps(checkpoint),
|
||||
),
|
||||
)
|
||||
|
||||
async def aget(self, config: RunnableConfig) -> Optional[Checkpoint]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def aput(self, config: RunnableConfig, checkpoint: Checkpoint) -> None:
|
||||
raise NotImplementedError
|
||||
@@ -1,4 +1,5 @@
|
||||
from langgraph.graph.graph import END, Graph
|
||||
from langgraph.graph.message import MessageGraph
|
||||
from langgraph.graph.state import StateGraph
|
||||
|
||||
__all__ = ["END", "Graph", "StateGraph"]
|
||||
__all__ = ["END", "Graph", "StateGraph", "MessageGraph"]
|
||||
|
||||
+35
-19
@@ -17,11 +17,14 @@ END = "__end__"
|
||||
|
||||
class Branch(NamedTuple):
|
||||
condition: Callable[..., str]
|
||||
ends: dict[str, str]
|
||||
ends: Optional[dict[str, str]]
|
||||
|
||||
def runnable(self, input: Any) -> Runnable:
|
||||
result = self.condition(input)
|
||||
destination = self.ends[result]
|
||||
if self.ends:
|
||||
destination = self.ends[result]
|
||||
else:
|
||||
destination = result
|
||||
return Channel.write_to(f"{destination}:inbox" if destination != END else END)
|
||||
|
||||
|
||||
@@ -59,15 +62,21 @@ class Graph:
|
||||
self,
|
||||
start_key: str,
|
||||
condition: Callable[..., str],
|
||||
conditional_edge_mapping: Dict[str, str],
|
||||
conditional_edge_mapping: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
if start_key not in self.nodes:
|
||||
raise ValueError(f"Need to add_node `{start_key}` first")
|
||||
if iscoroutinefunction(condition):
|
||||
raise ValueError("Condition cannot be a coroutine function")
|
||||
for destination in conditional_edge_mapping.values():
|
||||
if destination not in self.nodes and destination != END:
|
||||
raise ValueError(f"Need to add_node `{destination}` first")
|
||||
if conditional_edge_mapping and set(
|
||||
conditional_edge_mapping.values()
|
||||
).difference([END]).difference(self.nodes):
|
||||
raise ValueError(
|
||||
f"Missing nodes which are in conditional edge mapping. Mapping "
|
||||
f"contains possible destinations: "
|
||||
f"{list(conditional_edge_mapping.values())}. Possible nodes are "
|
||||
f"{list(self.nodes.keys())}."
|
||||
)
|
||||
|
||||
self.branches[start_key].append(Branch(condition, conditional_edge_mapping))
|
||||
|
||||
@@ -81,23 +90,30 @@ class Graph:
|
||||
|
||||
def validate(self) -> None:
|
||||
all_starts = {src for src, _ in self.edges} | {src for src in self.branches}
|
||||
all_ends = (
|
||||
{end for _, end in self.edges}
|
||||
| {
|
||||
end
|
||||
for branch_list in self.branches.values()
|
||||
for branch in branch_list
|
||||
for end in branch.ends.values()
|
||||
}
|
||||
| {self.entry_point}
|
||||
)
|
||||
|
||||
for node in self.nodes:
|
||||
if node not in all_ends:
|
||||
raise ValueError(f"Node `{node}` is not reachable")
|
||||
if node not in all_starts:
|
||||
raise ValueError(f"Node `{node}` is a dead-end")
|
||||
|
||||
if all(
|
||||
branch.ends is not None
|
||||
for branch_list in self.branches.values()
|
||||
for branch in branch_list
|
||||
):
|
||||
all_ends = (
|
||||
{end for _, end in self.edges}
|
||||
| {
|
||||
end
|
||||
for branch_list in self.branches.values()
|
||||
for branch in branch_list
|
||||
for end in branch.ends.values()
|
||||
}
|
||||
| {self.entry_point}
|
||||
)
|
||||
|
||||
for node in self.nodes:
|
||||
if node not in all_ends:
|
||||
raise ValueError(f"Node `{node}` is not reachable")
|
||||
|
||||
def compile(self, checkpointer: Optional[BaseCheckpointSaver] = None) -> Pregel:
|
||||
self.validate()
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from typing import Annotated, Union
|
||||
|
||||
from langchain_core.messages import AnyMessage
|
||||
|
||||
from langgraph.graph.state import StateGraph
|
||||
|
||||
Messages = Union[list[AnyMessage], AnyMessage]
|
||||
|
||||
|
||||
def add_messages(left: Messages, right: Messages) -> Messages:
|
||||
if not isinstance(left, list):
|
||||
left = [left]
|
||||
if not isinstance(right, list):
|
||||
right = [right]
|
||||
return left + right
|
||||
|
||||
|
||||
class MessageGraph(StateGraph):
|
||||
"""A StateGraph where every node
|
||||
- receives a list of messages as input
|
||||
- returns one or more messages as output."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(Annotated[list[AnyMessage], add_messages])
|
||||
+53
-24
@@ -3,16 +3,17 @@ from functools import partial
|
||||
from inspect import signature
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
from langchain_core.runnables import RunnableConfig, RunnableLambda
|
||||
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
|
||||
from langchain_core.runnables.base import RunnableLike
|
||||
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.base import BaseChannel, InvalidUpdateError
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.channels.last_value import LastValue
|
||||
from langgraph.checkpoint import BaseCheckpointSaver
|
||||
from langgraph.graph.graph import END, Graph
|
||||
from langgraph.pregel import Channel, Pregel
|
||||
from langgraph.pregel.read import ChannelRead
|
||||
from langgraph.pregel.write import ChannelWrite
|
||||
from langgraph.pregel.write import SKIP_WRITE, ChannelWrite
|
||||
|
||||
START = "__start__"
|
||||
|
||||
@@ -25,13 +26,32 @@ class StateGraph(Graph):
|
||||
if any(isinstance(c, BinaryOperatorAggregate) for c in self.channels.values()):
|
||||
self.support_multiple_edges = True
|
||||
|
||||
def add_node(self, key: str, action: RunnableLike) -> None:
|
||||
if key in self.channels:
|
||||
raise ValueError(
|
||||
f"'{key}' is already being used as a state attribute "
|
||||
"(a.k.a. a channel), cannot also be used as a node name."
|
||||
)
|
||||
return super().add_node(key, action)
|
||||
|
||||
def compile(self, checkpointer: Optional[BaseCheckpointSaver] = None) -> Pregel:
|
||||
self.validate()
|
||||
|
||||
if any(key in self.nodes for key in self.channels):
|
||||
raise ValueError("Cannot use channel names as node names")
|
||||
|
||||
state_keys = list(self.channels)
|
||||
state_keys_read = state_keys[0] if state_keys == ["__root__"] else state_keys
|
||||
update_channels = (
|
||||
[("__root__", None, True)]
|
||||
if not isinstance(state_keys_read, list)
|
||||
else [
|
||||
(key, RunnableLambda(partial(_dict_getter, state_keys, key)), False)
|
||||
for key in state_keys_read
|
||||
]
|
||||
)
|
||||
coerce_state = (
|
||||
partial(_coerce_state, self.schema)
|
||||
if isinstance(state_keys_read, list)
|
||||
else RunnablePassthrough()
|
||||
)
|
||||
|
||||
outgoing_edges = defaultdict(list)
|
||||
for start, end in self.edges:
|
||||
@@ -40,10 +60,9 @@ class StateGraph(Graph):
|
||||
nodes = {
|
||||
key: (
|
||||
Channel.subscribe_to(f"{key}:inbox")
|
||||
| partial(_coerce_state, self.schema) # coerce/validate using schema
|
||||
| coerce_state # coerce/validate using schema
|
||||
| node
|
||||
| _update_state
|
||||
| Channel.write_to(key)
|
||||
| ChannelWrite(channels=[(key, None, False)] + update_channels)
|
||||
)
|
||||
for key, node in self.nodes.items()
|
||||
}
|
||||
@@ -54,7 +73,7 @@ class StateGraph(Graph):
|
||||
if outgoing or key in self.branches:
|
||||
nodes[edges_key] = Channel.subscribe_to(
|
||||
key, tags=["langsmith:hidden"]
|
||||
) | ChannelRead(state_keys)
|
||||
) | ChannelRead(state_keys_read)
|
||||
if outgoing:
|
||||
nodes[edges_key] |= Channel.write_to(*[dest for dest in outgoing])
|
||||
if key in self.branches:
|
||||
@@ -63,14 +82,12 @@ class StateGraph(Graph):
|
||||
branch.runnable, name=f"{key}_condition"
|
||||
)
|
||||
|
||||
nodes[START] = (
|
||||
Channel.subscribe_to(f"{START}:inbox", tags=["langsmith:hidden"])
|
||||
| _update_state
|
||||
| Channel.write_to(START)
|
||||
)
|
||||
nodes[START] = Channel.subscribe_to(
|
||||
f"{START}:inbox", tags=["langsmith:hidden"]
|
||||
) | ChannelWrite(channels=[(START, None, False)] + update_channels)
|
||||
nodes[f"{START}:edges"] = (
|
||||
Channel.subscribe_to(START, tags=["langsmith:hidden"])
|
||||
| ChannelRead(state_keys)
|
||||
| ChannelRead(state_keys_read)
|
||||
| Channel.write_to(f"{self.entry_point}:inbox")
|
||||
)
|
||||
|
||||
@@ -88,26 +105,37 @@ def _coerce_state(schema: Type[Any], input: dict[str, Any]) -> dict[str, Any]:
|
||||
return schema(**input)
|
||||
|
||||
|
||||
def _update_state(input: dict[str, Any], config: RunnableConfig) -> dict[str, Any]:
|
||||
def _dict_getter(allowed_keys: str, key: str, input: dict) -> Any:
|
||||
if input is not None:
|
||||
ChannelWrite.do_write(config, **input)
|
||||
return input
|
||||
if not isinstance(input, dict) or any(key not in allowed_keys for key in input):
|
||||
raise InvalidUpdateError(
|
||||
f"Invalid state update,"
|
||||
f" expected dict with one or more of {allowed_keys}, got {input}"
|
||||
)
|
||||
return input.get(key, SKIP_WRITE)
|
||||
else:
|
||||
return SKIP_WRITE
|
||||
|
||||
|
||||
def _get_channels(schema: Type[dict]) -> dict[str, BaseChannel]:
|
||||
if not hasattr(schema, "__annotations__"):
|
||||
raise ValueError("Schema must be a class with type annotations")
|
||||
return {
|
||||
"__root__": _get_channel(schema),
|
||||
}
|
||||
|
||||
channels: dict[str, BaseChannel] = {}
|
||||
for name, typ in schema.__annotations__.items():
|
||||
if channel := _is_field_binop(typ):
|
||||
channels[name] = channel
|
||||
else:
|
||||
channels[name] = LastValue(typ)
|
||||
channels[name] = _get_channel(typ)
|
||||
|
||||
return channels
|
||||
|
||||
|
||||
def _get_channel(annotation: Any) -> Optional[BaseChannel]:
|
||||
if channel := _is_field_binop(annotation):
|
||||
return channel
|
||||
return LastValue(annotation)
|
||||
|
||||
|
||||
def _is_field_binop(typ: Type[Any]) -> Optional[BinaryOperatorAggregate]:
|
||||
if hasattr(typ, "__metadata__"):
|
||||
meta = typ.__metadata__
|
||||
@@ -122,3 +150,4 @@ def _is_field_binop(typ: Type[Any]) -> Optional[BinaryOperatorAggregate]:
|
||||
]
|
||||
):
|
||||
return BinaryOperatorAggregate(typ, meta[0])
|
||||
return None
|
||||
|
||||
@@ -2,10 +2,10 @@ import json
|
||||
import operator
|
||||
from typing import Annotated, Sequence, TypedDict
|
||||
|
||||
from langchain.tools.render import format_tool_to_openai_function
|
||||
from langchain_core.agents import AgentAction
|
||||
from langchain_core.messages import BaseMessage, FunctionMessage
|
||||
from langchain_core.runnables import RunnableLambda
|
||||
from langchain_core.utils.function_calling import convert_to_openai_function
|
||||
|
||||
from langgraph.graph import END, StateGraph
|
||||
from langgraph.prebuilt.tool_executor import ToolExecutor
|
||||
@@ -18,9 +18,7 @@ def create_function_calling_executor(model, tools):
|
||||
else:
|
||||
tool_executor = ToolExecutor(tools)
|
||||
tool_classes = tools
|
||||
model = model.bind_functions(
|
||||
[format_tool_to_openai_function(t) for t in tool_classes]
|
||||
)
|
||||
model = model.bind(functions=[convert_to_openai_function(t) for t in tool_classes])
|
||||
|
||||
# Define the function that determines whether to continue or not
|
||||
def should_continue(state):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Any, Sequence, Union
|
||||
|
||||
from langchain_core.load.serializable import Serializable
|
||||
from langchain_core.runnables import RunnableBinding, RunnableLambda
|
||||
from langchain_core.runnables import RunnableBinding, RunnableConfig, RunnableLambda
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
INVALID_TOOL_MSG_TEMPLATE = (
|
||||
@@ -47,7 +47,9 @@ class ToolExecutor(RunnableBinding):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _execute(self, tool_invocation: ToolInvocationInterface) -> Any:
|
||||
def _execute(
|
||||
self, tool_invocation: ToolInvocationInterface, *, config: RunnableConfig
|
||||
) -> Any:
|
||||
if tool_invocation.tool not in self.tool_map:
|
||||
return self.invalid_tool_msg_template.format(
|
||||
requested_tool_name=tool_invocation.tool,
|
||||
@@ -55,10 +57,12 @@ class ToolExecutor(RunnableBinding):
|
||||
)
|
||||
else:
|
||||
tool = self.tool_map[tool_invocation.tool]
|
||||
output = tool.invoke(tool_invocation.tool_input)
|
||||
output = tool.invoke(tool_invocation.tool_input, config=config)
|
||||
return output
|
||||
|
||||
async def _aexecute(self, tool_invocation: ToolInvocationInterface) -> Any:
|
||||
async def _aexecute(
|
||||
self, tool_invocation: ToolInvocationInterface, *, config: RunnableConfig
|
||||
) -> Any:
|
||||
if tool_invocation.tool not in self.tool_map:
|
||||
return self.invalid_tool_msg_template.format(
|
||||
requested_tool_name=tool_invocation.tool,
|
||||
@@ -66,5 +70,5 @@ class ToolExecutor(RunnableBinding):
|
||||
)
|
||||
else:
|
||||
tool = self.tool_map[tool_invocation.tool]
|
||||
output = await tool.ainvoke(tool_invocation.tool_input)
|
||||
output = await tool.ainvoke(tool_invocation.tool_input, config=config)
|
||||
return output
|
||||
|
||||
+296
-233
@@ -46,6 +46,7 @@ from langgraph.channels.base import (
|
||||
BaseChannel,
|
||||
ChannelsManager,
|
||||
EmptyChannelError,
|
||||
InvalidUpdateError,
|
||||
create_checkpoint,
|
||||
)
|
||||
from langgraph.channels.last_value import LastValue
|
||||
@@ -148,8 +149,8 @@ class Channel:
|
||||
"""Writes to channels the result of the lambda, or None to skip writing."""
|
||||
return ChannelWrite(
|
||||
channels=(
|
||||
[(c, None) for c in channels]
|
||||
+ [(k, _coerce_write_value(v)) for k, v in kwargs.items()]
|
||||
[(c, None, False) for c in channels]
|
||||
+ [(k, _coerce_write_value(v), True) for k, v in kwargs.items()]
|
||||
)
|
||||
)
|
||||
|
||||
@@ -194,10 +195,19 @@ class Pregel(
|
||||
|
||||
@property
|
||||
def config_specs(self) -> list[ConfigurableFieldSpec]:
|
||||
return get_unique_config_specs(
|
||||
[spec for node in self.nodes.values() for spec in node.config_specs]
|
||||
+ (self.checkpointer.config_specs if self.checkpointer is not None else [])
|
||||
)
|
||||
return [
|
||||
spec
|
||||
for spec in get_unique_config_specs(
|
||||
[spec for node in self.nodes.values() for spec in node.config_specs]
|
||||
+ (
|
||||
self.checkpointer.config_specs
|
||||
if self.checkpointer is not None
|
||||
else []
|
||||
)
|
||||
)
|
||||
# these are provided by the Pregel class
|
||||
if spec.id not in [CONFIG_KEY_READ, CONFIG_KEY_SEND]
|
||||
]
|
||||
|
||||
@property
|
||||
def InputType(self) -> Any:
|
||||
@@ -242,128 +252,150 @@ class Pregel(
|
||||
*,
|
||||
input_keys: Optional[Union[str, Sequence[str]]] = None,
|
||||
output_keys: Optional[Union[str, Sequence[str]]] = None,
|
||||
interrupt: Optional[Sequence[str]] = None,
|
||||
) -> Iterator[Union[dict[str, Any], Any]]:
|
||||
if config["recursion_limit"] < 1:
|
||||
raise ValueError("recursion_limit must be at least 1")
|
||||
# assign defaults
|
||||
if output_keys is None:
|
||||
output_keys = [chan for chan in self.channels if chan not in self.hidden]
|
||||
else:
|
||||
validate_keys(output_keys, self.channels)
|
||||
if input_keys is None:
|
||||
input_keys = self.input
|
||||
else:
|
||||
validate_keys(input_keys, self.channels)
|
||||
# copy nodes to ignore mutations during execution
|
||||
processes = {**self.nodes}
|
||||
# get checkpoint from saver, or create an empty one
|
||||
checkpoint = self.checkpointer.get(config) if self.checkpointer else None
|
||||
checkpoint = checkpoint or empty_checkpoint()
|
||||
# create channels from checkpoint
|
||||
with ChannelsManager(
|
||||
self.channels, checkpoint
|
||||
) as channels, get_executor_for_config(config) as executor:
|
||||
# map inputs to channel updates
|
||||
_apply_writes(
|
||||
checkpoint,
|
||||
channels,
|
||||
deque(w for c in input for w in map_input(input_keys, c)),
|
||||
config,
|
||||
0,
|
||||
)
|
||||
|
||||
read = partial(_read_channel, channels)
|
||||
|
||||
# Similarly to Bulk Synchronous Parallel / Pregel model
|
||||
# computation proceeds in steps, while there are channel updates
|
||||
# channel updates from step N are only visible in step N+1
|
||||
# channels are guaranteed to be immutable for the duration of the step,
|
||||
# with channel updates applied only at the transition between steps
|
||||
for step in range(config["recursion_limit"] + 1):
|
||||
next_tasks = _prepare_next_tasks(checkpoint, processes, channels)
|
||||
|
||||
# if no more tasks, we're done
|
||||
if not next_tasks:
|
||||
break
|
||||
elif step == config["recursion_limit"]:
|
||||
raise GraphRecursionError(
|
||||
f"Recursion limit of {config['recursion_limit']} reached"
|
||||
"without hitting a stop condition. You can increase the limit"
|
||||
"by setting the `recursion_limit` config key."
|
||||
)
|
||||
|
||||
if self.debug:
|
||||
print_step_start(step, next_tasks)
|
||||
|
||||
# collect all writes to channels, without applying them yet
|
||||
pending_writes = deque[tuple[str, Any]]()
|
||||
|
||||
# prepare tasks with config
|
||||
tasks_w_config = [
|
||||
(
|
||||
proc,
|
||||
input,
|
||||
patch_config(
|
||||
config,
|
||||
run_name=name,
|
||||
callbacks=run_manager.get_child(f"graph:step:{step}"),
|
||||
configurable={
|
||||
# deque.extend is thread-safe
|
||||
CONFIG_KEY_SEND: pending_writes.extend,
|
||||
CONFIG_KEY_READ: read,
|
||||
},
|
||||
),
|
||||
)
|
||||
for proc, input, name in next_tasks
|
||||
try:
|
||||
if config["recursion_limit"] < 1:
|
||||
raise ValueError("recursion_limit must be at least 1")
|
||||
# assign defaults
|
||||
if output_keys is None:
|
||||
output_keys = [
|
||||
chan for chan in self.channels if chan not in self.hidden
|
||||
]
|
||||
else:
|
||||
validate_keys(output_keys, self.channels)
|
||||
if input_keys is None:
|
||||
input_keys = self.input
|
||||
else:
|
||||
validate_keys(input_keys, self.channels)
|
||||
interrupt = interrupt or self.interrupt
|
||||
# copy nodes to ignore mutations during execution
|
||||
processes = {**self.nodes}
|
||||
# get checkpoint from saver, or create an empty one
|
||||
checkpoint = self.checkpointer.get(config) if self.checkpointer else None
|
||||
checkpoint = checkpoint or empty_checkpoint()
|
||||
# create channels from checkpoint
|
||||
with ChannelsManager(
|
||||
self.channels, checkpoint
|
||||
) as channels, get_executor_for_config(config) as executor:
|
||||
# map inputs to channel updates
|
||||
if input_writes := deque(
|
||||
w for c in input for w in map_input(input_keys, c)
|
||||
):
|
||||
# discard any unfinished tasks from previous checkpoint
|
||||
_prepare_next_tasks(checkpoint, processes, channels)
|
||||
# apply input writes
|
||||
_apply_writes(
|
||||
checkpoint,
|
||||
channels,
|
||||
input_writes,
|
||||
config,
|
||||
0,
|
||||
)
|
||||
|
||||
# execute tasks, and wait for one to fail or all to finish.
|
||||
# each task is independent from all other concurrent tasks
|
||||
done, inflight = concurrent.futures.wait(
|
||||
[
|
||||
read = partial(_read_channel, channels)
|
||||
|
||||
# Similarly to Bulk Synchronous Parallel / Pregel model
|
||||
# computation proceeds in steps, while there are channel updates
|
||||
# channel updates from step N are only visible in step N+1
|
||||
# channels are guaranteed to be immutable for the duration of the step,
|
||||
# with channel updates applied only at the transition between steps
|
||||
for step in range(config["recursion_limit"] + 1):
|
||||
next_tasks = _prepare_next_tasks(checkpoint, processes, channels)
|
||||
|
||||
# if no more tasks, we're done
|
||||
if not next_tasks:
|
||||
break
|
||||
elif step == config["recursion_limit"]:
|
||||
raise GraphRecursionError(
|
||||
f"Recursion limit of {config['recursion_limit']} reached"
|
||||
"without hitting a stop condition. You can increase the limit"
|
||||
"by setting the `recursion_limit` config key."
|
||||
)
|
||||
|
||||
if self.debug:
|
||||
print_step_start(step, next_tasks)
|
||||
|
||||
# collect all writes to channels, without applying them yet
|
||||
pending_writes = deque[tuple[str, Any]]()
|
||||
|
||||
# prepare tasks with config
|
||||
tasks_w_config = [
|
||||
(
|
||||
proc,
|
||||
input,
|
||||
patch_config(
|
||||
config,
|
||||
run_name=name,
|
||||
callbacks=run_manager.get_child(f"graph:step:{step}"),
|
||||
configurable={
|
||||
# deque.extend is thread-safe
|
||||
CONFIG_KEY_SEND: pending_writes.extend,
|
||||
CONFIG_KEY_READ: read,
|
||||
},
|
||||
),
|
||||
)
|
||||
for proc, input, name in next_tasks
|
||||
]
|
||||
|
||||
futures = [
|
||||
executor.submit(proc.invoke, input, config)
|
||||
for proc, input, config in tasks_w_config
|
||||
],
|
||||
return_when=concurrent.futures.FIRST_EXCEPTION,
|
||||
timeout=self.step_timeout,
|
||||
)
|
||||
]
|
||||
|
||||
# interrupt on failure or timeout
|
||||
_interrupt_or_proceed(done, inflight, step)
|
||||
# execute tasks, and wait for one to fail or all to finish.
|
||||
# each task is independent from all other concurrent tasks
|
||||
done, inflight = concurrent.futures.wait(
|
||||
futures,
|
||||
return_when=concurrent.futures.FIRST_EXCEPTION,
|
||||
timeout=self.step_timeout,
|
||||
)
|
||||
|
||||
# apply writes to channels
|
||||
_apply_writes(checkpoint, channels, pending_writes, config, step + 1)
|
||||
# interrupt on failure or timeout
|
||||
_interrupt_or_proceed(done, inflight, step)
|
||||
|
||||
if self.debug:
|
||||
print_checkpoint(step, channels)
|
||||
# apply writes to channels
|
||||
_apply_writes(
|
||||
checkpoint, channels, pending_writes, config, step + 1
|
||||
)
|
||||
|
||||
# yield current value and checkpoint view
|
||||
if step_output := map_output(output_keys, pending_writes, channels):
|
||||
yield step_output
|
||||
# we can detect updates when output is multiple channels (ie. dict)
|
||||
if not isinstance(output_keys, str):
|
||||
# if view was updated, apply writes to channels
|
||||
_apply_writes_from_view(checkpoint, channels, step_output)
|
||||
if self.debug:
|
||||
print_checkpoint(step, channels)
|
||||
|
||||
# save end of step checkpoint
|
||||
# yield current value and checkpoint view
|
||||
if step_output := map_output(output_keys, pending_writes, channels):
|
||||
yield step_output
|
||||
# we can detect updates when output is multiple channels (ie. dict)
|
||||
if not isinstance(output_keys, str):
|
||||
# if view was updated, apply writes to channels
|
||||
_apply_writes_from_view(checkpoint, channels, step_output)
|
||||
|
||||
# save end of step checkpoint
|
||||
if (
|
||||
self.checkpointer is not None
|
||||
and self.checkpointer.at == CheckpointAt.END_OF_STEP
|
||||
):
|
||||
checkpoint = create_checkpoint(checkpoint, channels)
|
||||
self.checkpointer.put(config, checkpoint)
|
||||
|
||||
# interrupt if any channel written to is in interrupt list
|
||||
if any(chan for chan, _ in pending_writes if chan in interrupt):
|
||||
break
|
||||
|
||||
# save end of run checkpoint
|
||||
if (
|
||||
self.checkpointer is not None
|
||||
and self.checkpointer.at == CheckpointAt.END_OF_STEP
|
||||
and self.checkpointer.at == CheckpointAt.END_OF_RUN
|
||||
):
|
||||
checkpoint = create_checkpoint(checkpoint, channels)
|
||||
self.checkpointer.put(config, checkpoint)
|
||||
|
||||
# interrupt if any channel written to is in interrupt list
|
||||
if any(chan for chan, _ in pending_writes if chan in self.interrupt):
|
||||
break
|
||||
|
||||
# save end of run checkpoint
|
||||
if (
|
||||
self.checkpointer is not None
|
||||
and self.checkpointer.at == CheckpointAt.END_OF_RUN
|
||||
):
|
||||
checkpoint = create_checkpoint(checkpoint, channels)
|
||||
self.checkpointer.put(config, checkpoint)
|
||||
finally:
|
||||
# cancel any pending tasks when generator is interrupted
|
||||
try:
|
||||
for task in futures:
|
||||
task.cancel()
|
||||
except NameError:
|
||||
pass
|
||||
|
||||
async def _atransform(
|
||||
self,
|
||||
@@ -373,140 +405,166 @@ class Pregel(
|
||||
*,
|
||||
input_keys: Optional[Union[str, Sequence[str]]] = None,
|
||||
output_keys: Optional[Union[str, Sequence[str]]] = None,
|
||||
interrupt: Optional[Sequence[str]] = None,
|
||||
) -> AsyncIterator[Union[dict[str, Any], Any]]:
|
||||
if config["recursion_limit"] < 1:
|
||||
raise ValueError("recursion_limit must be at least 1")
|
||||
# if running from astream_log() run each proc with streaming
|
||||
do_stream = next(
|
||||
(
|
||||
h
|
||||
for h in run_manager.handlers
|
||||
if isinstance(h, LogStreamCallbackHandler)
|
||||
),
|
||||
None,
|
||||
)
|
||||
# assign defaults
|
||||
if output_keys is None:
|
||||
output_keys = [chan for chan in self.channels if chan not in self.hidden]
|
||||
else:
|
||||
validate_keys(output_keys, self.channels)
|
||||
if input_keys is None:
|
||||
input_keys = self.input
|
||||
else:
|
||||
validate_keys(input_keys, self.channels)
|
||||
# copy nodes to ignore mutations during execution
|
||||
processes = {**self.nodes}
|
||||
# get checkpoint from saver, or create an empty one
|
||||
checkpoint = await self.checkpointer.aget(config) if self.checkpointer else None
|
||||
checkpoint = checkpoint or empty_checkpoint()
|
||||
# create channels from checkpoint
|
||||
async with AsyncChannelsManager(self.channels, checkpoint) as channels:
|
||||
# map inputs to channel updates
|
||||
_apply_writes(
|
||||
checkpoint,
|
||||
channels,
|
||||
deque([w async for c in input for w in map_input(input_keys, c)]),
|
||||
config,
|
||||
0,
|
||||
try:
|
||||
if config["recursion_limit"] < 1:
|
||||
raise ValueError("recursion_limit must be at least 1")
|
||||
# if running from astream_log() run each proc with streaming
|
||||
do_stream = next(
|
||||
(
|
||||
h
|
||||
for h in run_manager.handlers
|
||||
if isinstance(h, LogStreamCallbackHandler)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
read = partial(_read_channel, channels)
|
||||
|
||||
# Similarly to Bulk Synchronous Parallel / Pregel model
|
||||
# computation proceeds in steps, while there are channel updates
|
||||
# channel updates from step N are only visible in step N+1,
|
||||
# channels are guaranteed to be immutable for the duration of the step,
|
||||
# channel updates being applied only at the transition between steps
|
||||
for step in range(config["recursion_limit"] + 1):
|
||||
next_tasks = _prepare_next_tasks(checkpoint, processes, channels)
|
||||
|
||||
# if no more tasks, we're done
|
||||
if not next_tasks:
|
||||
break
|
||||
elif step == config["recursion_limit"]:
|
||||
raise GraphRecursionError(
|
||||
f"Recursion limit of {config['recursion_limit']} reached"
|
||||
"without hitting a stop condition. You can increase the limit"
|
||||
"by setting the `recursion_limit` config key."
|
||||
)
|
||||
|
||||
if self.debug:
|
||||
print_step_start(step, next_tasks)
|
||||
|
||||
# collect all writes to channels, without applying them yet
|
||||
pending_writes = deque[tuple[str, Any]]()
|
||||
|
||||
# prepare tasks with config
|
||||
tasks_w_config = [
|
||||
(
|
||||
proc,
|
||||
input,
|
||||
patch_config(
|
||||
config,
|
||||
run_name=name,
|
||||
callbacks=run_manager.get_child(f"graph:step:{step}"),
|
||||
configurable={
|
||||
# deque.extend is thread-safe
|
||||
CONFIG_KEY_SEND: pending_writes.extend,
|
||||
CONFIG_KEY_READ: read,
|
||||
},
|
||||
),
|
||||
)
|
||||
for proc, input, name in next_tasks
|
||||
# assign defaults
|
||||
if output_keys is None:
|
||||
output_keys = [
|
||||
chan for chan in self.channels if chan not in self.hidden
|
||||
]
|
||||
else:
|
||||
validate_keys(output_keys, self.channels)
|
||||
if input_keys is None:
|
||||
input_keys = self.input
|
||||
else:
|
||||
validate_keys(input_keys, self.channels)
|
||||
interrupt = interrupt or self.interrupt
|
||||
# copy nodes to ignore mutations during execution
|
||||
processes = {**self.nodes}
|
||||
# get checkpoint from saver, or create an empty one
|
||||
checkpoint = (
|
||||
await self.checkpointer.aget(config) if self.checkpointer else None
|
||||
)
|
||||
checkpoint = checkpoint or empty_checkpoint()
|
||||
# create channels from checkpoint
|
||||
async with AsyncChannelsManager(self.channels, checkpoint) as channels:
|
||||
# map inputs to channel updates
|
||||
if input_writes := deque(
|
||||
[w async for c in input for w in map_input(input_keys, c)]
|
||||
):
|
||||
# discard any unfinished tasks from previous checkpoint
|
||||
_prepare_next_tasks(checkpoint, processes, channels)
|
||||
# apply input writes
|
||||
_apply_writes(
|
||||
checkpoint,
|
||||
channels,
|
||||
input_writes,
|
||||
config,
|
||||
0,
|
||||
)
|
||||
|
||||
# execute tasks, and wait for one to fail or all to finish.
|
||||
# each task is independent from all other concurrent tasks
|
||||
done, inflight = await asyncio.wait(
|
||||
[
|
||||
asyncio.create_task(_aconsume(proc.astream(input, config)))
|
||||
for proc, input, config in tasks_w_config
|
||||
read = partial(_read_channel, channels)
|
||||
|
||||
# Similarly to Bulk Synchronous Parallel / Pregel model
|
||||
# computation proceeds in steps, while there are channel updates
|
||||
# channel updates from step N are only visible in step N+1,
|
||||
# channels are guaranteed to be immutable for the duration of the step,
|
||||
# channel updates being applied only at the transition between steps
|
||||
for step in range(config["recursion_limit"] + 1):
|
||||
next_tasks = _prepare_next_tasks(checkpoint, processes, channels)
|
||||
|
||||
# if no more tasks, we're done
|
||||
if not next_tasks:
|
||||
break
|
||||
elif step == config["recursion_limit"]:
|
||||
raise GraphRecursionError(
|
||||
f"Recursion limit of {config['recursion_limit']} reached"
|
||||
"without hitting a stop condition. You can increase the limit"
|
||||
"by setting the `recursion_limit` config key."
|
||||
)
|
||||
|
||||
if self.debug:
|
||||
print_step_start(step, next_tasks)
|
||||
|
||||
# collect all writes to channels, without applying them yet
|
||||
pending_writes = deque[tuple[str, Any]]()
|
||||
|
||||
# prepare tasks with config
|
||||
tasks_w_config = [
|
||||
(
|
||||
proc,
|
||||
input,
|
||||
patch_config(
|
||||
config,
|
||||
run_name=name,
|
||||
callbacks=run_manager.get_child(f"graph:step:{step}"),
|
||||
configurable={
|
||||
# deque.extend is thread-safe
|
||||
CONFIG_KEY_SEND: pending_writes.extend,
|
||||
CONFIG_KEY_READ: read,
|
||||
},
|
||||
),
|
||||
)
|
||||
for proc, input, name in next_tasks
|
||||
]
|
||||
if do_stream
|
||||
else [
|
||||
asyncio.create_task(proc.ainvoke(input, config))
|
||||
for proc, input, config in tasks_w_config
|
||||
],
|
||||
return_when=asyncio.FIRST_EXCEPTION,
|
||||
timeout=self.step_timeout,
|
||||
)
|
||||
|
||||
# interrupt on failure or timeout
|
||||
_interrupt_or_proceed(done, inflight, step)
|
||||
futures = (
|
||||
[
|
||||
asyncio.create_task(_aconsume(proc.astream(input, config)))
|
||||
for proc, input, config in tasks_w_config
|
||||
]
|
||||
if do_stream
|
||||
else [
|
||||
asyncio.create_task(proc.ainvoke(input, config))
|
||||
for proc, input, config in tasks_w_config
|
||||
]
|
||||
)
|
||||
|
||||
# apply writes to channels
|
||||
_apply_writes(checkpoint, channels, pending_writes, config, step + 1)
|
||||
# execute tasks, and wait for one to fail or all to finish.
|
||||
# each task is independent from all other concurrent tasks
|
||||
done, inflight = await asyncio.wait(
|
||||
futures,
|
||||
return_when=asyncio.FIRST_EXCEPTION,
|
||||
timeout=self.step_timeout,
|
||||
)
|
||||
|
||||
if self.debug:
|
||||
print_checkpoint(step, channels)
|
||||
# interrupt on failure or timeout
|
||||
_interrupt_or_proceed(done, inflight, step)
|
||||
|
||||
# yield current value and checkpoint view
|
||||
if step_output := map_output(output_keys, pending_writes, channels):
|
||||
yield step_output
|
||||
# we can detect updates when output is multiple channels (ie. dict)
|
||||
if not isinstance(output_keys, str):
|
||||
# if view was updated, apply writes to channels
|
||||
_apply_writes_from_view(checkpoint, channels, step_output)
|
||||
# apply writes to channels
|
||||
_apply_writes(
|
||||
checkpoint, channels, pending_writes, config, step + 1
|
||||
)
|
||||
|
||||
# save end of step checkpoint
|
||||
if self.debug:
|
||||
print_checkpoint(step, channels)
|
||||
|
||||
# yield current value and checkpoint view
|
||||
if step_output := map_output(output_keys, pending_writes, channels):
|
||||
yield step_output
|
||||
# we can detect updates when output is multiple channels (ie. dict)
|
||||
if not isinstance(output_keys, str):
|
||||
# if view was updated, apply writes to channels
|
||||
_apply_writes_from_view(checkpoint, channels, step_output)
|
||||
|
||||
# save end of step checkpoint
|
||||
if (
|
||||
self.checkpointer is not None
|
||||
and self.checkpointer.at == CheckpointAt.END_OF_STEP
|
||||
):
|
||||
checkpoint = create_checkpoint(checkpoint, channels)
|
||||
await self.checkpointer.aput(config, checkpoint)
|
||||
|
||||
# interrupt if any channel written to is in interrupt list
|
||||
if any(chan for chan, _ in pending_writes if chan in interrupt):
|
||||
break
|
||||
|
||||
# save end of run checkpoint
|
||||
if (
|
||||
self.checkpointer is not None
|
||||
and self.checkpointer.at == CheckpointAt.END_OF_STEP
|
||||
and self.checkpointer.at == CheckpointAt.END_OF_RUN
|
||||
):
|
||||
checkpoint = create_checkpoint(checkpoint, channels)
|
||||
await self.checkpointer.aput(config, checkpoint)
|
||||
|
||||
# interrupt if any channel written to is in interrupt list
|
||||
if any(chan for chan, _ in pending_writes if chan in self.interrupt):
|
||||
break
|
||||
|
||||
# save end of run checkpoint
|
||||
if (
|
||||
self.checkpointer is not None
|
||||
and self.checkpointer.at == CheckpointAt.END_OF_RUN
|
||||
):
|
||||
checkpoint = create_checkpoint(checkpoint, channels)
|
||||
await self.checkpointer.aput(config, checkpoint)
|
||||
finally:
|
||||
# cancel any pending tasks when generator is interrupted
|
||||
try:
|
||||
for task in futures:
|
||||
task.cancel()
|
||||
except NameError:
|
||||
pass
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
@@ -684,7 +742,12 @@ def _apply_writes(
|
||||
# Apply writes to channels
|
||||
for chan, vals in pending_writes_by_channel.items():
|
||||
if chan in channels:
|
||||
channels[chan].update(vals)
|
||||
try:
|
||||
channels[chan].update(vals)
|
||||
except InvalidUpdateError as e:
|
||||
raise InvalidUpdateError(
|
||||
f"Invalid update for channel {chan}: {e}"
|
||||
) from e
|
||||
checkpoint["channel_versions"][chan] += 1
|
||||
updated_channels.add(chan)
|
||||
else:
|
||||
|
||||
@@ -16,9 +16,9 @@ from langchain_core.runnables.base import (
|
||||
RunnableEach,
|
||||
coerce_to_runnable,
|
||||
)
|
||||
from langchain_core.runnables.config import merge_configs
|
||||
from langchain_core.runnables.utils import ConfigurableFieldSpec
|
||||
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.constants import CONFIG_KEY_READ
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class ChannelRead(RunnableLambda):
|
||||
name=CONFIG_KEY_READ,
|
||||
description=None,
|
||||
default=None,
|
||||
annotation=Callable[[BaseChannel], Any],
|
||||
annotation=None,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -103,7 +103,7 @@ class ChannelInvoke(RunnableBindingBase):
|
||||
when=when,
|
||||
bound=bound or default_bound,
|
||||
kwargs=kwargs or {},
|
||||
config={**(config or {}), "tags": tags or []},
|
||||
config=merge_configs(config, {"tags": tags or []}),
|
||||
**other_kwargs,
|
||||
)
|
||||
|
||||
|
||||
+28
-15
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Callable, Optional, Sequence
|
||||
|
||||
from langchain_core.runnables import (
|
||||
@@ -14,8 +15,11 @@ from langgraph.constants import CONFIG_KEY_SEND
|
||||
TYPE_SEND = Callable[[Sequence[tuple[str, Any]]], None]
|
||||
|
||||
|
||||
SKIP_WRITE = object()
|
||||
|
||||
|
||||
class ChannelWrite(RunnablePassthrough):
|
||||
channels: Sequence[tuple[str, Optional[Runnable]]]
|
||||
channels: Sequence[tuple[str, Optional[Runnable], bool]]
|
||||
"""
|
||||
Mapping of write channels to Runnables that return the value to be written,
|
||||
or None to skip writing.
|
||||
@@ -27,10 +31,10 @@ class ChannelWrite(RunnablePassthrough):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
channels: Sequence[tuple[str, Optional[Runnable]]],
|
||||
channels: Sequence[tuple[str, Optional[Runnable], bool]],
|
||||
):
|
||||
super().__init__(func=self._write, afunc=self._awrite, channels=channels)
|
||||
self.name = f"ChannelWrite<{','.join(chan for chan, _ in self.channels)}>"
|
||||
self.name = f"ChannelWrite<{','.join(chan for chan, _, _ in self.channels)}>"
|
||||
|
||||
def __repr_args__(self) -> Any:
|
||||
return [("channels", self.channels)]
|
||||
@@ -43,31 +47,34 @@ class ChannelWrite(RunnablePassthrough):
|
||||
name=CONFIG_KEY_SEND,
|
||||
description=None,
|
||||
default=None,
|
||||
annotation=TYPE_SEND,
|
||||
annotation=None,
|
||||
),
|
||||
]
|
||||
|
||||
def _write(self, input: Any, config: RunnableConfig) -> None:
|
||||
values = [
|
||||
(chan, r.invoke(input, config) if r else input) for chan, r in self.channels
|
||||
(chan, r.invoke(input, config) if r else input)
|
||||
for chan, r, _ in self.channels
|
||||
]
|
||||
values = [
|
||||
write
|
||||
for write, chan in zip(values, self.channels)
|
||||
if chan[1] is None or write[1] is not None
|
||||
for write, (_, _, skip_none) in zip(values, self.channels)
|
||||
if not skip_none or write[1] is not None
|
||||
]
|
||||
|
||||
self.do_write(config, **dict(values))
|
||||
|
||||
async def _awrite(self, input: Any, config: RunnableConfig) -> None:
|
||||
values = await asyncio.gather(
|
||||
*(
|
||||
r.ainvoke(input, config) if r else _mk_future(input)
|
||||
for _, r, _ in self.channels
|
||||
)
|
||||
)
|
||||
values = [
|
||||
(chan, await r.ainvoke(input, config) if r else input)
|
||||
for chan, r in self.channels
|
||||
]
|
||||
values = [
|
||||
write
|
||||
for write, chan in zip(values, self.channels)
|
||||
if chan[1] is None or write[1] is not None
|
||||
(chan, val)
|
||||
for val, (chan, _, skip_none) in zip(values, self.channels)
|
||||
if not skip_none or val is not None
|
||||
]
|
||||
|
||||
self.do_write(config, **dict(values))
|
||||
@@ -75,4 +82,10 @@ class ChannelWrite(RunnablePassthrough):
|
||||
@staticmethod
|
||||
def do_write(config: RunnableConfig, **values: Any) -> None:
|
||||
write: TYPE_SEND = config["configurable"][CONFIG_KEY_SEND]
|
||||
write([(chan, val) for chan, val in values.items()])
|
||||
write([(chan, val) for chan, val in values.items() if val is not SKIP_WRITE])
|
||||
|
||||
|
||||
def _mk_future(val: Any) -> asyncio.Future:
|
||||
fut = asyncio.Future()
|
||||
fut.set_result(val)
|
||||
return fut
|
||||
|
||||
Generated
+632
-654
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph"
|
||||
version = "0.0.12"
|
||||
version = "0.0.23"
|
||||
description = "langgraph"
|
||||
authors = []
|
||||
license = "LangGraph License"
|
||||
@@ -9,7 +9,7 @@ repository = "https://www.github.com/langchain-ai/langgraph"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.9.0,<4.0"
|
||||
langchain-core = "^0.1.8"
|
||||
langchain-core = "^0.1.16"
|
||||
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
@@ -24,6 +24,8 @@ pytest-mock = "^3.10.0"
|
||||
syrupy = "^4.0.2"
|
||||
httpx = "^0.26.0"
|
||||
pytest-watcher = "^0.3.4"
|
||||
langchain = "^0.1.0"
|
||||
aiosqlite = "^0.19.0"
|
||||
|
||||
[tool.poetry.group.lint.dependencies]
|
||||
ruff = "^0.1.4"
|
||||
|
||||
+420
-4
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
import operator
|
||||
import time
|
||||
import warnings
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from typing import Annotated, Generator, Optional, TypedDict, Union
|
||||
@@ -14,8 +16,12 @@ from langgraph.channels.context import Context
|
||||
from langgraph.channels.last_value import LastValue
|
||||
from langgraph.channels.topic import Topic
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
from langgraph.graph import END, Graph
|
||||
from langgraph.graph.message import MessageGraph
|
||||
from langgraph.graph.state import StateGraph
|
||||
from langgraph.prebuilt.chat_agent_executor import create_function_calling_executor
|
||||
from langgraph.prebuilt.tool_executor import ToolExecutor
|
||||
from langgraph.pregel import Channel, GraphRecursionError, Pregel
|
||||
from langgraph.pregel.reserved import ReservedChannels
|
||||
|
||||
@@ -43,6 +49,13 @@ def test_invoke_single_process_in_out(mocker: MockerFixture) -> None:
|
||||
|
||||
assert app.input_schema.schema() == {"title": "LangGraphInput", "type": "integer"}
|
||||
assert app.output_schema.schema() == {"title": "LangGraphOutput", "type": "integer"}
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error") # raise warnings as errors
|
||||
assert app.config_schema().schema() == {
|
||||
"properties": {},
|
||||
"title": "LangGraphConfig",
|
||||
"type": "object",
|
||||
}
|
||||
assert app.invoke(2) == 3
|
||||
assert app.invoke(2, output_keys=["output"]) == {"output": 3}
|
||||
assert repr(app), "does not raise recursion error"
|
||||
@@ -232,6 +245,40 @@ def test_invoke_two_processes_in_out(mocker: MockerFixture) -> None:
|
||||
assert step == 3
|
||||
|
||||
|
||||
def test_invoke_two_processes_in_out_interrupt(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
one = Channel.subscribe_to("input") | add_one | Channel.write_to("inbox")
|
||||
two = Channel.subscribe_to("inbox") | add_one | Channel.write_to("output")
|
||||
|
||||
memory = MemorySaver()
|
||||
app = Pregel(
|
||||
nodes={"one": one, "two": two}, checkpointer=memory, interrupt=["inbox"]
|
||||
)
|
||||
|
||||
# start execution, stop at inbox
|
||||
assert app.invoke(2, {"configurable": {"thread_id": 1}}) is None
|
||||
|
||||
# inbox == 3
|
||||
checkpoint = memory.get({"configurable": {"thread_id": 1}})
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["channel_values"]["inbox"] == 3
|
||||
|
||||
# resume execution, finish
|
||||
assert app.invoke(None, {"configurable": {"thread_id": 1}}) == 4
|
||||
|
||||
# start execution again, stop at inbox
|
||||
assert app.invoke(20, {"configurable": {"thread_id": 1}}) is None
|
||||
|
||||
# inbox == 21
|
||||
checkpoint = memory.get({"configurable": {"thread_id": 1}})
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["channel_values"]["inbox"] == 21
|
||||
|
||||
# send a new value in, interrupting the previous execution
|
||||
assert app.invoke(3, {"configurable": {"thread_id": 1}}) is None
|
||||
assert app.invoke(None, {"configurable": {"thread_id": 1}}) == 5
|
||||
|
||||
|
||||
def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
one = Channel.subscribe_to("input") | add_one | Channel.write_to("inbox")
|
||||
@@ -415,6 +462,56 @@ def test_invoke_checkpoint(mocker: MockerFixture) -> None:
|
||||
assert checkpoint["channel_values"].get("total") == 5
|
||||
|
||||
|
||||
def test_invoke_checkpoint_sqlite(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x["total"] + x["input"])
|
||||
|
||||
def raise_if_above_10(input: int) -> int:
|
||||
if input > 10:
|
||||
raise ValueError("Input is too large")
|
||||
return input
|
||||
|
||||
one = (
|
||||
Channel.subscribe_to(["input"]).join(["total"])
|
||||
| add_one
|
||||
| Channel.write_to("output", "total")
|
||||
| raise_if_above_10
|
||||
)
|
||||
|
||||
memory = SqliteSaver.from_conn_string(":memory:")
|
||||
|
||||
app = Pregel(
|
||||
nodes={"one": one},
|
||||
channels={"total": BinaryOperatorAggregate(int, operator.add)},
|
||||
checkpointer=memory,
|
||||
)
|
||||
|
||||
# total starts out as 0, so output is 0+2=2
|
||||
assert app.invoke(2, {"configurable": {"thread_id": "1"}}) == 2
|
||||
checkpoint = memory.get({"configurable": {"thread_id": "1"}})
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["channel_values"].get("total") == 2
|
||||
# total is now 2, so output is 2+3=5
|
||||
assert app.invoke(3, {"configurable": {"thread_id": "1"}}) == 5
|
||||
checkpoint = memory.get({"configurable": {"thread_id": "1"}})
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["channel_values"].get("total") == 7
|
||||
# total is now 2+5=7, so output would be 7+4=11, but raises ValueError
|
||||
with pytest.raises(ValueError):
|
||||
app.invoke(4, {"configurable": {"thread_id": "1"}})
|
||||
# checkpoint is not updated
|
||||
checkpoint = memory.get({"configurable": {"thread_id": "1"}})
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["channel_values"].get("total") == 7
|
||||
# on a new thread, total starts out as 0, so output is 0+5=5
|
||||
assert app.invoke(5, {"configurable": {"thread_id": "2"}}) == 5
|
||||
checkpoint = memory.get({"configurable": {"thread_id": "1"}})
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["channel_values"].get("total") == 7
|
||||
checkpoint = memory.get({"configurable": {"thread_id": "2"}})
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["channel_values"].get("total") == 5
|
||||
|
||||
|
||||
def test_invoke_two_processes_two_in_join_two_out(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
add_10_each = mocker.Mock(side_effect=lambda x: sorted(y + 10 for y in x))
|
||||
@@ -780,8 +877,6 @@ def test_conditional_graph() -> None:
|
||||
|
||||
|
||||
def test_conditional_graph_state() -> None:
|
||||
from copy import deepcopy
|
||||
|
||||
from langchain.llms.fake import FakeStreamingListLLM
|
||||
from langchain_community.tools import tool
|
||||
from langchain_core.agents import AgentAction, AgentFinish
|
||||
@@ -811,7 +906,7 @@ def test_conditional_graph_state() -> None:
|
||||
]
|
||||
)
|
||||
|
||||
def agent_parser(input: str) -> Union[AgentAction, AgentFinish]:
|
||||
def agent_parser(input: str) -> dict[str, Union[AgentAction, AgentFinish]]:
|
||||
if input.startswith("finish"):
|
||||
_, answer = input.split(":")
|
||||
return {
|
||||
@@ -886,7 +981,7 @@ def test_conditional_graph_state() -> None:
|
||||
),
|
||||
}
|
||||
|
||||
assert [deepcopy(c) for c in app.stream({"input": "what is weather in sf"})] == [
|
||||
assert [*app.stream({"input": "what is weather in sf"})] == [
|
||||
{
|
||||
"agent": {
|
||||
"agent_outcome": AgentAction(
|
||||
@@ -965,3 +1060,324 @@ def test_conditional_graph_state() -> None:
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_prebuilt_chat() -> None:
|
||||
from langchain.chat_models.fake import FakeMessagesListChatModel
|
||||
from langchain_community.tools import tool
|
||||
from langchain_core.messages import AIMessage, FunctionMessage, HumanMessage
|
||||
|
||||
class FakeFuntionChatModel(FakeMessagesListChatModel):
|
||||
def bind_functions(self, functions: list):
|
||||
return self
|
||||
|
||||
@tool()
|
||||
def search_api(query: str) -> str:
|
||||
"""Searches the API for the query."""
|
||||
return f"result for {query}"
|
||||
|
||||
tools = [search_api]
|
||||
|
||||
app = create_function_calling_executor(
|
||||
FakeFuntionChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": json.dumps("query"),
|
||||
}
|
||||
},
|
||||
),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": json.dumps("another"),
|
||||
}
|
||||
},
|
||||
),
|
||||
AIMessage(content="answer"),
|
||||
]
|
||||
),
|
||||
tools,
|
||||
)
|
||||
|
||||
assert app.invoke(
|
||||
{"messages": [HumanMessage(content="what is weather in sf")]}
|
||||
) == {
|
||||
"messages": [
|
||||
HumanMessage(content="what is weather in sf"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {"name": "search_api", "arguments": '"query"'}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for query", name="search_api"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {"name": "search_api", "arguments": '"another"'}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for another", name="search_api"),
|
||||
AIMessage(content="answer"),
|
||||
]
|
||||
}
|
||||
|
||||
assert [
|
||||
*app.stream({"messages": [HumanMessage(content="what is weather in sf")]})
|
||||
] == [
|
||||
{
|
||||
"agent": {
|
||||
"messages": [
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": '"query"',
|
||||
}
|
||||
},
|
||||
)
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": {
|
||||
"messages": [
|
||||
FunctionMessage(content="result for query", name="search_api")
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"agent": {
|
||||
"messages": [
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": '"another"',
|
||||
}
|
||||
},
|
||||
)
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": {
|
||||
"messages": [
|
||||
FunctionMessage(content="result for another", name="search_api")
|
||||
]
|
||||
}
|
||||
},
|
||||
{"agent": {"messages": [AIMessage(content="answer")]}},
|
||||
{
|
||||
"__end__": {
|
||||
"messages": [
|
||||
HumanMessage(content="what is weather in sf"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": '"query"',
|
||||
}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for query", name="search_api"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": '"another"',
|
||||
}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for another", name="search_api"),
|
||||
AIMessage(content="answer"),
|
||||
]
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_message_graph() -> None:
|
||||
from langchain.chat_models.fake import FakeMessagesListChatModel
|
||||
from langchain_community.tools import tool
|
||||
from langchain_core.agents import AgentAction
|
||||
from langchain_core.messages import AIMessage, FunctionMessage, HumanMessage
|
||||
|
||||
class FakeFuntionChatModel(FakeMessagesListChatModel):
|
||||
def bind_functions(self, functions: list):
|
||||
return self
|
||||
|
||||
@tool()
|
||||
def search_api(query: str) -> str:
|
||||
"""Searches the API for the query."""
|
||||
return f"result for {query}"
|
||||
|
||||
tools = [search_api]
|
||||
|
||||
model = FakeFuntionChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": json.dumps("query"),
|
||||
}
|
||||
},
|
||||
),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": json.dumps("another"),
|
||||
}
|
||||
},
|
||||
),
|
||||
AIMessage(content="answer"),
|
||||
]
|
||||
)
|
||||
|
||||
tool_executor = ToolExecutor(tools)
|
||||
|
||||
# Define the function that determines whether to continue or not
|
||||
def should_continue(messages):
|
||||
last_message = messages[-1]
|
||||
# If there is no function call, then we finish
|
||||
if "function_call" not in last_message.additional_kwargs:
|
||||
return "end"
|
||||
# Otherwise if there is, we continue
|
||||
else:
|
||||
return "continue"
|
||||
|
||||
def call_tool(messages):
|
||||
# Based on the continue condition
|
||||
# we know the last message involves a function call
|
||||
last_message = messages[-1]
|
||||
# We construct an AgentAction from the function_call
|
||||
action = AgentAction(
|
||||
tool=last_message.additional_kwargs["function_call"]["name"],
|
||||
tool_input=json.loads(
|
||||
last_message.additional_kwargs["function_call"]["arguments"]
|
||||
),
|
||||
log="",
|
||||
)
|
||||
# We call the tool_executor and get back a response
|
||||
response = tool_executor.invoke(action)
|
||||
# We use the response to create a FunctionMessage
|
||||
return FunctionMessage(content=str(response), name=action.tool)
|
||||
|
||||
# Define a new graph
|
||||
workflow = MessageGraph()
|
||||
|
||||
# Define the two nodes we will cycle between
|
||||
workflow.add_node("agent", model)
|
||||
workflow.add_node("action", call_tool)
|
||||
|
||||
# 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
|
||||
app = workflow.compile()
|
||||
|
||||
assert app.invoke(HumanMessage(content="what is weather in sf")) == [
|
||||
HumanMessage(content="what is weather in sf"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {"name": "search_api", "arguments": '"query"'}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for query", name="search_api"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {"name": "search_api", "arguments": '"another"'}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for another", name="search_api"),
|
||||
AIMessage(content="answer"),
|
||||
]
|
||||
|
||||
assert [*app.stream([HumanMessage(content="what is weather in sf")])] == [
|
||||
{
|
||||
"agent": AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {"name": "search_api", "arguments": '"query"'}
|
||||
},
|
||||
)
|
||||
},
|
||||
{"action": FunctionMessage(content="result for query", name="search_api")},
|
||||
{
|
||||
"agent": AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {"name": "search_api", "arguments": '"another"'}
|
||||
},
|
||||
)
|
||||
},
|
||||
{"action": FunctionMessage(content="result for another", name="search_api")},
|
||||
{"agent": AIMessage(content="answer")},
|
||||
{
|
||||
"__end__": [
|
||||
HumanMessage(content="what is weather in sf"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {"name": "search_api", "arguments": '"query"'}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for query", name="search_api"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": '"another"',
|
||||
}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for another", name="search_api"),
|
||||
AIMessage(content="answer"),
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
+420
-6
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
import operator
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import (
|
||||
@@ -21,8 +22,12 @@ from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.channels.context import Context
|
||||
from langgraph.channels.last_value import LastValue
|
||||
from langgraph.channels.topic import Topic
|
||||
from langgraph.checkpoint.aiosqlite import AsyncSqliteSaver
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.graph import END, Graph, StateGraph
|
||||
from langgraph.graph.message import MessageGraph
|
||||
from langgraph.prebuilt.chat_agent_executor import create_function_calling_executor
|
||||
from langgraph.prebuilt.tool_executor import ToolExecutor
|
||||
from langgraph.pregel import Channel, GraphRecursionError, Pregel
|
||||
from langgraph.pregel.reserved import ReservedChannels
|
||||
|
||||
@@ -248,6 +253,40 @@ async def test_invoke_two_processes_in_out(mocker: MockerFixture) -> None:
|
||||
assert step == 3
|
||||
|
||||
|
||||
async def test_invoke_two_processes_in_out_interrupt(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
one = Channel.subscribe_to("input") | add_one | Channel.write_to("inbox")
|
||||
two = Channel.subscribe_to("inbox") | add_one | Channel.write_to("output")
|
||||
|
||||
memory = MemorySaver()
|
||||
app = Pregel(
|
||||
nodes={"one": one, "two": two}, checkpointer=memory, interrupt=["inbox"]
|
||||
)
|
||||
|
||||
# start execution, stop at inbox
|
||||
assert await app.ainvoke(2, {"configurable": {"thread_id": 1}}) is None
|
||||
|
||||
# inbox == 3
|
||||
checkpoint = await memory.aget({"configurable": {"thread_id": 1}})
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["channel_values"]["inbox"] == 3
|
||||
|
||||
# resume execution, finish
|
||||
assert await app.ainvoke(None, {"configurable": {"thread_id": 1}}) == 4
|
||||
|
||||
# start execution again, stop at inbox
|
||||
assert await app.ainvoke(20, {"configurable": {"thread_id": 1}}) is None
|
||||
|
||||
# inbox == 21
|
||||
checkpoint = await memory.aget({"configurable": {"thread_id": 1}})
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["channel_values"]["inbox"] == 21
|
||||
|
||||
# send a new value in, interrupting the previous execution
|
||||
assert await app.ainvoke(3, {"configurable": {"thread_id": 1}}) is None
|
||||
assert await app.ainvoke(None, {"configurable": {"thread_id": 1}}) == 5
|
||||
|
||||
|
||||
async def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
one = Channel.subscribe_to("input") | add_one | Channel.write_to("inbox")
|
||||
@@ -438,6 +477,59 @@ async def test_invoke_checkpoint(mocker: MockerFixture) -> None:
|
||||
assert checkpoint["channel_values"].get("total") == 5
|
||||
|
||||
|
||||
async def test_invoke_checkpoint_sqlite(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x["total"] + x["input"])
|
||||
|
||||
def raise_if_above_10(input: int) -> int:
|
||||
if input > 10:
|
||||
raise ValueError("Input is too large")
|
||||
return input
|
||||
|
||||
one = (
|
||||
Channel.subscribe_to(["input"]).join(["total"])
|
||||
| add_one
|
||||
| Channel.write_to("output", "total")
|
||||
| raise_if_above_10
|
||||
)
|
||||
|
||||
memory = AsyncSqliteSaver.from_conn_string(":memory:")
|
||||
|
||||
app = Pregel(
|
||||
nodes={"one": one},
|
||||
channels={"total": BinaryOperatorAggregate(int, operator.add)},
|
||||
checkpointer=memory,
|
||||
debug=True,
|
||||
)
|
||||
|
||||
# total starts out as 0, so output is 0+2=2
|
||||
assert await app.ainvoke(2, {"configurable": {"thread_id": "1"}}) == 2
|
||||
checkpoint = await memory.aget({"configurable": {"thread_id": "1"}})
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["channel_values"].get("total") == 2
|
||||
# total is now 2, so output is 2+3=5
|
||||
assert await app.ainvoke(3, {"configurable": {"thread_id": "1"}}) == 5
|
||||
checkpoint = await memory.aget({"configurable": {"thread_id": "1"}})
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["channel_values"].get("total") == 7
|
||||
# total is now 2+5=7, so output would be 7+4=11, but raises ValueError
|
||||
with pytest.raises(ValueError):
|
||||
await app.ainvoke(4, {"configurable": {"thread_id": "1"}})
|
||||
# checkpoint is not updated
|
||||
checkpoint = await memory.aget({"configurable": {"thread_id": "1"}})
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["channel_values"].get("total") == 7
|
||||
# on a new thread, total starts out as 0, so output is 0+5=5
|
||||
assert await app.ainvoke(5, {"configurable": {"thread_id": "2"}}) == 5
|
||||
checkpoint = await memory.aget({"configurable": {"thread_id": "1"}})
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["channel_values"].get("total") == 7
|
||||
checkpoint = await memory.aget({"configurable": {"thread_id": "2"}})
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["channel_values"].get("total") == 5
|
||||
|
||||
await memory.conn.close()
|
||||
|
||||
|
||||
async def test_invoke_two_processes_two_in_join_two_out(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
add_10_each = mocker.Mock(side_effect=lambda x: sorted(y + 10 for y in x))
|
||||
@@ -834,8 +926,6 @@ async def test_conditional_graph() -> None:
|
||||
|
||||
|
||||
async def test_conditional_graph_state() -> None:
|
||||
from copy import deepcopy
|
||||
|
||||
from langchain.llms.fake import FakeStreamingListLLM
|
||||
from langchain_community.tools import tool
|
||||
from langchain_core.agents import AgentAction, AgentFinish
|
||||
@@ -865,7 +955,7 @@ async def test_conditional_graph_state() -> None:
|
||||
]
|
||||
)
|
||||
|
||||
def agent_parser(input: str) -> Union[AgentAction, AgentFinish]:
|
||||
def agent_parser(input: str) -> dict[str, Union[AgentAction, AgentFinish]]:
|
||||
if input.startswith("finish"):
|
||||
_, answer = input.split(":")
|
||||
return {
|
||||
@@ -940,9 +1030,7 @@ async def test_conditional_graph_state() -> None:
|
||||
),
|
||||
}
|
||||
|
||||
assert [
|
||||
deepcopy(c) async for c in app.astream({"input": "what is weather in sf"})
|
||||
] == [
|
||||
assert [c async for c in app.astream({"input": "what is weather in sf"})] == [
|
||||
{
|
||||
"agent": {
|
||||
"agent_outcome": AgentAction(
|
||||
@@ -1021,3 +1109,329 @@ async def test_conditional_graph_state() -> None:
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def test_prebuilt_chat() -> None:
|
||||
from langchain.chat_models.fake import FakeMessagesListChatModel
|
||||
from langchain_community.tools import tool
|
||||
from langchain_core.messages import AIMessage, FunctionMessage, HumanMessage
|
||||
|
||||
class FakeFuntionChatModel(FakeMessagesListChatModel):
|
||||
def bind_functions(self, functions: list):
|
||||
return self
|
||||
|
||||
@tool()
|
||||
def search_api(query: str) -> str:
|
||||
"""Searches the API for the query."""
|
||||
return f"result for {query}"
|
||||
|
||||
tools = [search_api]
|
||||
|
||||
app = create_function_calling_executor(
|
||||
FakeFuntionChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": json.dumps("query"),
|
||||
}
|
||||
},
|
||||
),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": json.dumps("another"),
|
||||
}
|
||||
},
|
||||
),
|
||||
AIMessage(content="answer"),
|
||||
]
|
||||
),
|
||||
tools,
|
||||
)
|
||||
|
||||
assert await app.ainvoke(
|
||||
{"messages": [HumanMessage(content="what is weather in sf")]}
|
||||
) == {
|
||||
"messages": [
|
||||
HumanMessage(content="what is weather in sf"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {"name": "search_api", "arguments": '"query"'}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for query", name="search_api"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {"name": "search_api", "arguments": '"another"'}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for another", name="search_api"),
|
||||
AIMessage(content="answer"),
|
||||
]
|
||||
}
|
||||
|
||||
assert [
|
||||
c
|
||||
async for c in app.astream(
|
||||
{"messages": [HumanMessage(content="what is weather in sf")]}
|
||||
)
|
||||
] == [
|
||||
{
|
||||
"agent": {
|
||||
"messages": [
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": '"query"',
|
||||
}
|
||||
},
|
||||
)
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": {
|
||||
"messages": [
|
||||
FunctionMessage(content="result for query", name="search_api")
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"agent": {
|
||||
"messages": [
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": '"another"',
|
||||
}
|
||||
},
|
||||
)
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": {
|
||||
"messages": [
|
||||
FunctionMessage(content="result for another", name="search_api")
|
||||
]
|
||||
}
|
||||
},
|
||||
{"agent": {"messages": [AIMessage(content="answer")]}},
|
||||
{
|
||||
"__end__": {
|
||||
"messages": [
|
||||
HumanMessage(content="what is weather in sf"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": '"query"',
|
||||
}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for query", name="search_api"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": '"another"',
|
||||
}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for another", name="search_api"),
|
||||
AIMessage(content="answer"),
|
||||
]
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def test_message_graph() -> None:
|
||||
from langchain.chat_models.fake import FakeMessagesListChatModel
|
||||
from langchain_community.tools import tool
|
||||
from langchain_core.agents import AgentAction
|
||||
from langchain_core.messages import AIMessage, FunctionMessage, HumanMessage
|
||||
|
||||
class FakeFuntionChatModel(FakeMessagesListChatModel):
|
||||
def bind_functions(self, functions: list):
|
||||
return self
|
||||
|
||||
@tool()
|
||||
def search_api(query: str) -> str:
|
||||
"""Searches the API for the query."""
|
||||
return f"result for {query}"
|
||||
|
||||
tools = [search_api]
|
||||
|
||||
model = FakeFuntionChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": json.dumps("query"),
|
||||
}
|
||||
},
|
||||
),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": json.dumps("another"),
|
||||
}
|
||||
},
|
||||
),
|
||||
AIMessage(content="answer"),
|
||||
]
|
||||
)
|
||||
|
||||
tool_executor = ToolExecutor(tools)
|
||||
|
||||
# Define the function that determines whether to continue or not
|
||||
def should_continue(messages):
|
||||
last_message = messages[-1]
|
||||
# If there is no function call, then we finish
|
||||
if "function_call" not in last_message.additional_kwargs:
|
||||
return "end"
|
||||
# Otherwise if there is, we continue
|
||||
else:
|
||||
return "continue"
|
||||
|
||||
async def call_tool(messages):
|
||||
# Based on the continue condition
|
||||
# we know the last message involves a function call
|
||||
last_message = messages[-1]
|
||||
# We construct an AgentAction from the function_call
|
||||
action = AgentAction(
|
||||
tool=last_message.additional_kwargs["function_call"]["name"],
|
||||
tool_input=json.loads(
|
||||
last_message.additional_kwargs["function_call"]["arguments"]
|
||||
),
|
||||
log="",
|
||||
)
|
||||
# We call the tool_executor and get back a response
|
||||
response = await tool_executor.ainvoke(action)
|
||||
# We use the response to create a FunctionMessage
|
||||
return FunctionMessage(content=str(response), name=action.tool)
|
||||
|
||||
# Define a new graph
|
||||
workflow = MessageGraph()
|
||||
|
||||
# Define the two nodes we will cycle between
|
||||
workflow.add_node("agent", model)
|
||||
workflow.add_node("action", call_tool)
|
||||
|
||||
# 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
|
||||
app = workflow.compile()
|
||||
|
||||
assert await app.ainvoke(HumanMessage(content="what is weather in sf")) == [
|
||||
HumanMessage(content="what is weather in sf"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {"name": "search_api", "arguments": '"query"'}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for query", name="search_api"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {"name": "search_api", "arguments": '"another"'}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for another", name="search_api"),
|
||||
AIMessage(content="answer"),
|
||||
]
|
||||
|
||||
assert [
|
||||
c async for c in app.astream([HumanMessage(content="what is weather in sf")])
|
||||
] == [
|
||||
{
|
||||
"agent": AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {"name": "search_api", "arguments": '"query"'}
|
||||
},
|
||||
)
|
||||
},
|
||||
{"action": FunctionMessage(content="result for query", name="search_api")},
|
||||
{
|
||||
"agent": AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {"name": "search_api", "arguments": '"another"'}
|
||||
},
|
||||
)
|
||||
},
|
||||
{"action": FunctionMessage(content="result for another", name="search_api")},
|
||||
{"agent": AIMessage(content="answer")},
|
||||
{
|
||||
"__end__": [
|
||||
HumanMessage(content="what is weather in sf"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {"name": "search_api", "arguments": '"query"'}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for query", name="search_api"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {
|
||||
"name": "search_api",
|
||||
"arguments": '"another"',
|
||||
}
|
||||
},
|
||||
),
|
||||
FunctionMessage(content="result for another", name="search_api"),
|
||||
AIMessage(content="answer"),
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user