From 0d817c876f0461c8017475419fd5ff52b75fe8ef Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Mon, 21 Sep 2026 09:11:37 +0200 Subject: [PATCH] Echo DeepSeek thinking reasoning_content on the next turn. HubAudio U11/U12/U38 400'd after a thinking-only review turn because the retry dropped reasoning_content. Native client now appends that field and continues; hierarchical sheet ingest stays in 2.60.10. --- .../backend/services/llm/deepseek_provider.py | 65 ++++++++++++++++--- periscope/src/frontend/content/changelog.md | 5 +- tests/test_deepseek_provider.py | 49 ++++++++++++-- 3 files changed, 102 insertions(+), 17 deletions(-) diff --git a/periscope/src/backend/services/llm/deepseek_provider.py b/periscope/src/backend/services/llm/deepseek_provider.py index ad64d3f..a3afcce 100644 --- a/periscope/src/backend/services/llm/deepseek_provider.py +++ b/periscope/src/backend/services/llm/deepseek_provider.py @@ -76,6 +76,30 @@ def _to_openai_tool_choice(c: ToolChoice) -> dict | str: raise ValueError(f"Invalid tool_choice: {c!r}") +def _message_reasoning(msg: Any) -> str | None: + """DeepSeek thinking text. OpenAI SDK often parks it in model_extra.""" + val = getattr(msg, "reasoning_content", None) + if val: + return str(val) + extra = getattr(msg, "model_extra", None) + if isinstance(extra, dict) and extra.get("reasoning_content"): + return str(extra["reasoning_content"]) + extra2 = getattr(msg, "__pydantic_extra__", None) + if isinstance(extra2, dict) and extra2.get("reasoning_content"): + return str(extra2["reasoning_content"]) + dump = getattr(msg, "model_dump", None) + if callable(dump): + try: + data = dump() + except Exception: + data = None + if isinstance(data, dict) and data.get("reasoning_content"): + return str(data["reasoning_content"]) + if isinstance(msg, dict) and msg.get("reasoning_content"): + return str(msg["reasoning_content"]) + return None + + def _reasoning_from_blocks(blocks: list[ContentBlock]) -> str | None: for b in blocks: rc = getattr(b, "reasoning_content", None) @@ -213,7 +237,7 @@ def completion_from_openai(resp: Any) -> Completion: choice = resp.choices[0] msg = choice.message text = msg.content or "" - reasoning = getattr(msg, "reasoning_content", None) or None + reasoning = _message_reasoning(msg) tool_calls: list[ToolCall] = [] raw_blocks: list[ContentBlock] = [] @@ -302,7 +326,7 @@ class DeepSeekSession(LLMSession): extra_body["reasoning_effort"] = self._reasoning_effort kwargs: dict[str, Any] = { "model": self.model, - "messages": oai_messages, + "messages": list(oai_messages), "max_tokens": self._max_tokens, "extra_body": extra_body, } @@ -314,22 +338,45 @@ class DeepSeekSession(LLMSession): resp = await self._create(kwargs) completion = completion_from_openai(resp) - # Thinking-only (no visible text, no tools) cannot be replayed on the - # next turn. Retry once with thinking off so the review loop gets a - # real content/tool_calls message instead of a 400 on the follow-up. + # Thinking-only (no visible text, no tools): the next request must + # echo reasoning_content. Live U11/U12/U38 400'd because we retried + # the same user turn with thinking off and dropped that field. if ( thinking and not (completion.text or "").strip() and not completion.tool_calls ): + reasoning = _reasoning_from_blocks(completion.raw_assistant_blocks) + if not reasoning: + reasoning = _message_reasoning(resp.choices[0].message) log.warning( "DeepSeek thinking-only turn (no content/tool_calls); " - "retrying with thinking disabled" + "continuing with reasoning_content echoed" ) - extra_body = { - "thinking": {"type": "disabled"}, + asst: dict[str, Any] = { + "role": "assistant", + "content": completion.text or "", + } + if reasoning: + asst["reasoning_content"] = reasoning + follow = list(oai_messages) + [ + asst, + { + "role": "user", + "content": ( + "Continue. Call a graph tool or submit_review. " + "Do not reply with reasoning only." + ), + }, + ] + kwargs = { + **kwargs, + "messages": follow, + "extra_body": { + "thinking": {"type": "enabled"}, + "reasoning_effort": self._reasoning_effort, + }, } - kwargs = {**kwargs, "extra_body": extra_body} resp = await self._create(kwargs) completion = completion_from_openai(resp) return completion diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index 7dad447..50b4043 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,12 +2,15 @@ What's new in Periscope. -## 2.60.10 — 2026-09-21 — Hierarchical sheets, skip .history +## 2.60.10 — 2026-09-21 — Hierarchical sheets + DeepSeek thinking echo Apri progetto still skips `.history` / `.git` / backups so the folder pick does not hang. It now keeps **every** linked schematic: all `*.kicad_sch` next to the `.kicad_pro`, plus `Sheetfile` / `(sheet` / `file=` children in a subfolder. Not only `HubAudio.kicad_sch`. +DeepSeek thinking-only review turns (HubAudio U11/U12/U38) 400'd because the next request dropped `reasoning_content`. The native client echoes it on the follow-up. Retry failed IC reviews after this deploy. + - [Fixed] USB / Codec / POWER (and other hierarchical modules) are ingested with the root sheet. - [Fixed] Nested sheet paths are followed; `.history` copies are ignored. +- [Fixed] Thinking-mode `reasoning_content` is passed back on subsequent turns. ## 2.60.9 — 2026-09-21 — Apri progetto does not walk forever diff --git a/tests/test_deepseek_provider.py b/tests/test_deepseek_provider.py index ff94b8d..35fded7 100644 --- a/tests/test_deepseek_provider.py +++ b/tests/test_deepseek_provider.py @@ -182,31 +182,36 @@ def test_forced_tool_choice_disables_thinking(): assert captured["tool_choice"]["function"]["name"] == "save_resolved_specs" -def test_thinking_only_retries_with_thinking_disabled(): +def test_thinking_only_echoes_reasoning_content_on_follow_up(): + """Live U11/U12/U38: thinking-only then retry without reasoning_content → 400.""" calls: list[dict] = [] class FakeCompletions: async def create(self, **kwargs): calls.append({ "thinking": kwargs["extra_body"]["thinking"]["type"], + "messages": kwargs["messages"], }) usage = SimpleNamespace( prompt_tokens=10, completion_tokens=5, prompt_cache_hit_tokens=0, prompt_tokens_details=None, ) - if kwargs["extra_body"]["thinking"]["type"] == "enabled": + if kwargs["extra_body"]["thinking"]["type"] == "enabled" and len(calls) == 1: msg = SimpleNamespace( content=None, reasoning_content="pondering the schematic", tool_calls=None, + model_extra=None, ) return SimpleNamespace( choices=[SimpleNamespace(message=msg, finish_reason="stop")], usage=usage, ) - fn = SimpleNamespace(name="get_pintable", arguments='{"ref":"U19"}') + fn = SimpleNamespace(name="get_pintable", arguments='{"ref":"U11"}') tc = SimpleNamespace(id="c1", function=fn) - msg = SimpleNamespace(content=None, reasoning_content=None, tool_calls=[tc]) + msg = SimpleNamespace( + content=None, reasoning_content=None, tool_calls=[tc], model_extra=None, + ) return SimpleNamespace( choices=[SimpleNamespace(message=msg, finish_reason="tool_calls")], usage=usage, @@ -222,14 +227,14 @@ def test_thinking_only_retries_with_thinking_disabled(): session = DeepSeekSession( client=FakeClient(), - model="deepseek-v4-pro", + model="deepseek-flash", system="sys", max_tokens=256, thinking=True, reasoning_effort="medium", ) completion = asyncio.run(session.complete( - messages=[Message("user", [TextBlock("review U19")])], + messages=[Message("user", [TextBlock("review U11")])], tools=[ToolSchema( name="get_pintable", description="x", input_schema={"type": "object"}, @@ -238,10 +243,40 @@ def test_thinking_only_retries_with_thinking_disabled(): )) assert len(calls) == 2 assert calls[0]["thinking"] == "enabled" - assert calls[1]["thinking"] == "disabled" + assert calls[1]["thinking"] == "enabled" + asst = [m for m in calls[1]["messages"] if m.get("role") == "assistant"] + assert asst + assert asst[0]["reasoning_content"] == "pondering the schematic" + assert asst[0]["content"] == "" assert completion.tool_calls[0].name == "get_pintable" +def test_completion_from_openai_reads_model_extra_reasoning(): + fn = SimpleNamespace(name="get_pintable", arguments='{"ref":"U11"}') + tc = SimpleNamespace(id="c1", function=fn) + msg = SimpleNamespace( + content=None, + reasoning_content=None, + tool_calls=[tc], + model_extra={"reasoning_content": "need pin table"}, + ) + usage = SimpleNamespace( + prompt_tokens=10, completion_tokens=5, + prompt_cache_hit_tokens=0, prompt_tokens_details=None, + ) + resp = SimpleNamespace( + choices=[SimpleNamespace(message=msg, finish_reason="tool_calls")], + usage=usage, + ) + completion = completion_from_openai(resp) + assert completion.raw_assistant_blocks[0].reasoning_content == "need pin table" + out = messages_to_openai( + [Message("assistant", list(completion.raw_assistant_blocks))], + vision=False, + ) + assert out[0]["reasoning_content"] == "need pin table" + + def test_tool_schema_and_choice(): schema = ToolSchema( name="save_pintable",