DEV Community

Sattyam Jain
Sattyam Jain

Posted on

Strip the tool arguments your LLM made up, before the tool runs

``Your tool schema says five fields. Nothing stops the model from sending six.

Tool-call arguments are JSON the model generates. The schema you pass is a very strong hint, and most of the time the model follows it. The rest of the time you get a key nobody declared, a list where you wanted a string, or a value that is close to your enum but not in it. This post builds a small stdlib Python check that sits at the tool boundary and decides what happens to each of those before your function runs.

What goes wrong at the boundary

There are three shapes I care about:

  1. Invented keys. The model adds cc, force, bcc_all or similar. I call these ghost arguments.
  2. Wrong types. "to": ["a@example.com"] when the schema says string.
  3. Enum drift. "priority": "urgent" when the schema allows low, normal and high.

How a tool wrapper reacts decides the damage. If it calls fn(**args) on a strict signature, an invented key raises TypeError and the agent loop retries, which costs a call. If the function takes **kwargs and passes them on to an HTTP client or an SDK, the invented key can reach a real API. That second case is the one I worry about.

Why not just turn on strict mode

Use it where you have it. Provider-side strict schemas help a lot. They don't cover everything you run, though. A local OpenAI-compatible server may not pass strict through at all (there is a vLLM forum thread titled "The OpenAI endpoint doesn't support function strict setting"), framework adapters translate schemas between providers, and an MCP server receives arguments from whichever client connects to it. The tool boundary is the one place you always own, so the check goes there.

The rule

  • Unknown key: strip it, log it, let the call continue.
  • Missing required field, wrong type, value outside the enum: block the call.
  • Never coerce. A list is not a string because it has one element.

Strip versus block for unknown keys is a judgment call. I feel stripping is right for read-only tools. For tools that send, delete or pay, you can argue for blocking instead, and the code below makes that a one line change.

The code

Save as ghost_args.py. Python 3.8+, no dependencies.

#!/usr/bin/env python3
"""Check LLM tool-call arguments against the tool's JSON schema before the tool runs.

Unknown keys are stripped and logged. Missing, mistyped or out-of-enum values block the call.
Stdlib only, Python 3.8+.
"""
import json

TYPES = {"string": str, "integer": int, "number": (int, float), "boolean": bool,
         "object": dict, "array": list}

def check(schema, args, path=""):
    """Return (clean_args, stripped_keys, errors) for one object level, recursing into objects."""
    props = schema.get("properties", {})
    clean, stripped, errors = {}, [], []
    for key in args:
        if key not in props:
            stripped.append(path + key)
    for key in schema.get("required", []):
        if key not in args:
            errors.append(f"missing required '{path}{key}'")
    for key, spec in props.items():
        if key not in args:
            continue
        val, want = args[key], spec.get("type")
        py = TYPES.get(want)
        # bool is a subclass of int in Python, so reject it explicitly for integer/number
        bad_bool = isinstance(val, bool) and want in ("integer", "number")
        if py and (not isinstance(val, py) or bad_bool):
            errors.append(f"'{path}{key}' should be {want}, got {type(val).__name__}")
            continue
        if "enum" in spec and val not in spec["enum"]:
            errors.append(f"'{path}{key}'={val!r} not in {spec['enum']}")
            continue
        if want == "object":
            sub, s2, e2 = check(spec, val, path + key + ".")
            clean[key], stripped, errors = sub, stripped + s2, errors + e2
        else:
            clean[key] = val
    return clean, stripped, errors

SEND_EMAIL = {
    "type": "object",
    "required": ["to", "subject", "body"],
    "properties": {
        "to": {"type": "string"},
        "subject": {"type": "string"},
        "body": {"type": "string"},
        "priority": {"type": "string", "enum": ["low", "normal", "high"]},
        "options": {"type": "object", "properties": {"track_opens": {"type": "boolean"}}},
    },
}

# (label, arguments as the model emitted them)
CALLS = [
    ("clean call", {"to": "a@example.com", "subject": "Q3", "body": "attached"}),
    ("invented cc", {"to": "a@example.com", "subject": "Q3", "body": "attached",
                     "cc": "someone@example.org"}),
    ("invented nested key", {"to": "a@example.com", "subject": "Q3", "body": "x",
                             "options": {"track_opens": False, "bcc_all": True}}),
    ("wrong type", {"to": ["a@example.com"], "subject": "Q3", "body": "x"}),
    ("enum drift", {"to": "a@example.com", "subject": "Q3", "body": "x",
                    "priority": "urgent"}),
    ("missing field", {"to": "a@example.com", "body": "x"}),
]

def main():
    allowed = blocked = stripped_total = 0
    print(f"{'call':<22}{'verdict':<9}{'stripped':<22}errors")
    for label, raw in CALLS:
        clean, stripped, errors = check(SEND_EMAIL, raw)
        verdict = "BLOCK" if errors else "ALLOW"
        blocked += bool(errors)
        allowed += not errors
        stripped_total += len(stripped)
        print(f"{label:<22}{verdict:<9}{','.join(stripped) or '-':<22}{'; '.join(errors) or '-'}")
        if not errors and stripped:
            print(f"{'':<22}runs with {json.dumps(clean, sort_keys=True)}")
    print(f"\n{allowed} allowed, {blocked} blocked, {stripped_total} invented keys stripped")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

## Run it

$ python3 ghost_args.py
call                  verdict  stripped              errors
clean call            ALLOW    -                     -
invented cc           ALLOW    cc                    -
                      runs with {"body": "attached", "subject": "Q3", "to": "a@example.com"}
invented nested key   ALLOW    options.bcc_all       -
                      runs with {"body": "x", "options": {"track_opens": false}, "subject": "Q3", "to": "a@example.com"}
wrong type            BLOCK    -                     'to' should be string, got list
enum drift            BLOCK    -                     'priority'='urgent' not in ['low', 'normal', 'high']
missing field         BLOCK    -                     missing required 'subject'
Enter fullscreen mode Exit fullscreen mode

3 allowed, 3 blocked, 2 invented keys stripped

What to take from the output

The two invented keys never reach send_email, and the log names both of them. The nested one is the case people miss. A check that only compares top-level keys against the schema would pass options.bcc_all straight through, because options itself is declared.

The three blocked calls fail before any side effect, with a message the model can read. Feed that error string back as the tool result and most models fix the call on the next turn, which is cheaper than letting the function throw somewhere deep inside an SDK.

One Python detail bit me while writing this: isinstance(True, int) is True. Without the bad_bool line, {"count": true} would pass as an integer.

If you want unknown keys to block on write tools, change the first loop to append to errors instead of stripped when the tool is marked as having side effects. That is the one line I mentioned.

What this toy does not cover

It handles type, required, enum and nested objects. It does not check array items, oneOf, string formats or lengths. In production I'd use a real validator, either the jsonschema package or a Pydantic model with extra="forbid", and keep the same policy on top: strip or block, never coerce. I maintain agent-airlock, which does ghost-argument stripping and type validation as a decorator on the tool function, and the policy in this post is the same one it follows.

Try this next

Put check() in front of your tool dispatcher and count stripped keys per tool per day. That number is a cheap signal. When it jumps, something changed, a prompt, a model version, or a schema someone edited without telling the agent.

If you run tools behind an LLM today, do you strip invented arguments, block them, or let them through?

Enter fullscreen mode Exit fullscreen mode

Top comments (0)