ANTIGRAVITY LABJP
Articles/Agents & Manager
Agents & Manager/2026-09-05Intermediate

A Custom Tool That Says any_of Loses Its Constraint Without Ever Erroring

I tested ten spelling variants in custom tool parameter definitions to see which ones fail loudly and which slip through unnoticed, then measured how argument validation shifts once normalization lands.

Antigravity363Custom ToolsJSON SchemaLocal LLM8Ollama16

I was reading back through the logs of a run that had gone sideways when I noticed that an agent talking to a local Ollama endpoint had accepted a number where a file path belonged, and had carried on without hesitating. My tool definition declared the type. Nothing had rejected the value anywhere along the way.

The cause was a single word in the parameter definition. I had written any_of. The spelling is anyOf.

What makes this awkward is that nobody warns you. Write a type as String with a capital letter and it fails immediately. Misspell a combiner and everything proceeds as though the constraint were there. I'd have preferred the loud failure, and I only understood that after tracing the whole thing back.

The Antigravity changelog notes that in 2.12.2, custom Python tools now emit OpenAPI / JSON Schema compliant parameter definitions — lowercase types, camelCase combiners — which removes schema errors when you connect to OpenAI-compatible local engines such as Ollama, LM Studio, or vLLM. That's a welcome fix. But a fix here also means constraints that weren't doing anything are about to start doing something.

The numbers below come from running JSON Schema Draft 2020-12 locally as the judge (Python's jsonschema 4.26.0). I measured against the spec the changelog names as its target, not against any one engine's implementation.

Capitalized types fail. Snake_case combiners don't

I built ten variants that all express the same intent, and checked two things for each: whether the schema itself passes metaschema validation, and whether an obviously invalid argument gets flagged.

SpellingMetaschema checkInvalid argumentVerdict
type: "String"1 errorUnknownType at runtimeYou'll notice
type: "Integer"1 errorUnknownType at runtimeYou'll notice
required given a string1 error1 violationYou'll notice
any_of0 errors0 violationsPasses in silence
one_of0 errors0 violationsPasses in silence
all_of0 errors0 violationsPasses in silence
enum_values0 errors0 violationsPasses in silence
min_items0 errors0 violationsPasses in silence
exclusive_minimum0 errors0 violationsPasses in silence
additional_properties0 errors0 violationsPasses in silence

Seven of the ten sailed past both checks. That's the specification working as designed: JSON Schema says unknown keywords are ignored. min_items isn't an error — it's an annotation sitting outside the vocabulary. It never registers as a constraint, so it never produces a violation either.

Types are the exception because type closes its set of allowed values. That's the one place the spec refuses to guess, so String is rejected outright.

I stopped filing "breaks loudly" and "breaks quietly" under the same heading. The first kind surfaces on its own. The second only surfaces if I go looking.

Once normalization lands, arguments that used to pass get rejected

Next I wanted to see what actually changes when the spelling is corrected. I kept every type lowercase and misspelled only the combiners and constraint keywords, then ran identical arguments through the schema before and after normalization.

ArgumentBeforeAfter
The expected shapePassesPasses
mode given a numberPassesRejected (1 violation)
tags as an empty arrayPassesRejected (1 violation)
An undeclared depth keyPassesRejected (1 violation)

Metaschema validation returned zero errors on both versions. As JSON Schema documents, both are perfectly valid. And yet all three malformed arguments passed the first and were stopped by the second.

That's the part I'd want to know in advance. Normalization is announced as a change that removes errors, but from your tool's side it's equally a change that starts refusing arguments it used to accept. Agents don't send the same shapes every time, so the day something stops going through is rarely a quiet one.

The normalization itself is the right call. The problem is the ordering — you find out how loose your definitions were only after the fix arrives.

Count your definitions before you upgrade

What you need isn't a spell check. It's a count of how many keywords in your parameter definitions are being ignored. Sixty lines will do it.

# schema_lint.py — count keywords that are never evaluated as constraints
import json, sys
from jsonschema import Draft202012Validator as V
 
VALID_TYPES = {"string", "number", "integer", "boolean", "object", "array", "null"}
 
def collect_keywords():
    """Harvest the Draft 2020-12 vocabulary from the metaschema itself."""
    ks = set()
    def walk(node):
        if isinstance(node, dict):
            for k, v in node.items():
                if k == "properties" and isinstance(v, dict):
                    ks.update(v.keys())
                walk(v)
        elif isinstance(node, list):
            for x in node:
                walk(x)
    walk(V.META_SCHEMA)
    return ks
 
KEYWORDS = collect_keywords() | {"nullable", "examples", "title", "description", "default"}
 
def lint(schema, path="$"):
    out = []
    if not isinstance(schema, dict):
        return out
    parent = path.split(".")[-1]
    for k, v in schema.items():
        here = f"{path}.{k}"
        if k not in KEYWORDS and parent not in ("properties", "$defs", "patternProperties"):
            out.append((here, f"unknown keyword '{k}' — never evaluated as a constraint"))
        if k == "type":
            for t in (v if isinstance(v, list) else [v]):
                if isinstance(t, str) and t not in VALID_TYPES:
                    out.append((here, f"invalid type '{t}' (lowercase only)"))
        if k == "properties" and isinstance(v, dict):
            for pn, ps in v.items():
                out += lint(ps, f"{path}.properties.{pn}")
        elif isinstance(v, dict):
            out += lint(v, here)
        elif isinstance(v, list):
            for i, x in enumerate(v):
                out += lint(x, f"{here}[{i}]")
    return out
 
if __name__ == "__main__":
    findings = lint(json.load(open(sys.argv[1])))
    for p, m in findings:
        print(f"  {p}: {m}")
    print(f"{len(findings)} finding(s)")
    sys.exit(1 if findings else 0)

The check is skipped when the parent is properties because what sits there are your own parameter names, not vocabulary. Forget that guard and path and limit both get reported as unknown keywords, which drowns the output. I did exactly that on my first pass, produced close to a hundred false positives, and threw the report away.

Point it at a broken definition and you get:

$ python3 schema_lint.py tool.json
  $.properties.path.type: invalid type 'String' (lowercase only)
  $.properties.limit.min_items: unknown keyword 'min_items' — never evaluated as a constraint
  $.properties.mode.any_of: unknown keyword 'any_of' — never evaluated as a constraint
  $.additional_properties: unknown keyword 'additional_properties' — never evaluated as a constraint
4 finding(s)

It returns an exit code, so it drops straight into CI. I've written up the same shape of problem for configuration files in How Silently Ignored Config Keys Slip Through CI, and Where to Block Them — the idea is identical: when a system stays quiet about what it ignored, you have to add the part that speaks up.

One thing worth checking after you fix the spelling

Correcting the keywords is mechanical. The order is what I'd be careful about.

  1. Run schema_lint.py across every tool definition and write down the finding count before you change anything. It matters later.
  2. Fix the spellings: any_ofanyOf, min_itemsminItems, additional_propertiesadditionalProperties, types to lowercase.
  3. Replay arguments that actually flowed through in production against the corrected schema. Twenty or thirty pulled from your logs is plenty. Anything rejected here is an invalid argument that had been passing unnoticed.
  4. For each rejection, decide whether to loosen the schema or fix the caller.

Please don't skip step three. Constraints coming alive means some of what your agent has been sending will now stop. Learning that from your own logs beats learning it in production, and the difference shows up in how the next morning goes.

If you're still stuck on the connection itself, sort that out before the schema. I've covered the wiring in Calling Local LLMs from Antigravity — Ollama and LM Studio Integration in Practice. And when a tool can't read a file it clearly should be able to read, the cause is often an exclusion rule rather than a schema; that separation is in Git Tracks the File, but Your Agent Can't See It. Here's Where It Drops Out.

If you do one thing today

Pick the custom tool with the widest argument surface and run schema_lint.py against it. Zero findings tells you that's a place you don't have to think about. Even one finding tells you that constraint has never once done its job.

I've stopped treating "no errors" as evidence that something works. When a system stays quiet, I give it a separate part whose only job is to report the quiet — a small habit, though the detour it saved me from is one I won't forget for a while.

Thank you for reading this far. I hope it shortens the path for someone stuck at the same place.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Integrations2026-07-09
Calling Local LLMs from Antigravity — Ollama and LM Studio Integration in Practice
Running local LLMs from Antigravity via Ollama or LM Studio: a real benchmark harness, how to confirm the model is actually on the GPU, a monthly breakeven model, and a wrapper that forces JSON output.
Integrations2026-05-04
Integrating Gemma 4 Into Antigravity — A for Offline and Air-Gapped AI Development
With Apache 2.0–licensed Gemma 4, you can now run Antigravity's agent experience inside confidential or offline projects. Here is the full implementation walkthrough — Ollama/vLLM wiring, Architect/Builder prompt tuning, and production gotchas.
Integrations2026-04-25
Antigravity Can't Connect to Ollama or LM Studio: A Diagnostic Guide
Why Antigravity fails to reach a local LLM running in Ollama or LM Studio, and how to walk through ports, CORS, model names, and OpenAI-compatible endpoints to fix it.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →