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

Enable tool schemas to support more dynamic generation #278

Open
willbakst opened this issue May 23, 2024 · 33 comments · Fixed by #340
Open

Enable tool schemas to support more dynamic generation #278

willbakst opened this issue May 23, 2024 · 33 comments · Fixed by #340
Labels
Feature Request New feature or request

Comments

@willbakst
Copy link
Contributor

Description

Consider the following tool:

from mirascope.openai import OpenAITool
from pydantic import Field

class MyTool(OpenAITool):
    """This is the overall tool description of my tool that gets injected."""

    foo: str = Field(..., description="This is the field specific description that gets injected.")

What happens when we want the descriptions to be more dynamic with templating like we have for prompt_template? For example:

from mirascope.openai import OpenAITool
from pydantic import Field

template_var_to_insert = "inserted"

class MyTool(OpenAITool):
    """This is the overall tool description of my tool that gets {template_var_to_insert}."""

    foo: str = Field(
        ...,
        description=f"This is the field specific description that gets {template_var_to_insert}."
    )

While the above would work for foo, it doesn't work for the docstring. Furthermore, this only works if you have the variable available at the time of definition. What happens if you want to update the template variable dynamically at run time with multiple different options? Especially since you pass tools into the call_params classvar.

My initial instinct would be to enable dynamically updating the schema at generation time and then surfacing something like a config that can be passed in at run time to actually go and update the generation. Something like:

from mirascope.openai import OpenAICall, OpenAICallParams, OpenAITool
from pydantic import Field

template_var_to_insert = "inserted"

class MyTool(OpenAITool):
    """This is the overall tool description of my tool that gets {template_var_to_insert}."""

    foo: str = Field(
        ...,
        description=f"This is the field specific description that gets {template_var_to_insert}."
    )

class MyCall(OpenAICall):
    prompt_template = "..."

    call_params = OpenAICallParams(tools=[MyTool])


my_call = MyCall()
response = my_call.call(update_tools={"MyTool": {"template_var_to_insert": "inserted"}})

I don't love this exact design, but I think it's headed in the direction of what would potentially work.

@off6atomic I would love your thoughts here.

@willbakst
Copy link
Contributor Author

@off6atomic I'm also wondering if I was off about wanting dynamic generation at runtime vs. just templating at definition time?

@willbakst willbakst self-assigned this May 24, 2024
@off6atomic
Copy link
Contributor

off6atomic commented May 24, 2024

If we want maximum flexibility like when calling OpenAI API, I think all the data sent to OpenAI should be modifiable in any way that users want at runtime.

This means that these needs to modifiable at runtime:

  • docstring
  • field type
  • field description
  • whether the tool is necessary

One approach that could work is that Mirascope should provide convenience in defining the default base values at definition time but then let users modify them at runtime however they want.


As a user who seeks least magical behavior, I want to be able to intercept the data immediately preceding the OpenAI API call, modify it, and then pass it back into the call. This data could be in a dictionary form or some kind of class object.

When I debug machine learning models, it's really crucial for me to inspect the processed data that gets sent to the model because that's how I learn what's going on and find bugs effectively. So I think the same ability for inspection is crucial here. Something like call.messages() functions are very important. We need more of it.

At this point I think call.get_tool() function will be important.

This is the rough API I expect for now:

from mirascope.openai import OpenAICall, OpenAICallParams, OpenAITool
from pydantic import Field

class MyTool(OpenAITool):
    """This is the overall tool description of my tool that gets {template_var_to_insert}."""

    foo: str = Field(
        ...,
        description=f"This is the field specific description that gets {template_var_to_insert}."
    )

class MyCall(OpenAICall):
    prompt_template = "..."

    call_params = OpenAICallParams(tools=[MyTool])


my_call = MyCall()

# access and modify the tool
# this would create a copy of the MyTool for users to modify; could be a dict or an object; this copy would always be a copy of the base tool
my_tool = my_call.get_tool("MyTool")  
# users could print it to see all the attributes they can modify
print(my_tool) 
# the reason we allow users access to the docstring is so that they might set it to empty or generate entirely new string
my_tool.docstring = my_tool.docstring.format(template_var_to_insert="INSERT SCHEMA HERE")
# user can change type from string to int
my_tool.foo.type = "int"  

response = my_call.call(update_tools=[my_tool])

Maybe we should even allow modifying the list of tools at runtime, but I'm not sure if that kind of flexibility would hurt other principles of Mirascope. If it's not hurting anything then we should do that.

The question would be "Should the tool being returned be a dict or an object?"

if it's a dict, then might as well just be a dict in OpenAI format directly. If it's an object, maybe it can be in Mirascope proprietary format that is easy for users to understand. I think if we want to enforce less abstraction onto the user, then OpenAI dict should be used (especially when the class itself is called OpenAITool anyway)


This kind of idea of interception might be applicable to other variables as well e.g. the concept of user_message.
This would allow users to inject additional image data to the prompt.

message = call.get_user_message()
message["content"].append('an image url')
response = call.call(user_message=user_message)

@willbakst
Copy link
Contributor Author

Ok I see what you're saying. Something like a get_tool function could work. It's worth noting that you can already override tools at runtime if you want simply by providing them as a keyword arg e.g. my_call.call(tools=[...]).

As for updating the tool, I'm thinking the best path forward here would be to simply rely on the create_model function in Pydantic to generate a new tool instance where you can update whatever fields you want. This will be necessary to update the type of a field since you can't just update the type through access as you've written (it's a fixed type at definition time, so we need to dynamically generate the new model). It's also the most flexible approach since you can have complete control over the dynamic generation.

It might be worth adding a convenience wrapper around create_model to make this specific flow more convenience, maybe something like create_tool, but uncertain before playing around with this further.

Ultimately this would result is something like response = my_call.call(update_tools={"MyTool": create_model(...)})

@willbakst
Copy link
Contributor Author

Thinking through this further, you can technically already do this without any changes to the existing library. The only thing missing is potential added convenience.

Right now you could:

  1. Grab all the current tools from the call i.e. tools = my_call.call_params.tools
  2. Iterate through tools and create a new updated_tools array where you either insert the tool unchanged or insert the updated tool by calling create_model.
  3. Make the call by overriding the tools with the updated tools i.e. response = my_call.call(tools=updated_tools)

@off6atomic
Copy link
Contributor

off6atomic commented May 24, 2024

When I print the value of my_call.call_params.tools I get this output:
[<function list_fabrics_sorted_by_unit_cost at 0x10437b640>]

This is problematic because it's hard to create a new tool given the old tool function when I don't see the attributes of the function being printed. Maybe your approach only works if the tool is created using class instead of function?

@willbakst
Copy link
Contributor Author

Ah, in this case you can use the OpenAITool.from_fn(my_fn) utility to create the tool. The same exists on tools for other providers as well as it's a shared utility.

@willbakst
Copy link
Contributor Author

I believe that updates we'll make in #294 should make this easier to implement / improve the interface.

Still needs to be further fleshed out alongside the other features.

@willbakst
Copy link
Contributor Author

From #322 moved here:

from mirascope.openai import openai_call, OpenAITool
from pydantic import Field

class PrintBook(OpenAITool):
    """Prints the title and author of a {genre} book."""

    title: str = Field(..., description="The title of a {genre} book."
    author: str = Field(...,  description="The author of {genre} book `title`.")

    def call(self):
        return f"{title} by {author}"

@openai_call(model="gpt-4o")
def recommend_book(genre: str) -> CallReturn:
    """Recommend a {genre} book"""
    return { "tools": [PrintBook.tool_schema(genre=genre)] }

@willbakst
Copy link
Contributor Author

@koxudaxi's response about typing: Yes. In the same way type checking is no longer possible.

It looks a bit redundant, but from a type-checking point of view this looks better.

class PrintBook(OpenAITool):
    """Prints the title and author of a {genre} book."""

    title: str = Field(..., description="The title of a {genre} book.")
    author: str = Field(...,  description="The author of {genre} book `title`.")

    def call(self):
        return f"{self.title} by {self.author}"

@tool_schema(PrintBook)
def print_book(genre: str) -> ...:
    ...

print_book_tool = print_book("fantasy")
assert isinstance(print_book_tool, PrintBook)
assert print_book_tool.__doc__ == "Prints the title and author of a fantasy book."

@willbakst
Copy link
Contributor Author

willbakst commented Jun 13, 2024

My primary concern with this approach is print_book_tool = print_book("fantasy").

I want print_book to be the function/tool that the LLM uses, but now instead it is a function that returns the tool. This doesn't feel quite right. I want tool.call to be the same as print_book(title, author).

Consider the following function as a tool:

def print_book(title: str, author: str):
    """Prints the title and author of a {genre} book."""

However we dynamically template genre, I want the result to still just be print_book except it's docstring has been updated. For example:

def set_tool_template(**kwargs):
    def decorator(fn):
        fn.__doc__ = fn.__doc__.format(**kwargs)
        return fn
    return decorator

def print_book(title: str, author: str):
    """Prints the title and author of a {genre} book."""
    print(f"{title} by {author}")

@openai_call(model="gpt-4o")
def recommend_book(genre: str):
    """Recommend a {genre} book."""
    return { "tools": [set_tool_template(genre=genre)(print_book)] }

There's no type hints in this example, but it showcases what I want -- the output is still just print_book except it's description / args descriptions have been templated with genre.

Can you think of a way to implement something like this but with proper type hinting?

@willbakst willbakst removed their assignment Jun 13, 2024
@willbakst willbakst removed this from the v0.17 milestone Jun 13, 2024
@koxudaxi
Copy link

The function doesn't have the type-hint for {genre}

def print_book(title: str, author: str):
    """Prints the title and author of a {genre} book."""
    print(f"{title} by {author}")

Also, Does this description method make sense?

 return { "tools": [set_tool_template(genre=genre)(print_book)] }

If I can define the type somewhere, I can give tools to the decorator to check the type.

I don't think it is wise to let the user create a higher-order function, but here is an image of what it might look like.

def print_book(genre: str):
   def inner(title: str, author: str):
       """Prints the title and author of a {genre} book."""
       print(f"{title} by {author}")
   return inner


def openai_call(model: str, tools: list[Callable[P, Any]] | None = None, **kwargs):
   def decorator(fn: Callable[P, Any]):
       def inner(*args: P.args, **kwargs: P.kwargs) -> Any:
           return fn(*args, **kwargs)
       return inner
   return decorator


@openai_call(model="gpt-4o", tools=[print_book])
def recommend_book(genre: str):
   """Recommend a {genre} book."""

@willbakst
Copy link
Contributor Author

The snippet you've shared still has the same issue -- print_book is now a function that takes genre as an argument and returns the tool inner that takes title and author, but I want print_book to just be a function that takes title and author.

Also, it looks like the tool in your snippet would then require that all tools share the same param spec as the main function, which may not necessarily be the case. For example:

def print_book(title: str, author: str):
    """Prints the title and author of a book.
    
    User reading level: {reading_level}
    """
    print(f"{title} by {author}")

@openai_call(model="gpt-4o")
def recommend_book(genre: str, reading_level: Literal["beginner", "intermediate", "advanced"]):
    """Recommend a {genre} book for a user with {reading_level} reading level."""
    return { "tools": [set_tool_template(reading_level=reading_level)(print_book)] }

Here the output of set_tool_template(reading_level=reading_level)(print_book) is just the print_book function except the docstring has been templated with reading_level (and doesn't use genre). I would want the same behavior if using a PrintBook tool definition instead of the function.

@willbakst
Copy link
Contributor Author

Given new features coming soon + the new interface, I guess you could do something like this (although it forces the tool to be scoped within the function using it):

@openai_call(model="gpt-4o")
def recommend_book(genre: str, reading_level: Literal["beginner", "advanced"]):
    """Recommend a {genre} book."""

    class PrintBook(OpenAITool):

        @classmethod
        def description(cls) -> str:
            return dedent(f"""
            Prints the title and author of a book.

            User reading level: {reading_level}
            """).strip()

        title: str = Field(..., description=f"The title of a {reading_level} {genre} book.")
        author: str = Field(..., description=f"The author of the {reading_level} {genre} book `title`.")

    return { "tools": [PrintBook] }

One concern here is that every call to recommend_book will redefine the PrintBook class, which isn't free.

We could do this more simply but without type hints:

class PrintBook(OpenAITool):
    """Prints the title and author of a book.

    User reading level: {reading_level}
    """

    title: str = Field(..., description=f"The title of a {reading_level} {genre} book.")
    author: str = Field(..., description=f"The author of the {reading_level} {genre} book `title`.")

@openai_call(model="gpt-4o")
def recommend_book(genre: str, reading_level: Literal["beginner", "advanced"]):
    """Recommend a {genre} book."""
    return { "tools": [PrintBook.templated(genre=genre, reading_level=reading_level)] }

The idea would be that PrintBook.templated(genre=genre, reading_level=reading_level) returns a PrintBook instance except the description and name have been updated with the dynamic template variables.

Of course, we could still make this fail at runtime by failing in attempts to template variables that aren't correct, but we won't catch missing variables.

For the function case, it would look like this? (although honestly I find this gross)

@openai_call(model="gpt-4o")
def recommend_book(genre: str, reading_level: Literal["beginner", "advanced"]):
    """Recommend a {genre} book."""

    print_book_docstr = f"""Prints the title and author of a book.

    User reading level: {reading_level}
    
    Args:
        title: The title of a {reading_level} {genre} book. 
        author: The author of the {reading_level} {genre} book `title`.
    """

    @set_docstr(print_book_docstr)  # this just returns the wrapped function with `fn.__doc__` set since we can't f-string the docstr
    def print_book(title: str, author: str):
        ...

    return { "tools": [print_book] }

We could make this better but not sure if we could type hint it:

@openai_call(model="gpt-4o")
def recommend_book(genre: str, reading_level: Literal["beginner", "advanced"]):
    """Recommend a {genre} book."""

    @template_docstr(genre=genre, reading_level=reading_level)
    def print_book(title: str, author: str):
        """Prints the title and author of a book.

        User reading level: {reading_level}
    
        Args:
            title: The title of a {reading_level} {genre} book. 
            author: The author of the {reading_level} {genre} book `title`.
        """

    return { "tools": [print_book] }

@koxudaxi
Copy link

Also, it looks like the tool in your snippet would then require that all tools share the same param spec as the main function, which may not necessarily be the case. For example:

There seems to be some misunderstanding, so I will explain.

def print_book(genre: str):
   def inner(title: str, author: str):
       """Prints the title and author of a {genre} book."""
       print(f"{title} by {author}")
   return inner


def openai_call(model: str, tools: list[Callable[P, Any]] | None = None):
   def decorator(fn: Callable[P, Any]):
       def inner(*args: P.args, **kwargs: P.kwargs) -> Any:
           if tools:
               result = {"tools": [set_tool_template(tool(*args, **kwargs)) for tool in tools]}
           else:
               result = {}
           # do something with result
           return fn(*args, **kwargs)
       return inner
   return decorator


@openai_call(model="gpt-4o", tools=[print_book])
def recommend_book(genre: str):
   """Recommend a {genre} book."""

This decorator does not work correctly as logic, but the image looks like this.
My idea is to pass a public function to become a tool and the decorator builds the tool.
So the decorator is responsible for this line, not the user.

return { "tools": [set_tool_template(genre=genre)(print_book)] }

Here the output of set_tool_template(reading_level=reading_level)(print_book) is just the print_book function except the docstring has been templated with reading_level (and doesn't use genre).

It is assumed that all arguments such as genre, reading_level, etc. are passed to the tool or that only the necessary ones are picked up.

I am most concerned about.
I am wondering if it would be confusing for users to write statements like the following.
Therefore, I came up with the idea of passing it as a decorator argument.

 return { "tools": [set_tool_template(reading_level=reading_level)(print_book)] }

One concern here is that every call to recommend_book will redefine the PrintBook class, which isn't free.

Admittedly, this is a bit of a problem.

For the function case, it would look like this? (although honestly I find this gross)

Users find it difficult to understand.

class PrintBook(OpenAITool):
    """Prints the title and author of a book.

    User reading level: {reading_level}
    """

    title: str = Field(..., description="The title of a {reading_level} {genre} book.")
    author: str = Field(..., description="The author of the {reading_level} {genre} book `title`.")

@openai_call(model="gpt-4o", tool_templates=[PrintBook])
def recommend_book(genre: str, reading_level: Literal["beginner", "advanced"]):
    """Recommend a {genre} book."""

Similar to my earlier suggestion, how about this one?
If I've identified your requirements correctly, this one shouldn't be too bad - I don't know what is the string to be templated in PrintBook, but you can either pass all arguments to PrinBook, or pick up only the templated variables and pass them to the constructor.

Of course these should be done inside the decorator, not by the user.
Passing PrintBook as a decorator argument should allow for prior argument and template validation at function definition time.

Of course, we could still make this fail at runtime by failing in attempts to template variables that aren't correct, but we won't catch missing variables .

Do you think this will not detect missing variables even with my proposal?

Regardless of this proposal, it would be difficult to check for type hints in the correct sense, as templated variables are not typed defined anywhere.
The best I can think of is to validate arguments and template variables with decorators when defining functions, and to cut down on user writing costs.

@willbakst
Copy link
Contributor Author

willbakst commented Jun 15, 2024

Do you think this will not detect missing variables even with my proposal?

I think that the decorator approach with Callable[P, Any] could work with typing, but it breaks my mental model of how we're defining tools.

def print_book(genre: str):
   def inner(title: str, author: str):
       """Prints the title and author of a {genre} book."""
       print(f"{title} by {author}")
   return inner

In this example, we call print_book("fantasy") to get the dynamic tool definition, but now the function is actually named inner and not print_book. This can't be the interface.

I'm thinking we need to differentiate between dynamic and non-dynamic tools. Users should be able to generate the dynamic tool definition agnostic to any call. The decorator should just provide proper type hinting when using dynamic tools. I also want to ensure that the naming conventions are clear.

Honestly, I wonder if we're overthinking this and we should put a little bit more on the user to get proper type hinting instead of trying to do everything internally.

Here are my thoughts dumped. I'm going to limit this to functional definitions of tools, but I think we could fairly easily expand this to the class based definition approach. I'll leave that to the imagination for this discussion.

First, we create a decorator for templating a docstring that lives in the mirascope library:

from typing import Any, Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def format_docstr(**kwargs) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def decorator(fn: Callable[P, Any]) -> Callable[P, R]:
        doc = fn.__doc__
        if not doc:
            raise ValueError("No docstring")
        fn.__doc__ = doc.format(**kwargs)
        return fn

    return decorator

Now the user can define a generator function to generate a dynamic tool like this:

from typing import Literal

from mirascope.base import format_docstr

def dynamic_format_book(reading_level: Literal["beginner", "advanced"]):
    """Dynamically generates the `format_book` function using `reading_level`."""

    # no type hints, but I think that's ok in this instance since it's an f-string template
    @format_docstr(reading_level=reading_level)
    def format_book(title: str, author: str) -> str:
        """Returns the title and author of a book nicely formatted.

        Reading level: {reading_level}
        """
        return f"{title} by {author}"

    return format_book

format_book = dynamic_format_book(reading_level="beginner")

In this example it's clear that dynamic_format_book is a dynamic tool and that calling it will generate the actual tool function to use. The result here will be equivalent to a functional tool that is not dynamic.

The user can also at any point agnostic to any call run dynamic_format_book("beginner") to get the tool definition, and the type hinting will be correct.

Inside of our call, we can then differentiate between tools and dynamic tools:

@openai_call(model="gpt-4o", dynamic_tools=[dynamic_format_book])
def recommend_book(genre: str, reading_level: Literal["beginner", "advanced"]):
    """Recommend a {genre} book."""

This will explicitly be separated from the tools argument that expects tools that are already fully defined. This means that you could provide tools=[dynamic_format_book(reading_level="advanced")] and it should work.

As you've mentioned, we can create the openai_call decorator such that it can properly ensure that dynamic_format_book has the correct arguments and that they are provided by the recommend_book function. Under the hood, we can then create the tool by calling the dynamic tool generator using the correct template variables. My one (primary) concern here is that it might not be possible to pick and choose from the param spec. For example, what if recommend_book takes genre and reading_level and takes two dynamic tools uses_genre and uses_reading_level where each only takes one of the parameters and not the full spec.

@willbakst
Copy link
Contributor Author

willbakst commented Jun 15, 2024

I would love to do something like this:

from typing import Any, Callable, Literal, ParamSpec, TypedDict, TypeVar, Unpack

class ToolTemplate(TypedDict):
    pass

ToolTemplateT = TypeVar("ToolTemplateT", bound=ToolTemplate)
P = ParamSpec("P")
R = TypeVar("R")

# this would be internal to mirascope and imported by the user
def dynamic_tool(
    template: type[ToolTemplateT],
) -> Callable[[Callable[P, R]], Callable[[Unpack[ToolTemplateT]], Callable[P, R]]]:
    def inner(fn: Callable[P, R]) -> Callable[[Unpack[ToolTemplateT]], Callable[P, R]]:
        def wrapper(**kwargs: Unpack[ToolTemplateT]) -> Callable[P, R]:
            return format_docstr(**kwargs)(fn)

        return wrapper

    return inner

class FormatBookTemplate(ToolTemplate):
    reading_level: Literal["beginner", "advanced"]

@dynamic_tool(FormatBookTemplate)
def format_book(title: str, author: str) -> str:
    """Returns the title and author of a book nicely formatted.

    Reading level: {reading_level}
    """
    return f"{title} by {author}"

tool = format_book(reading_level="beginner")

This works except for typing since unfortunately this runs into the same issues as described in python/typing#1399

@koxudaxi
Copy link

In this example, we call print_book("fantasy") to get the dynamic tool definition, but now the function is actually named inner and not print_book. This can't be the interface.

I know this is not the essence of the discussion, but I think we can do whatever we want to operate within the decorator.

templated = print_book("fantasy")
templated.__name__ = print_book.__name__
assert templated.__name__ == "print_book"

However, as you say, it is a hassle for the user, but it is good to be explicit to let the user do the correct naming or inject by using this decorator.
If you do everything dynamically with @openai_call, it will be difficult to understand the behaviour. I agree that it is difficult to understand the behaviour if you do everything dynamically with @openai_call and that it is not possible to maintain consistency.

@format_docstr(reading_level=reading_level)

My one (primary) concern here is that it might not be possible to pick and choose from the param spec. For example, what if recommend_book takes genre and reading_level and takes two dynamic tools uses_genre and uses_reading_level where each only takes one of the parameters and not the full spec.

If we match the recommend_book and dynamic_tool call arguments in this way, we can correctly call dynamic_tool with the appropriate arguments.

import inspect

def openai_call(model: str, dynamic_tools: list[Callable[P, Any]] | None = None):
    def decorator(fn: Callable[P, Any]):
        function_signature_parameters = {k: v.default for k, v in inspect.signature(fn).parameters.items()}

        def inner(*args: P.args, **kwargs: P.kwargs) -> Any:
            if dynamic_tools:
                # Collect all arguments when calling the function
                call_kwargs = {}
                for function_signature_parameters_key, arg in zip(function_signature_parameters.keys(), args):
                    call_kwargs[function_signature_parameters_key] = arg
                remaining_kwargs = {k: kwargs.get(k, v) for k, v in function_signature_parameters.items() if
                                    k not in call_kwargs}
                call_kwargs.update(remaining_kwargs)
                # Fill arguments for dynamic tools with the call_kwargs
                tools = []
                for dynamic_tool in dynamic_tools:
                    dynamic_tool_signature = inspect.signature(dynamic_tool)
                    tool_kwargs = {k: call_kwargs[k] for k in dynamic_tool_signature.parameters.keys()}
                    tools.append(dynamic_tool(**tool_kwargs))
            # do something with result

        return inner

    return decorator


def uses_reading_level(reading_level: Literal["beginner", "advanced"]):
    ...

def uses_genre(genre: str):
    ...

@openai_call(model="gpt-4o", dynamic_tools=[uses_reading_level, uses_genre])
def recommend_book(genre: str, reading_level: Literal["beginner", "advanced"]):
    """Recommend a {genre} book."""


recommend_book("fantasy", reading_level="beginner")

I would love to do something like this:

I agree with you.
We can use pydantic model for this use-case with pyright and mypy

from typing import Callable, Literal, ParamSpec, TypeVar, TypeAlias

from pydantic import BaseModel


class ToolTemplate(BaseModel):
    pass


P = ParamSpec("P")
R = TypeVar("R")
WrappedFunc: TypeAlias = Callable[P, R]
ToolTemplateP = ParamSpec("ToolTemplateP")
ToolTemplateT = TypeVar("ToolTemplateT", bound=ToolTemplate)


def dynamic_tool(
        template: Callable[ToolTemplateP, ToolTemplateT]
) -> Callable[[WrappedFunc], Callable[ToolTemplateP, WrappedFunc]]:
    def inner(fn: WrappedFunc) -> Callable[ToolTemplateP, WrappedFunc]:
        def wrapper(*args: ToolTemplateP.args, **kwargs: ToolTemplateP.kwargs) -> WrappedFunc:
            return format_docstr(**kwargs)(fn)  # type: ignore

        return wrapper

    return inner


class FormatBookTemplate(ToolTemplate):
    reading_level: Literal["beginner", "advanced"]


@dynamic_tool(FormatBookTemplate)
def format_book(title: str, author: str) -> str:
    """Returns the title and author of a book nicely formatted.

    Reading level: {reading_level}
    """
    return f"{title} by {author}"


tool = format_book(reading_level="beginner")
tool = format_book(reading_level="beginner", xyz="")
$ mypy main.py
main4.py:43: error: Unexpected keyword argument "xyz" for "format_book"  [call-arg]
Found 1 error in 1 file (checked 1 source file)

$ pyright main.py
/Users/koudai/PycharmProjects/pythonProject1/main.py
  /Users/koudai/PycharmProjects/pythonProject1/main.py:43:46 - error: No parameter named "xyz" (reportCallIssue)
1 error, 0 warnings, 0 informations 

@willbakst
Copy link
Contributor Author

Ok yes this is exactly what I want. I like this design we should implement it like this :)

@willbakst
Copy link
Contributor Author

For class based tool definitions, I think we can just recommend doing it yourself like this:

from typing import Literal

from mirascope.base import format_docstr

def dynamic_format_book(reading_level: Literal["beginner", "advanced"]):
    """Dynamically generates the `FormatBook` tool using `reading_level`."""

    class FormatBook(OpenAITool):
        __doc__ = """Returns the title and author of a book nicely formatted.

        Reading level: {reading_level}
        """

        title: str = Field(..., description=f"The title of a {reading_level} book.")
        author: str = Field(..., description=f"The author of the {reading_level} book `title`.")

    return FormatBook

FormatBook = dynamic_format_book(reading_level="beginner")

This way when we add the automatic handling of dynamic tools for the new interface it will support both this and the functional definition styles.

@willbakst
Copy link
Contributor Author

willbakst commented Jun 15, 2024

Thinking about this a bit more, could we update the decorator to also work on the class? I'm imagining something like this:

@dynamic_tool(FormatBookTemplate)
class DynamicFormatBook(OpenAITool):
    """Returns the title and author of a book nicely formatted.

    Reading level: {reading_level}
    """

    title: str
    author: str

FormatBook = DynamicFormatBook(reading_level="beginner")

@koxudaxi
Copy link

koxudaxi commented Jun 15, 2024

I tested it. pyright works fine. But, mypy doesn't work correctly.

ToolTemplateP = ParamSpec("ToolTemplateP")
ToolTemplateT = TypeVar("ToolTemplateT", bound=ToolTemplate)
OpenAIToolT = TypeVar("OpenAIToolT", bound=OpenAITool)


def dynamic_tool(
        template: Callable[ToolTemplateP, ToolTemplateT]
) -> Callable[[type[OpenAIToolT]], Callable[ToolTemplateP, type[OpenAIToolT]]]:
    def func(cls: type[OpenAIToolT]) -> Callable[ToolTemplateP, type[OpenAIToolT]]:
        def inner(*args: ToolTemplateP.args, **kwargs: ToolTemplateP.kwargs) -> type[OpenAIToolT]:
            return cls

        return inner

    return func


class FormatBookTemplate(ToolTemplate):
    reading_level: Literal["beginner", "advanced"]


@dynamic_tool(FormatBookTemplate)
class DynamicFormatBook(OpenAITool):
    """Returns the title and author of a book nicely formatted.

    Reading level: {reading_level}
    """

    title: str
    author: str


FormatBook = DynamicFormatBook(reading_level="beginner")
FormatBook = DynamicFormatBook(reading_level="beginner", xyz="")
 pyright main.py

/Users/koudai/PycharmProjects/pythonProject1/main.py
  /Users/koudai/PycharmProjects/pythonProject1/main.py:71:58 - error: No parameter named "xyz" (reportCallIssue)
1 error, 0 warnings, 0 informations 
mypy main.py   
main.py:65: error: Unexpected keyword argument "reading_level" for "DynamicFormatBook"  [call-arg]
main.py:66: error: Unexpected keyword argument "reading_level" for "DynamicFormatBook"  [call-arg]
main.py:66: error: Unexpected keyword argument "xyz" for "DynamicFormatBook"  [call-arg]
Found 3 errors in 1 file (checked 1 source file)

@willbakst
Copy link
Contributor Author

Ok, I think that's fine. If it works for pyright that's fine by me. We can just mention that mypy doesn't properly support it and recommend using pyright. In fact we should probably switch to use pyright internally anyway since mypy is generally quite slow...

@koxudaxi
Copy link

I think that's good. If you really want to support mypy, you could create a mypy plugin like pydantic and customise mypy. I think I sent a PR for a mypy plugin to the pydantic repository to support the pydantic dataclass a long time ago.

@koxudaxi
Copy link

@willbakst
Let me check on the specifications.
For example, it is easy if the fields defined in the Model of the template and the templateised variables are exactly the same, as shown below.

class FormatBookTemplate(ToolTemplate):
    reading_level: Literal["beginner", "advanced"]


@dynamic_tool(FormatBookTemplate)
class DynamicFormatBook(OpenAITool):
    """Returns the title and author of a book nicely formatted.

    Reading level: {reading_level}
    """

    title: str = Field(..., description='title for {reading_level}')
    author: str

However, is there a case where there are too many of one or the other? In other words, check if there are variables that are resolved at call time rather than at tool creation time. for example, the genre in the code.

class FormatBookTemplate(ToolTemplate):
    reading_level: Literal["beginner", "advanced"]


@dynamic_tool(FormatBookTemplate)
class DynamicFormatBook(OpenAITool):
    """Returns the title and author of a book nicely formatted.

    Reading level: {reading_level}
    Genre: {genre}
    """

    title: str = Field(..., description='title for {genre}')
    author: str

@koxudaxi
Copy link

koxudaxi commented Jun 18, 2024

Also, Do we need the baseTool type for function decoration?
Because, I think @dynamic_tool decorator create a function to return type[BaseToolT] object

@dynamic_tool(FormatBookTemplate, base=OpenAITool)
def format_book(title: str, author: str) -> str:
    """Returns the title and author of a book nicely formatted.

    Reading level: {reading_level}
    """
    return f"{title} by {author}"```

@willbakst
Copy link
Contributor Author

I had a thought for potentially a better solution for dynamic tools with the new interface. What do you think about the following?

from mirascope.core import BaseTool, Toolkit


class BookRecommendationTools(Toolkit):
    """A toolkit for recommending books."""

    reading_level: Literal["beginner", "advanced"]

    def format_book(self, title: str, author: str) -> str:
        """Returns the title and author of a book nicely formatted.

        Reading level: {self.reading_level}
        """
        return f"{title} by {author}"

Then, with the new interface we would do something like this:

from mirascope.core.openai import openai_call

@openai_call(model="gpt-4o")
def recommend_book(genre: str, reading_level: Literal["beginner", "advanced"]):
    """Recommend a {genre} book."""
    toolkit = BookRecommendationTools(reading_level=reading_level)
    return {"tools": [toolkit.create_tools()]}

We will also fail at runtime before making any API calls if there are any missing template variables.

@koxudaxi
Copy link

koxudaxi commented Jun 18, 2024

Is the idea to clarify the namespace of variables like {self.reading_level}?

What about making it inherit some class that inherits from BaseModel instead of Toolkit? reading_level: Literal["beginner", "advanced"], as this clarifies the handling and allows validation.

from mirascope.core import BaseTool, Toolkit

Or, Does it indicate a newly created class rather than an existing Toolkit?

@willbakst
Copy link
Contributor Author

Yeah, we would clarify the namespace.

Maybe better to call it BaseToolkit instead of Toolkit? The idea is that BaseToolkit would be a BaseModel with additional functionality like create_tools, and it would resolve items like self.reading_level when creating the tools.

@koxudaxi
Copy link

koxudaxi commented Jun 18, 2024

Looks good.
Just who calls the format_book, is it called in the __doc__ in the recommend_book?

@willbakst
Copy link
Contributor Author

willbakst commented Jun 18, 2024

The user flow would be the following:

from mirascope.core import BaseTool, Toolkit
from mirascope.core.openai import openai_call

class BookRecommendationTools(Toolkit):
    """A toolkit for recommending books."""

    reading_level: Literal["beginner", "advanced"]

    # Could be namespaced as `BookRecommendationTools.format_book`
    # Should also provide a `namespace` ClassVar that the user can override or set to `None`
    def format_book(self, title: str, author: str) -> str:
        """Returns the title and author of a book nicely formatted.

        Reading level: {self.reading_level}
        """
        return f"{title} by {author}"

@openai_call(model="gpt-4o")
def recommend_book(genre: str, reading_level: Literal["beginner", "advanced"]):
    """Recommend a {genre} book."""
    toolkit = BookRecommendationTools(reading_level=reading_level)
    return {"tools": [toolkit.create_tools()]}

response = recommend_book("fantasy", "beginner")
if tool := response.tool:
    output = tool.call()  # this is calling `format_book` as it's the only available tool
    print(output)
    #> The Name of the Wind by Patrick Rothfuss
else:
    print(response.content)
    #> Sure! I would recommend...

@koxudaxi
Copy link

Okay, so you want to dynamically detect that method.
If there are no argument type problems,
I felt it was more straightforward to override a method like

@abstractmethod
 def format(self, **kwargs) -> str:

I am not enforcing this proposal.
It means that it is better if the user understands clearly how it should be implemented.

@willbakst
Copy link
Contributor Author

willbakst commented Jun 18, 2024

What if we added a decorator to make it clear like this:

class BookRecommendationTools(Toolkit):
    """A toolkit for recommending books."""

    reading_level: Literal["beginner", "advanced"]

    @toolkit_tool() # not sure about this naming...
    def format_book(self, title: str, author: str) -> str:
        """Returns the title and author of a book nicely formatted.

        Reading level: {self.reading_level}
        """
        return f"{title} by {author}"

@koxudaxi
Copy link

What if we added a decorator to make it clear like this:

It is good to be explicit.

@willbakst willbakst linked a pull request Jun 21, 2024 that will close this issue
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature Request New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants