mirror of
https://github.com/langchain-ai/langgraph-codeact.git
synced 2026-07-09 13:45:45 -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
|
||||
|
||||
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
|
||||
|
||||
@@ -21,11 +21,13 @@ pip install langgraph-codeact
|
||||
To run the example install also
|
||||
|
||||
```bash
|
||||
pip install langchain langchain-mcp-adapters langchain-anthropic
|
||||
pip install langchain langchain-anthropic
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
A full version of this in one file can be found [here](examples/math_example.py)
|
||||
|
||||
### 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.
|
||||
@@ -35,52 +37,42 @@ import math
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
@tool
|
||||
def add(a: float, b: float) -> float:
|
||||
"""Add two numbers together."""
|
||||
return a + b
|
||||
|
||||
@tool
|
||||
def multiply(a: float, b: float) -> float:
|
||||
"""Multiply two numbers together."""
|
||||
return a * b
|
||||
|
||||
@tool
|
||||
def divide(a: float, b: float) -> float:
|
||||
"""Divide two numbers."""
|
||||
return a / b
|
||||
|
||||
@tool
|
||||
def subtract(a: float, b: float) -> float:
|
||||
"""Subtract two numbers."""
|
||||
return a - b
|
||||
|
||||
@tool
|
||||
def sin(a: float) -> float:
|
||||
"""Take the sine of a number."""
|
||||
return math.sin(a)
|
||||
|
||||
@tool
|
||||
def cos(a: float) -> float:
|
||||
"""Take the cosine of a number."""
|
||||
return math.cos(a)
|
||||
|
||||
@tool
|
||||
def radians(a: float) -> float:
|
||||
"""Convert degrees to radians."""
|
||||
return math.radians(a)
|
||||
|
||||
@tool
|
||||
def exponentiation(a: float, b: float) -> float:
|
||||
"""Raise one number to the power of another."""
|
||||
return a**b
|
||||
|
||||
@tool
|
||||
def sqrt(a: float) -> float:
|
||||
"""Take the square root of a number."""
|
||||
return math.sqrt(a)
|
||||
|
||||
@tool
|
||||
def ceil(a: float) -> float:
|
||||
"""Round a number up to the nearest integer."""
|
||||
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 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
|
||||
import builtins
|
||||
import contextlib
|
||||
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:
|
||||
with redirect_stdout(io.StringIO()) as f:
|
||||
with contextlib.redirect_stdout(io.StringIO()) as f:
|
||||
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:
|
||||
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
|
||||
@@ -133,7 +138,8 @@ from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
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!
|
||||
@@ -142,9 +148,14 @@ You can use the `.invoke()` method to get the final result, or the `.stream()` m
|
||||
|
||||
```py
|
||||
|
||||
for typ, chunk in code_act.stream(
|
||||
"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.",
|
||||
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="")
|
||||
|
||||
@@ -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
|
||||
from collections import ChainMap
|
||||
from typing import Any, Callable, Optional, Sequence
|
||||
from typing import Any, Awaitable, Callable, Optional, Sequence, Type, TypeVar, Union
|
||||
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, BaseMessage, MessageLikeRepresentation
|
||||
from langchain_core.tools import Tool
|
||||
from langchain_core.tools import StructuredTool
|
||||
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.func import entrypoint, task
|
||||
from langgraph.store.base import BaseStore
|
||||
from langgraph_codeact.utils import extract_and_combine_codeblocks
|
||||
|
||||
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.
|
||||
- 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}
|
||||
- text to be shown directly to the user, if you want to ask for more information or provide the final answer.
|
||||
|
||||
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 += """
|
||||
|
||||
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
|
||||
def agent(
|
||||
messages: Sequence[MessageLikeRepresentation],
|
||||
) -> tuple[AIMessage, Optional[str]]:
|
||||
"""Calls model for next script or answer."""
|
||||
msg = model.invoke(messages)
|
||||
# extract code block
|
||||
if "```" in msg.content:
|
||||
# get content between fences
|
||||
code = msg.content.split("```")[1]
|
||||
# remove first line, which is the language or empty string
|
||||
code = "\n".join(code.splitlines()[1:])
|
||||
return msg, code
|
||||
Reminder: use Python code snippets to call tools"""
|
||||
return prompt
|
||||
|
||||
|
||||
def create_codeact(
|
||||
model: BaseChatModel,
|
||||
tools: Sequence[Union[StructuredTool, Callable]],
|
||||
eval_fn: Union[EvalFunction, EvalCoroutine],
|
||||
*,
|
||||
prompt: Optional[str] = None,
|
||||
state_schema: StateSchemaType = CodeActState,
|
||||
) -> StateGraph:
|
||||
"""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:
|
||||
# no code block, return None
|
||||
return msg, None
|
||||
# no code block, end the loop and respond to the user
|
||||
return Command(update={"messages": [response], "script": None})
|
||||
|
||||
@task
|
||||
def sandbox(script: str, context: dict[str, Callable]) -> str:
|
||||
"""Executes the script in a sandboxed environment."""
|
||||
# execute the script
|
||||
return eval(script, context)
|
||||
# If eval_fn is a async, we define async node function.
|
||||
if inspect.iscoroutinefunction(eval_fn):
|
||||
|
||||
@entrypoint(checkpointer=checkpointer, store=store, config_schema=config_schema)
|
||||
def codeact(
|
||||
task: str, *, previous: Optional[tuple[list[BaseMessage], dict[str, Any]]]
|
||||
) -> str:
|
||||
# will accumulate messages
|
||||
msgs = [("system", prompt)]
|
||||
# will accumulate variables defined at script top-level
|
||||
locs = {}
|
||||
# contains locals + tools
|
||||
context = ChainMap(locs, {tool.name: tool.func for tool in tools})
|
||||
# add previous turn
|
||||
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
|
||||
async def sandbox(state: StateSchema):
|
||||
existing_context = state.get("context", {})
|
||||
context = {**existing_context, **tools_context}
|
||||
# Execute the script in the sandbox
|
||||
output, new_vars = await eval_fn(state["script"], context)
|
||||
new_context = {**existing_context, **new_vars}
|
||||
return {
|
||||
"messages": [{"role": "user", "content": output}],
|
||||
"context": new_context,
|
||||
}
|
||||
else:
|
||||
|
||||
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]
|
||||
name = "langgraph-codeact"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
version = "0.1.3"
|
||||
description = "LangGraph implementation of CodeAct agent that generates and executes code instead of tool calling."
|
||||
authors = [
|
||||
{name = "Nuno Campos",email = "nuno@langchain.dev"}
|
||||
]
|
||||
license = {text = "MIT"}
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9,<4.0"
|
||||
requires-python = ">=3.10,<4.0"
|
||||
dependencies = [
|
||||
"langgraph (>=0.3.5,<0.4.0)"
|
||||
]
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["pdm-backend"]
|
||||
build-backend = "pdm.backend"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
langchain-anthropic = "^0.3.9"
|
||||
langchain = "^0.3.20"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"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