DEV Community

Mukunda Rao Katta
Mukunda Rao Katta

Posted on

Fill Missing Tool Args From Schema Defaults: Stop Your Agent From Omitting Optional Parameters

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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)