← All courses ← Course home

Lesson 04 of 05

JSON mode and tools do not mix

JSON mode and tool calling both force the shape of the output. When you turn on both, one of them usually loses.

Agent brief (llms.md)

Environment setup

Use the same local-lab folder from lesson 01 (uv, Python 3.11+, and uv add numpy). If you skipped it, do the setup in lesson 01 first.

Install Ollama from ollama.com/download. Check it with ollama --version.

Install llama.cpp. On macOS: brew install llama.cpp. On Linux or Windows, download a release from github.com/ggml-org/llama.cpp/releases. Check it with llama-server --version.

The Ollama case

Ollama #18944 (Ollama 0.40.3): with "format": "json" on /api/chat, the model cannot call tools. It writes the call as JSON text in content instead:

{
  "message": {
    "role": "assistant",
    "content": "{\"name\": \"time\", \"parameters\": {}}"
  }
}

No tool_calls field. If your code only checks tool_calls, the tool never runs and the user sees raw JSON.

The llama.cpp case

llama.cpp #30321: when a JSON schema lists an optional field before a required one, the grammar only allows required fields first. Many models then skip the optional field. The JSON is still valid, so nothing warns you. llama.cpp #30256 (lesson 02) is another JSON schema problem: thinking leaks in.

The rule

Step needs a tool?tools, no format
Step needs data?format or schema, no tools

Check it with a test

import json
import urllib.request

def chat(body):
    req = urllib.request.Request("http://127.0.0.1:11434/api/chat", json.dumps(body).encode(),
                                 {"Content-Type": "application/json"})
    return json.load(urllib.request.urlopen(req))

tool = {"type": "function", "function": {"name": "get_time", "description": "Get the current time.",
        "parameters": {"type": "object", "properties": {"zone": {"type": "string"}}, "required": ["zone"]}}}
base = {"model": "qwen3:1.7b", "stream": False, "tools": [tool],
        "messages": [{"role": "user", "content": "Use your tool to get the time in UTC."}]}

for fmt in (None, "json"):
    body = dict(base, **({"format": fmt} if fmt else {}))
    msg = chat(body)["message"]
    print("format =", fmt, "| tool_calls:", bool(msg.get("tool_calls")), "| content:", msg.get("content", "")[:60])

Output on the test box, Ollama 0.40.4, Qwen3 1.7B Q8_0:

format = None | tool_calls: True | content: 
format = json | tool_calls: False | content: {"name": "get_time", "arguments": {"zone": "UTC"}}

So the problem from #18944 is still there in 0.40.4. Pull the model first with ollama pull qwen3:1.7b. If tool_calls is false when format is json, your build has the problem from #18944. Keep this test in CI.

Key concepts

  • JSON mode and tool calling both constrain the output.
  • Ollama #18944: with format: json, tool calls come back as text in content.
  • llama.cpp #30321: an optional field before a required one can be dropped silently.
  • Valid JSON is not the same as complete JSON.

Takeaways

  • Send either tools or a schema in one request, not both.
  • Put required fields first and log missing optional fields.
  • Keep a CI test that checks tool_calls with and without JSON mode.