10 Commits

Author SHA1 Message Date
vbarda ed7748bb45 release 0.1.3 2025-04-25 09:36:01 -04:00
Vadym Barda d7486984fe support async eval function and add pyodide sandbox example (#23) 2025-04-25 09:32:44 -04:00
Vadym Barda efdb41a8ec release 0.1.2 (#19) 2025-04-11 14:44:25 -04:00
Vadym Barda 65b55a9960 add state schema (#18) 2025-04-11 14:42:32 -04:00
Vadym Barda 2095e8ad15 release 0.1.1 (#16) 2025-04-05 12:25:35 -04:00
Vadym Barda f8cca3a4e4 handle multiple codeblocks (#15) 2025-04-05 12:24:48 -04:00
sohn e732f61178 Fix: incorrect math_example.py path in README (fix 404 error) (#4)
The previous README file referenced `example/math.py`, which resulted in a 404 error.  
Updated the path to the correct `example/math_example.py` .
2025-03-26 14:21:04 -04:00
Vadym Barda b30ae0f80f add description (#3) 2025-03-26 13:27:23 -04:00
Vadym Barda da92d6a68a add CI & release GHA (#2) 2025-03-26 10:29:29 -04:00
Harrison Chase 4abac8b17e some changes (#1) 2025-03-26 09:53:37 -04:00
17 changed files with 2509 additions and 103 deletions
+21
View File
@@ -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 }}
+44
View File
@@ -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
+42
View File
@@ -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
+58
View File
@@ -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
+151
View File
@@ -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
+53
View File
@@ -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 --'
+31 -20
View File
@@ -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="")
+170
View File
@@ -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,
)
+115
View File
@@ -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)
+180
View File
@@ -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
View File
@@ -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
+61
View File
@@ -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
View File
@@ -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
View File
+6
View File
@@ -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,
)
+175
View File
@@ -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
Generated
+1249
View File
File diff suppressed because it is too large Load Diff