fix(engine): drop Qwen3 control-token tool calls from Ollama responses (#578)

Qwen3 treats /think and /no_think as soft-switch control tokens. On small
models a multi-line prompt makes the model emit one as the sole tool argument
(e.g. {"command": "/no_think"}); OpenJarvis forwards Ollama's native tool_calls
verbatim, so the operative agent executes garbage. Filter control-token-only
tool calls in both the non-streaming generate() and streaming _run_stream()
paths, keeping legitimate calls like {"command": "date"}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-06-22 11:40:25 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent a65592fecb
commit 6dbe5461bb
2 changed files with 243 additions and 10 deletions
+67 -9
View File
@@ -22,6 +22,49 @@ from openjarvis.engine._stubs import StreamChunk
logger = logging.getLogger(__name__)
# Qwen3 treats ``/think`` and ``/no_think`` as soft-switch control tokens that
# toggle reasoning mode. Small models (e.g. qwen3:14b) fed a multi-line prompt
# sometimes emit one of these as the sole tool argument, e.g.
# ``{"command": "/no_think"}`` instead of the real command. Ollama parses that
# into a fully-formed tool_call via the model's chat template, so we have to
# drop it on our side before the agent executes garbage.
_QWEN_CONTROL_TOKENS = frozenset({"/think", "/no_think"})
def _is_control_token_only_args(raw_args: Any) -> bool:
"""Return True if tool-call arguments contain nothing but a Qwen3 token.
``raw_args`` may be a dict (Ollama's native shape) or a JSON / bare string.
A call is considered degenerate only when it carries at least one control
token and no other usable content, so legitimate calls such as
``{"command": "date"}`` or ``{"command": "echo /no_think"}`` are kept.
"""
parsed: Any = raw_args
if isinstance(raw_args, str):
try:
parsed = json.loads(raw_args)
except (json.JSONDecodeError, TypeError):
parsed = raw_args
if isinstance(parsed, str):
return parsed.strip().lower() in _QWEN_CONTROL_TOKENS
if not isinstance(parsed, dict) or not parsed:
return False
saw_token = False
for value in parsed.values():
if not isinstance(value, str):
return False # a non-string value is real content
stripped = value.strip()
if not stripped:
continue
if stripped.lower() in _QWEN_CONTROL_TOKENS:
saw_token = True
else:
return False # real string content
return saw_token
def _default_num_ctx() -> int:
"""Default context window (tokens). Override with ``JARVIS_NUM_CTX``.
@@ -168,14 +211,19 @@ class OllamaEngine(InferenceEngine):
if raw_tool_calls:
tool_calls = []
for i, tc in enumerate(raw_tool_calls):
raw_args = tc.get("function", {}).get(
"arguments",
"{}",
)
fn = tc.get("function", {})
raw_args = fn.get("arguments", "{}")
if _is_control_token_only_args(raw_args):
logger.warning(
"Dropping Qwen3 control-token tool call %s(%r)",
fn.get("name", ""),
raw_args,
)
continue
tool_calls.append(
{
"id": tc.get("id", f"call_{i}"),
"name": tc.get("function", {}).get("name", ""),
"name": fn.get("name", ""),
"arguments": (
json.dumps(raw_args)
if isinstance(raw_args, dict)
@@ -183,7 +231,8 @@ class OllamaEngine(InferenceEngine):
),
}
)
result["tool_calls"] = tool_calls
if tool_calls:
result["tool_calls"] = tool_calls
return result
async def stream(
@@ -340,14 +389,22 @@ class OllamaEngine(InferenceEngine):
# OpenAI-delta fragment shape that agent_manager_routes
# expects in _merge_tool_call_fragments.
fragments: List[Dict[str, Any]] = []
for i, tc in enumerate(raw_tool_calls):
for tc in raw_tool_calls:
fn = tc.get("function", {}) or {}
raw_args = fn.get("arguments", "{}")
if _is_control_token_only_args(raw_args):
logger.warning(
"Dropping Qwen3 control-token tool call %s(%r)",
fn.get("name", ""),
raw_args,
)
continue
args_str = (
json.dumps(raw_args)
if isinstance(raw_args, dict)
else str(raw_args)
)
i = len(fragments)
fragments.append(
{
"index": i,
@@ -359,8 +416,9 @@ class OllamaEngine(InferenceEngine):
},
}
)
yield StreamChunk(tool_calls=fragments)
finish_reason = "tool_calls"
if fragments:
yield StreamChunk(tool_calls=fragments)
finish_reason = "tool_calls"
if chunk.get("done", False):
reported_prompt = chunk.get("prompt_eval_count", 0)
+176 -1
View File
@@ -11,7 +11,7 @@ import respx
from openjarvis.core.registry import EngineRegistry
from openjarvis.core.types import Message, Role
from openjarvis.engine._base import EngineConnectionError
from openjarvis.engine.ollama import OllamaEngine
from openjarvis.engine.ollama import OllamaEngine, _is_control_token_only_args
@pytest.fixture()
@@ -82,6 +82,181 @@ class TestOllamaHealth:
assert engine.health() is False
class TestControlTokenFilter:
"""Qwen3 ``/think`` / ``/no_think`` soft-switch tokens sometimes leak into
tool-call arguments on small models (e.g. ``{"command": "/no_think"}``).
Such a call is never valid and must be dropped before execution.
"""
@pytest.mark.parametrize(
"raw_args",
[
{"command": "/no_think"},
{"command": "/think"},
{"command": " /no_think "},
{"command": "/NO_THINK"},
"/no_think",
json.dumps({"command": "/no_think"}),
{"command": "/no_think", "note": ""},
],
)
def test_detects_control_token_only(self, raw_args) -> None:
assert _is_control_token_only_args(raw_args) is True
@pytest.mark.parametrize(
"raw_args",
[
{"command": "date"},
{"command": "echo /no_think"},
{"query": "what is /no_think"},
{"command": "date", "note": "/no_think"},
{"timeout": 30},
{},
"date",
"not json at all",
],
)
def test_keeps_legitimate_args(self, raw_args) -> None:
assert _is_control_token_only_args(raw_args) is False
class TestOllamaGenerateControlToken:
def test_generate_drops_control_token_tool_call(self, engine: OllamaEngine) -> None:
with respx.mock:
respx.post("http://testhost:11434/api/chat").mock(
return_value=httpx.Response(
200,
json={
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": {
"name": "shell_exec",
"arguments": {"command": "/no_think"},
}
}
],
},
"model": "qwen3:14b",
},
)
)
result = engine.generate(
[Message(role=Role.USER, content="run date")],
model="qwen3:14b",
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
)
assert not result.get("tool_calls")
def test_generate_keeps_valid_tool_call(self, engine: OllamaEngine) -> None:
with respx.mock:
respx.post("http://testhost:11434/api/chat").mock(
return_value=httpx.Response(
200,
json={
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": {
"name": "shell_exec",
"arguments": {"command": "date"},
}
}
],
},
"model": "qwen3:14b",
},
)
)
result = engine.generate(
[Message(role=Role.USER, content="run date")],
model="qwen3:14b",
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
)
assert len(result["tool_calls"]) == 1
assert json.loads(result["tool_calls"][0]["arguments"]) == {"command": "date"}
def test_generate_drops_only_control_token_among_many(
self, engine: OllamaEngine
) -> None:
with respx.mock:
respx.post("http://testhost:11434/api/chat").mock(
return_value=httpx.Response(
200,
json={
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": {
"name": "shell_exec",
"arguments": {"command": "/no_think"},
}
},
{
"function": {
"name": "shell_exec",
"arguments": {"command": "date"},
}
},
],
},
"model": "qwen3:14b",
},
)
)
result = engine.generate(
[Message(role=Role.USER, content="run date")],
model="qwen3:14b",
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
)
assert len(result["tool_calls"]) == 1
assert json.loads(result["tool_calls"][0]["arguments"]) == {"command": "date"}
class TestOllamaStreamFullControlToken:
@pytest.mark.asyncio
async def test_stream_full_drops_control_token_tool_call(
self, engine: OllamaEngine
) -> None:
lines = [
json.dumps(
{
"message": {
"content": "",
"tool_calls": [
{
"function": {
"name": "shell_exec",
"arguments": {"command": "/no_think"},
}
}
],
},
"done": True,
}
),
]
body = "\n".join(lines)
with respx.mock:
respx.post("http://testhost:11434/api/chat").mock(
return_value=httpx.Response(200, text=body)
)
chunks = []
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="run date")],
model="qwen3:14b",
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
):
chunks.append(chunk)
assert all(not c.tool_calls for c in chunks)
class TestOllamaStream:
@pytest.mark.asyncio
async def test_stream_yields_content(self, engine: OllamaEngine) -> None: