Chat with Tools#
This guide explains how to use LLM tool calling (function calling) with ContextGem’s chat interface. Tools allow the LLM to invoke Python functions during a conversation to perform actions or retrieve information.
Note
Tool support is only available in chat(...) and chat_async(...) methods. It is not used by extraction methods.
Basic Usage#
Use the @register_tool decorator to register a function as a tool. The tool schema is auto-generated from the function signature, type hints, and docstring.
import os
from typing import TypedDict
from contextgem import ChatSession, DocumentLLM, register_tool
# Define a TypedDict for structured tool parameters.
# This ensures the auto-generated schema includes proper types for each field.
class InvoiceItem(TypedDict):
qty: float
price: float
# Define tool handlers with @register_tool decorator.
# The schema is auto-generated from the function signature and docstring.
@register_tool
def compute_invoice_total(items: list[InvoiceItem]) -> str:
"""
Compute invoice total as sum(qty*price) over items.
:param items: List of invoice items with quantity and price
"""
total = 0
for it in items:
qty = float(it.get("qty", 0))
price = float(it.get("price", 0))
total += qty * price
return str(total)
# Configure an LLM that supports tool use.
# Simply pass the decorated function(s) directly.
llm = DocumentLLM(
model="azure/gpt-4.1-mini",
api_key=os.getenv("CONTEXTGEM_AZURE_OPENAI_API_KEY"),
api_version=os.getenv("CONTEXTGEM_AZURE_OPENAI_API_VERSION"),
api_base=os.getenv("CONTEXTGEM_AZURE_OPENAI_API_BASE"),
system_message="You are a helpful assistant.", # override default system message for chat
tools=[compute_invoice_total], # Pass the function directly
)
# Maintain history across turns
session = ChatSession()
prompt = (
"What's the invoice total for the items "
"[{'qty':2.0,'price':3.5},{'qty':1.0,'price':3.0}]?"
)
answer = llm.chat(prompt, chat_session=session)
print("Answer:", answer)
Key points:
Decorate functions with
@register_toolto make them available as toolsPass decorated functions directly to
tools=[...]when creating the LLMTool handlers must return a string (serialize structured data with
json.dumpsif needed)Use
ChatSessionto maintain conversation history across turns
Docstring Formats#
The schema generator extracts parameter descriptions from docstrings. Multiple formats are supported:
Sphinx/reST
@register_tool
def my_tool(query: str) -> str:
"""
Search for information.
:param query: The search query
"""
return "results"
Google style:
@register_tool
def my_tool(query: str) -> str:
"""Search for information.
Args:
query: The search query
"""
return "results"
NumPy style:
@register_tool
def my_tool(query: str) -> str:
"""
Search for information.
Parameters
----------
query : str
The search query
"""
return "results"
Schema Generation Best Practices#
Use TypedDict for Object Parameters#
Plain dict generates a generic {"type": "object"} schema without property definitions.
Use TypedDict to specify field names and types explicitly:
from typing import TypedDict
class InvoiceItem(TypedDict):
qty: float
price: float
@register_tool
def compute_total(items: list[InvoiceItem]) -> str:
"""
Compute invoice total.
:param items: List of invoice items
"""
total = sum(it["qty"] * it["price"] for it in items)
return str(total)
This generates a proper schema with typed properties:
{
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"qty": {"type": "number"},
"price": {"type": "number"}
},
"required": ["qty", "price"]
}
}
}
Supported Type Hints#
The schema generator supports the following Python type hints:
Primitives:
str,int,float,bool,NoneCollections:
list[T],dict[K, V],tuple[T, ...],set[T]Optionals:
Optional[T],T | NoneUnions:
Union[X, Y, ...],X | YLiterals:
Literal["a", "b", "c"]Structured:
TypedDict
Note
JSON serialization of collections: Since tool arguments are transmitted as JSON,
tuple and set types are received as Python list at runtime (JSON only has
arrays). If you need specific collection behavior, convert inside your function:
items = set(items) or items = tuple(items).
Examples:
from typing import Literal, TypedDict
class SearchFilters(TypedDict):
category: str
max_price: float
@register_tool
def search(
query: str,
limit: int = 10,
sort: Literal["relevance", "date", "price"] = "relevance",
filters: SearchFilters | None = None,
) -> str:
"""
Search products.
:param query: Search query
:param limit: Maximum results to return
:param sort: Sort order
:param filters: Optional filters
"""
return "results"
Custom Schema Override#
For full control over the tool schema, pass an explicit OpenAI-compatible schema to @register_tool:
@register_tool(schema={
"type": "function",
"function": {
"name": "search_database",
"description": "Search the product database",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query"
},
"category": {
"type": "string",
"enum": ["electronics", "clothing", "books"],
"description": "Product category filter"
}
},
"required": ["query"]
}
}
})
def search_database(query: str, category: str = None) -> str:
return f"Results for '{query}' in {category or 'all categories'}"
This is useful when you need:
Custom parameter descriptions beyond what docstrings provide
Specific enum values or constraints
Complex nested schemas
Tool Configuration Options#
The DocumentLLM class accepts several tool-related parameters:
Parameter |
Default |
Description |
|---|---|---|
|
|
List of tool functions decorated with |
|
|
Controls how the model uses tools. Options: |
|
|
Whether to enable parallel tool execution. When |
|
|
Maximum number of tool execution rounds per request. Prevents infinite or excessively long tool chains. |
Note
Why ``tool_choice=”required”`` is relaxed to ``”auto”`` in follow-up rounds:
When you set tool_choice="required", the model MUST call at least one tool on the
initial request. However, if this setting were kept for follow-up rounds (after tool
results are returned to the model), the model would be forced to call tools again,
potentially causing:
Infinite loops or hitting
tool_max_roundsunnecessarilyInability to produce a final text response to the user
To solve this, tool_choice="required" (and forced function dicts like
{"type": "function", "function": {"name": "..."}}) are automatically relaxed to
"auto" in follow-up rounds. This allows the model to either:
Call additional tools if needed (multi-round tool chaining)
Produce a final text response when done processing tool results
Return Value Requirements#
Tool handlers must return a string. For structured data, serialize it before returning:
import json
@register_tool
def get_user_data(user_id: str) -> str:
"""
Retrieve user data.
:param user_id: The user ID
"""
data = {"name": "John", "email": "john@example.com"}
return json.dumps(data)
The LLM will interpret the returned string and incorporate it into its response.