feat(api_server): stream inline <think> as delta.reasoning_content (unify A/B-class reasoning) - #23638
Conversation
The scrubber now returns `(visible, reasoning)` from both `feed()` and `flush()` instead of discarding `<think>…</think>` block contents on the floor. Unifies A-class (structured `delta.reasoning_content` from DeepSeek/Moonshot/Kimi thinking modes) and B-class (inline `<think>` tags from open-weights models) reasoning on a single downstream channel. Changes: - `agent/think_scrubber.py`: `feed()` and `flush()` return `
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused extension of the existing reasoning callback path. Current main still drops this API-server surface: gateway/platforms/api_server.py:2271-2282 passes visible and tool callbacks only, while _write_sse_chat_completion() at gateway/platforms/api_server.py:2449-2470 has no delta.reasoning_content branch. run_agent.py:4701-4706 also only receives the scrubber's visible string today, so the inline-tag part of the premise is current.
Problems
- The PR adds only scrubber-unit coverage.
tests/gateway/test_api_server.py:1097-1123validates ordinary content streaming but does not exercise a reasoning callback or assert adelta.reasoning_contentSSE frame. This leaves the public HTTP callback threading and queue discrimination unguarded.
Suggested changes
- Add an aiohttp chat-completions streaming test that invokes both callbacks and asserts ordered reasoning/content chunks, with no reasoning in
delta.content. - Add an agent streaming-path test for split inline `` tags to verify
_fire_stream_deltaroutes recovered text to `reasoning_callback`.
Automated hermes-sweeper review.
| @@ -14,23 +23,37 @@ | |||
| from agent.think_scrubber import StreamingThinkScrubber | |||
There was a problem hiding this comment.
These tests validate tuple recovery only. Please also add an API-server SSE test: current tests/gateway/test_api_server.py:1097-1123 drives only stream_delta_callback, so it would not catch a lost reasoning_callback thread or a __reasoning__ tuple being serialized as delta.content.
Summary
Surface the model's chain-of-thought on the de-facto OpenAI-compatible
delta.reasoning_contentfield in/v1/chat/completions(streaming branch only), and unify two previously-divergent reasoning sources onto this single wire format:delta.reasoning_contentfrom DeepSeek / Moonshot / Kimi / GLM / MiniMax / Tencent thinking modes; already arrives on the model's dedicated reasoning channel viarun_agent._fire_reasoning_delta.<think>/<thinking>/<reasoning>tags emitted on the normal content channel by open-weights models (Qwen3 thinking, DeepSeek-R1 finetunes following that prompt template, MiniMax-M2.7, etc.).The mechanism:
StreamingThinkScrubber.feed()/flush()now return(visible, reasoning)instead of silently dropping scrubbed-out<think>…</think>block contents on the floor, so the same SSE writer can route both sources through one path.delta.content/message.content/ stored conversation history remain unchanged.Output (both A-class and B-class models)
Why
delta.reasoning_content(not a custom SSE event)delta.reasoning_contentis the field popularised by DeepSeek's API and is already consumed natively by:No client-side adapter needed — existing DeepSeek-compatible frontends just work against Hermes now, including for open-weights models that only emit
<think>inline.Design
agent/think_scrubber.py—feed()andflush()return(visible, reasoning). Inside a<think>block, bytes that previously fell on the floor are now surfaced on the second channel. Partial close-tag held-back prefixes on stream termination are surfaced as reasoning (not discarded or leaked into visible).run_agent.py—_fire_stream_deltaand the end-of-stream flush route thereasoninghalf through the existing_fire_reasoning_delta(same path A-class already uses). Visible half still goes through context scrubber +stream_delta_callback.gateway/platforms/api_server.py—_create_agent/_run_agentthread a newreasoning_callbackthrough toAIAgent._handle_chat_completionsstreaming branch registers_on_reasoningthat enqueues("__reasoning__", text)tuples onto the SSE queue. The SSE writer emits them aschat.completion.chunkframes carryingdelta.reasoning_content.Scope
Streaming
/v1/chat/completionsonly. Non-streaming,/v1/responses,/v1/runs, and every non-api_server gateway platform are untouched.Acceptance
(text, "")).delta.reasoning_content, body arrives ondelta.content.<think>block contents arrive ondelta.reasoning_content, post-</think>body ondelta.content.delta.contentnever contains<think>/</think>markers. Memory / conversation-history paths still see the fully-scrubbed visible text.hermes.tool.progress,finish_reason,usage,X-Hermes-Session-Id,X-Hermes-Session-Key,[DONE], and client-disconnect interrupt behaviour are unaffected.Tests
tests/agent/test_think_scrubber.py— rewritten against the(visible, reasoning)return shape; adds coverage for recovered B-class content, partial close-tag tails surfaced on flush, multi-block streams, and nested/malformed tags.Risk / rollback
Isolated to the api_server streaming branch and the think-scrubber return contract. All internal callers of the scrubber updated in-tree. Revert = single-commit revert.