Skip to content

Add CodeAct module #8222

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open

Conversation

TomeHirata
Copy link
Collaborator

This PR add a new module called CodeAct, which adopts the CodeAct architecture to manipulate the Python interpreter with predefined tools.

from dspy.predict import CodeAct
def factorial(n):
    if n == 1:
        return 1
    return n * factorial(n-1)

act = CodeAct("n->factorial", tools=[factorial])
act(n=5) # 120

@TomeHirata TomeHirata force-pushed the feat/codeact/init branch from a6ef78b to 5ab5d99 Compare May 15, 2025 13:32
@@ -47,6 +47,7 @@ def __init__(self, signature, tools: list[Callable], max_iters=5):

for idx, tool in enumerate(tools.values()):
instr.append(f"({idx + 1}) {tool}")
instr.append("You should pass the tool argument in JSON format")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe revise to "When providing next_tool_args, the value inside the field must be in JSON format".

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I'm confused about line 37, which has

                "When selecting the next_tool_name and its next_tool_args, the tool must be one of:\n",

I thought that was removed? Am I thinking of a different PR?

Copy link
Collaborator Author

@TomeHirata TomeHirata May 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 37 will be removed in #8190 👍

Example:

```python
from dspy.predict import CodeAct
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we follow up with a code tutorial demonstrating a use case of CodeAct?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, I will file a follow up PR for documentation

if any(
not inspect.isfunction(tool.func) for tool in tools
):
raise ValueError("CodeAct only accepts functions and not callable objects.")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious - why do we have this constraint?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it is hard to define the callable object in the python interpreter. Unlike functions, we need to define class and what parameters are passed to the object initialization, which is not visible.

Copy link
Collaborator

@chenmoneygithub chenmoneygithub left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome work!

f"Your goal is to generate executable Python code that collects any necessary information for producing {outputs}.\n"
"For each iteration, you will generate a code snippet that either solves the task or progresses towards the solution.\n"
"Ensure any output you wish to extract from the code is printed to the console. The code should be enclosed in a fenced code block.\n"
f"When all information for producing the outputs ({outputs}) are available to be extracted, mark `finished=True` besides the final Python code.\n"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should that be "besides the final Python code" or finished=True and generated_code is not None be mutual exclusive?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we have an extractor at last, I designed it in a way that the code passed with finished=True is executed to minimize the interaction count.

def forward(self, **kwargs):
# Define the tool funcitons in the interpreter
for tool in self.tools.values():
self.interpreter(inspect.getsource(tool.func))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shall we move it to init, and only run this line for call-time tools? (the new change in ReAct)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since python interpreter is shutdown every forward, this needs to happen in forward too.

return a + b

@skip_if_deno_not_available
def test_codeact_tool_validation():
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may also want to test the input flow - use a mock litellm.completion to capture the prompt, and validate our tool + code information are correctly included in the prompt.

Copy link
Collaborator

@chenmoneygithub chenmoneygithub left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code looks good to me! I will need to play with this new Module a bit today or tomorrow.

@chenmoneygithub
Copy link
Collaborator

chenmoneygithub commented May 24, 2025

I encountered an error when the function arg is of pydantic type:

import dspy

lm_4o_mini = dspy.LM("openai/gpt-4o-mini")
dspy.configure(
    lm=lm_4o_mini,
)


from pydantic import BaseModel

from dspy.datasets import DataLoader

kwargs = dict(fields=("claim", "supporting_facts", "hpqa_id", "num_hops"), input_keys=("claim",))
hover = DataLoader().from_huggingface(dataset_name="hover-nlp/hover", split="train", trust_remote_code=True, **kwargs)

hpqa_ids = set()
filtered_hover = []
for x in hover:
    if x["num_hops"] == 3 and x["hpqa_id"] not in hpqa_ids:
        hpqa_ids.add(x["hpqa_id"])
        filtered_hover.append(
            dspy.Example(claim=x.claim, titles=list(set([y["key"] for y in x.supporting_facts]))).with_inputs("claim")
        )
hover = filtered_hover

trainset, devset, testset = hover[:100], hover[100:150], hover[650:]

example = trainset[0]

print("Claim:", example.claim)
print("Pages that must be retrieved:", example.titles)

DOCS = {}


class SearchInput(BaseModel):
    query: str


def search(query: str, k: int) -> list[str]:
    results = dspy.ColBERTv2(url="http://20.102.90.50:2017/wiki17_abstracts")(query, k=k)
    results = [x["text"] for x in results]

    for result in results:
        title, text = result.split(" | ", 1)
        DOCS[title] = text

    return results


def search_wikipedia(query: SearchInput) -> list[str]:
    """Returns top-5 results and then the titles of the top-5 to top-30 results."""

    topK = search(query.query, 30)
    titles, topK = [f"`{x.split(' | ')[0]}`" for x in topK[5:30]], topK[:5]
    return topK + [f"Other retrieved pages have titles: {', '.join(titles)}."]


def lookup_wikipedia(title: str) -> str:
    """Returns the text of the Wikipedia page, if it exists."""

    if title in DOCS:
        return DOCS[title]

    results = [x for x in search(title, 10) if x.startswith(title + " | ")]
    if not results:
        return f"No Wikipedia page found for title: {title}"
    return results[0]


instructions = "Find all Wikipedia titles relevant to verifying (or refuting) the claim."
signature = dspy.Signature("claim -> titles: list[str]", instructions)

tools = [dspy.Tool(search_wikipedia), dspy.Tool(lookup_wikipedia)]

codeact = dspy.CodeAct(signature, tools=tools, max_iters=20)

output = codeact(claim="David Gregory was born in 1625.")

Error:

dspy.primitives.python_interpreter.InterpreterError: NameError: ["name 'SearchInput' is not defined"]

Looks like we also need to register the custom types to the interpreter.

I tried to change query: SearchInput to query: str, but doesn't work either with the following error:

[[ ## observation_18 ## ]]
Failed to execute the generated code: NameError: ["name 'search' is not defined"]

search is a helper function called inside search_wikipedia(query: str), and the registration misses it.

@TomeHirata
Copy link
Collaborator Author

TomeHirata commented May 26, 2025

@chenmoneygithub Thanks for trying this out. That is a known limitation where Tool functions can only depend on built-in naming. In other words, tools should be self-contained. We could re-register non-tool variables in locals(), but I worry that this causes unnecessary latency or failures of the re-registration due to external packages or definition ordering. Since LLM can generate code rather than json to pass args to the tools, I don't think this is the blocking limitation. The langgraph-codeact also has this limitation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants