Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/kaggle_benchmarks/actors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from kaggle_benchmarks.actors.base import Actor, assertion, system, user
from kaggle_benchmarks.actors.base import Actor, Tool, assertion, system, user
from kaggle_benchmarks.actors.llms import LLMChat
5 changes: 5 additions & 0 deletions src/kaggle_benchmarks/actors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ def __str__(self) -> str:
return f"{self.avatar} {self.name}"


class Tool(Actor):
def __init__(self, name: str = "tool"):
super().__init__(name=name, role="tool")


system = Actor(name="System", role="system", avatar="⚙️")
assertion = Actor(name="Assertion", role="system", avatar="🚨️")
user = Actor(name="User", role="user", avatar="👤")
10 changes: 9 additions & 1 deletion src/kaggle_benchmarks/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from kaggle_benchmarks.tools import container, python, search, web
from kaggle_benchmarks.tools import container, functions, python, search, web
from kaggle_benchmarks.tools.base import (
ModelResponse,
ToolCallModel,
ToolInvocation,
ToolInvocationResult,
describe_tools,
invoke_tool,
)
126 changes: 126 additions & 0 deletions src/kaggle_benchmarks/tools/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Copyright 2026 Kaggle Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import dataclasses
import inspect
from typing import Any, Callable, Generic, TypeVar

import pydantic

T = TypeVar("T")


@dataclasses.dataclass
class ToolInvocation:
"""Represents a tool invocation requested by the LLM."""

name: str
arguments: dict[str, Any]
call_id: str | None = None


@dataclasses.dataclass
class ToolInvocationResult:
"""Represents the result of a tool invocation."""

name: str
arguments: dict[str, Any]
call_id: str | None = None
output: Any = None

def describe(self):
return f"{self.name}({self.arguments}) -> {self.output}"


class ToolCallModel(pydantic.BaseModel):
"""Represents a tool call in a structured response."""

name: str
arguments: dict[str, Any]


class ModelResponse(pydantic.BaseModel, Generic[T]):
"""A structured response from the LLM that may contain tool calls or a message."""

tools: list[ToolCallModel] | None = None
message: T | None = None


def describe_tools(tools: list[Callable]) -> str:
"""Generates a plain English description of the available tools."""
descriptions = []
for tool in tools:
sig = inspect.signature(tool)
params = []
for param in sig.parameters.values():
param_str = param.name
if param.annotation != inspect.Parameter.empty:
try:
param_str += f": {param.annotation.__name__}"
except AttributeError:
param_str += f": {str(param.annotation)}"
if param.default != inspect.Parameter.empty:
param_str += f" = {param.default!r}"
params.append(param_str)

param_list_str = ", ".join(params)

return_annotation = ""
if sig.return_annotation != inspect.Parameter.empty:
try:
return_annotation = f" -> {sig.return_annotation.__name__}"
except AttributeError:
return_annotation = f" -> {str(sig.return_annotation)}"

docstring = (tool.__doc__ or "").strip()
description = (
f"- `{tool.__name__}({param_list_str}){return_annotation}`: {docstring}"
)
descriptions.append(description)

if not descriptions:
return "No tools available."

return "\n".join(descriptions)


def invoke_tool(call: ToolInvocation, tools: list[Callable]) -> ToolInvocationResult:
"""Invokes a tool and returns the result."""
tool = next((t for t in tools if t.__name__ == call.name), None)
if tool is None:
error_message = f"Error: Tool '{call.name}' not found."
return ToolInvocationResult(
name=call.name,
arguments=call.arguments,
output=error_message,
call_id=call.call_id,
)
try:
output = tool(**call.arguments)
return ToolInvocationResult(
name=call.name,
arguments=call.arguments,
output=output,
call_id=call.call_id,
)
except KeyboardInterrupt:
raise
except Exception as e:
error_message = f"Error invoking tool '{call.name}': {e}"
return ToolInvocationResult(
name=call.name,
arguments=call.arguments,
output=error_message,
call_id=call.call_id,
)
87 changes: 87 additions & 0 deletions src/kaggle_benchmarks/tools/functions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Copyright 2026 Kaggle Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import inspect
from typing import Any, Callable, Union

import pydantic
from google.genai import types


class ToolSchemaError(Exception):
"""Raised when a function schema cannot be generated."""


def get_function_schema(func: Callable) -> dict:
"""Generates a JSON schema for a function's parameters using Pydantic."""
sig = inspect.signature(func)
fields = {}

for name, param in sig.parameters.items():
annotation = (
param.annotation if param.annotation != inspect.Parameter.empty else Any
)
default = param.default if param.default != inspect.Parameter.empty else ...

fields[name] = (annotation, default)

try:
DynamicModel = pydantic.create_model(f"{func.__name__}", **fields)
return DynamicModel.model_json_schema()
except pydantic.PydanticSchemaGenerationError as e:
raise ToolSchemaError(
"Unable to generate json schema for function {func.__name__} arugments", e
)


def function_to_openai_tool(func: Callable) -> dict:
"""Converts a Python function into an OpenAI-compatible tool definition."""
schema = get_function_schema(func)

parameters = {
"type": "object",
"properties": schema.get("properties", {}),
"required": schema.get("required", []),
}
if "$defs" in schema:
parameters["$defs"] = schema["$defs"]

return {
"type": "function",
"name": func.__name__,
"description": (func.__doc__ or "").strip(),
"parameters": parameters,
}


def function_to_genai_tool(
tool: Union[Callable, dict],
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: Shall we support the dict with a function key as well like {"type": "function", "function": {...}}, e.g., by

if "function" in tool:
  tool = tool["function"]

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Where is such a format used?

Copy link
Contributor

Choose a reason for hiding this comment

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

) -> types.FunctionDeclaration:
"""Converts a Python function or an OpenAI-style tool dictionary into a Google GenAI FunctionDeclaration."""
if isinstance(tool, Callable):
return types.FunctionDeclaration(
name=tool.__name__,
description=tool.__doc__,
parameters=get_function_schema(tool),
)

elif isinstance(tool, dict):
# map from openai style
return types.FunctionDeclaration(
name=tool.get("name"),
description=tool.get("description"),
parameters=tool.get("parameters"),
)
else:
raise ValueError("Unknown tool type")
60 changes: 60 additions & 0 deletions tests/tools/test_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Copyright 2026 Kaggle Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from kaggle_benchmarks.tools.base import (
ToolInvocation,
describe_tools,
invoke_tool,
)

Copy link
Contributor

Choose a reason for hiding this comment

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

Shall we add a test case for code like

from pydantic import BaseModel

# Pydantic or dataclass
class User(BaseModel):
    name: str
    age: int

def add_user(user: User):
    """Adds a new user to the database."""
    pass


def simple_tool(a: int, b: str = "default") -> str:
"""A simple tool for testing."""
return f"{a}-{b}"


def tool_that_raises():
"""A tool that always raises an exception."""
raise ValueError("This tool failed.")


def test_describe_tools_no_tools():
assert describe_tools([]) == "No tools available."


def test_describe_tools_with_tools():
description = describe_tools([simple_tool, tool_that_raises])
assert "simple_tool(a: int, b: str = 'default') -> str" in description
assert "A simple tool for testing." in description
assert "tool_that_raises()" in description
assert "A tool that always raises an exception." in description


def test_invoke_tool_success():
call = ToolInvocation(name="simple_tool", arguments={"a": 1, "b": "test"})
result = invoke_tool(call, [simple_tool])
assert result.output == "1-test"
assert result.name == "simple_tool"


def test_invoke_tool_not_found():
call = ToolInvocation(name="non_existent_tool", arguments={})
result = invoke_tool(call, [simple_tool])
assert "Error: Tool 'non_existent_tool' not found." in result.output


def test_invoke_tool_exception():
call = ToolInvocation(name="tool_that_raises", arguments={})
result = invoke_tool(call, [tool_that_raises])
assert "Error invoking tool 'tool_that_raises': This tool failed." in result.output
Loading