Skip to content

fix(buzz-agent): promote reasoning_content to visible text when content is empty - #5819

Open
csh0101 wants to merge 1 commit into
block:mainfrom
csh0101:fix/agent-reasoning-content-promotion
Open

fix(buzz-agent): promote reasoning_content to visible text when content is empty#5819
csh0101 wants to merge 1 commit into
block:mainfrom
csh0101:fix/agent-reasoning-content-promotion

Conversation

@csh0101

@csh0101 csh0101 commented Aug 14, 2026

Copy link
Copy Markdown

Problem

When using OpenAI-compatible reasoning models (e.g. DeepSeek, etc.), the model often puts the entire answer in reasoning_content while leaving content empty (or with a gateway placeholder like [System: Empty message content sanitised...]). The parse_openai function extracted text="" and reasoning="answer", but agent_message_chunk was never emitted (gated on !text.is_empty()), so the turn ended silently with end_turn — no error, no warning, just an empty reply.

The user sees the agent "turn" complete normally, but the channel gets no message.

Root Cause

parse_openai in crates/buzz-agent/src/llm.rs did not check whether reasoning_content should be promoted to visible text when content is empty. The same gap existed in parse_responses (Responses API route) and parse_anthropic (Anthropic extended thinking).

Fix

  1. openai_content_parts(): Recognize a gateway placeholder prefix ([System: Empty message content sanitised...]) as empty content, so reasoning_content is promoted to visible text.
  2. parse_openai(): After extracting text+reasoning, if text is empty and reasoning is non-empty, swap them so the model's answer reaches the agent's publish path.
  3. parse_responses(): Same promotion logic for the Responses API route.
  4. parse_anthropic(): Same promotion logic for Anthropic extended thinking blocks.
  5. agent.rs: Added warn!-level logging when end_turn fires with no publish attempt and require_reply is enabled, giving operators a signal in the logs to detect silent drops.

Tests

7 new tests covering:

  • parse_openai_promotes_reasoning_content_to_text_when_content_empty — empty content with reasoning_content → text promoted
  • parse_openai_promotes_reasoning_content_when_content_is_gateway_placeholder — gateway placeholder recognized as empty
  • parse_openai_empty_end_turn_with_no_reasoning_is_warned — genuinely empty end_turn is allowed (model may choose silence)
  • parse_openai_does_not_promote_reasoning_when_content_present — normal case: both survive
  • parse_openai_does_not_reject_empty_end_turn_with_tool_calls — tool calls with empty text is valid
  • parse_responses_promotes_reasoning_to_text_when_text_empty — Responses API reasoning promotion
  • parse_responses_empty_end_turn_with_no_reasoning_is_ok — Responses API empty end_turn allowed

All 484 unit tests pass.

Local Reproduction

# Set up a buzz-agent config with a reasoning model that returns
# content="" and reasoning_content="answer"
# Run a prompt; observe the agent turn completes with no channel message.
# With this fix, the reasoning content is promoted to visible text
# and published to the channel.

…nt is empty

DeepSeek and other reasoning models sometimes put the entire answer in
reasoning_content while leaving content empty (or with a gateway
placeholder like '[System: Empty message content sanitised...]').
The parser extracted text='' and reasoning='answer', so
agent_message_chunk was never emitted (gated on !text.is_empty()),
and the turn ended silently with end_turn — no error, no warning,
just an empty reply.

Changes:
1. llm.rs: openai_content_parts() — recognize the gateway placeholder
   prefix as empty content, so reasoning_content is promoted to visible
   text.
2. llm.rs: parse_openai() — after extracting text+reasoning, if text is
   empty and reasoning is non-empty, swap them (reasoning→text,
   text→reasoning). This makes the model's answer visible to the
   agent's publish path.
3. llm.rs: parse_responses() — same promotion for the Responses API
   route.
4. llm.rs: parse_anthropic() — same promotion for Anthropic extended
   thinking.
5. agent.rs: Add warn-level log when end_turn fires with no publish
   attempt and require_reply is enabled, giving operators a signal in
   the logs.
6. llm.rs tests: 7 new tests covering reasoning_content promotion
   (empty content + gateway placeholder), empty end_turn (allowed),
   no promotion when content present, empty text with tool calls,
   Responses API reasoning promotion, and Responses API empty end_turn.

Signed-off-by: AI Agent <agent@example.com>
Signed-off-by: cs <shi.chen@robotics.cc>
@csh0101
csh0101 requested a review from a team as a code owner August 14, 2026 00:26
@Chessing234

Copy link
Copy Markdown
Contributor

promoting reasoning into the published message changes what lands in a shared channel, not just whether something lands. reasoning items are the model's chain of thought — for several providers they are explicitly not intended for display, and some route them separately for exactly that reason. a kind-9 in a team channel is durable and readable by every member, so this turns "the answer was lost" into "the working-out was published under the agent's name". worth gating on an explicit opt-in, or prefixing/marking it as reasoning rather than substituting it for the answer.

the two warn!s in agent.rs read as the right instinct for #5732, but they are diagnosis after the fact — the turn still publishes. if the goal is that a turn never produces a zero-byte event, that decision belongs where the event is built, not in a log line.

@Chessing234 Chessing234 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the promotion isn't gated on the round having no tool call. parse_openai does the swap before tool_calls are parsed, and agent.rs:638 emits agent_message_chunk whenever text is non-empty regardless of tool_calls — so an r1-style model returning content:"" + reasoning_content:"" + a buzz_reply tool call (the normal shape for those models doing tool use) would publish its chain of thought as the visible message and then publish the real reply from the tool. requiring tool_calls.is_empty() before the swap keeps the fix to the case you actually hit.

the anthropic hunk has the same shape but bites harder: empty text with non-empty thinking is mostly the max_tokens / thinking-budget case, and agent.rs:660 pushes response.text into history as the assistant message when stop is MaxTokens, so a truncated chain of thought becomes the turn's answer. that hunk also has no test — the seven listed are all parse_openai/parse_responses.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants