Repository: stanfordnlp/dspy
Pull request #7621 (2025-01-31 05:42:59)

Fix react bug in 2.6.0 that cause tools without type hint to fail
## Problem
When using functions without type hints as tools in ReAct, the code would throw an error when trying to access `arg_types[k]` since `arg_types` would be empty. This made it impossible to use simple functions without type annotations as tools, even for basic operations.

## Solution
Modified the tool argument parsing logic to:
1. First check if the tool has `arg_types` attribute
2. Then check if the specific argument key exists in `arg_types`
3. Only attempt type validation for arguments that have explicit type hints
4. Pass through arguments without type hints as-is

Added a test case `test_tool_calling_without_typehint` that demonstrates:
- A simple function `foo(a, b)` without type hints can be used as a tool
- Arguments are correctly passed through without type validation
- The tool executes successfully and returns expected results
- Previous tests, including the ones that uses pydantic args, does not break


Patch:
diff --git a/dspy/clients/base_lm.py b/dspy/clients/base_lm.py
index d71d384b..1cb97575 100644
--- a/dspy/clients/base_lm.py
+++ b/dspy/clients/base_lm.py
@@ -1,5 +1,7 @@
 from abc import ABC, abstractmethod
 
+import dspy
+
 GLOBAL_HISTORY = []
 
 class BaseLM(ABC):
@@ -18,6 +20,9 @@ class BaseLM(ABC):
         _inspect_history(self.history, n)
 
     def update_global_history(self, entry):
+        if dspy.settings.disable_history:
+            return
+
         GLOBAL_HISTORY.append(entry)
 
 
@@ -73,4 +78,4 @@ def _inspect_history(history, n: int = 1):
 
 def inspect_history(n: int = 1):
     """The global history shared across all LMs."""
-    return _inspect_history(GLOBAL_HISTORY, n)
\ No newline at end of file
+    return _inspect_history(GLOBAL_HISTORY, n)
diff --git a/dspy/clients/lm.py b/dspy/clients/lm.py
index 44d0ae06..a315458a 100644
--- a/dspy/clients/lm.py
+++ b/dspy/clients/lm.py
@@ -126,6 +126,9 @@ class LM(BaseLM):
         else:
             outputs = [c.message.content if hasattr(c, "message") else c["text"] for c in response["choices"]]
 
+        if dspy.settings.disable_history:
+            return outputs
+
         # Logging, with removed api key & where `cost` is None on cache hit.
         kwargs = {k: v for k, v in kwargs.items() if not k.startswith("api_")}
         entry = dict(prompt=prompt, messages=messages, kwargs=kwargs, response=response)
diff --git a/dspy/dsp/cache_utils.py b/dspy/dsp/cache_utils.py
index 16b91735..6861d903 100644
--- a/dspy/dsp/cache_utils.py
+++ b/dspy/dsp/cache_utils.py
@@ -22,21 +22,16 @@ def noop_decorator(arg=None, *noop_args, **noop_kwargs):
     else:
         return decorator
 
-
-cachedir = os.environ.get('DSP_CACHEDIR') or os.path.join(Path.home(), 'cachedir_joblib')
-CacheMemory = Memory(location=cachedir, verbose=0)
-
-cachedir2 = os.environ.get('DSP_NOTEBOOK_CACHEDIR')
 NotebookCacheMemory = dotdict()
 NotebookCacheMemory.cache = noop_decorator
 
-if cachedir2:
-    NotebookCacheMemory = Memory(location=cachedir2, verbose=0)
-
+CacheMemory = dotdict()
+CacheMemory.cache = noop_decorator
 
-if not cache_turn_on:
-    CacheMemory = dotdict()
-    CacheMemory.cache = noop_decorator
+if cache_turn_on:
+    cachedir = os.environ.get('DSP_CACHEDIR') or os.path.join(Path.home(), 'cachedir_joblib')
+    CacheMemory = Memory(location=cachedir, verbose=0)
 
-    NotebookCacheMemory = dotdict()
-    NotebookCacheMemory.cache = noop_decorator
+    cachedir2 = os.environ.get('DSP_NOTEBOOK_CACHEDIR')
+    if cachedir2:
+        NotebookCacheMemory = Memory(location=cachedir2, verbose=0)
diff --git a/dspy/dsp/utils/settings.py b/dspy/dsp/utils/settings.py
index 2f3b619a..98d96d6a 100644
--- a/dspy/dsp/utils/settings.py
+++ b/dspy/dsp/utils/settings.py
@@ -19,6 +19,7 @@ DEFAULT_CONFIG = dotdict(
     callbacks=[],
     async_max_workers=8,
     send_stream=None,
+    disable_history=False,
 )
 
 # Global base configuration and owner tracking
diff --git a/dspy/predict/react.py b/dspy/predict/react.py
index e05fe2fa..252965a2 100644
--- a/dspy/predict/react.py
+++ b/dspy/predict/react.py
@@ -10,7 +10,13 @@ from dspy.utils.callback import with_callbacks
 
 
 class Tool:
-    def __init__(self, func: Callable, name: str = None, desc: str = None, args: dict[str, Any] = None):
+    def __init__(
+        self,
+        func: Callable,
+        name: str = None,
+        desc: str = None,
+        args: dict[str, Any] = None,
+    ):
         annotations_func = func if inspect.isfunction(func) or inspect.ismethod(func) else func.__call__
         self.func = func
         self.name = name or getattr(func, "__name__", type(func).__name__)
@@ -59,7 +65,12 @@ class ReAct(Module):
             f"Signals that the final outputs, i.e. {outputs}, are now available and marks the task as complete."
         )
         finish_args = {}  # k: v.annotation for k, v in signature.output_fields.items()}
-        tools["finish"] = Tool(func=lambda **kwargs: "Completed.", name="finish", desc=finish_desc, args=finish_args)
+        tools["finish"] = Tool(
+            func=lambda **kwargs: "Completed.",
+            name="finish",
+            desc=finish_desc,
+            args=finish_args,
+        )
 
         for idx, tool in enumerate(tools.values()):
             args = tool.args if hasattr(tool, "args") else str({tool.input_variable: str})
@@ -76,7 +87,8 @@ class ReAct(Module):
         )
 
         fallback_signature = dspy.Signature(
-            {**signature.input_fields, **signature.output_fields}, signature.instructions
+            {**signature.input_fields, **signature.output_fields},
+            signature.instructions,
         ).append("trajectory", dspy.InputField(), type_=str)
 
         self.tools = tools
@@ -91,7 +103,10 @@ class ReAct(Module):
 
         trajectory = {}
         for idx in range(self.max_iters):
-            pred = self.react(**input_args, trajectory=format(trajectory, last_iteration=(idx == self.max_iters - 1)))
+            pred = self.react(
+                **input_args,
+                trajectory=format(trajectory, last_iteration=(idx == self.max_iters - 1)),
+            )
 
             trajectory[f"thought_{idx}"] = pred.next_thought
             trajectory[f"tool_name_{idx}"] = pred.next_tool_name
@@ -99,12 +114,16 @@ class ReAct(Module):
 
             try:
                 parsed_tool_args = {}
+                tool = self.tools[pred.next_tool_name]
                 for k, v in pred.next_tool_args.items():
-                    arg_type = self.tools[pred.next_tool_name].arg_types[k]
-                    if isinstance((origin := get_origin(arg_type) or arg_type), type) and issubclass(origin, BaseModel):
-                        parsed_tool_args[k] = arg_type.model_validate(v)
-                    else:
-                        parsed_tool_args[k] = v
+                    if hasattr(tool, "arg_types") and k in tool.arg_types:
+                        arg_type = tool.arg_types[k]
+                        if isinstance((origin := get_origin(arg_type) or arg_type), type) and issubclass(
+                            origin, BaseModel
+                        ):
+                            parsed_tool_args[k] = arg_type.model_validate(v)
+                            continue
+                    parsed_tool_args[k] = v
                 trajectory[f"observation_{idx}"] = self.tools[pred.next_tool_name](**parsed_tool_args)
             except Exception as e:
                 trajectory[f"observation_{idx}"] = f"Failed to execute: {e}"


Test patch:
diff --git a/tests/predict/test_react.py b/tests/predict/test_react.py
index 2d99cda1..58477cf8 100644
--- a/tests/predict/test_react.py
+++ b/tests/predict/test_react.py
@@ -1,10 +1,10 @@
 from dataclasses import dataclass
 
-import dspy
-from dspy.utils.dummies import DummyLM, dummy_rm
-from dspy.predict import react
 from pydantic import BaseModel
 
+import dspy
+from dspy.predict import react
+from dspy.utils.dummies import DummyLM, dummy_rm
 
 # def test_example_no_tools():
 #     # Create a simple dataset which the model will use with the Retrieve tool.
@@ -228,3 +228,35 @@ def test_tool_calling_with_pydantic_args():
         "observation_1": "Completed.",
     }
     assert outputs.trajectory == expected_trajectory
+
+
+def test_tool_calling_without_typehint():
+    def foo(a, b):
+        """Add two numbers."""
+        return a + b
+
+    react = dspy.ReAct("a, b -> c:int", tools=[foo])
+    lm = DummyLM(
+        [
+            {"next_thought": "I need to add two numbers.", "next_tool_name": "foo", "next_tool_args": {"a": 1, "b": 2}},
+            {"next_thought": "I have the sum, now I can finish.", "next_tool_name": "finish", "next_tool_args": {}},
+            {"reasoning": "I added the numbers successfully", "c": 3},
+        ]
+    )
+    dspy.settings.configure(lm=lm)
+    outputs = react(a=1, b=2)
+
+    expected_trajectory = {
+        "thought_0": "I need to add two numbers.",
+        "tool_name_0": "foo",
+        "tool_args_0": {
+            "a": 1,
+            "b": 2,
+        },
+        "observation_0": 3,
+        "thought_1": "I have the sum, now I can finish.",
+        "tool_name_1": "finish",
+        "tool_args_1": {},
+        "observation_1": "Completed.",
+    }
+    assert outputs.trajectory == expected_trajectory