Skip to content
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

core: method tools #28160

Closed
wants to merge 12 commits into from
Closed

core: method tools #28160

wants to merge 12 commits into from

Conversation

ethanglide
Copy link

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.

This is_method parameter will create a structured tool that automatically injects the self reference into the tool
More features for method tools:
- automatic detection (no need to specify is_method=True) anymore, detects from the `self` parameter being present
- support for multiple input types: strings, dictionaries, toolcalls
Copy link

vercel bot commented Nov 17, 2024

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 Dec 9, 2024 9:36pm

@ethanglide
Copy link
Author

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
Copy link
Author

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
Copy link
Author

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.

@ethanglide ethanglide marked this pull request as ready for review November 29, 2024 01:56
@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. langchain Related to the langchain package labels Nov 29, 2024
@ethanglide
Copy link
Author

@eyurtsev @efriis We figured out everything with the linter and now all tests are passing. This feature is good to go.

Copy link
Member

@efriis efriis left a comment

Choose a reason for hiding this comment

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

how is this handled for classmethods, e.g. if you use A.foo as a tool instead of a.foo in the test?

If we could add a test case for that, that would be great!

sorry for the delay

@efriis efriis self-assigned this Dec 9, 2024
Unfortunately, classmethod support will not work with 3.13 or higher. As classmethod is no longer able to wrap property methods.

[source](https://docs.python.org/3.13/library/functions.html#classmethod)
Accidentally was skipping the wrong test, moved the decorator onto the correct test
@ethanglide
Copy link
Author

@efriis I have added support and test cases for classmethods. Note that since we are using descriptors (the @property decorator in particular) in order to keep the syntax consistent, this will not work in python 3.13 and above. The reason for this is that 3.13 removed the ability to wrap descriptors with the @classmethod decorator.

Copy link
Member

@efriis efriis left a comment

Choose a reason for hiding this comment

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

thanks and appreciate your responsiveness for the back and forth!

libs/core/langchain_core/tools/base.py Show resolved Hide resolved
libs/core/langchain_core/tools/convert.py Show resolved Hide resolved
Comment on lines +2235 to +2239
@classmethod
@tool
def foo(cls, a: int, b: int) -> int:
"""Add two numbers to c."""
return a + b + cls.c
Copy link
Member

Choose a reason for hiding this comment

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

this case actually doesn't matter - I think the one that matters is this

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

Copy link
Author

Choose a reason for hiding this comment

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

I will be marking all the rest of the conversations surrounding this general issue as resolved. This thread here will be to discuss everything around the classmethod support.

Unfortunately, it seems that in order to get the desired behaviour the @classmethod decorator must be applied last. This is because we want A.foo to pass in the class A as the cls parameter. This behaviour does not seem to happen unless @classmethod happens last.

Here is what happens with making the @tool decorator return a method tool of the classmethods underlying function:

class A:
    c = 5

    @tool
    @classmethod
    def foo(cls, a: int, b: int) -> int:
        """Add two numbers to c."""
        return a + b + cls.c
    
>       assert A.foo.invoke({"a": 1, "b": 2}) == 8
E       AttributeError: 'property' object has no attribute 'invoke'

It is no longer a classmethod! The cls value does not get passed in as we would wish for it to.

This can be fixed by making this simply apply the decorators in the desired order through some simple logic in convert.py:

elif isinstance(name_or_callable, classmethod):
  return classmethod(tool(name_or_callable.__func__))

However this is not everything that we want since Python 3.13 still removed wrapping descriptors (like propertys) with classmethod. So it would still not be compatible with 3.13 and above. For this reason I would be able to support the syntax you've requested but would not be able to support newer Python versions.

Copy link
Member

Choose a reason for hiding this comment

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

from all these differences, what are your thoughts on introducing a new decorator tool_property or something to distinguish these cases? Would we also need a tool_classmethod to be separate too?

Copy link
Member

Choose a reason for hiding this comment

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

Some additional thoughts:

  • in principle, it makes sense to force the user to distinguish rather than try to auto-detect that your thing is a property/classmethod
  • especially for crazy edge cases like below, where the tool decorator would run in a non-class context, and then screw up later
@tool
def my_tool(...):
    ...

class A():
    it = my_tool

A.it2 = my_tool

a = A()
a.it3 = my_tool

Copy link
Author

Choose a reason for hiding this comment

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

Having a separate decorator specifically for implementing this feature was always an idea, and is definitely the better solution.

To be honest, the reason things are implemented the way they currently are is because I thought that if I did not implement this in a way that satisfied the example code given by Eugene in the original issue then that issue would not be resolved.

Now that I know there is more leeway and freedom to implement this feature in the way that makes most sense rather than being locked into using the original tool decorator, that can absolutely be done.

How about I first create some sort of new decorator that is for method tools, and then from there I can see if classmethods are doable?

Copy link
Member

Choose a reason for hiding this comment

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

sounds great!

Copy link
Member

Choose a reason for hiding this comment

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

doing it as 2 separate PRs is also good practice. If you'd like to avoid double-work, you're welcome to put up a PR with just the unit tests first, just to make sure we're envisioning the same interface

Copy link
Author

Choose a reason for hiding this comment

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

Check out my new PR #28695.

On top of doing what you suggested, I also came up with a much more elegant solution without any strange workarounds or shortcomings. The actual implementation is only something like 20 lines of added logic. Taking a second shot at this was definitely worth it.

libs/core/tests/unit_tests/test_tools.py Show resolved Hide resolved
libs/core/tests/unit_tests/test_tools.py Show resolved Hide resolved
libs/core/tests/unit_tests/test_tools.py Show resolved Hide resolved
@efriis
Copy link
Member

efriis commented Dec 14, 2024

closing in favor of the other one - it's a much better track agreed! #28695

@efriis efriis closed this Dec 14, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
langchain Related to the langchain package size:L This PR changes 100-499 lines, ignoring generated files.
Projects
Status: Closed
Development

Successfully merging this pull request may close these issues.

make sure that @tool decorator can be applied to a method (regression?)
2 participants