that actually worked. Now, an API could have hundreds of different parameters, is this still the recommended solution for such an API?
No - with hundreds of parameters, you should split into separate tools per operation.
Better approach for complex APIs:
def geopandas_read_file(dataset_path: str) -> str:
"""
Read a geospatial file into memory.
Args:
dataset_path: Path to file (e.g., 'natural_earth/ne_110m_populated_places.shp')
Returns:
str: Success message with dataset info
"""
import geopandas as gpd
gdf = gpd.read_file(dataset_path)
return f"Loaded {len(gdf)} features from {dataset_path}"
def geopandas_spatial_join(
left_dataset: str,
right_dataset: str,
how: str = "inner",
predicate: str = "intersects"
) -> str:
"""
Perform spatial join between two datasets.
Args:
left_dataset: Path to left dataset
right_dataset: Path to right dataset
how: Join type (inner, left, right)
predicate: Spatial predicate (intersects, within, contains)
Returns:
str: Join result summary
"""
# Implementation here
pass
def geopandas_buffer(
dataset_path: str,
distance: float,
output_path: Optional[str] = None
) -> str:
"""
Create buffer around geometries.
Args:
dataset_path: Path to input dataset
distance: Buffer distance in CRS units
output_path: Path to save result (optional)
Returns:
str: Buffer operation result
"""
# Implementation here
pass
Why this is better:
- Clarity: Agent sees focused tools with clear purposes
- Maintainability: Easier to update individual operations
- Schema size: Smaller, more digestible tool descriptions
- Error handling: Operation-specific validation and error messages
- Performance: Agent doesnât need to reason about irrelevant parameters
When to use a single parameterized tool:
- API has 5-10 related operations
- Parameters are mostly shared across operations
- Operations are very similar conceptually
When to split into separate tools:
- API has 10+ distinct operations
- Each operation has unique parameters
- Operations serve different purposes
- You want better error messages per operation
For an API with hundreds of parameters, splitting is the only practical approach.
is the fact that kwargs will not work in a tool method documented somewhere ?
No, itâs not explicitly documented - this is another docs gap.
The custom tools page (Define and customize tools | Letta) mentions:
âFor your agent to call a tool, Letta constructs an OpenAI tool schema (contained in
json_schemafield)â
But it doesnât explicitly state that **kwargs wonât work or explain why.
The underlying reason:
- OpenAI tool schemas require all parameters to be explicitly defined
**kwargsis dynamic - parameters arenât known until runtime- Schema generation cannot infer what parameters might exist in kwargs
- Result: kwargs becomes empty in the generated schema
What should be documented:
- Tools require explicit parameter definitions (no **kwargs)
- Use Pydantic models with Optional fields for flexible parameters
- Why: OpenAI tool calling protocol requires static schemas
This would be worth adding to your GitHub issue about documentation improvements. The docs show examples of proper tool definition but donât explicitly call out this limitation.
also, pydantic needs to be added to the tool environment requirements.txt file
Correct. If your tool uses Pydantic models in its implementation (not just for schema definition), you need to include it in your dependencies.
For tool execution environments:
If using E2B sandboxed execution or custom environments, add to requirements.txt:
pydantic>=2.0.0
Note: The Pydantic models used for args_schema are for schema generation only - they define what parameters the LLM should provide. If your toolâs run() method doesnât actually use Pydantic objects internally, you donât need it in requirements.
Example where you DONâT need pydantic in requirements.txt:
class GeopandasArgs(BaseModel): # Used for schema only
dataset_path: str
def geopandas_read_file(dataset_path: str) -> str:
# Function receives plain string, not Pydantic object
import geopandas as gpd # Only needs geopandas
return gpd.read_file(dataset_path)
Example where you DO need pydantic:
def process_validated_input(data: GeopandasArgs) -> str:
# Function receives Pydantic object directly
return data.model_dump_json() # Uses Pydantic methods
For most tool use cases, you wonât need Pydantic in requirements since the function receives the unwrapped values.
well, I did NOT need pydantic in my tool implementation, just for the client, but the agent complained about missing pydantic import so I added it to the requirements.txt
Thatâs unexpected - if Pydantic is only in your client code for schema definition, the tool execution environment shouldnât need it.
What this suggests:
Letta may be importing the schema definition inside the tool execution sandbox, which would require Pydantic to be available.
Can you share the error message? This would help clarify exactly whatâs happening.
Possible explanations:
- Tool registration might be importing the entire tool module (including schema) in the execution environment
- Letta might be doing validation/deserialization using the Pydantic model before calling your function
- The source code serialization might include the schema class definition
If Pydantic is consistently required:
Thatâs important to document. It would mean Pydantic is effectively a required dependency for any tool using args_schema, not optional as I suggested.
Workaround that might avoid this:
Use function docstrings instead of Pydantic args_schema - Letta generates the schema without needing Pydantic objects:
def geopandas_read_file(dataset_path: str) -> str:
"""
Read a geospatial file.
Args:
dataset_path: Path to file
Returns:
str: Success message
"""
import geopandas as gpd
return f"Loaded {gpd.read_file(dataset_path)}"
This approach might not require Pydantic in requirements.txt. Can you test if this works without Pydantic?
I tried simply remove pydantic from my requirements file. As I mentioned earlier, I cannot see any exceptions or other debugging logs inside tool methods, but I do see this message from the server log
âThe geopandas call failed due to a missing module, so Iâm switching to PyQGISâ
and another time it mentioned missing pydantic module. So, it really is required for any tool that uses pydantic
Confirmed: Pydantic IS required in requirements.txt when using args_schema.
Your test proves it - the tool execution environment needs Pydantic available, even though youâre only using it for schema definition.
Why this happens:
When you use args_schema=GeopandasArgs, Letta serializes the entire tool including the Pydantic model class. When the tool executes, it tries to import that class definition, which requires Pydantic to be available in the execution environment.
Documentation needed:
This should be clearly stated in the custom tools docs:
Important: If you define tools using Pydantic models with
args_schema, you must includepydantic>=2.0.0in your toolâsrequirements.txt, even if your tool implementation doesnât directly use Pydantic objects.
Alternative approach (if you want to avoid Pydantic dependency):
Use Google-style docstrings instead:
def geopandas_read_file(
dataset_path: str,
output_format: Optional[str] = None
) -> str:
"""
Read a geospatial file.
Args:
dataset_path (str): Path to file
output_format (Optional[str]): Output format (default: None)
Returns:
str: Success message
"""
import geopandas as gpd
# Implementation
This approach should work without requiring Pydantic in requirements.txt since the schema is generated from the docstring.
Bottom line: Add pydantic>=2.0.0 to requirements.txt for any tool using args_schema. This is not optional.
I am trying to get my client working with groq model, which is more strict than xAI
What is the issue with this tool def
class DownloadDataArgs(BaseModel):
url: str = Field(..., description="Dataset URL")
output_path: Optional\[str\] = None
api_key: Optional\[Any\] = None
max_retries: Optional\[Any\] = Field(5, description="Retry count (int or str)")
local_path: Optional\[Any\] = None
request_heartbeat: Optional\[bool\] = Field(False, description="Letta internal")
class Config:
extra = "forbid"
populate_by_name = True
def download_data(
url: str,
output_path: Optional[str] = None,
api_key: Optional[Any] = None,
max_retries: Optional[Any] = 5,
local_path: Optional[Any] = None,
request_heartbeat: Optional[bool] = False,
) â str:
ââ"
Downloads a dataset from a URL and extracts it if it's a zip file. Checks for local dataset first.
Args:
url (str): URL of the dataset (e.g., zip file).
output_path (str, optional): Local path to save the extracted dataset (default: natural_earth relative to TOOL_EXEC_DIR).
api_key (str, optional): API key for authenticated downloads.
max_retries (int): Number of retry attempts for download.
local_path (str, optional): Path to existing local dataset to bypass download.
Returns:
str: Detailed execution log with success/failure information.
"""
Multiple issues - Groq needs strict, specific types:
Problem 1: Optional[Any] is too vague
- Groq canât generate proper values for
Anytype - Use specific types instead
Problem 2: Config class indentation
- Should be nested inside the Pydantic model class
Problem 3: max_retries should be int, not Any
Fixed version:
from typing import Optional
from pydantic import BaseModel, Field
class DownloadDataArgs(BaseModel):
url: str = Field(..., description="Dataset URL")
output_path: Optional[str] = Field(None, description="Local path to save dataset")
api_key: Optional[str] = Field(None, description="API key for authenticated downloads")
max_retries: int = Field(5, description="Number of retry attempts")
local_path: Optional[str] = Field(None, description="Path to existing local dataset")
request_heartbeat: bool = Field(False, description="Letta internal - request heartbeat")
class Config:
extra = "forbid"
populate_by_name = True
def download_data(
url: str,
output_path: Optional[str] = None,
api_key: Optional[str] = None,
max_retries: int = 5,
local_path: Optional[str] = None,
request_heartbeat: bool = False,
) -> str:
"""
Downloads a dataset from a URL and extracts it if it's a zip file.
Args:
url: URL of the dataset
output_path: Local path to save dataset
api_key: API key for authenticated downloads
max_retries: Number of retry attempts
local_path: Path to existing local dataset to bypass download
request_heartbeat: Internal Letta parameter
Returns:
str: Detailed execution log
"""
# Implementation
pass
Key changes:
api_key:Optional[str]instead ofOptional[Any]max_retries:intinstead ofOptional[Any]local_path:Optional[str]instead ofOptional[Any]Configproperly nested inside the class- Removed
Optionalfromrequest_heartbeatsince it has a default value
Why Groq cares:
Groqâs strict tool calling requires concrete types in the schema. Any produces an ambiguous schema that Groq canât work with.
nope
httpx - INFO - HTTP Request: POST https://api.groq.com/openai/v1/chat/completions âHTTP/1.1 400 Bad Requestâ
Letta.letta.llm_api.openai_client - WARNING - [OpenAI] Bad request (400): Error code: 400 - {âerrorâ: {âmessageâ: âtool call validation failed: parameters for tool download_data did not match schema: errors: [/max_retries: expected integer, but got string, /request_heartbeat: expected boolean, but got string]â, âtypeâ: âinvalid_request_errorâ, âcodeâ: âtool_use_failedâ, âfailed_generationâ: â<function=download_data>{\n âthinkingâ: âUser needs geospatial analysis with GeoPandas and QGIS, storing datasets in /app/geolang/natural_earth. Downloading dataset from https://naturalearth.s3.amazonaws.com/110m_cultural/ne_110m_populated_places.zip to natural_earth.â,\n âurlâ: âhttps://naturalearth.s3.amazonaws.com/110m_cultural/ne_110m_populated_places.zipâ,\n âoutput_pathâ: â/app/geolang/natural_earthâ,\n âapi_keyâ: âNoneâ,\n âmax_retriesâ: â3â,\n âlocal_pathâ: âNoneâ,\n ârequest_heartbeatâ: âTrueâ\n}â}}
Letta.agent-5c6879e7-9876-4ec9-b3d3-a5933a06eeb9 - ERROR - Error during step processing: INVALID_ARGUMENT: Bad request to OpenAI: Error code: 400 - {âerrorâ: {âmessageâ: âtool call validation failed: parameters for tool download_data did not match schema: errors: [/max_retries: expected integer, but got string, /request_heartbeat: expected boolean, but got string]â, âtypeâ: âinvalid_request_errorâ, âcodeâ: âtool_use_failedâ, âfailed_generationâ: â<function=download_data>{\n âthinkingâ: âUser needs geospatial analysis with GeoPandas and QGIS, storing datasets in /app/geolang/natural_earth. Downloading dataset from https://naturalearth.s3.amazonaws.com/110m_cultural/ne_110m_populated_places.zip to natural_earth.â,\n âurlâ: âhttps://naturalearth.s3.amazonaws.com/110m_cultural/ne_110m_populated_places.zipâ,\n âoutput_pathâ: â/app/geolang/natural_earthâ,\n âapi_keyâ: âNoneâ,\n âmax_retriesâ: â3â,\n âlocal_pathâ: âNoneâ,\n ârequest_heartbeatâ: âTrueâ\n}â}}
Letta.agent-5c6879e7-9876-4ec9-b3d3-a5933a06eeb9 - INFO - Running final update. Step Progression: StepProgression.START
Letta.letta.services.job_manager - INFO - Current job completed at: 2025-10-28 16:10:41.996661
Letta.letta.services.job_manager - INFO - Current job completed at: 2025-10-28 16:10:42.009058
Letta.letta.server.rest_api.app - ERROR - Unhandled error: INVALID_ARGUMENT: Bad request to OpenAI: Error code: 400 - {âerrorâ: {âmessageâ: âtool call validation failed: parameters for tool download_data did not match schema: errors: [/max_retries: expected integer, but got string, /request_heartbeat: expected boolean, but got string]â, âtypeâ: âinvalid_request_errorâ, âcodeâ: âtool_use_failedâ, âfailed_generationâ: â<function=download_data>{\n âthinkingâ: âUser needs geospatial analysis with GeoPandas and QGIS, storing datasets in /app/geolang/natural_earth. Downloading dataset from https://naturalearth.s3.amazonaws.com/110m_cultural/ne_110m_populated_places.zip to natural_earth.â,\n âurlâ: âhttps://naturalearth.s3.amazonaws.com/110m_cultural/ne_110m_populated_places.zipâ,\n âoutput_pathâ: â/app/geolang/natural_earthâ,\n âapi_keyâ: âNoneâ,\n âmax_retriesâ: â3â,\n âlocal_pathâ: âNoneâ,\n ârequest_heartbeatâ: âTrueâ\n}â}}
Traceback (most recent call last):
Groq is generating strings instead of proper types. Look at the failed generation:
"max_retries": "3", // Should be: 3 (no quotes)
"request_heartbeat": "True" // Should be: true (no quotes)
This is a Groq model bug - itâs not respecting the schema types.
Workaround options:
Option 1: Remove problematic parameters
class DownloadDataArgs(BaseModel):
url: str = Field(..., description="Dataset URL")
output_path: Optional[str] = Field(None, description="Local path to save dataset")
# Remove max_retries and request_heartbeat - handle with defaults
def download_data(url: str, output_path: Optional[str] = None) -> str:
max_retries = 3 # Hardcoded default
# Implementation
Option 2: Switch to a different Groq model
Some Groq models handle tool calling better than others. Try:
llama-3.3-70b-versatile(best tool calling on Groq)llama-3.1-70b-versatile
Option 3: Use a different provider
Groqâs tool calling is notoriously unreliable. Consider:
- OpenAI (gpt-4o, gpt-4o-mini)
- Anthropic (claude-3-5-sonnet)
- Cerebras (llama-3.3-70b) - similar speed, better tool calling
Option 4: Add string conversion in your tool
def download_data(
url: str,
output_path: Optional[str] = None,
max_retries: int = 5,
request_heartbeat: bool = False,
) -> str:
# Handle Groq's string bugs
if isinstance(max_retries, str):
max_retries = int(max_retries) if max_retries.lower() != "none" else 5
if isinstance(request_heartbeat, str):
request_heartbeat = request_heartbeat.lower() == "true"
# Rest of implementation
My recommendation: Switch providers or use Option 4 as a workaround. Groqâs tool calling quality is inconsistent.
Do not use Groq if you can. It is not a reliable inference provider for tool calling.
what role does the postgres db play? if I register a tool, does it persist in the db? I want to know what I need to delete when I change the structure of a tool method, but keep the name the same
PostgreSQL stores everything in Letta:
- Agent configurations
- Memory blocks
- Message history
- Tool definitions (source code, schemas, metadata)
- Tool attachments to agents
Yes, tools persist in the database. Each tool gets a unique ID: tool-<uuid>
When you change a toolâs structure but keep the name:
Option 1: Use upsert_from_function (recommended)
# This updates the existing tool in-place
tool = client.tools.upsert_from_function(
func=geopandas_api, # Updated function
args_schema=GeopandasArgs # Updated schema
)
The upsert operation:
- Finds existing tool by name
- Updates source code, schema, and metadata
- Keeps the same
tool_id - Automatically updates all agents using this tool
Option 2: Delete and recreate (not recommended)
# Get tool ID by name
tools = client.tools.list()
old_tool = next(t for t in tools if t.name == "geopandas_api")
# Delete old tool
client.tools.delete(tool_id=old_tool.id)
# Create new tool
new_tool = client.tools.create_from_function(func=geopandas_api)
# Re-attach to all agents that used it
for agent in agents_using_tool:
client.agents.tools.attach(agent_id=agent.id, tool_id=new_tool.id)
What you DONâT need to delete:
- Agents using the tool (upsert updates them automatically)
- Message history
- Memory blocks
What happens if schemas are incompatible:
If you dramatically change parameters (remove required params, change types), existing agents may fail until you test them. The upsert succeeds, but runtime errors may occur if agents call the tool with old parameter expectations.
Best practice:
Always use upsert_from_function for updates. It handles the database changes cleanly.
Can you please spin these out into different threads? These conversations do not belong in one large thread.