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

Improving BaseTool classes for more control and additional convenience #294

Closed
4 of 5 tasks
willbakst opened this issue Jun 1, 2024 · 6 comments · Fixed by #337
Closed
4 of 5 tasks

Improving BaseTool classes for more control and additional convenience #294

willbakst opened this issue Jun 1, 2024 · 6 comments · Fixed by #337
Assignees
Labels
Feature Request New feature or request
Milestone

Comments

@willbakst
Copy link
Contributor

willbakst commented Jun 1, 2024

Description

There are a few things from various discussions that I think would help improve tools in Mirascope. I'll use this issue to track progress on the improvements:

  • More granular control over the tool's name and description.
    Right now the generated tool schemas use the class name and docstring, but this is a bit restrictive. What if you want to use a name that wouldn't be a valid class or method name? What if you want to have the description for the tool be separate from the docstring? Idea here would be to add new name and description class methods that will use __name__ and __doc__ respectively by default but allow for the user to overwrite the method if desired. For example:
class MyTool(OpenAITool):
    """This is now just a normal docstring."""
    
    param: str

    @classmethod
    def name(cls) -> str:
        return "invalid.method-name"
    
    @classmethod
    def description(cls) -> str:
        return "This would be the inserted description."

As part of this improvement, we should also ensure that tools auto-generated from functions actually use the original function name. I noticed while digging that we convert into the proper tool name in pascal case, but that's really bad and needs to be fixed.

  • Improve recommendation / standard for calling tools
    The current way users are expected to call tools right now is tool.fn(**tool.args), but this means that you either need to write the tool as a function or you need to write the function and attach it to the tool. It seems like a better recommendation / interface to instead have a call method that internally calls tool.fn(**tool.args) by default but then can be overridden by the user for a better interface for writing more complex tools. For example:
def my_fn(param: str) -> str:
    return f"This is the separate fn using {param} that is attached"

@tool_fn(my_fn)
class MyTool(OpenAITool):
    """A tool."""

    param: str


my_tool = MyTool(param="param", tool_call=...)
print(my_tool.call())  # internally calls `my_tool.fn(**my_tool.args)`


class MyToolWithCall(OpenAITool):
    """A tool."""

    param: str

    def call(self) -> str:
        return f"This is the internal method for calling with {self.param} that's overridden."

my_tool_with_call = MyToolWithCall(param="param", tool_call=...)
print(my_tool_with_call.call())
  • Add a Toolkit class for organizing a set of tools under a namespace
    Consider a bunch of functions / tools for interacting with a file system. It would be nice to be able to organize these tools under a single namespace and provide the LLM with the namespaced tools so it has more context around where the tools are coming from an how to use them. For example:
from mirascope.base import Toolkit
import file_system as fs

fs_toolkit = Toolkit(tools=[fs.ls, fs.rm, fs.mkdir], namespace="fs")
fs_tools = fs_toolkit.tools  # these can then be added like any other list of tools
  • Add documentation for how to use examples with tool calls
    It's fairly clear how to add examples for normal calls (just insert them into the prompt), but for tool calls there are slightly better ways by using the JSON schema examples directly. We should better document how to do this so it's clear without additional digging.
  • Provide convenience for re-inserting a tool call into chat history
    Users currently need to reconstruct the tool call message manually, which is annoying. Instead we should provide something like tool.message_param that automatically generates the message parameter to insert.
@jbbakst
Copy link

jbbakst commented Jun 3, 2024

While you're thinking about this, I think it might also be worthwhile to think about stateful tools. This goes for both the tool schema and the tool fn implementation.

As a very simple example, let's say I have a chatbot that stores information about the user it's talking to in a scratchpad. The content of the scratchpad and the ID of the user will not always be constant, so the tool description and implementation need to be dynamic based on the session user.

@willbakst
Copy link
Contributor Author

100%. I think #278 is along these lines? Essentially dynamically update the tool based on the state.

Seems to me like that state might be stored in the call/agent and not the tool itself where the tool then updates dynamically based on that state?

@jbbakst
Copy link

jbbakst commented Jun 6, 2024

@willbakst this is what I was talking about yesterday, with trying to pass methods that are defined on a class (and therefore require self) as tools

@willbakst
Copy link
Contributor Author

Yeah we need to be able to use tools that are defined internally to your class for colocation (and also the ability to edit internal state easily through tools). Without enabling this natively, every current solution smells.

@willbakst
Copy link
Contributor Author

  • Enable using a set of tools defined internally to the call
    Users may want to write internal methods to the call but then provide the call with access to those tools so that it can call them. Initial thoughts here would be to add something like a tools property to the call classes that defaults to the tools in call_params but the user can optionally override. Of course, passing them at runtime should still override everything.

Opting not to implement this unless heavily requested since the new interface in #322 allows for this. Really the only reason to have a tool with access to self is to access and edit state, which goes against the idea of calls being stateless.

@willbakst
Copy link
Contributor Author

After working on it for a little, I realized that the tool.message_param design doesn't work. The primary issue is that not all providers have a "tool" message param. For example, Anthropic expects all interactions to be alternating between assistant and user where tool message params are just a user message, and if multiple tools are called all of them live in the content array of the user message.

Instead we'll need to generate all of the tool message parameters at once. I'm thinking a good flow would be something like:

response = my_call.call()
tools_and_outputs = []
if tools := response.tools:
    tool_outputs = []
    for tool in tools:
        output = tool.call()
        tools_and_outputs.append((tool, output))
...
tool_message_params = response.tool_message_params(tools_and_outputs)

For streaming, we would do the same thing (for providers that support streaming tools):

stream = ProviderStream(my_call.stream())
tools_and_outputs = []
for chunk, tool in stream:
    if tool:
        output = tool.call()
        tools_and_outputs.append((tool, output))
    ...
tool_message_params = stream.tool_message_params(tools_and_outputs)

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
2 participants