DEV Community

Taylor Wang
Taylor Wang

Posted on

48-Hour Field Notes: The Tool Schema Said Integer. JSON Sent 1.0 Anyway.

I spent forty-eight hours blaming a tool-calling model for sending bad arguments into a tiny Python worker. The schema said integer, the logs showed 1, and the handler still raised a TypeError before any business logic ran. Have you ever trusted the pretty log line more than the actual JSON token that arrived on the wire? That was my whole weekend, and the fix lived in the decoder, not in the prompt.

I was wiring a small Python loop that accepted tool arguments as JSON, validated them, then called a local function. The fixture looked boring on purpose: one integer field named count, a short description, and a handler that indexed a list. Why would that fail after the model produced what looked like a clean object? Because print() and my logging formatter both hid the fractional zero that json.loads had already created.

What I tried first

I did the usual prompt surgery before I touched a decoder, because that felt faster than reading bytes. I told the model to emit integers only, added “no trailing .0” to the schema description, and reran the same three prompts until the transcript looked clean. Did the TypeError leave? No. The logs still printed count=1, which is exactly how Python prints a float whose fractional part is zero.

Then I copied the worker onto another machine so my shell aliases could not keep lying for me. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode there only because free model access and a free server option let me rerun the same fixture away from laptop helpers, then treat any schema rewrite as a hypothesis. The remote run failed the same way, which was the first useful result of the weekend.

The commands that finally told the truth

I stopped logging the parsed dict and started printing repr() of the raw body plus the decoded type. These are the commands I would run again before I touch a prompt:

python -c "import json; print(json.dumps({'count': 1.0}))"
python -c "import json; print(repr(json.loads('{\"count\": 1.0}')['count']))"
python -c "import json; v=json.loads('{\"count\": 1.0}')['count']; print(type(v).__name__, isinstance(v, int))"
Enter fullscreen mode Exit fullscreen mode

The first line writes {"count": 1.0} because Python’s encoder keeps a float as a float. The second line returns 1.0, not 1. The third line prints float False, which is the whole incident in three tokens. Would you have noticed that from a log line that said count=1? I did not, for most of day one.

What actually broke

Three layers failed in sequence, and each one made the previous layer look healthy. I am listing them because I kept fixing the wrong layer:

  1. Display lied. print(1.0) and f-strings render 1, so the transcript looked schema-compliant.
  2. json.loads did its job. A JSON number with a fraction becomes a Python float, even when the value is whole.
  3. My validator asked the wrong question. isinstance(value, int) is false for 1.0, and Pydantic strict mode agrees.

I had also written a homemade check that looked serious and still missed the token:

import json

def load_count(raw: str) -> int:
    payload = json.loads(raw)
    value = payload["count"]
    if not isinstance(value, int):
        raise TypeError(f"count must be int, got {type(value).__name__}: {value!r}")
    return value
Enter fullscreen mode Exit fullscreen mode

Feed it '{"count": 1}' and it returns 1. Feed it '{"count": 1.0}' and it raises. Feed it '{"count": 1.00}' and it still raises. The model did not need to go off-schema in a dramatic way; it only needed to emit a JSON number the way many decoders and a lot of Python code already emit floats.

Pydantic v2 made the split even sharper once I stopped mixing modes. Lax mode will coerce 1.0 into 1 and hide the incident until a later runtime enables strict mode. Strict mode rejects the float and looks like a model failure. Which mode was I on when I declared the prompt broken? Lax on my laptop, strict in the worker, and I did not notice the mismatch until hour thirty.

from pydantic import BaseModel, ConfigDict, ValidationError

class LaxArgs(BaseModel):
    count: int

class StrictArgs(BaseModel):
    model_config = ConfigDict(strict=True)
    count: int

print(LaxArgs.model_validate_json('{"count": 1.0}'))
try:
    StrictArgs.model_validate_json('{"count": 1.0}')
except ValidationError as exc:
    print(exc.errors()[0]["type"])
Enter fullscreen mode Exit fullscreen mode

A reproducible harness I will keep

I wanted one artifact I could run without a model in the loop, because otherwise every retry looked like prompt work. The harness below is labeled as a local test, not as production telemetry. It round-trips three encodings and records whether a naive int check, a numeric-whole check, and Pydantic strict mode accept the payload.

# tool_int_harness.py — local fixture, not a live model client
import json
from dataclasses import dataclass
from pydantic import BaseModel, ConfigDict, ValidationError

class StrictArgs(BaseModel):
    model_config = ConfigDict(strict=True)
    count: int

@dataclass
class Row:
    label: str
    raw: str
    py_type: str
    isinstance_int: bool
    whole_number: bool
    strict_ok: bool

def is_whole_number(value: object) -> bool:
    return isinstance(value, int) or (
        isinstance(value, float) and value.is_integer()
    )

def classify(label: str, raw: str) -> Row:
    value = json.loads(raw)["count"]
    try:
        StrictArgs.model_validate_json(raw)
        strict_ok = True
    except ValidationError:
        strict_ok = False
    return Row(
        label=label,
        raw=raw,
        py_type=type(value).__name__,
        isinstance_int=isinstance(value, int),
        whole_number=is_whole_number(value),
        strict_ok=strict_ok,
    )

FIXTURES = [
    ("json_int_token", '{"count": 1}'),
    ("json_float_token", '{"count": 1.0}'),
    ("python_dumps_float", json.dumps({"count": 1.0})),
    ("python_dumps_int", json.dumps({"count": 1})),
]

if __name__ == "__main__":
    rows = [classify(label, raw) for label, raw in FIXTURES]
    print(f"{'label':20} {'py':6} {'isinstance':11} {'whole':6} {'strict':6} raw")
    for row in rows:
        print(
            f"{row.label:20} {row.py_type:6} {str(row.isinstance_int):11} "
            f"{str(row.whole_number):6} {str(row.strict_ok):6} {row.raw}"
        )
Enter fullscreen mode Exit fullscreen mode

Run it like this and keep the table next to the schema, not next to the prompt:

python tool_int_harness.py
Enter fullscreen mode Exit fullscreen mode

On my runs the table is stable: the integer token is an int and passes strict mode, while every 1.0 token is a float, fails isinstance(..., int), still counts as a whole number, and fails Pydantic strict mode. That is the decision surface. If you only log the value, you will keep arguing with the model.

Decision table I wish I had on hour one

  • JSON token 1 → Python intisinstance true → strict Pydantic accepts.
  • JSON token 1.0 → Python floatisinstance false → strict Pydantic rejects → lax Pydantic coerces.
  • json.dumps({"count": 1.0}) → writes 1.0 → same failure as a model that emitted a float.
  • json.dumps({"count": 1}) → writes 1 → passes the naive check.
  • JSON Schema type: integer → many validators accept a numeric value with a zero fraction; Python int does not equal that rule.
  • Log line count=1 → evidence of nothing; always print repr(raw) first.

I also added a normalizer that I will reuse, and I am labeling it as a proposal rather than a universal library:

def coerce_whole_int(value: object, *, field: str) -> int:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise TypeError(f"{field} must be a whole number, got {value!r}")
    if isinstance(value, float):
        if not value.is_integer():
            raise TypeError(f"{field} is not whole: {value!r}")
        value = int(value)
    return int(value)
Enter fullscreen mode Exit fullscreen mode

Notice the bool guard. In Python, True is an int subclass, so a tool argument of true can sneak through a lazy isinstance(..., int) check and become 1. Do you want a boolean to index your list? I do not.

What I would repeat in the next forty-eight hours

I would start with bytes, not with vibes, and I would keep the model out of the first loop. The order that actually saved time was:

  1. Dump the raw HTTP or stdin body with repr() before json.loads.
  2. Classify the token with the harness, including python_dumps_float.
  3. Decide whether the API contract is JSON Schema integer, Python int, or “whole number.”
  4. Pick one mode: coerce at the edge, or reject at the edge, never both.
  5. Only then ask a model to change the schema description or the example payload.

I would also freeze the worker’s Pydantic mode in config, because mixing lax locally and strict remotely is how this bug survived code review. A one-line comment in the model class is cheaper than another weekend. And I would keep a second checkout on a quiet server so print aliases, pretty loggers, and notebook formatters cannot keep rendering 1.0 as 1.

Limitations, and who should not copy this

This harness does not prove that a hosted model will emit integers tomorrow; it only proves what your decoder will do when it sees 1.0. JSON Schema, OpenAPI, Pydantic lax mode, Pydantic strict mode, and isinstance(..., int) are four different contracts pretending to be one word. If you need bit-identical integer tokens for a signature or a wire protocol, coercion is the wrong tool.

Do not use this approach if you are validating money, counters that must not truncate, or anything where 1.9 should never become 1. Do not disable strict mode globally just to make tool calls pass. Do not treat a free remote run as a load test, a quota promise, or a benchmark; I used it as a second machine, nothing else. And if your handler already accepts float and you like that, this article is not telling you to break it.

The useful part is the fixture, not the product detour. I only reached for a second machine when my laptop’s printers had already lied, and I would rather you copy the harness than copy my weekend.

Top comments (0)