Skip to content

Commit e907ce4

Browse files
authored
overhaul tools for model calls (#88)
* switch ModelOptions.TOOLS to be a list; standardize tool handling; add tests * allow modeloptions.tools to be mapping or iterable; add func for extracting tools from list of actions * uncomment out tests * add fx to get actions for tool use for contexts; add test * add tools from context to backend calls; add test for tool from context calling * update handling of tools for the given action * move to separate test file * add section to the tutorial on tool calling * fix python 3.11 err with f-strings * fix tool test parsing error * fix the fstring issues
1 parent 03d93b4 commit e907ce4

File tree

11 files changed

+290
-107
lines changed

11 files changed

+290
-107
lines changed

docs/tutorial.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- [Chapter 9: Interoperability with Other Frameworks](#chapter-9-interoperability-with-other-frameworks)
2121
- [Chapter 10: Prompt Engineering for Mellea](#chapter-10-prompt-engineering-for-m)
2222
- [Custom Templates](#custom-templates)
23+
- [Chapter 11: Tool Calling](#chapter-11-tool-calling)
2324
- [Appendix: Contributing to Melles](#appendix-contributing-to-mellea)
2425

2526
## Chapter 1: What Is Generative Programming
@@ -1281,6 +1282,33 @@ To customize the template and template representation of an existing class, simp
12811282

12821283
See [`mellea/docs/examples/mify/rich_document_advanced.py`](./examples/mify/rich_document_advanced.py)
12831284

1285+
## Chapter 11: Tool Calling
1286+
Mellea supports tool calling for providers/models that support it. Most session level functions support setting a tool_calls boolean. Setting this to true allows tools to be called, but there's no guarantee that a model will call them.
1287+
1288+
Tools can be made available for the model to call in a few ways:
1289+
1. Components: components can have a TemplateRepresentation object that contains tools.
1290+
2. Context: depending on the context, the components in that context can be used as sources of additional tools in the exact same way they would if they were the current action.
1291+
3. `ModelOptions.TOOLS`: model options can include a tools parameter. The preferred way of passing these tools is as a list of function objects.
1292+
1293+
Currently, tools are identified by the name of the function. If there are conflicts, the most recent tool with that name will be preferred. This means the tools available to the model will have the same priority listed above:
1294+
1. Tools from the current component will always be included
1295+
2. Tools from the context will be included if there are no name conflicts. A given context can decide what tools to surface, but in most cases, tools from the most recent component in the context will take priority over tools from older requests.
1296+
3. Tools from `ModelOptions.TOOLS` will only be added if they do not conflict with any of the above functions.
1297+
1298+
For examples on adding tools to the template representation of a component, see the `Table` object in [richdocument.py](../mellea/stdlib/docs/richdocument.py).
1299+
1300+
Here's an example of adding a tool through model options. This can be useful when you want to add a tool like web search that should almost always be available:
1301+
```python
1302+
from mellea.backends.types import ModelOption
1303+
1304+
def web_search(query: str) -> str:
1305+
...
1306+
1307+
model_opts = {
1308+
ModelOptions.TOOLS: [web_search]
1309+
}
1310+
```
1311+
12841312
## Appendix: Contributing to Mellea
12851313

12861314
### Contributor Guide: Requirements and Verifiers

mellea/backends/huggingface.py

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@
1010
import datetime
1111
import inspect
1212
import json
13-
import os
1413
from collections.abc import Callable
15-
from typing import TYPE_CHECKING, Any, Optional
14+
from typing import TYPE_CHECKING, Any
1615

1716
import outlines
1817
import outlines_core
@@ -32,8 +31,9 @@
3231
from mellea.backends.formatter import Formatter, FormatterBackend, TemplateFormatter
3332
from mellea.backends.model_ids import ModelIdentifier
3433
from mellea.backends.tools import (
34+
add_tools_from_context_actions,
35+
add_tools_from_model_options,
3536
convert_tools_to_json,
36-
get_tools_from_action,
3737
parse_tools,
3838
)
3939
from mellea.backends.types import ModelOption
@@ -45,7 +45,6 @@
4545
GenerateLog,
4646
ModelOutputThunk,
4747
ModelToolCall,
48-
TemplateRepresentation,
4948
)
5049
from mellea.stdlib.chat import Message
5150
from mellea.stdlib.requirement import ALoraRequirement, LLMaJRequirement, Requirement
@@ -325,28 +324,15 @@ def _generate_from_context_standard(
325324
f"Tool calling typically uses constrained generation, but you have specified a `format` in your generate call. NB: tool calling is superseded by format; we will NOT call tools for your request: {action}"
326325
)
327326
else:
328-
if isinstance(action, Component) and isinstance(
329-
action.format_for_llm(), TemplateRepresentation
330-
):
331-
tools = get_tools_from_action(action)
332-
333-
model_options_tools = model_options.get(ModelOption.TOOLS, None)
334-
if model_options_tools is not None:
335-
assert isinstance(model_options_tools, dict)
336-
for fn_name in model_options_tools:
337-
# invariant re: relationship between the model_options set of tools and the TemplateRepresentation set of tools
338-
assert fn_name not in tools.keys(), (
339-
f"Cannot add tool {fn_name} because that tool was already defined in the TemplateRepresentation for the action."
340-
)
341-
# type checking because ModelOptions is an untyped dict and the calling convention for tools isn't clearly documented at our abstraction boundaries.
342-
assert type(fn_name) is str, (
343-
"When providing a `ModelOption.TOOLS` parameter to `model_options`, always used the type Dict[str, Callable] where `str` is the function name and the callable is the function."
344-
)
345-
assert callable(model_options_tools[fn_name]), (
346-
"When providing a `ModelOption.TOOLS` parameter to `model_options`, always used the type Dict[str, Callable] where `str` is the function name and the callable is the function."
347-
)
348-
# Add the model_options tool to the existing set of tools.
349-
tools[fn_name] = model_options_tools[fn_name]
327+
add_tools_from_model_options(tools, model_options)
328+
add_tools_from_context_actions(
329+
tools, ctx.actions_for_available_tools()
330+
)
331+
332+
# Add the tools from the action for this generation last so that
333+
# they overwrite conflicting names.
334+
add_tools_from_context_actions(tools, [action])
335+
FancyLogger.get_logger().info(f"Tools for call: {tools.keys()}")
350336

351337
seed = model_options.get(ModelOption.SEED, None)
352338
if seed is not None:

mellea/backends/ollama.py

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import asyncio
44
import datetime
5-
import os
65
from collections.abc import Callable
76
from typing import Any
87

@@ -13,7 +12,10 @@
1312
from mellea.backends import BaseModelSubclass
1413
from mellea.backends.formatter import Formatter, FormatterBackend, TemplateFormatter
1514
from mellea.backends.model_ids import ModelIdentifier
16-
from mellea.backends.tools import get_tools_from_action
15+
from mellea.backends.tools import (
16+
add_tools_from_context_actions,
17+
add_tools_from_model_options,
18+
)
1719
from mellea.backends.types import ModelOption
1820
from mellea.helpers.fancy_logger import FancyLogger
1921
from mellea.stdlib.base import (
@@ -295,28 +297,12 @@ def generate_from_chat_context(
295297
f"Tool calling typically uses constrained generation, but you have specified a `format` in your generate call. NB: tool calling is superseded by format; we will NOT call tools for your request: {action}"
296298
)
297299
else:
298-
if isinstance(action, Component) and isinstance(
299-
action.format_for_llm(), TemplateRepresentation
300-
):
301-
tools = get_tools_from_action(action)
302-
303-
model_options_tools = model_opts.get(ModelOption.TOOLS, None)
304-
if model_options_tools is not None:
305-
assert isinstance(model_options_tools, dict)
306-
for fn_name in model_options_tools:
307-
# invariant re: relationship between the model_options set of tools and the TemplateRepresentation set of tools
308-
assert fn_name not in tools.keys(), (
309-
f"Cannot add tool {fn_name} because that tool was already defined in the TemplateRepresentation for the action."
310-
)
311-
# type checking because ModelOptions is an untyped dict and the calling convention for tools isn't clearly documented at our abstraction boundaries.
312-
assert type(fn_name) is str, (
313-
"When providing a `ModelOption.TOOLS` parameter to `model_options`, always used the type Dict[str, Callable] where `str` is the function name and the callable is the function."
314-
)
315-
assert callable(model_options_tools[fn_name]), (
316-
"When providing a `ModelOption.TOOLS` parameter to `model_options`, always used the type Dict[str, Callable] where `str` is the function name and the callable is the function."
317-
)
318-
# Add the model_options tool to the existing set of tools.
319-
tools[fn_name] = model_options_tools[fn_name]
300+
add_tools_from_model_options(tools, model_opts)
301+
add_tools_from_context_actions(tools, ctx.actions_for_available_tools())
302+
303+
# Add the tools from the action for this generation last so that
304+
# they overwrite conflicting names.
305+
add_tools_from_context_actions(tools, [action])
320306
FancyLogger.get_logger().info(f"Tools for call: {tools.keys()}")
321307

322308
# Generate a chat response from ollama, using the chat messages.

mellea/backends/openai.py

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
from mellea.backends.aloras import Alora, AloraBackendMixin
2121
from mellea.backends.formatter import Formatter, FormatterBackend, TemplateFormatter
2222
from mellea.backends.model_ids import ModelIdentifier
23-
from mellea.backends.tools import convert_tools_to_json, get_tools_from_action
23+
from mellea.backends.tools import (
24+
add_tools_from_context_actions,
25+
add_tools_from_model_options,
26+
convert_tools_to_json,
27+
)
2428
from mellea.backends.types import ModelOption
2529
from mellea.helpers.fancy_logger import FancyLogger
2630
from mellea.stdlib.base import (
@@ -30,7 +34,6 @@
3034
GenerateLog,
3135
ModelOutputThunk,
3236
ModelToolCall,
33-
TemplateRepresentation,
3437
)
3538
from mellea.stdlib.chat import Message
3639
from mellea.stdlib.requirement import ALoraRequirement, LLMaJRequirement, Requirement
@@ -404,28 +407,13 @@ def _generate_from_chat_context_standard(
404407
f"Tool calling typically uses constrained generation, but you have specified a `format` in your generate call. NB: tool calling is superseded by format; we will NOT call tools for your request: {action}"
405408
)
406409
else:
407-
if isinstance(action, Component) and isinstance(
408-
action.format_for_llm(), TemplateRepresentation
409-
):
410-
tools = get_tools_from_action(action)
411-
412-
model_options_tools = model_opts.get(ModelOption.TOOLS, None)
413-
if model_options_tools is not None:
414-
assert isinstance(model_options_tools, dict)
415-
for fn_name in model_options_tools:
416-
# invariant re: relationship between the model_options set of tools and the TemplateRepresentation set of tools
417-
assert fn_name not in tools.keys(), (
418-
f"Cannot add tool {fn_name} because that tool was already defined in the TemplateRepresentation for the action."
419-
)
420-
# type checking because ModelOptions is an untyped dict and the calling convention for tools isn't clearly documented at our abstraction boundaries.
421-
assert type(fn_name) is str, (
422-
"When providing a `ModelOption.TOOLS` parameter to `model_options`, always used the type Dict[str, Callable] where `str` is the function name and the callable is the function."
423-
)
424-
assert callable(model_options_tools[fn_name]), (
425-
"When providing a `ModelOption.TOOLS` parameter to `model_options`, always used the type Dict[str, Callable] where `str` is the function name and the callable is the function."
426-
)
427-
# Add the model_options tool to the existing set of tools.
428-
tools[fn_name] = model_options_tools[fn_name]
410+
add_tools_from_model_options(tools, model_opts)
411+
add_tools_from_context_actions(tools, ctx.actions_for_available_tools())
412+
413+
# Add the tools from the action for this generation last so that
414+
# they overwrite conflicting names.
415+
add_tools_from_context_actions(tools, [action])
416+
FancyLogger.get_logger().info(f"Tools for call: {tools.keys()}")
429417

430418
thinking = model_opts.get(ModelOption.THINKING, None)
431419
if type(thinking) is bool and thinking:

mellea/backends/tools.py

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,64 @@
11
"""Utilities for dealing with tools."""
22

33
import json
4-
from collections.abc import Callable, Generator, Mapping
4+
from collections.abc import Callable, Generator, Iterable, Mapping
55
from typing import Any
66

77
from ollama._utils import convert_function_to_tool
88

9-
from mellea.stdlib.base import Component, TemplateRepresentation
9+
from mellea.backends.types import ModelOption
10+
from mellea.stdlib.base import CBlock, Component, TemplateRepresentation
1011

1112

12-
def get_tools_from_action(action: Any) -> dict[str, Callable]:
13-
"""If an object is a Component with a TemplateRepresentation, grabs it's tools field.
13+
def add_tools_from_model_options(
14+
tools_dict: dict[str, Callable], model_options: dict[str, Any]
15+
):
16+
"""If model_options has tools, add those tools to the tools_dict."""
17+
model_opts_tools = model_options.get(ModelOption.TOOLS, None)
18+
if model_opts_tools is None:
19+
return
20+
21+
# Mappings are iterable.
22+
assert isinstance(model_opts_tools, Iterable), (
23+
"ModelOption.TOOLS must be a list of Callables or dict[str, Callable]"
24+
)
25+
26+
if isinstance(model_opts_tools, Mapping):
27+
# Handle the dict case.
28+
for func_name, func in model_opts_tools.items():
29+
assert isinstance(func_name, str), (
30+
f"If ModelOption.TOOLS is a dict, it must be a dict of [str, Callable]; found {type(func_name)} as the key instead"
31+
)
32+
assert callable(func), (
33+
f"If ModelOption.TOOLS is a dict, it must be a dict of [str, Callable]; found {type(func)} as the value instead"
34+
)
35+
tools_dict[func_name] = func
36+
else:
37+
# Handle any other iterable / list here.
38+
for func in model_opts_tools:
39+
assert callable(func), (
40+
f"If ModelOption.TOOLS is a list, it must be a list of Callables; found {type(func)}"
41+
)
42+
tools_dict[func.__name__] = func
43+
44+
45+
def add_tools_from_context_actions(
46+
tools_dict: dict[str, Callable], ctx_actions: list[Component | CBlock] | None
47+
):
48+
"""If any of the actions in ctx_actions have tools in their template_representation, add those to the tools_dict."""
49+
if ctx_actions is None:
50+
return
51+
52+
for action in ctx_actions:
53+
if not isinstance(action, Component):
54+
continue # Only components have template representations.
1455

15-
Returns:
16-
dict: mapping function names to callables
17-
"""
18-
if isinstance(action, Component):
1956
tr = action.format_for_llm()
20-
if isinstance(tr, TemplateRepresentation):
21-
if tr.tools:
22-
return tr.tools
57+
if not isinstance(tr, TemplateRepresentation) or tr.tools is None:
58+
continue
2359

24-
return {}
60+
for tool_name, func in tr.tools.items():
61+
tools_dict[tool_name] = func
2562

2663

2764
def convert_tools_to_json(tools: dict[str, Callable]) -> list[dict]:

mellea/backends/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ class ModelOption:
1717
"""
1818

1919
TOOLS = "@@@tools@@@"
20+
"""Must be a list of callables or a dict[str, Callable]."""
21+
2022
MAX_NEW_TOKENS = "@@@max_new_tokens@@@"
2123
SYSTEM_PROMPT = "@@@system_prompt@@@"
2224
TEMPERATURE = "temperature"

mellea/backends/watsonx.py

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
from mellea.backends import BaseModelSubclass, model_ids
1414
from mellea.backends.formatter import Formatter, FormatterBackend, TemplateFormatter
1515
from mellea.backends.model_ids import ModelIdentifier
16-
from mellea.backends.tools import convert_tools_to_json, get_tools_from_action
16+
from mellea.backends.tools import (
17+
add_tools_from_context_actions,
18+
add_tools_from_model_options,
19+
convert_tools_to_json,
20+
)
1721
from mellea.backends.types import ModelOption
1822
from mellea.helpers.fancy_logger import FancyLogger
1923
from mellea.stdlib.base import (
@@ -261,25 +265,13 @@ def generate_from_chat_context(
261265
f"tool calling is superseded by format; will not call tools for request: {action}"
262266
)
263267
else:
264-
tools = get_tools_from_action(action)
265-
266-
model_options_tools = model_opts.get(ModelOption.TOOLS, None)
267-
if model_options_tools is not None:
268-
assert isinstance(model_options_tools, dict)
269-
for fn_name in model_options_tools:
270-
# invariant re: relationship between the model_options set of tools and the TemplateRepresentation set of tools
271-
assert fn_name not in tools.keys(), (
272-
f"Cannot add tool {fn_name} because that tool was already defined in the TemplateRepresentation for the action."
273-
)
274-
# type checking because ModelOptions is an untyped dict and the calling convention for tools isn't clearly documented at our abstraction boundaries.
275-
assert type(fn_name) is str, (
276-
"When providing a `ModelOption.TOOLS` parameter to `model_options`, always used the type Dict[str, Callable] where `str` is the function name and the callable is the function."
277-
)
278-
assert callable(model_options_tools[fn_name]), (
279-
"When providing a `ModelOption.TOOLS` parameter to `model_options`, always used the type Dict[str, Callable] where `str` is the function name and the callable is the function."
280-
)
281-
# Add the model_options tool to the existing set of tools.
282-
tools[fn_name] = model_options_tools[fn_name]
268+
add_tools_from_model_options(tools, model_opts)
269+
add_tools_from_context_actions(tools, ctx.actions_for_available_tools())
270+
271+
# Add the tools from the action for this generation last so that
272+
# they overwrite conflicting names.
273+
add_tools_from_context_actions(tools, [action])
274+
FancyLogger.get_logger().info(f"Tools for call: {tools.keys()}")
283275

284276
formatted_tools = convert_tools_to_json(tools)
285277
chat_response = self._model.chat(

0 commit comments

Comments
 (0)