Skip to content
Merged
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
18 changes: 16 additions & 2 deletions example/src/simple_example/server.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
from typing import List, Optional
from fastapi import FastAPI
from pydantic import BaseModel
from utcp.shared.provider import HttpProvider
from utcp.shared.tool import utcp_tool
from utcp.shared.utcp_manual import UtcpManual

class TestInput(BaseModel):
value: str

class TestRequest(BaseModel):
value: str
arr: List[TestInput]

class TestResponse(BaseModel):
received: str

__version__ = "1.0.0"
BASE_PATH = "http://localhost:8080"
Expand All @@ -23,5 +30,12 @@ def get_utcp():
http_method="POST"
))
@app.post("/test")
def test_endpoint(data: TestRequest):
return {"received": data.value}
def test_endpoint(data: TestRequest) -> Optional[TestResponse]:
"""Test endpoint to receive a string value.

Args:
data (TestRequest): The input data containing a string value.
Returns:
TestResponse: A dictionary with the received value.
"""
return TestResponse(received=data.value)
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "utcp"
version = "0.1.4"
version = "0.1.7"
authors = [
{ name = "Razvan-Ion Radulescu" },
{ name = "Andrei-Stefan Ghiurtu" },
Expand All @@ -31,7 +31,7 @@ classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
]
license = {text = "MPL-2.0"}
license = "MPL-2.0"

[project.optional-dependencies]
dev = [
Expand Down
4 changes: 3 additions & 1 deletion src/utcp/client/utcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def __init__(self, config: UtcpClientConfig, tool_repository: ToolRepository, se
self.config = config

@classmethod
async def create(cls, config: Optional[Union[Dict[str, Any], UtcpClientConfig]] = None, tool_repository: ToolRepository = InMemToolRepository(), search_strategy: Optional[ToolSearchStrategy] = None) -> 'UtcpClient':
async def create(cls, config: Optional[Union[Dict[str, Any], UtcpClientConfig]] = None, tool_repository: Optional[ToolRepository] = None, search_strategy: Optional[ToolSearchStrategy] = None) -> 'UtcpClient':
"""
Create a new instance of UtcpClient.

Expand All @@ -110,6 +110,8 @@ async def create(cls, config: Optional[Union[Dict[str, Any], UtcpClientConfig]]
Returns:
A new instance of UtcpClient.
"""
if tool_repository is None:
tool_repository = InMemToolRepository()
if search_strategy is None:
search_strategy = TagSearchStrategy(tool_repository)
if config is None:
Expand Down
286 changes: 240 additions & 46 deletions src/utcp/shared/tool.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from typing import Dict, Any, Optional, List, get_type_hints
from pydantic import BaseModel, Field, TypeAdapter
import inspect
from typing import Dict, Any, Optional, List, Set, Tuple, get_type_hints, get_origin, get_args, Union
from typing import get_origin, get_args, List, Dict, Optional, Union, Any
from pydantic import BaseModel, Field
from utcp.shared.provider import ProviderUnion


class ToolInputOutputSchema(BaseModel):
type: str = Field(default="object")
properties: Dict[str, Any] = Field(default_factory=dict)
Expand Down Expand Up @@ -38,6 +41,228 @@ def get_tools() -> List[Tool]:
"""Get the list of tools available in the UTCP server."""
return ToolContext.tools

########## UTCP Tool Decorator ##########
def python_type_to_json_type(py_type):
origin = get_origin(py_type)
args = get_args(py_type)

if origin is Union:
# Handle Optional[X] = Union[X, NoneType]
non_none_args = [arg for arg in args if arg is not type(None)]
if len(non_none_args) == 1:
return python_type_to_json_type(non_none_args[0]) # Treat as Optional
else:
return "object" # Generic union

if origin is list or origin is List:
return "array"
if origin is dict or origin is Dict:
return "object"
if origin is tuple or origin is Tuple:
return "array"
if origin is set or origin is Set:
return "array"

# Handle concrete base types
mapping = {
str: "string",
int: "integer",
float: "number",
bool: "boolean",
bytes: "string",
type(None): "null",
Any: "object",
}

return mapping.get(py_type, "object")

def get_docstring_description_input(func) -> Dict[str, Optional[str]]:
"""
Extracts descriptions for parameters from the function docstring.
Returns a dict mapping param names to their descriptions.
"""
doc = func.__doc__
if not doc:
return {}
descriptions = {}
for line in map(str.strip, doc.splitlines()):
for param in inspect.signature(func).parameters:
if param == "self":
continue
if line.startswith(param):
descriptions[param] = line.split(param, 1)[1].strip()
return descriptions

def get_docstring_description_output(func) -> Dict[str, Optional[str]]:
"""
Extracts the return value description from the function docstring.
Returns a dict with key 'return' and its description.
"""
doc = func.__doc__
if not doc:
return {}
for i, line in enumerate(map(str.strip, doc.splitlines())):
if line.lower().startswith("returns:") or line.lower().startswith("return:"):
desc = line.split(":", 1)[1].strip()
if desc:
return {"return": desc}
# If description is on the next line
if i + 1 < len(doc.splitlines()):
return {"return": doc.splitlines()[i + 1].strip()}
return {}

def get_param_description(cls, param_name=None):
# Try to get description for a specific param if available
if param_name:
# Check if there's a class variable or annotation with description
doc = getattr(cls, "__doc__", "") or ""
for line in map(str.strip, doc.splitlines()):
if line.startswith(param_name):
return line.split(param_name, 1)[1].strip()
# Check if param has a 'description' attribute (for pydantic/BaseModel fields)
if hasattr(cls, "__fields__") and param_name in cls.__fields__:
return getattr(cls.__fields__[param_name], "field_info", {}).get("description", "")
# Fallback to class-level description
return getattr(cls, "description", "") or (getattr(cls, "__doc__", "") or "")

def is_optional(t):
origin = get_origin(t)
args = get_args(t)
return origin is Union and type(None) in args

def recurse_type(param_type):
json_type = python_type_to_json_type(param_type)

# Handle array/list types
if json_type == "array":
# Try to get the element type if available
item_type = getattr(param_type, "__args__", [Any])[0]
return {
"type": "array",
"items": recurse_type(item_type),
"description": "An array of items"
}

# Handle object types
if json_type == "object":
if hasattr(param_type, "__annotations__") or is_optional(param_type):
sub_properties = {}
sub_required = []

if is_optional(param_type):
# If it's Optional, we treat it as an object with no required fields
param_type = param_type.__args__[0] if param_type.__args__ else Any
for key, value_type in getattr(param_type, "__annotations__", {}).items():
key_desc = get_param_description(param_type, key)
sub_properties[key] = recurse_type(value_type)
sub_properties[key]["description"] = key_desc or f"Auto-generated description for {key}"
if value_type is not None and value_type is not type(None) and value_type is not Optional and not is_optional(value_type):
sub_required.append(key)
return {
"type": "object",
"properties": sub_properties,
"required": sub_required,
"description": get_param_description(param_type)
}

return {
"type": "object",
"properties": {},
"description": "A generic dictionary object"
}

# Fallback for primitive types
return {
"type": json_type,
"description": ""
}

def type_to_json_schema(param_type, param_name=None, param_description=None):
json_type = python_type_to_json_type(param_type)

# Recurse for object and dict types
if json_type == "object":
val = recurse_type(param_type)
val["description"] = get_param_description(param_type, param_name) or param_description.get(param_name, f"Auto-generated description for {param_name}")
elif json_type == "array" and hasattr(param_type, "__args__"):
# Handle list/array types with recursion for element type
item_type = param_type.__args__[0] if param_type.__args__ else Any
val = {
"type": "array",
"items": recurse_type(item_type),
"description": param_description.get(param_name, f"Auto-generated description for {param_name}")
}
else:
val = {
"type": json_type,
"description": param_description.get(param_name, f"Auto-generated description for {param_name}")
}

return val

def generate_input_schema(func, title, description):
sig = inspect.signature(func)
type_hints = get_type_hints(func)

properties = {}
required = []

func_name = func.__name__
func_description = description or func.__doc__ or ""
param_description = get_docstring_description_input(func)

for param_name, param in sig.parameters.items():
if param_name == "self": # skip methods' self
continue

param_type = type_hints.get(param_name, str)
properties[param_name] = type_to_json_schema(param_type, param_name, param_description)

if param.default is inspect.Parameter.empty:
required.append(param_name)

input_desc = "\n".join([f"{name}: {desc}" for name, desc in param_description.items() if desc])
schema = ToolInputOutputSchema(
type="object",
properties=properties,
required=required,
description=input_desc or func_description,
title=title or func_name
)

return schema

def generate_output_schema(func, title, description):
type_hints = get_type_hints(func)
func_name = func.__name__
func_description = description or func.__doc__ or ""

properties = {}
required = []

return_type = type_hints.get('return', None)
output_desc = get_docstring_description_output(func).get('return', None)
if return_type:
properties["result"] = type_to_json_schema(return_type, "result", {"result": output_desc})
if return_type is not None and return_type is not type(None) and return_type is not Optional and not is_optional(return_type):
required.append("result")
else:
properties["result"] = {
"type": "null",
"description": f"No return value for {func_name}"
}

schema = ToolInputOutputSchema(
type="object",
properties=properties,
required=required,
description=output_desc or func_description,
title=title or func_name
)

return schema


def utcp_tool(
tool_provider: ProviderUnion,
name: Optional[str] = None,
Expand All @@ -48,61 +273,30 @@ def utcp_tool(
):
def decorator(func):
if tool_provider.name is None:
_provider_name = f"{func.__name__}_provider"
tool_provider.name = _provider_name
else:
_provider_name = tool_provider.name
tool_provider.name = f"{func.__name__}_provider"

func_name = func.__name__
func_name = name or func.__name__
func_description = description or func.__doc__ or ""

if not inputs:
# Extract input schema
input_tool_schema = TypeAdapter(func).json_schema()
input_tool_schema["title"] = func_name
input_tool_schema["description"] = func_description

if not outputs:
# Extract output schema
hints = get_type_hints(func)
return_type = hints.pop("return", None)
if return_type is not None:
output_schema = TypeAdapter(return_type).json_schema()
output_tool_schema = ToolInputOutputSchema(
type=output_schema.get("type", "object") if output_schema.get("type") == "object" else "value",
properties=output_schema.get("properties", {}) if output_schema.get("type") == "object" else {},
required=output_schema.get("required", []) if output_schema.get("type") == "object" else [],
title=func_name,
description=func_description
)
else:
output_tool_schema = ToolInputOutputSchema(
type="null",
properties={},
required=[],
title=func_name,
description=func_description
)

# Create the complete tool definition

input_tool_schema = inputs or generate_input_schema(func, f"{func_name} Input", func_description)
output_tool_schema = outputs or generate_output_schema(func, f"{func_name} Output", func_description)

def get_tool_definition():
return Tool(
name=name or func_name,
description=description or func_description,
name=func_name,
description=func_description,
tags=tags,
inputs=inputs or input_tool_schema,
outputs=outputs or output_tool_schema,
inputs=input_tool_schema,
outputs=output_tool_schema,
tool_provider=tool_provider
)

# Attach methods to function

func.input = lambda: input_tool_schema
func.output = lambda: output_tool_schema
func.tool_definition = get_tool_definition

# Add the tool to the UTCP manual context
ToolContext.add_tool(get_tool_definition())

return func

return decorator
2 changes: 1 addition & 1 deletion src/utcp/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import tomli
from pathlib import Path

__version__ = "0.1.4"
__version__ = "0.1.7"
try:
__version__ = version("utcp")
except PackageNotFoundError:
Expand Down