mirror of
https://github.com/langchain-ai/langgraph-codeact.git
synced 2026-07-15 21:29:39 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed7748bb45 | |||
| d7486984fe | |||
| efdb41a8ec | |||
| 65b55a9960 | |||
| 2095e8ad15 | |||
| f8cca3a4e4 | |||
| e732f61178 | |||
| b30ae0f80f | |||
| da92d6a68a | |||
| 4abac8b17e |
@@ -0,0 +1,21 @@
|
|||||||
|
# TODO: https://docs.astral.sh/uv/guides/integration/github/#caching
|
||||||
|
|
||||||
|
name: uv-install
|
||||||
|
description: Set up Python and uv
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
python-version:
|
||||||
|
description: Python version, supporting MAJOR.MINOR only
|
||||||
|
required: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
UV_VERSION: "0.5.25"
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Install uv and set the python version
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: ${{ env.UV_VERSION }}
|
||||||
|
python-version: ${{ inputs.python-version }}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
working-directory:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
description: "From which folder this pipeline executes"
|
||||||
|
python-version:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
description: "Python version to use"
|
||||||
|
|
||||||
|
env:
|
||||||
|
WORKDIR: ${{ inputs.working-directory == '' && '.' || inputs.working-directory }}
|
||||||
|
|
||||||
|
# This env var allows us to get inline annotations when ruff has complaints.
|
||||||
|
RUFF_OUTPUT_FORMAT: github
|
||||||
|
|
||||||
|
UV_FROZEN: "true"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: "make lint #${{ inputs.python-version }}"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python ${{ inputs.python-version }} + uv
|
||||||
|
uses: "./.github/actions/uv_setup"
|
||||||
|
with:
|
||||||
|
python-version: ${{ inputs.python-version }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: ${{ inputs.working-directory }}
|
||||||
|
run: |
|
||||||
|
uv sync --group test
|
||||||
|
|
||||||
|
- name: Analysing the code with our lint
|
||||||
|
working-directory: ${{ inputs.working-directory }}
|
||||||
|
run: |
|
||||||
|
make lint
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: test
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
working-directory:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
description: "From which folder this pipeline executes"
|
||||||
|
python-version:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
description: "Python version to use"
|
||||||
|
|
||||||
|
env:
|
||||||
|
UV_FROZEN: "true"
|
||||||
|
UV_NO_SYNC: "true"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ${{ inputs.working-directory }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
name: "make test #${{ inputs.python-version }}"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python ${{ inputs.python-version }} + uv
|
||||||
|
uses: "./.github/actions/uv_setup"
|
||||||
|
id: setup-python
|
||||||
|
with:
|
||||||
|
python-version: ${{ inputs.python-version }}
|
||||||
|
- name: Install dependencies
|
||||||
|
shell: bash
|
||||||
|
run: uv sync --group test
|
||||||
|
|
||||||
|
- name: Run core tests
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
make test
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
name: Run CI Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch: # Allows to trigger the workflow manually in GitHub UI
|
||||||
|
|
||||||
|
# If another push to the same PR or branch happens while this workflow is still running,
|
||||||
|
# cancel the earlier run in favor of the next run.
|
||||||
|
#
|
||||||
|
# There's no point in testing an outdated version of the code. GitHub only allows
|
||||||
|
# a limited number of job runners to be active at the same time, so it's better to cancel
|
||||||
|
# pointless jobs early so that more useful jobs can run sooner.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
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.12"
|
||||||
|
uses:
|
||||||
|
./.github/workflows/_lint.yml
|
||||||
|
with:
|
||||||
|
working-directory: .
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
secrets: inherit
|
||||||
|
test:
|
||||||
|
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.10"
|
||||||
|
- "3.12"
|
||||||
|
uses:
|
||||||
|
./.github/workflows/_test.yml
|
||||||
|
with:
|
||||||
|
working-directory: .
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
name: release
|
||||||
|
run-name: Release ${{ inputs.working-directory }} by @${{ github.actor }}
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
working-directory:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
description: "From which folder this pipeline executes"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
working-directory:
|
||||||
|
description: "From which folder this pipeline executes"
|
||||||
|
default: "."
|
||||||
|
dangerous-nonmain-release:
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: "Release from a non-main branch (danger!)"
|
||||||
|
|
||||||
|
env:
|
||||||
|
PYTHON_VERSION: "3.11"
|
||||||
|
UV_FROZEN: "true"
|
||||||
|
UV_NO_SYNC: "true"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: github.ref == 'refs/heads/main' || inputs.dangerous-nonmain-release
|
||||||
|
environment: Scheduled testing
|
||||||
|
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 + uv
|
||||||
|
uses: "./.github/actions/uv_setup"
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
|
# 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: uv build
|
||||||
|
- name: Upload build
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: ${{ inputs.working-directory }}/dist/
|
||||||
|
|
||||||
|
- name: Check Version
|
||||||
|
id: check-version
|
||||||
|
shell: python
|
||||||
|
working-directory: ${{ inputs.working-directory }}
|
||||||
|
run: |
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
with open("pyproject.toml", "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
pkg_name = data["project"]["name"]
|
||||||
|
version = data["project"]["version"]
|
||||||
|
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
||||||
|
f.write(f"pkg-name={pkg_name}\n")
|
||||||
|
f.write(f"version={version}\n")
|
||||||
|
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
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ${{ inputs.working-directory }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python + uv
|
||||||
|
uses: "./.github/actions/uv_setup"
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: ${{ inputs.working-directory }}/dist/
|
||||||
|
|
||||||
|
- name: Publish package distributions to PyPI
|
||||||
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
with:
|
||||||
|
packages-dir: ${{ inputs.working-directory }}/dist/
|
||||||
|
verbose: true
|
||||||
|
print-hash: true
|
||||||
|
# Temp workaround since attestations are on by default as of gh-action-pypi-publish v1.11.0
|
||||||
|
attestations: false
|
||||||
|
|
||||||
|
mark-release:
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
- publish
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
# This permission is needed by `ncipollo/release-action` to
|
||||||
|
# create the GitHub release.
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ${{ inputs.working-directory }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python + uv
|
||||||
|
uses: "./.github/actions/uv_setup"
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: ${{ inputs.working-directory }}/dist/
|
||||||
|
|
||||||
|
- name: Create Tag
|
||||||
|
uses: ncipollo/release-action@v1
|
||||||
|
with:
|
||||||
|
artifacts: "dist/*"
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
generateReleaseNotes: true
|
||||||
|
tag: ${{needs.build.outputs.pkg-name}}==${{ needs.build.outputs.version }}
|
||||||
|
body: ${{ needs.release-notes.outputs.release-body }}
|
||||||
|
commit: main
|
||||||
|
makeLatest: true
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
.PHONY: all lint format test help
|
||||||
|
|
||||||
|
# Default target executed when no arguments are given to make.
|
||||||
|
all: help
|
||||||
|
|
||||||
|
######################
|
||||||
|
# TESTING AND COVERAGE
|
||||||
|
######################
|
||||||
|
|
||||||
|
# Define a variable for the test file path.
|
||||||
|
TEST_FILE ?= tests/
|
||||||
|
|
||||||
|
test:
|
||||||
|
uv run pytest --disable-socket --allow-unix-socket $(TEST_FILE)
|
||||||
|
|
||||||
|
test_watch:
|
||||||
|
uv run ptw . -- $(TEST_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
######################
|
||||||
|
# LINTING AND FORMATTING
|
||||||
|
######################
|
||||||
|
|
||||||
|
# Define a variable for Python and notebook files.
|
||||||
|
lint format: PYTHON_FILES=.
|
||||||
|
lint_diff format_diff: PYTHON_FILES=$(shell git diff --relative=. --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$')
|
||||||
|
|
||||||
|
lint lint_diff:
|
||||||
|
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
|
||||||
|
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check $(PYTHON_FILES) --diff
|
||||||
|
# [ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES)
|
||||||
|
|
||||||
|
format format_diff:
|
||||||
|
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check --fix $(PYTHON_FILES)
|
||||||
|
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
######################
|
||||||
|
# HELP
|
||||||
|
######################
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo '===================='
|
||||||
|
@echo '-- LINTING --'
|
||||||
|
@echo 'format - run code formatters'
|
||||||
|
@echo 'lint - run linters'
|
||||||
|
@echo '-- TESTS --'
|
||||||
|
@echo 'test - run unit tests'
|
||||||
|
@echo 'test TEST_FILE=<test_file> - run all tests in file'
|
||||||
|
@echo '-- DOCUMENTATION tasks are from the top-level Makefile --'
|
||||||
|
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# langgraph-codeact
|
# langgraph-codeact
|
||||||
|
|
||||||
This library implements the [CodeAct architecture](https://arxiv.org/abs/2402.01030) in LangGraph. This is the architecture is used by Manus.im. It implements an alternative to JSON function-calling, which enables solving more complex tasks in less steps. This is achieved by making use of the full power of a Turing complete programming language (such as Python used here) to combine and transform the outputs of multiple tools.
|
This library implements the [CodeAct architecture](https://arxiv.org/abs/2402.01030) in LangGraph. This is the architecture is used by [Manus.im](https://manus.im/). It implements an alternative to JSON function-calling, which enables solving more complex tasks in less steps. This is achieved by making use of the full power of a Turing complete programming language (such as Python used here) to combine and transform the outputs of multiple tools.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -21,11 +21,13 @@ pip install langgraph-codeact
|
|||||||
To run the example install also
|
To run the example install also
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install langchain langchain-mcp-adapters langchain-anthropic
|
pip install langchain langchain-anthropic
|
||||||
```
|
```
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
|
A full version of this in one file can be found [here](examples/math_example.py)
|
||||||
|
|
||||||
### 1. Define your tools
|
### 1. Define your tools
|
||||||
|
|
||||||
You can use any tools you want, including custom tools, LangChain tools, or MCP tools. In this example, we define a few simple math functions.
|
You can use any tools you want, including custom tools, LangChain tools, or MCP tools. In this example, we define a few simple math functions.
|
||||||
@@ -35,52 +37,42 @@ import math
|
|||||||
|
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
|
|
||||||
@tool
|
|
||||||
def add(a: float, b: float) -> float:
|
def add(a: float, b: float) -> float:
|
||||||
"""Add two numbers together."""
|
"""Add two numbers together."""
|
||||||
return a + b
|
return a + b
|
||||||
|
|
||||||
@tool
|
|
||||||
def multiply(a: float, b: float) -> float:
|
def multiply(a: float, b: float) -> float:
|
||||||
"""Multiply two numbers together."""
|
"""Multiply two numbers together."""
|
||||||
return a * b
|
return a * b
|
||||||
|
|
||||||
@tool
|
|
||||||
def divide(a: float, b: float) -> float:
|
def divide(a: float, b: float) -> float:
|
||||||
"""Divide two numbers."""
|
"""Divide two numbers."""
|
||||||
return a / b
|
return a / b
|
||||||
|
|
||||||
@tool
|
|
||||||
def subtract(a: float, b: float) -> float:
|
def subtract(a: float, b: float) -> float:
|
||||||
"""Subtract two numbers."""
|
"""Subtract two numbers."""
|
||||||
return a - b
|
return a - b
|
||||||
|
|
||||||
@tool
|
|
||||||
def sin(a: float) -> float:
|
def sin(a: float) -> float:
|
||||||
"""Take the sine of a number."""
|
"""Take the sine of a number."""
|
||||||
return math.sin(a)
|
return math.sin(a)
|
||||||
|
|
||||||
@tool
|
|
||||||
def cos(a: float) -> float:
|
def cos(a: float) -> float:
|
||||||
"""Take the cosine of a number."""
|
"""Take the cosine of a number."""
|
||||||
return math.cos(a)
|
return math.cos(a)
|
||||||
|
|
||||||
@tool
|
|
||||||
def radians(a: float) -> float:
|
def radians(a: float) -> float:
|
||||||
"""Convert degrees to radians."""
|
"""Convert degrees to radians."""
|
||||||
return math.radians(a)
|
return math.radians(a)
|
||||||
|
|
||||||
@tool
|
|
||||||
def exponentiation(a: float, b: float) -> float:
|
def exponentiation(a: float, b: float) -> float:
|
||||||
"""Raise one number to the power of another."""
|
"""Raise one number to the power of another."""
|
||||||
return a**b
|
return a**b
|
||||||
|
|
||||||
@tool
|
|
||||||
def sqrt(a: float) -> float:
|
def sqrt(a: float) -> float:
|
||||||
"""Take the square root of a number."""
|
"""Take the square root of a number."""
|
||||||
return math.sqrt(a)
|
return math.sqrt(a)
|
||||||
|
|
||||||
@tool
|
|
||||||
def ceil(a: float) -> float:
|
def ceil(a: float) -> float:
|
||||||
"""Round a number up to the nearest integer."""
|
"""Round a number up to the nearest integer."""
|
||||||
return math.ceil(a)
|
return math.ceil(a)
|
||||||
@@ -106,20 +98,33 @@ You can use any code sandbox you want, pass it in as a function which accepts tw
|
|||||||
- the string of code to run
|
- the string of code to run
|
||||||
- the dictionary of locals to run it in (includes the tools, and any variables you set in the previous turns)
|
- the dictionary of locals to run it in (includes the tools, and any variables you set in the previous turns)
|
||||||
|
|
||||||
**NOTE:** use a sandboxed environment in production! The `eval` function below is just for demonstration purposes, not safe!
|
> [!Warning]
|
||||||
|
> Use a sandboxed environment in production! The `eval` function below is just for demonstration purposes, not safe!
|
||||||
|
|
||||||
```py
|
```py
|
||||||
import builtins
|
import builtins
|
||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def eval(code: str, _locals: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||||
|
# Store original keys before execution
|
||||||
|
original_keys = set(_locals.keys())
|
||||||
|
|
||||||
def eval(code: str, _locals: dict) -> str:
|
|
||||||
try:
|
try:
|
||||||
with redirect_stdout(io.StringIO()) as f:
|
with contextlib.redirect_stdout(io.StringIO()) as f:
|
||||||
exec(code, builtins.__dict__, _locals)
|
exec(code, builtins.__dict__, _locals)
|
||||||
return f.getvalue()
|
result = f.getvalue()
|
||||||
|
if not result:
|
||||||
|
result = "<code ran, no output printed to stdout>"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error during execution: {repr(e)}"
|
result = f"Error during execution: {repr(e)}"
|
||||||
|
|
||||||
|
# Determine new variables created during execution
|
||||||
|
new_keys = set(_locals.keys()) - original_keys
|
||||||
|
new_vars = {key: _locals[key] for key in new_keys}
|
||||||
|
return result, new_vars
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Create the CodeAct graph
|
### 3. Create the CodeAct graph
|
||||||
@@ -133,7 +138,8 @@ from langgraph.checkpoint.memory import MemorySaver
|
|||||||
|
|
||||||
model = init_chat_model("claude-3-7-sonnet-latest", model_provider="anthropic")
|
model = init_chat_model("claude-3-7-sonnet-latest", model_provider="anthropic")
|
||||||
|
|
||||||
code_act = create_codeact(tools, model, eval, checkpointer=MemorySaver())
|
code_act = create_codeact(model, tools, eval)
|
||||||
|
agent = code_act.compile(checkpointer=MemorySaver())
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Run it!
|
### 4. Run it!
|
||||||
@@ -142,9 +148,14 @@ You can use the `.invoke()` method to get the final result, or the `.stream()` m
|
|||||||
|
|
||||||
```py
|
```py
|
||||||
|
|
||||||
for typ, chunk in code_act.stream(
|
messages = [{
|
||||||
"A batter hits a baseball at 45.847 m/s at an angle of 23.474° above the horizontal. The outfielder, who starts facing the batter, picks up the baseball as it lands, then throws it back towards the batter at 24.12 m/s at an angle of 39.12 degrees. How far is the baseball from where the batter originally hit it? Assume zero air resistance.",
|
"role": "user",
|
||||||
|
"content": "A batter hits a baseball at 45.847 m/s at an angle of 23.474° above the horizontal. The outfielder, who starts facing the batter, picks up the baseball as it lands, then throws it back towards the batter at 24.12 m/s at an angle of 39.12 degrees. How far is the baseball from where the batter originally hit it? Assume zero air resistance."
|
||||||
|
}]
|
||||||
|
for typ, chunk in agent.stream(
|
||||||
|
{"messages": messages},
|
||||||
stream_mode=["values", "messages"],
|
stream_mode=["values", "messages"],
|
||||||
|
config={"configurable": {"thread_id": 1}},
|
||||||
):
|
):
|
||||||
if typ == "messages":
|
if typ == "messages":
|
||||||
print(chunk[0].content, end="")
|
print(chunk[0].content, end="")
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import base64
|
||||||
|
import builtins
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain.chat_models import init_chat_model
|
||||||
|
from langchain_core.runnables import RunnableConfig
|
||||||
|
from langgraph.checkpoint.memory import MemorySaver
|
||||||
|
|
||||||
|
from langgraph_codeact import create_codeact, create_default_prompt
|
||||||
|
|
||||||
|
|
||||||
|
def eval(code: str, _locals: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||||
|
# Store original keys before execution
|
||||||
|
original_keys = set(_locals.keys())
|
||||||
|
|
||||||
|
try:
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()) as f:
|
||||||
|
exec(code, builtins.__dict__, _locals)
|
||||||
|
result = f.getvalue()
|
||||||
|
if not result:
|
||||||
|
result = "<code ran, no output printed to stdout>"
|
||||||
|
except Exception as e:
|
||||||
|
result = f"Error during execution: {repr(e)}"
|
||||||
|
|
||||||
|
# Determine new variables created during execution
|
||||||
|
new_keys = set(_locals.keys()) - original_keys
|
||||||
|
new_vars = {key: _locals[key] for key in new_keys}
|
||||||
|
return result, new_vars
|
||||||
|
|
||||||
|
|
||||||
|
def caesar_shift_decode(text: str, shift: int) -> str:
|
||||||
|
"""Decode text that was encoded using Caesar shift.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The encoded text to decode
|
||||||
|
shift: The number of positions to shift back (positive number)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The decoded text
|
||||||
|
"""
|
||||||
|
result = ""
|
||||||
|
for char in text:
|
||||||
|
if char.isalpha():
|
||||||
|
# Determine the case and base ASCII value
|
||||||
|
ascii_base = ord("A") if char.isupper() else ord("a")
|
||||||
|
# Shift the character back and wrap around if needed
|
||||||
|
shifted = (ord(char) - ascii_base - shift) % 26
|
||||||
|
result += chr(ascii_base + shifted)
|
||||||
|
else:
|
||||||
|
result += char
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def base64_decode(text: str) -> str:
|
||||||
|
"""Decode text that was encoded using base64.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The base64 encoded text to decode
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The decoded text as a string
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If the input is not valid base64
|
||||||
|
"""
|
||||||
|
# Add padding if needed
|
||||||
|
padding = 4 - (len(text) % 4)
|
||||||
|
if padding != 4:
|
||||||
|
text += "=" * padding
|
||||||
|
|
||||||
|
# Decode the base64 string
|
||||||
|
decoded_bytes = base64.b64decode(text)
|
||||||
|
return decoded_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def caesar_shift_encode(text: str, shift: int) -> str:
|
||||||
|
"""Encode text using Caesar shift.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to encode
|
||||||
|
shift: The number of positions to shift forward (positive number)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The encoded text
|
||||||
|
"""
|
||||||
|
result = ""
|
||||||
|
for char in text:
|
||||||
|
if char.isalpha():
|
||||||
|
# Determine the case and base ASCII value
|
||||||
|
ascii_base = ord("A") if char.isupper() else ord("a")
|
||||||
|
# Shift the character forward and wrap around if needed
|
||||||
|
shifted = (ord(char) - ascii_base + shift) % 26
|
||||||
|
result += chr(ascii_base + shifted)
|
||||||
|
else:
|
||||||
|
result += char
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def base64_encode(text: str) -> str:
|
||||||
|
"""Encode text using base64.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to encode
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The base64 encoded text as a string
|
||||||
|
"""
|
||||||
|
# Convert text to bytes and encode
|
||||||
|
text_bytes = text.encode("utf-8")
|
||||||
|
encoded_bytes = base64.b64encode(text_bytes)
|
||||||
|
return encoded_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# List of available tools
|
||||||
|
tools = [
|
||||||
|
caesar_shift_decode,
|
||||||
|
base64_decode,
|
||||||
|
caesar_shift_encode,
|
||||||
|
base64_encode,
|
||||||
|
]
|
||||||
|
|
||||||
|
model = init_chat_model("gemini-2.0-flash", model_provider="google_genai")
|
||||||
|
code_act = create_codeact(
|
||||||
|
model,
|
||||||
|
tools,
|
||||||
|
eval,
|
||||||
|
prompt=create_default_prompt(
|
||||||
|
tools,
|
||||||
|
"Once you have the final answer, respond to the user with plain text, do not respond with a code snippet.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
agent = code_act.compile(checkpointer=MemorySaver())
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
def stream_from_agent(messages: list[dict], config: RunnableConfig):
|
||||||
|
for typ, chunk in agent.stream(
|
||||||
|
{"messages": messages},
|
||||||
|
stream_mode=["values", "messages"],
|
||||||
|
config=config,
|
||||||
|
):
|
||||||
|
if typ == "messages":
|
||||||
|
print(chunk[0].content, end="")
|
||||||
|
elif typ == "values":
|
||||||
|
print("\n\n---answer---\n\n", chunk)
|
||||||
|
|
||||||
|
# first turn
|
||||||
|
config = {"configurable": {"thread_id": 1}}
|
||||||
|
stream_from_agent(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Decipher this text: 'VGhybCB6dnRsYW9wdW4gZHZ1a2x5bWJz'",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
|
||||||
|
# second turn
|
||||||
|
stream_from_agent(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Using the same cipher as the original text, encode this text: 'The work is mysterious and important'",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
config,
|
||||||
|
)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import builtins
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain.chat_models import init_chat_model
|
||||||
|
from langgraph.checkpoint.memory import MemorySaver
|
||||||
|
|
||||||
|
from langgraph_codeact import create_codeact
|
||||||
|
|
||||||
|
|
||||||
|
def eval(code: str, _locals: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||||
|
# Store original keys before execution
|
||||||
|
original_keys = set(_locals.keys())
|
||||||
|
|
||||||
|
try:
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()) as f:
|
||||||
|
exec(code, builtins.__dict__, _locals)
|
||||||
|
result = f.getvalue()
|
||||||
|
if not result:
|
||||||
|
result = "<code ran, no output printed to stdout>"
|
||||||
|
except Exception as e:
|
||||||
|
result = f"Error during execution: {repr(e)}"
|
||||||
|
|
||||||
|
# Determine new variables created during execution
|
||||||
|
new_keys = set(_locals.keys()) - original_keys
|
||||||
|
new_vars = {key: _locals[key] for key in new_keys}
|
||||||
|
return result, new_vars
|
||||||
|
|
||||||
|
|
||||||
|
def add(a: float, b: float) -> float:
|
||||||
|
"""Add two numbers together."""
|
||||||
|
return a + b
|
||||||
|
|
||||||
|
|
||||||
|
def multiply(a: float, b: float) -> float:
|
||||||
|
"""Multiply two numbers together."""
|
||||||
|
return a * b
|
||||||
|
|
||||||
|
|
||||||
|
def divide(a: float, b: float) -> float:
|
||||||
|
"""Divide two numbers."""
|
||||||
|
return a / b
|
||||||
|
|
||||||
|
|
||||||
|
def subtract(a: float, b: float) -> float:
|
||||||
|
"""Subtract two numbers."""
|
||||||
|
return a - b
|
||||||
|
|
||||||
|
|
||||||
|
def sin(a: float) -> float:
|
||||||
|
"""Take the sine of a number."""
|
||||||
|
return math.sin(a)
|
||||||
|
|
||||||
|
|
||||||
|
def cos(a: float) -> float:
|
||||||
|
"""Take the cosine of a number."""
|
||||||
|
return math.cos(a)
|
||||||
|
|
||||||
|
|
||||||
|
def radians(a: float) -> float:
|
||||||
|
"""Convert degrees to radians."""
|
||||||
|
return math.radians(a)
|
||||||
|
|
||||||
|
|
||||||
|
def exponentiation(a: float, b: float) -> float:
|
||||||
|
"""Raise one number to the power of another."""
|
||||||
|
return a**b
|
||||||
|
|
||||||
|
|
||||||
|
def sqrt(a: float) -> float:
|
||||||
|
"""Take the square root of a number."""
|
||||||
|
return math.sqrt(a)
|
||||||
|
|
||||||
|
|
||||||
|
def ceil(a: float) -> float:
|
||||||
|
"""Round a number up to the nearest integer."""
|
||||||
|
return math.ceil(a)
|
||||||
|
|
||||||
|
|
||||||
|
tools = [
|
||||||
|
add,
|
||||||
|
multiply,
|
||||||
|
divide,
|
||||||
|
subtract,
|
||||||
|
sin,
|
||||||
|
cos,
|
||||||
|
radians,
|
||||||
|
exponentiation,
|
||||||
|
sqrt,
|
||||||
|
ceil,
|
||||||
|
]
|
||||||
|
|
||||||
|
model = init_chat_model("claude-3-7-sonnet-latest", model_provider="anthropic")
|
||||||
|
|
||||||
|
code_act = create_codeact(model, tools, eval)
|
||||||
|
agent = code_act.compile(checkpointer=MemorySaver())
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "A batter hits a baseball at 45.847 m/s at an angle of 23.474° above the horizontal. The outfielder, who starts facing the batter, picks up the baseball as it lands, then throws it back towards the batter at 24.12 m/s at an angle of 39.12 degrees. How far is the baseball from where the batter originally hit it? Assume zero air resistance.",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
for typ, chunk in agent.stream(
|
||||||
|
{"messages": messages},
|
||||||
|
stream_mode=["values", "messages"],
|
||||||
|
config={"configurable": {"thread_id": 1}},
|
||||||
|
):
|
||||||
|
if typ == "messages":
|
||||||
|
print(chunk[0].content, end="")
|
||||||
|
elif typ == "values":
|
||||||
|
print("\n\n---answer---\n\n", chunk)
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain.chat_models import init_chat_model
|
||||||
|
from langchain_sandbox import PyodideSandbox
|
||||||
|
from langgraph.checkpoint.memory import MemorySaver
|
||||||
|
|
||||||
|
from langgraph_codeact import EvalCoroutine, create_codeact
|
||||||
|
|
||||||
|
|
||||||
|
def create_pyodide_eval_fn(
|
||||||
|
sandbox_dir: str = "./sessions", session_id: str | None = None
|
||||||
|
) -> EvalCoroutine:
|
||||||
|
"""Create an eval_fn that uses PyodideSandbox.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sandbox_dir: Directory to store session files
|
||||||
|
session_id: ID of the session to use
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A function that evaluates code using PyodideSandbox
|
||||||
|
"""
|
||||||
|
sandbox = PyodideSandbox(sandbox_dir, allow_net=True)
|
||||||
|
|
||||||
|
async def async_eval_fn(code: str, _locals: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||||
|
# Create a wrapper function that will execute the code and return locals
|
||||||
|
wrapper_code = f"""
|
||||||
|
def execute():
|
||||||
|
try:
|
||||||
|
# Execute the provided code
|
||||||
|
{chr(10).join(" " + line for line in code.strip().split(chr(10)))}
|
||||||
|
return locals()
|
||||||
|
except Exception as e:
|
||||||
|
return {{"error": str(e)}}
|
||||||
|
|
||||||
|
execute()
|
||||||
|
"""
|
||||||
|
# Convert functions in _locals to their string representation
|
||||||
|
context_setup = ""
|
||||||
|
for key, value in _locals.items():
|
||||||
|
if callable(value):
|
||||||
|
# Get the function's source code
|
||||||
|
src = inspect.getsource(value)
|
||||||
|
context_setup += f"\n{src}"
|
||||||
|
else:
|
||||||
|
context_setup += f"\n{key} = {repr(value)}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Execute the code and get the result
|
||||||
|
response = await sandbox.execute(
|
||||||
|
code=context_setup + "\n\n" + wrapper_code,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if execution was successful
|
||||||
|
if response.stderr:
|
||||||
|
return f"Error during execution: {response.stderr}", {}
|
||||||
|
|
||||||
|
# Get the output from stdout
|
||||||
|
output = (
|
||||||
|
response.stdout if response.stdout else "<Code ran, no output printed to stdout>"
|
||||||
|
)
|
||||||
|
result = response.result
|
||||||
|
|
||||||
|
# If there was an error in the result, return it
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error during execution: {result['error']}", {}
|
||||||
|
|
||||||
|
# Get the new variables by comparing with original locals
|
||||||
|
new_vars = {
|
||||||
|
k: v for k, v in result.items() if k not in _locals and not k.startswith("_")
|
||||||
|
}
|
||||||
|
return output, new_vars
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error during PyodideSandbox execution: {repr(e)}", {}
|
||||||
|
|
||||||
|
return async_eval_fn
|
||||||
|
|
||||||
|
|
||||||
|
def add(a: float, b: float) -> float:
|
||||||
|
"""Add two numbers together."""
|
||||||
|
return a + b
|
||||||
|
|
||||||
|
|
||||||
|
def multiply(a: float, b: float) -> float:
|
||||||
|
"""Multiply two numbers together."""
|
||||||
|
return a * b
|
||||||
|
|
||||||
|
|
||||||
|
def divide(a: float, b: float) -> float:
|
||||||
|
"""Divide two numbers."""
|
||||||
|
return a / b
|
||||||
|
|
||||||
|
|
||||||
|
def subtract(a: float, b: float) -> float:
|
||||||
|
"""Subtract two numbers."""
|
||||||
|
return a - b
|
||||||
|
|
||||||
|
|
||||||
|
def sin(a: float) -> float:
|
||||||
|
"""Take the sine of a number."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
return math.sin(a)
|
||||||
|
|
||||||
|
|
||||||
|
def cos(a: float) -> float:
|
||||||
|
"""Take the cosine of a number."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
return math.cos(a)
|
||||||
|
|
||||||
|
|
||||||
|
def radians(a: float) -> float:
|
||||||
|
"""Convert degrees to radians."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
return math.radians(a)
|
||||||
|
|
||||||
|
|
||||||
|
def exponentiation(a: float, b: float) -> float:
|
||||||
|
"""Raise one number to the power of another."""
|
||||||
|
return a**b
|
||||||
|
|
||||||
|
|
||||||
|
def sqrt(a: float) -> float:
|
||||||
|
"""Take the square root of a number."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
return math.sqrt(a)
|
||||||
|
|
||||||
|
|
||||||
|
def ceil(a: float) -> float:
|
||||||
|
"""Round a number up to the nearest integer."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
return math.ceil(a)
|
||||||
|
|
||||||
|
|
||||||
|
tools = [
|
||||||
|
add,
|
||||||
|
multiply,
|
||||||
|
divide,
|
||||||
|
subtract,
|
||||||
|
sin,
|
||||||
|
cos,
|
||||||
|
radians,
|
||||||
|
exponentiation,
|
||||||
|
sqrt,
|
||||||
|
ceil,
|
||||||
|
]
|
||||||
|
|
||||||
|
model = init_chat_model("claude-3-7-sonnet-latest", model_provider="anthropic")
|
||||||
|
|
||||||
|
eval_fn = create_pyodide_eval_fn()
|
||||||
|
code_act = create_codeact(model, tools, eval_fn)
|
||||||
|
agent = code_act.compile(checkpointer=MemorySaver())
|
||||||
|
|
||||||
|
query = """A batter hits a baseball at 45.847 m/s at an angle of 23.474° above the horizontal. The outfielder, who starts facing the batter, picks up the baseball as it lands, then throws it back towards the batter at 24.12 m/s at an angle of 39.12 degrees. How far is the baseball from where the batter originally hit it? Assume zero air resistance."""
|
||||||
|
|
||||||
|
|
||||||
|
async def run_agent(query: str, thread_id: str):
|
||||||
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
|
# Stream agent outputs
|
||||||
|
async for typ, chunk in agent.astream(
|
||||||
|
{"messages": query},
|
||||||
|
stream_mode=["values", "messages"],
|
||||||
|
config=config,
|
||||||
|
):
|
||||||
|
if typ == "messages":
|
||||||
|
print(chunk[0].content, end="")
|
||||||
|
elif typ == "values":
|
||||||
|
print("\n\n---answer---\n\n", chunk)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Run the agent
|
||||||
|
asyncio.run(run_agent(query, "1"))
|
||||||
+103
-74
@@ -1,34 +1,38 @@
|
|||||||
import inspect
|
import inspect
|
||||||
from collections import ChainMap
|
from typing import Any, Awaitable, Callable, Optional, Sequence, Type, TypeVar, Union
|
||||||
from typing import Any, Callable, Optional, Sequence
|
|
||||||
|
|
||||||
from langchain_core.language_models import BaseChatModel
|
from langchain_core.language_models import BaseChatModel
|
||||||
from langchain_core.messages import AIMessage, BaseMessage, MessageLikeRepresentation
|
from langchain_core.tools import StructuredTool
|
||||||
from langchain_core.tools import Tool
|
from langchain_core.tools import tool as create_tool
|
||||||
|
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
from langgraph_codeact.utils import extract_and_combine_codeblocks
|
||||||
from langgraph.func import entrypoint, task
|
|
||||||
from langgraph.store.base import BaseStore
|
EvalFunction = Callable[[str, dict[str, Any]], tuple[str, dict[str, Any]]]
|
||||||
|
EvalCoroutine = Callable[[str, dict[str, Any]], Awaitable[tuple[str, dict[str, Any]]]]
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_PROMPT = """You will be given a task to perform. You should output either
|
class CodeActState(MessagesState):
|
||||||
|
"""State for CodeAct agent."""
|
||||||
|
|
||||||
|
script: Optional[str]
|
||||||
|
"""The Python code script to be executed."""
|
||||||
|
context: dict[str, Any]
|
||||||
|
"""Dictionary containing the execution context with available tools and variables."""
|
||||||
|
|
||||||
|
|
||||||
|
StateSchema = TypeVar("StateSchema", bound=CodeActState)
|
||||||
|
StateSchemaType = Type[StateSchema]
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_prompt(tools: list[StructuredTool], base_prompt: Optional[str] = None):
|
||||||
|
"""Create default prompt for the CodeAct agent."""
|
||||||
|
tools = [t if isinstance(t, StructuredTool) else create_tool(t) for t in tools]
|
||||||
|
prompt = f"{base_prompt}\n\n" if base_prompt else ""
|
||||||
|
prompt += """You will be given a task to perform. You should output either
|
||||||
- a Python code snippet that provides the solution to the task, or a step towards the solution. Any output you want to extract from the code should be printed to the console. Code should be output in a fenced code block.
|
- a Python code snippet that provides the solution to the task, or a step towards the solution. Any output you want to extract from the code should be printed to the console. Code should be output in a fenced code block.
|
||||||
- text to be shown directly to the user, if you want to ask for more information or provide the final answer."""
|
- text to be shown directly to the user, if you want to ask for more information or provide the final answer.
|
||||||
|
|
||||||
|
|
||||||
def create_codeact(
|
|
||||||
tools: Sequence[Tool],
|
|
||||||
model: BaseChatModel,
|
|
||||||
eval: Callable[[str, dict[str, Callable]], str],
|
|
||||||
*,
|
|
||||||
prompt: str = DEFAULT_PROMPT,
|
|
||||||
checkpointer: Optional[BaseCheckpointSaver] = None,
|
|
||||||
store: Optional[BaseStore] = None,
|
|
||||||
config_schema: Optional[type[Any]] = None,
|
|
||||||
):
|
|
||||||
# create the prompt
|
|
||||||
prompt = """
|
|
||||||
{prompt}
|
|
||||||
|
|
||||||
In addition to the Python Standard Library, you can use the following functions:
|
In addition to the Python Standard Library, you can use the following functions:
|
||||||
"""
|
"""
|
||||||
@@ -42,58 +46,83 @@ def {tool.name}{str(inspect.signature(tool.func))}:
|
|||||||
|
|
||||||
prompt += """
|
prompt += """
|
||||||
|
|
||||||
Variables defined at the top level of previous code snippets can be referenced in your code."""
|
Variables defined at the top level of previous code snippets can be referenced in your code.
|
||||||
|
|
||||||
@task
|
Reminder: use Python code snippets to call tools"""
|
||||||
def agent(
|
return prompt
|
||||||
messages: Sequence[MessageLikeRepresentation],
|
|
||||||
) -> tuple[AIMessage, Optional[str]]:
|
|
||||||
"""Calls model for next script or answer."""
|
def create_codeact(
|
||||||
msg = model.invoke(messages)
|
model: BaseChatModel,
|
||||||
# extract code block
|
tools: Sequence[Union[StructuredTool, Callable]],
|
||||||
if "```" in msg.content:
|
eval_fn: Union[EvalFunction, EvalCoroutine],
|
||||||
# get content between fences
|
*,
|
||||||
code = msg.content.split("```")[1]
|
prompt: Optional[str] = None,
|
||||||
# remove first line, which is the language or empty string
|
state_schema: StateSchemaType = CodeActState,
|
||||||
code = "\n".join(code.splitlines()[1:])
|
) -> StateGraph:
|
||||||
return msg, code
|
"""Create a CodeAct agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: The language model to use for generating code
|
||||||
|
tools: List of tools available to the agent. Can be passed as python functions or StructuredTool instances.
|
||||||
|
eval_fn: Function or coroutine that executes code in a sandbox. Takes code string and locals dict,
|
||||||
|
returns a tuple of (stdout output, new variables dict)
|
||||||
|
prompt: Optional custom system prompt. If None, uses default prompt.
|
||||||
|
To customize default prompt you can use `create_default_prompt` helper:
|
||||||
|
`create_default_prompt(tools, "You are a helpful assistant.")`
|
||||||
|
state_schema: The state schema to use for the agent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A StateGraph implementing the CodeAct architecture
|
||||||
|
"""
|
||||||
|
tools = [t if isinstance(t, StructuredTool) else create_tool(t) for t in tools]
|
||||||
|
|
||||||
|
if prompt is None:
|
||||||
|
prompt = create_default_prompt(tools)
|
||||||
|
|
||||||
|
# Make tools available to the code sandbox
|
||||||
|
tools_context = {tool.name: tool.func for tool in tools}
|
||||||
|
|
||||||
|
def call_model(state: StateSchema) -> Command:
|
||||||
|
messages = [{"role": "system", "content": prompt}] + state["messages"]
|
||||||
|
response = model.invoke(messages)
|
||||||
|
# Extract and combine all code blocks
|
||||||
|
code = extract_and_combine_codeblocks(response.content)
|
||||||
|
if code:
|
||||||
|
return Command(goto="sandbox", update={"messages": [response], "script": code})
|
||||||
else:
|
else:
|
||||||
# no code block, return None
|
# no code block, end the loop and respond to the user
|
||||||
return msg, None
|
return Command(update={"messages": [response], "script": None})
|
||||||
|
|
||||||
@task
|
# If eval_fn is a async, we define async node function.
|
||||||
def sandbox(script: str, context: dict[str, Callable]) -> str:
|
if inspect.iscoroutinefunction(eval_fn):
|
||||||
"""Executes the script in a sandboxed environment."""
|
|
||||||
# execute the script
|
|
||||||
return eval(script, context)
|
|
||||||
|
|
||||||
@entrypoint(checkpointer=checkpointer, store=store, config_schema=config_schema)
|
async def sandbox(state: StateSchema):
|
||||||
def codeact(
|
existing_context = state.get("context", {})
|
||||||
task: str, *, previous: Optional[tuple[list[BaseMessage], dict[str, Any]]]
|
context = {**existing_context, **tools_context}
|
||||||
) -> str:
|
# Execute the script in the sandbox
|
||||||
# will accumulate messages
|
output, new_vars = await eval_fn(state["script"], context)
|
||||||
msgs = [("system", prompt)]
|
new_context = {**existing_context, **new_vars}
|
||||||
# will accumulate variables defined at script top-level
|
return {
|
||||||
locs = {}
|
"messages": [{"role": "user", "content": output}],
|
||||||
# contains locals + tools
|
"context": new_context,
|
||||||
context = ChainMap(locs, {tool.name: tool.func for tool in tools})
|
}
|
||||||
# add previous turn
|
else:
|
||||||
if previous is not None:
|
|
||||||
prev_msgs, prev_locals = previous
|
|
||||||
msgs.extend(prev_msgs)
|
|
||||||
locs.update(prev_locals)
|
|
||||||
# add task to messages
|
|
||||||
msgs.append(("user", task))
|
|
||||||
while True:
|
|
||||||
# call agent
|
|
||||||
msg, script = agent(msgs).result()
|
|
||||||
# add message to history
|
|
||||||
msgs.append(msg)
|
|
||||||
if script is not None:
|
|
||||||
output = sandbox(script, context).result()
|
|
||||||
# add script output to messages
|
|
||||||
msgs.append(("user", output))
|
|
||||||
else:
|
|
||||||
return msg.content
|
|
||||||
|
|
||||||
return codeact
|
def sandbox(state: StateSchema):
|
||||||
|
existing_context = state.get("context", {})
|
||||||
|
context = {**existing_context, **tools_context}
|
||||||
|
# Execute the script in the sandbox
|
||||||
|
output, new_vars = eval_fn(state["script"], context)
|
||||||
|
new_context = {**existing_context, **new_vars}
|
||||||
|
return {
|
||||||
|
"messages": [{"role": "user", "content": output}],
|
||||||
|
"context": new_context,
|
||||||
|
}
|
||||||
|
|
||||||
|
agent = StateGraph(state_schema)
|
||||||
|
agent.add_node(call_model, destinations=(END, "sandbox"))
|
||||||
|
agent.add_node(sandbox)
|
||||||
|
agent.add_edge(START, "call_model")
|
||||||
|
agent.add_edge("sandbox", "call_model")
|
||||||
|
return agent
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
BACKTICK_PATTERN = r"(?:^|\n)```(.*?)(?:```(?:\n|$))"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_and_combine_codeblocks(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Extracts all codeblocks from a text string and combines them into a single code string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: A string containing zero or more codeblocks, where each codeblock is
|
||||||
|
surrounded by triple backticks (```).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A string containing the combined code from all codeblocks, with each codeblock
|
||||||
|
separated by a newline.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
text = '''Here's some code:
|
||||||
|
|
||||||
|
```python
|
||||||
|
print('hello')
|
||||||
|
```
|
||||||
|
And more:
|
||||||
|
|
||||||
|
```
|
||||||
|
print('world')
|
||||||
|
```'''
|
||||||
|
|
||||||
|
result = extract_and_combine_codeblocks(text)
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
print('hello')
|
||||||
|
|
||||||
|
print('world')
|
||||||
|
"""
|
||||||
|
# Find all code blocks in the text using regex
|
||||||
|
# Pattern matches anything between triple backticks, with or without a language identifier
|
||||||
|
code_blocks = re.findall(BACKTICK_PATTERN, text, re.DOTALL)
|
||||||
|
|
||||||
|
if not code_blocks:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Process each codeblock
|
||||||
|
processed_blocks = []
|
||||||
|
for block in code_blocks:
|
||||||
|
# Strip leading and trailing whitespace
|
||||||
|
block = block.strip()
|
||||||
|
|
||||||
|
# If the first line looks like a language identifier, remove it
|
||||||
|
lines = block.split("\n")
|
||||||
|
if lines and (not lines[0].strip() or " " not in lines[0].strip()):
|
||||||
|
# First line is empty or likely a language identifier (no spaces)
|
||||||
|
block = "\n".join(lines[1:])
|
||||||
|
|
||||||
|
processed_blocks.append(block)
|
||||||
|
|
||||||
|
# Combine all codeblocks with newlines between them
|
||||||
|
combined_code = "\n\n".join(processed_blocks)
|
||||||
|
return combined_code
|
||||||
+50
-9
@@ -1,23 +1,64 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langgraph-codeact"
|
name = "langgraph-codeact"
|
||||||
version = "0.1.0"
|
version = "0.1.3"
|
||||||
description = ""
|
description = "LangGraph implementation of CodeAct agent that generates and executes code instead of tool calling."
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Nuno Campos",email = "nuno@langchain.dev"}
|
{name = "Nuno Campos",email = "nuno@langchain.dev"}
|
||||||
]
|
]
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9,<4.0"
|
requires-python = ">=3.10,<4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"langgraph (>=0.3.5,<0.4.0)"
|
"langgraph (>=0.3.5,<0.4.0)"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
requires = ["pdm-backend"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "pdm.backend"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[dependency-groups]
|
||||||
langchain-anthropic = "^0.3.9"
|
dev = [
|
||||||
langchain = "^0.3.20"
|
"langchain-anthropic>=0.3.9,<0.4.0",
|
||||||
|
"langchain>=0.3.20,<0.4.0",
|
||||||
|
"langchain-sandbox>=0.0.3,<0.1.0"
|
||||||
|
]
|
||||||
|
test = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"ruff>=0.9.4",
|
||||||
|
"mypy>=1.8.0",
|
||||||
|
"pytest-socket>=0.7.0",
|
||||||
|
"types-setuptools>=69.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
minversion = "8.0"
|
||||||
|
addopts = "-ra -q -v"
|
||||||
|
testpaths = [
|
||||||
|
"tests",
|
||||||
|
]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py310"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"F", # pyflakes
|
||||||
|
"I", # isort
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"E501" # line-length
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
def test_import() -> None:
|
||||||
|
"""Test that the code can be imported"""
|
||||||
|
from langgraph_codeact import ( # noqa: F401
|
||||||
|
create_codeact,
|
||||||
|
create_default_prompt,
|
||||||
|
)
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
from langgraph_codeact.utils import extract_and_combine_codeblocks
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_text():
|
||||||
|
"""Test when the input text has no codeblocks."""
|
||||||
|
text = "This is a text without any code blocks."
|
||||||
|
result = extract_and_combine_codeblocks(text)
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_codeblock_no_language():
|
||||||
|
"""Test extracting a single codeblock without language identifier."""
|
||||||
|
text = """Here is a code block:
|
||||||
|
```
|
||||||
|
print("Hello, world!")
|
||||||
|
x = 10
|
||||||
|
```
|
||||||
|
End of the code."""
|
||||||
|
|
||||||
|
expected = """\
|
||||||
|
print("Hello, world!")
|
||||||
|
x = 10\
|
||||||
|
"""
|
||||||
|
result = extract_and_combine_codeblocks(text)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_codeblock_with_language():
|
||||||
|
"""Test extracting a single codeblock with language identifier."""
|
||||||
|
text = """Here is a code block:
|
||||||
|
```python
|
||||||
|
print("Hello, world!")
|
||||||
|
x = 10
|
||||||
|
```
|
||||||
|
End of the code."""
|
||||||
|
|
||||||
|
expected = """\
|
||||||
|
print("Hello, world!")
|
||||||
|
x = 10\
|
||||||
|
"""
|
||||||
|
result = extract_and_combine_codeblocks(text)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_codeblocks():
|
||||||
|
"""Test extracting and combining multiple codeblocks."""
|
||||||
|
text = """Here's the first code block:
|
||||||
|
```python
|
||||||
|
def hello():
|
||||||
|
print("Hello!")
|
||||||
|
```
|
||||||
|
|
||||||
|
And here's the second one:
|
||||||
|
```python
|
||||||
|
result = 42
|
||||||
|
print(f"The answer is {result}")
|
||||||
|
```"""
|
||||||
|
|
||||||
|
expected = """\
|
||||||
|
def hello():
|
||||||
|
print("Hello!")
|
||||||
|
|
||||||
|
result = 42
|
||||||
|
print(f"The answer is {result}")\
|
||||||
|
"""
|
||||||
|
result = extract_and_combine_codeblocks(text)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_codeblocks_mixed():
|
||||||
|
"""Test codeblocks with a mix of language identifiers / no identifiers."""
|
||||||
|
text = """Different language identifiers:
|
||||||
|
```python
|
||||||
|
x = 10
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
y = 20
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
z = 30
|
||||||
|
```"""
|
||||||
|
|
||||||
|
expected = """\
|
||||||
|
x = 10
|
||||||
|
|
||||||
|
y = 20
|
||||||
|
|
||||||
|
z = 30\
|
||||||
|
"""
|
||||||
|
result = extract_and_combine_codeblocks(text)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_codeblock():
|
||||||
|
"""Test an empty codeblock."""
|
||||||
|
text = "Empty block: `````` should be ignored."
|
||||||
|
result = extract_and_combine_codeblocks(text)
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_language_with_spaces():
|
||||||
|
"""Test a codeblock with a language identifier containing spaces."""
|
||||||
|
text = """Here is code with a more unusual language tag:
|
||||||
|
```python code
|
||||||
|
x = 10
|
||||||
|
y = 20
|
||||||
|
```"""
|
||||||
|
|
||||||
|
# The first line shouldn't be removed since it contains spaces
|
||||||
|
expected = """\
|
||||||
|
python code
|
||||||
|
x = 10
|
||||||
|
y = 20\
|
||||||
|
"""
|
||||||
|
result = extract_and_combine_codeblocks(text)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_nested_backticks():
|
||||||
|
"""Test with nested backticks inside the code block."""
|
||||||
|
text = """Code with nested backticks:
|
||||||
|
```
|
||||||
|
def example():
|
||||||
|
code = "```nested```"
|
||||||
|
return code
|
||||||
|
```"""
|
||||||
|
|
||||||
|
expected = """\
|
||||||
|
def example():
|
||||||
|
code = "```nested```"
|
||||||
|
return code\
|
||||||
|
"""
|
||||||
|
result = extract_and_combine_codeblocks(text)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_realistic_example():
|
||||||
|
"""Test with a realistic example similar to the one provided in the user query."""
|
||||||
|
text = """First, I'll find where the baseball lands when hit by the batter. Then, I'll calculate where the ball lands after being thrown by the outfielder.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Constants
|
||||||
|
g = 9.81 # acceleration due to gravity
|
||||||
|
v0_batter = 45.847 # initial velocity
|
||||||
|
angle_batter_deg = 23.474 # angle in degrees
|
||||||
|
|
||||||
|
print(f"The ball lands {distance:.2f} meters away")
|
||||||
|
```
|
||||||
|
|
||||||
|
Now, let's calculate the second trajectory:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Outfielder's throw
|
||||||
|
v0_outfielder = 24.12 # initial velocity
|
||||||
|
distance_2 = v0_outfielder * 2 # simplified calculation
|
||||||
|
print(f"Final position: {distance_2:.2f} meters")
|
||||||
|
```"""
|
||||||
|
|
||||||
|
expected = """\
|
||||||
|
# Constants
|
||||||
|
g = 9.81 # acceleration due to gravity
|
||||||
|
v0_batter = 45.847 # initial velocity
|
||||||
|
angle_batter_deg = 23.474 # angle in degrees
|
||||||
|
|
||||||
|
print(f"The ball lands {distance:.2f} meters away")
|
||||||
|
|
||||||
|
# Outfielder's throw
|
||||||
|
v0_outfielder = 24.12 # initial velocity
|
||||||
|
distance_2 = v0_outfielder * 2 # simplified calculation
|
||||||
|
print(f"Final position: {distance_2:.2f} meters")\
|
||||||
|
"""
|
||||||
|
result = extract_and_combine_codeblocks(text)
|
||||||
|
assert result == expected
|
||||||
Reference in New Issue
Block a user