diff --git a/src/llm_core.py b/src/llm_core.py index e809d7968..e1f732b9a 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -87,7 +87,7 @@ _host_health_lock = threading.Lock() _model_activity: Dict[str, float] = {} _HARMONY_MARKER_RE = re.compile( - r"<\|channel\|>(analysis|final)" + r"<\|channel\|>(analysis|commentary|final)" r"|<\|start\|>(?:assistant|system|user|tool)?" r"|<\|message\|>" r"|<\|end\|>" @@ -96,6 +96,7 @@ _HARMONY_MARKER_RE = re.compile( ) _HARMONY_MARKERS = ( "<|channel|>analysis", + "<|channel|>commentary", "<|channel|>final", "<|start|>assistant", "<|start|>system", @@ -145,7 +146,10 @@ class _HarmonyStreamRouter: out.append((text, False)) return if self._in_message: - out.append((text, self._channel == "analysis")) + # analysis + commentary (tool-call preambles / function-arg bodies) + # are internal, not user-facing — route them to thinking so they + # don't leak into the visible answer; only `final` is visible. + out.append((text, self._channel in ("analysis", "commentary"))) def _handle_marker(self, match: re.Match[str]) -> None: marker = match.group(0) diff --git a/tests/test_llm_core_reasoning.py b/tests/test_llm_core_reasoning.py index 0cc966199..7ed2c2f0d 100644 --- a/tests/test_llm_core_reasoning.py +++ b/tests/test_llm_core_reasoning.py @@ -206,3 +206,33 @@ def test_harmony_analysis_channel_routes_to_thinking(monkeypatch): assert answer == "Here are the files." assert "<|channel|>" not in thinking + answer assert "<|message|>" not in thinking + answer + + +def test_harmony_commentary_channel_no_marker_or_toolarg_leak(monkeypatch): + # gpt-oss commentary channel (tool-call preambles / function-arg bodies) is + # internal — it must not leak the channel marker, the `to=functions.*` + # recipient, or its body into the visible answer. The `<|channel|>comm` / + # `entary` split also exercises the suffix-hold for the new marker. + deltas = _run_stream( + "gpt-oss:20b", + [ + 'data: {"choices":[{"delta":{"content":"<|channel|>comm"}}]}', + 'data: {"choices":[{"delta":{"content":"entary to=functions.web_search<|message|>Let me search the web."}}]}', + 'data: {"choices":[{"delta":{"content":"<|end|><|channel|>final<|message|>Here are the "}}]}', + 'data: {"choices":[{"delta":{"content":"results.<|end|>"}}]}', + "data: [DONE]", + ], + monkeypatch, + ) + thinking = "".join(d["delta"] for d in deltas if d.get("thinking")) + answer = "".join(d["delta"] for d in deltas if not d.get("thinking")) + + # final channel is the only user-facing text + assert answer == "Here are the results." + # commentary body routed to thinking, not the visible answer + assert thinking == "Let me search the web." + # no harmony markers, channel name, or tool recipient leak anywhere + assert "<|channel|>" not in thinking + answer + assert "<|message|>" not in thinking + answer + assert "commentary" not in answer + assert "to=functions.web_search" not in thinking + answer