langchain-ai / langchain

🦜🔗 Build context-aware reasoning applications
https://python.langchain.com
MIT License
95.36k stars 15.47k forks source link

core: method tools #28160

Open ethanglide opened 1 week ago

ethanglide commented 1 week ago

Add support for methods to the @tool decorator. Previously using the @tool decorator on methods was not possible. Now the @tool decorator will have the same behaviours on methods as on regular functions.

Resolves #27471

Example:

class A:
        def __init__(self, c: int):
            self.c = c

        @tool
        def foo(self, a: int, b: int) -> int:
            """Add two numbers to c."""
            return a + b + self.c

a = A(10)
a.foo.invoke({"a": 1, "b": 2}) # 13

Note that no self argument had to be passed into a.foo.invoke. This would not work in the past even if self was passed in manually.

vercel[bot] commented 1 week ago

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
langchain ✅ Ready (Inspect) Visit Preview 💬 Add feedback Nov 17, 2024 2:28am
ethanglide commented 1 week ago

The CI checks have to do with me having trouble replacing the return types of the tool function and inner functions. The linter is upset for good reason but its better to get this draft out and get my idea reviewed, then those return types can get fixed up soon.

ethanglide commented 6 days ago

Hello @efriis just following up on this from the issue. I would appreciate it if any of the maintainers were able to give this a review. The functionality is ready, just the linter is upset about a few things.

On that note, I am having trouble appeasing mypy and was wondering if I could get some help with making it happy here. Upon running make lint I get this output from mypy:

langchain_core/tools/structured.py:65: error: TypedDict "ToolCall" has no key "self"  [typeddict-unknown-key]
langchain_core/tools/convert.py:259: error: "property" used with a non-method  [misc]
langchain_core/tools/convert.py:306: error: Incompatible return value type (got "Callable[[Callable[..., Any] | Runnable[Any, Any]], BaseTool | Callable[[Callable[..., Any]], BaseTool]]", expected "Callable[[Callable[..., Any] | Runnable[Any, Any]], BaseTool]")  [return-value]
Found 3 errors in 2 files (checked 292 source files)

The first error comes from this new helper function in StructuredTool:

def _add_outer_self(self, input: Union[str, dict, ToolCall]) -> dict | ToolCall:
        """Add outer self into arguments for method tools."""

        # If input is a string, then it is the first argument
        if isinstance(input, str):
            args = {"self": self.outer_self}
            for x in self.args:  # loop should only happen once
                args[x] = input
            return args

        # ToolCall
        if "type" in input and input["type"] == "tool_call":
            input["args"]["self"] = self.outer_self
            return input

        # Dict
        input["self"] = self.outer_self # <-- ERROR HERE
        return input

I am not able to use isinstance(input, ToolCall) since it gives an error that TypedDict does not work with isinstance. How can I make mypy okay with this?

The second error comes from here inside the tool function:

@property # <-- ERROR HERE
def method_tool(self: Callable) -> StructuredTool:
  return StructuredTool.from_function(
      func,
      coroutine,
      name=tool_name,
      description=description,
      return_direct=return_direct,
      args_schema=schema,
      infer_schema=infer_schema,
      response_format=response_format,
      parse_docstring=parse_docstring,
      error_on_invalid_docstring=error_on_invalid_docstring,
      outer_self=self,
  )

Where @property is used so that the tool can be accessed like a.foo instead of a.foo() (assuming foo is a method decorated by @tool). Is there any way to either get mypy to relax or change around the code to get the same result without using @property?

The third error is because the return types are changing. This one is being really difficult because every time I fix the return type on one function it will change the return type of the parent function it is nested within, and so on and so on. I am not an expert on the types in Python so cleaning this up has proven to be hard thing to do. I figured that someone with a bit more knowledge on the codebase would be able to clean up this refactor easily, so any potential help would be appreciated. That, or a way to get the same functionality without needing to change the return types somehow could save some time.

ethanglide commented 4 days ago

Some documentation for this feature to help maintainers:

When the @tool is placed above a method who's first parameter is self, it will detect that it is a method and create a method tool. A method tool is actually a function that takes in a self parameter and will give back a StructuredTool with the new outer_self field populated by that self parameter. This function is decorated with @property to make it accessible without using parenthesis like other tools.

When you call .args on a StructuredTool with the outer_self field set, it will return the arguments without the self included since this tool will add that in automatically. When you call .invoke or .ainvoke then it will automatically pass in the outer_self field as the self argument to the tool.

Once that self argument gets passed in, there are some difficulties with keeping it the whole time. The argument must be called self because the arguments will get checked before the tool is run, but the argument also must NOT be called self because when these arguments are turned into a tuple and passed into run an error will be thrown since there are two arguments called self. The solution is to change the name of any self arguments into outer_self AFTER type checking, and then back into self directly before being put into the tool's underlying func or coroutine.