Skip to content

Empty tools: [] triggers tool-calling on Llama 3.x client-side templates #767

Open

Description

@hungry-bogart

Summary

The library sends "tools": [] in every chat() request body, even when the caller passed no tools. On servers that render Llama 3.x's chat template directly (notably rkllama), this routes the request into tool-calling mode: the template's tools is not none check passes for an empty list, so the model is told it has tools available and answers with a JSON function call instead of plain text.

Ollama's own server masks this because it coerces template inputs. Qwen's template uses if tools: (truthiness) rather than is not none, so Qwen-family models are unaffected regardless of which server renders the template.

Related but distinct from #733, which covers serialization crashes on malformed tool contents; this one is about the always-sent empty list.

Reproduction

Any Llama 3.x model served by rkllama (which renders the model's own chat template):

from ollama import Client
client = Client(host="http://<rkllama-host>:8080")
resp = client.chat(
    model="llama3.2:3b",
    messages=[{"role": "user", "content": "list three colors"}],
)
print(resp["message"]["content"])

Expected: "Red, blue, green." or similar prose.
Observed: '{"name": "list_colors", "arguments": {}}' or similar JSON function invocation.

Measured on an Indiedroid Nova (RK3588 NPU, rkllama image built 2026-08-25, RKLLM runtime 1.3.0) with Llama 3.2 3B Instruct (W8A8, HanzoHuang build, RKLLM toolkit 1.3.0): 5/5 chat requests through this library produced JSON function calls; 0/3 produced JSON when the tools field was removed from the request body by hand.

Root cause

Llama 3.x's chat template guards the tool-calling preamble with:

{% if tools is not none %}
    # render "you have access to the following tools" preamble
{% endif %}

An empty list [] is not None, so the preamble renders and the model is instructed to answer with a tool call. Qwen's template uses {% if tools %} (truthiness), which treats [] as falsy and skips the preamble.

Suggested fix

Omit tools from the serialized request body when the list is empty — distinguish "no tools" from "tools, but empty list" at the serialization layer. Real tool lists would pass through unchanged.

Workaround

A custom httpx transport that strips "tools": [] from outgoing /api/chat requests works as a local fix. Example implementation (MIT):

import json
import httpx
from ollama import Client


class DropEmptyTools(httpx.BaseTransport):
    """Strip `"tools": []` from ollama chat requests before send."""

    def __init__(self, inner: httpx.BaseTransport | None = None) -> None:
        self._inner = inner or httpx.HTTPTransport()

    def handle_request(self, request: httpx.Request) -> httpx.Response:
        if request.method == "POST" and request.url.path.endswith("/api/chat"):
            try:
                body = json.loads(request.content)
            except ValueError:
                body = None
            if isinstance(body, dict) and body.get("tools") == []:
                del body["tools"]
                headers = [(k, v) for k, v in request.headers.multi_items()
                           if k.lower() != "content-length"]
                request = httpx.Request(
                    request.method, request.url,
                    headers=headers,
                    content=json.dumps(body).encode(),
                    extensions=request.extensions,
                )
        return self._inner.handle_request(request)

    def close(self) -> None:
        self._inner.close()


# Usage:
client = Client(host="http://your-server:8080", transport=DropEmptyTools())

Verified on my side: with DropEmptyTools in place, my downstream project’s test suite (120 tests, no server needed) still passes, and the model returns plain text for the repro above. Happy to run further tests on rkllama + Llama 3.x on RK3588 if useful — not a common setup, and I have it available.

Activity

  1. Mrun25 commented on Oct 19, 2026

    @Mrun25

    Verified on current main (35d9667): client.chat(model=..., messages=[...]) without tools still sends "tools": [] in the request body - _copy_tools(None) yields an empty list, and model_dump(exclude_none=True) keeps the key. Plan: normalize empty tool lists to absent at both chat() call sites (sync and async), so tools is only sent when tools are actually provided, with tests. I will open a PR shortly.

  2. Mrun25 commented on Oct 19, 2026

    @Mrun25

    Opened a fix: #768

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions