Your tool schema defines max_results with a default of 10. The LLM calls your tool and omits max_results entirely. Your tool function signature has max_results=10 as a Python default. You think everything is fine.
It is fine — until you add a validation layer, or a middleware that passes kwargs explicitly, or a schema-validated dispatcher that rejects calls with missing required-ish fields. Then "the LLM omitted an optional arg" becomes a runtime error.
llm-tool-arg-default fills missing tool arguments from schema defaults before the call reaches your function.
The Shape of the Fix
from llm_tool_arg_default import ToolArgDefault
schema = {
"type": "object",
"properties": {
"query": {"type": "string"},
"max_results": {"type": "integer", "default": 10},
"include_metadata": {"type": "boolean", "default": False},
"sort_by": {"type": "string", "default": "relevance"},
},
"required": ["query"],
}
filler = ToolArgDefault(schema)
# LLM provided only "query"
raw_args = {"query": "Q3 revenue"}
filled_args = filler.fill(raw_args)
# {"query": "Q3 revenue", "max_results": 10, "include_metadata": False, "sort_by": "relevance"}
Every property with a "default" field in the schema is filled in if the LLM omitted it. Properties without a default are left as-is.
What It Does NOT Do
llm-tool-arg-default does not validate the filled arguments against the schema. It only fills defaults. If the LLM passed max_results: "lots" instead of an integer, that invalid value is passed through unchanged. For validation, use tool-result-validator or a schema validation library like jsonschema.
It does not overwrite arguments the LLM provided. If the LLM passes max_results: 5, the filler respects that value and does not overwrite it with the schema default. Existing values are never touched.
It does not handle nested schemas. If your tool takes a nested object as a parameter, defaults inside the nested object are not recursively filled. Only top-level property defaults are applied.
Inside the Library
The implementation is deliberately simple:
class ToolArgDefault:
def __init__(self, schema: dict):
self._defaults: dict[str, Any] = {}
properties = schema.get("properties", {})
for name, prop in properties.items():
if "default" in prop:
self._defaults[name] = prop["default"]
def fill(self, args: dict) -> dict:
result = dict(args) # shallow copy
for name, default in self._defaults.items():
if name not in result:
result[name] = default
return result
def fill_inplace(self, args: dict) -> dict:
for name, default in self._defaults.items():
if name not in args:
args[name] = default
return args
The schema is parsed once at construction time. fill() returns a new dict (safe for concurrent use). fill_inplace() mutates the dict (faster when you own the dict and do not need immutability).
For a registry of tools with different schemas:
class ToolArgDefaultRegistry:
def __init__(self):
self._fillers: dict[str, ToolArgDefault] = {}
def register(self, tool_name: str, schema: dict) -> None:
self._fillers[tool_name] = ToolArgDefault(schema)
def fill(self, tool_name: str, args: dict) -> dict:
filler = self._fillers.get(tool_name)
if filler is None:
return args # Unknown tool, pass through unchanged
return filler.fill(args)
The registry pattern is the common use case: one registry per agent, registered at startup with all tool schemas, used to fill args before every tool dispatch.
When to Use It
Use it in any agent dispatcher that passes **kwargs to tool functions. Explicit arg filling makes the "which args were LLM-provided vs defaulted" behavior visible and testable, instead of relying on Python's implicit default resolution.
Use it before schema validation. Validation libraries often reject missing optional fields even when the schema has defaults for them. Fill defaults first, then validate. The validation then receives a complete set of fields.
Use it for logging. After filling, you can log the complete argument set including defaults. This makes the tool call record in your step log show exactly what the function received, not just what the LLM provided.
Skip it if your tool functions already handle missing args gracefully through Python defaults and you have no validation layer between the LLM call and the function. In that case, Python's own default handling does the same thing.
Install
pip install git+https://github.com/MukundaKatta/llm-tool-arg-default
# Or from PyPI
pip install llm-tool-arg-default
from llm_tool_arg_default import ToolArgDefaultRegistry
registry = ToolArgDefaultRegistry()
TOOLS = {
"search_documents": {
"type": "object",
"properties": {
"query": {"type": "string"},
"max_results": {"type": "integer", "default": 10},
"include_metadata": {"type": "boolean", "default": False},
},
"required": ["query"],
},
"create_ticket": {
"type": "object",
"properties": {
"title": {"type": "string"},
"priority": {"type": "string", "default": "medium"},
"description": {"type": "string", "default": ""},
},
"required": ["title"],
},
}
for name, schema in TOOLS.items():
registry.register(name, schema)
def dispatch_tool(tool_name: str, raw_args: dict) -> dict:
# Fill defaults before dispatching
filled_args = registry.fill(tool_name, raw_args)
# Log the complete arg set (not just what LLM provided)
step_log.record_tool_call(
tool=tool_name,
input=filled_args, # full args including defaults
)
return tool_functions[tool_name](**filled_args)
Sibling Libraries
| Library | What it solves |
|---|---|
tool-arg-coerce-py |
Coerce LLM tool args to expected Python types |
tool-arg-fuzzy |
Fuzzy-match LLM string args to enum values |
tool-arg-rename |
Convert LLM-provided kwarg case conventions |
agent-tool-spec-pack |
Generate multi-provider schemas from function signatures |
tool-schema-from-fn |
Single-function annotation-to-schema |
The arg pipeline: llm-tool-arg-default fills defaults, tool-arg-coerce-py coerces types, tool-arg-fuzzy handles enum variants, tool-arg-rename normalizes casing — all before the call reaches your function.
What's Next
Nested default filling: recursively apply defaults inside nested object properties. The most common case is a tool that takes a settings object with defaults inside it.
Required check: filler.check_required(args) that returns a list of required fields that are still missing after filling. This gives you a clean validation result before the call errors out inside the function.
Default override at call time: filler.fill(args, overrides={"max_results": 20}) to inject per-call defaults that take precedence over schema defaults but not over LLM-provided values. Useful for A/B testing different default values.
Built as part of the agent-stack family: composable Python primitives for production LLM agents.
Top comments (0)