From 6dbe5461bbf2cf927ecbb3f1436ec1f68570a9c1 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:40:25 -0700 Subject: [PATCH] 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) --- src/openjarvis/engine/ollama.py | 76 ++++++++++++-- tests/engine/test_ollama.py | 177 +++++++++++++++++++++++++++++++- 2 files changed, 243 insertions(+), 10 deletions(-) diff --git a/src/openjarvis/engine/ollama.py b/src/openjarvis/engine/ollama.py index 8724cdcd..c959d44f 100644 --- a/src/openjarvis/engine/ollama.py +++ b/src/openjarvis/engine/ollama.py @@ -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) diff --git a/tests/engine/test_ollama.py b/tests/engine/test_ollama.py index 40d98e9a..0e62a343 100644 --- a/tests/engine/test_ollama.py +++ b/tests/engine/test_ollama.py @@ -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: