Skip to content

fix(runtime): embed developer-loop placeholder in first result delivery (closes #6251)#6254

Merged
houko merged 3 commits into
librefang:mainfrom
maoxin1234:fix-6251-developer-loop-consecutive-user
Jun 23, 2026
Merged

fix(runtime): embed developer-loop placeholder in first result delivery (closes #6251)#6254
houko merged 3 commits into
librefang:mainfrom
maoxin1234:fix-6251-developer-loop-consecutive-user

Conversation

@maoxin1234

Copy link
Copy Markdown
Contributor

Problem

When aggregate_developer_loops collapses a long run of developer-tool steps, it previously inserted a standalone Role::User placeholder message immediately after the first step's Role::User tool-result delivery.

session_repair intentionally skips merging pure tool-result deliveries, so the placeholder and the result delivery stayed as two consecutive User messages. Anthropic's Messages API rejects this with:

role User cannot follow role User

Fixes #6251.

Change

  • developer_loop_placeholder() now returns a ContentBlock::Text instead of a full Message.
  • Added append_block_to_message() helper to safely append a block to either MessageContent::Text or MessageContent::Blocks.
  • aggregate_developer_loops() embeds the aggregation notice inside the first result delivery instead of pushing a separate User message.
  • Updated existing tests to expect 4 messages instead of 5 after aggregation.
  • Added aggregate_developer_loops_avoids_consecutive_user_messages to assert no adjacent User messages and that the placeholder lives in the first result delivery.

Verification

  • cargo fmt --all
  • cargo clippy -p librefang-runtime --all-targets -- -D warnings
  • cargo test -p librefang-runtime aggregate_developer_loops (6 passed)

When aggregate_developer_loops collapsed a long run of developer-tool
steps, it inserted a standalone Role::User placeholder message right
after the first step's Role::User tool-result delivery. session_repair
intentionally skips merging pure tool-result deliveries, so Anthropic's
Messages API received two consecutive User messages and returned 400.

Instead of creating a separate User placeholder, append the aggregation
notice as a Text block inside the first result delivery. This keeps the
history alternating Assistant/User and avoids the API error.

Closes librefang#6251
@github-actions github-actions Bot added the size/M 50-249 lines changed label Jun 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for your first pull request! 🎉

A maintainer will review it soon. While you wait, please make sure:

  • PR title follows conventional format (feat:, fix:, docs:, etc.)
  • cargo test --workspace passes
  • cargo clippy --workspace --all-targets -- -D warnings is clean

We aim to provide initial feedback within 7 days. See CONTRIBUTING.md for details.

@github-actions github-actions Bot added the area/runtime Agent loop, LLM drivers, WASM sandbox label Jun 20, 2026
houko
houko previously requested changes Jun 21, 2026

@houko houko 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.

Thanks — the Anthropic-side fix (avoiding the consecutive role: user turns that triggered the 400) is correct, but the chosen approach regresses OpenAI-compatible providers, which is the exact failure mode issue #6251 warned about.

The placeholder is appended as a separate ContentBlock::Text after the ToolResult (crates/librefang-runtime/src/compactor.rs, aggregate_developer_loopsappend_block_to_message), producing Blocks([ToolResult, Text]). In the OpenAI driver, emission of the accumulated text parts is gated on !has_tool_results (crates/librefang-llm-drivers/src/drivers/openai.rs:940); since the message now contains a ToolResult, has_tool_results is true, so the placeholder Text is silently discarded and never reaches OpenAI / Groq / Moonshot / any OAI-compatible backend. Before this PR the standalone MessageContent::Text placeholder went through the (Role::User, MessageContent::Text) arm (openai.rs:841) and was sent. So this is a behavioral regression for those providers.

Issue #6251 explicitly recommended embedding the notice into the first ToolResult's content string for this reason. Please append the notice into the ToolResult content (e.g. add the [developer loop aggregated …] text to its content field) rather than adding a separate Text block — that survives both the Anthropic and the OpenAI translation paths. A driver-level round-trip test feeding Blocks([ToolResult, Text]) through both drivers and asserting the notice survives would lock this in (the current test only checks message structure / text_content(), which doesn't catch the driver-layer drop).

Heads-up: the real Rust CI (cargo check / clippy / nextest / secret-scan) hasn't run on this PR yet — it's held in action_required on the first-time-contributor approval gate, so the current green checks are only the lightweight housekeeping workflows.

…urvive OpenAI path

The first librefang#6254 fix appended the aggregation notice as a separate
`ContentBlock::Text` after the `ToolResult` in the first result delivery.
That survives the Anthropic path but the OpenAI-compatible driver gates
emission of accumulated text `parts` on `!has_tool_results`, so a `Text`
block sharing a message with a `ToolResult` is silently dropped — the
notice never reached OpenAI / Groq / Moonshot. This was the exact
OpenAI-compatible regression librefang#6251 warned about.

Fold the notice into the first `ToolResult`'s `content` string instead.
That single shape survives both the Anthropic and the OpenAI translation
paths.

- `developer_loop_placeholder()` now returns the notice `String`.
- Replace `append_block_to_message()` with `append_notice_to_first_tool_result()`,
  which appends the notice to the first `ToolResult.content` (with a
  Text-block fallback if no ToolResult is present).
- Add driver-level round-trip tests in both `openai.rs` and `anthropic.rs`
  that feed a `ToolResult` carrying the notice through the request builder
  and assert the notice reaches the wire payload — the message-structure
  test alone did not catch the driver-layer drop.
- Update the runtime tests to assert the notice lives in the ToolResult
  content.

cargo fmt --all; cargo clippy -p librefang-runtime -p librefang-llm-drivers
--all-targets -- -D warnings; cargo test -p librefang-runtime --lib
aggregate_developer_loops (6 passed); cargo test -p librefang-llm-drivers
--lib dev_loop_notice (2 passed).
@maoxin1234

Copy link
Copy Markdown
Contributor Author

Thanks for catching the OpenAI-compatible regression — you're right, and I've pushed the fix in 2d8cfc0.

Change: the notice is now folded into the first ToolResult's content string rather than appended as a separate ContentBlock::Text. That's the only shape that survives both translation paths: the OpenAI driver's !has_tool_results gate (crates/librefang-llm-drivers/src/drivers/openai.rs:940) drops any Text block sharing a message with a ToolResult, while a ToolResult.content string passes through both convert_message (Anthropic) and the role: "tool" emission (OpenAI).

  • developer_loop_placeholder() now returns the notice String.
  • append_block_to_message() is replaced by append_notice_to_first_tool_result(), which appends the notice to the first ToolResult.content (Text-block fallback only if no ToolResult is present).

Driver-level round-trip tests (the part the old structure-only test missed):

  • build_request_folds_dev_loop_notice_into_tool_result_content (openai.rs) — feeds a ToolResult carrying the notice through build_request and asserts the notice reaches the role: "tool" message content and the serialized request body. This fails on the previous separate-Text-block approach.
  • convert_message_preserves_dev_loop_notice_in_tool_result_content (anthropic.rs) — asserts the notice survives into the Anthropic ToolResult content.

Verification:

  • cargo fmt --all
  • cargo clippy -p librefang-runtime -p librefang-llm-drivers --all-targets -- -D warnings (clean)
  • cargo test -p librefang-runtime --lib aggregate_developer_loops (6 passed)
  • cargo test -p librefang-llm-drivers --lib dev_loop_notice (2 passed)

@houko
houko dismissed their stale review June 23, 2026 01:17

Addressed by the follow-up commit (reviewer's requested approach implemented + driver round-trip tests added); branch updated to current main. Re-reviewed and clean.

@houko
houko enabled auto-merge (squash) June 23, 2026 01:17
@houko
houko merged commit b5c8b03 into librefang:main Jun 23, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/runtime Agent loop, LLM drivers, WASM sandbox size/M 50-249 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Consecutive Role::User Messages in Aggregated Developer Loop (Anthropic API 400)

2 participants