Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 83 additions & 16 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2313,27 +2313,94 @@ impl Agent {
})
.cloned()
.collect();
// When thinking arrived in an earlier stream chunk it was stored as
// a standalone thinking-only message; reuse that thinking on the
// tool-call messages and drop the standalone so it isn't duplicated.
let response_thinking = if direct_thinking.is_empty() {
let prior = messages_to_add.messages().iter().rposition(|m| {
m.role == response.role
&& !m.content.is_empty()
&& m.content.iter().all(|c| {
matches!(
// When thinking arrived in earlier stream chunks it was stored as
// standalone thinking-only messages; reuse that thinking on the
// tool-call messages and drop the standalone messages so the
// thinking isn't duplicated.
// Always accumulate ALL prior thinking — even when
// direct_thinking is non-empty (reasoning arrived on the same
// chunk as tool_calls) — because otherwise only the last chunk's
// reasoning ends up on split tool-call messages.
// Also extract thinking from mixed (thinking+text) messages,
// not just pure-thinking-only ones.
let mut accumulated_prior: Vec<MessageContent> = Vec::new();
let mut indices_to_remove: Vec<usize> = Vec::new();
for (idx, m) in messages_to_add.messages_mut().iter_mut().enumerate()
{
if m.role != response.role || m.content.is_empty() {
continue;
}
let thinking_only = m.content.iter().all(|c| {
matches!(
c,
MessageContent::Thinking(_)
| MessageContent::RedactedThinking(_)
)
});
let has_thinking = m.content.iter().any(|c| {
matches!(
c,
MessageContent::Thinking(_)
| MessageContent::RedactedThinking(_)
)
});
if has_thinking {
// Only accumulate thinking from messages that
// have not already been split into tool-call
// request_msg items — prior-split messages
// already carry their own thinking copy.
if !m.content.iter().any(|c| {
matches!(c, MessageContent::ToolRequest(_))
}) {
for c in &m.content {
if matches!(
c,
MessageContent::Thinking(_)
| MessageContent::RedactedThinking(_)
)
})
});
match prior {
Some(idx) => messages_to_add.remove(idx).content,
None => Vec::new(),
) {
accumulated_prior.push(c.clone());
}
}
}
}
} else {
if thinking_only {
indices_to_remove.push(idx);
} else if has_thinking
&& !m.content.iter().any(|c| {
matches!(c, MessageContent::ToolRequest(_))
})
{
// Strip thinking blocks from mixed text+thinking
// messages so the same signed/unsigned thinking is not
// duplicated when carried onto the tool-call request
// messages below. Messages that already contain tool
// requests are prior-split request_msg items whose
// thinking was already attached — stripping their
// thinking would leave only the last split message
// with reasoning, violating the signed-thinking
// dedup expectation that the first split message
// retains it.
m.content.retain(|c| {
!matches!(
c,
MessageContent::Thinking(_)
| MessageContent::RedactedThinking(_)
)
});
}
}
// Remove in reverse order to preserve indices
for idx in indices_to_remove.into_iter().rev() {
messages_to_add.remove(idx);
}
let response_thinking = if direct_thinking.is_empty() {
accumulated_prior
} else if accumulated_prior.is_empty() {
direct_thinking
} else {
let mut merged = accumulated_prior;
merged.extend(direct_thinking);
merged
};

for request in frontend_requests.iter().chain(remaining_requests.iter()) {
Expand Down
Loading