mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
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:
co-authored by
Claude Opus 4.8
parent
a65592fecb
commit
6dbe5461bb
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user