Problem Description
In the recently merged developer-loop aggregation feature (#6173 / #6239), the compaction pass collapses consecutive developer-tool steps into the first step, a placeholder, and the last step.
Currently, the placeholder is inserted into the message history as a standalone Message with role: Role::User containing the aggregation notice:
fn developer_loop_placeholder(elided: usize, tools: &[String]) -> Message {
...
Message {
role: Role::User,
content: MessageContent::Text(text),
pinned: false,
timestamp: None,
}
}
This leads to the following sequence in the compacted message list:
Message (Role::User) — The tool result of the first retained step (pure ContentBlock::ToolResult).
Message (Role::User) — The placeholder text message (pure ContentBlock::Text).
Message (Role::Assistant) — The tool use of the last retained step.
When this history goes through the session repair pipeline in session_repair.rs, the same-role merge phase explicitly skips merging these consecutive user messages. This is because the first message consists solely of tool results (message_is_only_tool_results(last) is true), which blocks the merge:
if last.role == msg.role
&& !message_has_tool_use(last)
&& !message_is_only_tool_results(&msg)
&& !message_has_tool_use(&msg)
&& !message_is_only_tool_results(last) // <-- Returns false, skipping the merge
As a result, two consecutive user role messages are sent raw to the LLM drivers. While some provider APIs tolerate this, Anthropic's Messages API strictly rejects consecutive user messages, returning a 400 Bad Request validation error.
Proposed Solution: Embed the Placeholder in the Tool Result
Instead of creating a standalone Message for the aggregation notice, we can embed the placeholder notice directly into the ToolResult of the first (or last) retained step inside compactor.rs.
For example, if the first step's tool result was "ok", we append the aggregation note directly to its text content:
ok
[DEVELOPER LOOP AGGREGATED] 3 intermediate developer-tool step(s) elided during compaction. The first and last steps are retained for context.
Why this is the best approach:
- Zero Role-Alternation Issues: Since the note is part of the existing tool result block, no additional
user message is inserted. The alternating roles sequence remains perfectly intact.
- OpenAI / Anthropic Compatibility: Appending the text directly to the tool result string works across all drivers. (Note: keeping them as separate blocks in one message is risky, as the OpenAI driver ignores
Text parts in a user message if ToolResult blocks are present).
- Localized & Simple: Requires zero changes to the complex
session_repair.rs pipeline or the driver translation layers.
Suggested Code Change in crates/librefang-runtime/src/compactor.rs
In aggregate_developer_loops:
if steps.len() >= max_steps && steps.len() > 2 {
let first = steps[0];
let last = steps[steps.len() - 1];
// 1. Keep the first step's ToolUse
result.push(messages[first.0].clone());
// 2. Append the aggregation note to the first step's ToolResult
let mut first_result = messages[first.1].clone();
let elided = steps.len() - 2;
let tools = collect_dev_tool_names(messages, &steps[1..steps.len() - 1]);
append_aggregation_note(&mut first_result, elided, &tools);
result.push(first_result);
// 3. Keep the last step
result.push(messages[last.0].clone());
result.push(messages[last.1].clone());
i = j;
Verification & Proof (Failing Unit Test)
We wrote a unit test simulating a realistic scenario (a User request, a read-only file read loop, a developer mutating loop, and an assistant final response) to verify if the consecutive Role::User messages are indeed present after compaction and session repair.
#[test]
fn test_developer_loop_aggregation_causes_consecutive_user_roles() {
let mut msgs = Vec::new();
// 1. First message from User
msgs.push(Message {
role: Role::User,
content: MessageContent::Text("perform refactoring".to_string()),
pinned: false,
timestamp: None,
});
// 2. Read file (Read-only tool, not a developer tool)
// Assistant: requests file_read
msgs.push(Message {
role: Role::Assistant,
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
id: "t_read".to_string(),
name: "file_read".to_string(),
input: serde_json::json!({ "path": "src/main.rs" }),
provider_metadata: None,
}]),
pinned: false,
timestamp: None,
});
// User: returns file content
msgs.push(Message {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "t_read".to_string(),
tool_name: "file_read".to_string(),
content: "fn main() { println!(\"hello\"); }".to_string(),
is_error: false,
status: Default::default(),
approval_request_id: None,
}]),
pinned: false,
timestamp: None,
});
// 3. Mutating edits sequence (Developer loop)
// 4 steps of writing to file
for k in 0..4 {
msgs.push(dev_tool_use(&format!("t_write_{k}"), "file_write"));
msgs.push(tool_result(&format!("t_write_{k}"), "file_write"));
}
// 4. Final Assistant response
msgs.push(Message {
role: Role::Assistant,
content: MessageContent::Text("I have finished the refactoring".to_string()),
pinned: false,
timestamp: None,
});
// Run developer loop aggregation (threshold of 2 steps)
aggregate_developer_loops(&mut msgs, 2);
// Verify aggregation actually triggered and reduced the message count:
// 1 (user) + 2 (read) + 8 (write steps) + 1 (assistant) = 12 messages.
// After aggregating 4 write steps (8 messages) down to 5 messages:
// 1 (user) + 2 (read) + 5 (aggregated writes) + 1 (assistant) = 9 messages.
assert_eq!(msgs.len(), 9);
// Run session repair
let (repaired, _stats) = crate::session_repair::validate_and_repair_with_stats(&msgs);
// Check for consecutive User-role messages.
// The test MUST fail (panic) if consecutive user messages are found.
let mut last_was_user = false;
let mut consecutive_indices = None;
for (idx, m) in repaired.iter().enumerate() {
if m.role == Role::User {
if last_was_user {
consecutive_indices = Some((idx - 1, idx));
break;
}
last_was_user = true;
} else {
last_was_user = false;
}
}
if let Some((i1, i2)) = consecutive_indices {
panic!(
"FAIL: Found consecutive user messages at indices {} and {} in the repaired session!\n\
Message {}: {:?}\n\
Message {}: {:?}",
i1, i2,
i1, repaired[i1],
i2, repaired[i2]
);
}
}
Test Output
Running this test yields a panic indicating consecutive User messages:
failures:
---- compactor::tests::test_developer_loop_aggregation_causes_consecutive_user_roles stdout ----
thread 'compactor::tests::test_developer_loop_aggregation_causes_consecutive_user_roles' panicked at crates/librefang-runtime/src/compactor.rs:2460:13:
FAIL: Found consecutive user messages at indices 4 and 5 in the repaired session!
Message 4: Message { role: User, content: Blocks([ToolResult { tool_use_id: "t_write_0", tool_name: "file_write", content: "ok", is_error: false, status: Completed, approval_request_id: None }]), pinned: false, timestamp: None }
Message 5: Message { role: User, content: Text("[DEVELOPER LOOP AGGREGATED] 2 intermediate developer-tool step(s) elided during compaction (tools: file_write). The first and last steps are retained for context."), pinned: false, timestamp: None }
This confirms the issue and highlights the vulnerability in the current developer-loop aggregation strategy.
Problem Description
In the recently merged developer-loop aggregation feature (#6173 / #6239), the compaction pass collapses consecutive developer-tool steps into the first step, a placeholder, and the last step.
Currently, the placeholder is inserted into the message history as a standalone
Messagewithrole: Role::Usercontaining the aggregation notice:This leads to the following sequence in the compacted message list:
Message(Role::User) — The tool result of the first retained step (pureContentBlock::ToolResult).Message(Role::User) — The placeholder text message (pureContentBlock::Text).Message(Role::Assistant) — The tool use of the last retained step.When this history goes through the session repair pipeline in
session_repair.rs, the same-role merge phase explicitly skips merging these consecutive user messages. This is because the first message consists solely of tool results (message_is_only_tool_results(last)istrue), which blocks the merge:As a result, two consecutive
userrole messages are sent raw to the LLM drivers. While some provider APIs tolerate this, Anthropic's Messages API strictly rejects consecutive user messages, returning a400 Bad Requestvalidation error.Proposed Solution: Embed the Placeholder in the Tool Result
Instead of creating a standalone
Messagefor the aggregation notice, we can embed the placeholder notice directly into theToolResultof the first (or last) retained step insidecompactor.rs.For example, if the first step's tool result was
"ok", we append the aggregation note directly to its text content:Why this is the best approach:
usermessage is inserted. The alternating roles sequence remains perfectly intact.Textparts in a user message ifToolResultblocks are present).session_repair.rspipeline or the driver translation layers.Suggested Code Change in
crates/librefang-runtime/src/compactor.rsIn
aggregate_developer_loops:Verification & Proof (Failing Unit Test)
We wrote a unit test simulating a realistic scenario (a User request, a read-only file read loop, a developer mutating loop, and an assistant final response) to verify if the consecutive
Role::Usermessages are indeed present after compaction and session repair.Test Output
Running this test yields a panic indicating consecutive
Usermessages:This confirms the issue and highlights the vulnerability in the current developer-loop aggregation strategy.