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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user