v1.5.0: Inline subagent panels, Bedrock compaction fixes, context window debug view
New features: - Inline subagent panels with expand/collapse and click-to-toggle - /context slash command to inspect bedrock_message_history - Child-to-parent question routing with auto-answer for subagents - Subagent token usage and cost merging into parent conversation - Randomized session-colored user avatar silhouettes Bug fixes: - Bedrock: remove orphaned tool_results after compaction - Bedrock: self-healing exchange lookup for out-of-order streaming - Bedrock: append continuation prompt when conversation ends with assistant - Duration sanity check rejects epoch-time artifacts from session restore - Cache hit rate calculation uses actual total_input_tokens - Hide "Time to first token" when value is zero Improvements: - Demote verbose bedrock-debug logs to debug/trace levels - Bedrock tool usage counting falls back to action counting - Remove logout menu item from workspace menu Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6f54e2cb30
commit
f278e53b7e
@@ -430,8 +430,155 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
|
||||
/// Call this on the combined (history + new input) messages before
|
||||
/// sending to `build_converse_request`.
|
||||
pub fn sanitize_messages_for_bedrock(messages: &mut Vec<ConversationMessage>) {
|
||||
remove_orphaned_tool_results(messages);
|
||||
ensure_starts_with_user_message(messages);
|
||||
ensure_tool_results_paired(messages);
|
||||
ensure_ends_with_user_message(messages);
|
||||
}
|
||||
|
||||
/// Bedrock requires the conversation to end with a user message.
|
||||
/// If the last message is an assistant message (e.g. after compaction),
|
||||
/// append a continuation prompt.
|
||||
fn ensure_ends_with_user_message(messages: &mut Vec<ConversationMessage>) {
|
||||
if messages.last().is_some_and(|m| m.role == MessageRole::Assistant) {
|
||||
log::info!("[bedrock] Appending continuation prompt (conversation ended with assistant message)");
|
||||
messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("Continue.".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes tool_result content that references tool_use IDs not present in any
|
||||
/// preceding assistant message. This happens after compaction when the history
|
||||
/// is replaced with a summary but the next request still carries tool_results
|
||||
/// from the old (now-discarded) exchanges.
|
||||
fn remove_orphaned_tool_results(messages: &mut Vec<ConversationMessage>) {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Collect all tool_use IDs from assistant messages.
|
||||
let mut valid_tool_use_ids = HashSet::new();
|
||||
for msg in messages.iter() {
|
||||
if msg.role == MessageRole::Assistant {
|
||||
collect_tool_use_ids_into(&msg.content, &mut valid_tool_use_ids);
|
||||
}
|
||||
}
|
||||
|
||||
if valid_tool_use_ids.is_empty() {
|
||||
// No tool_use in history — remove ALL tool_results from user messages.
|
||||
let before_count = messages.len();
|
||||
messages.retain(|msg| {
|
||||
if msg.role != MessageRole::User {
|
||||
return true;
|
||||
}
|
||||
!is_pure_tool_result(&msg.content)
|
||||
});
|
||||
|
||||
// Also strip tool_result parts from MultiPart user messages.
|
||||
for msg in messages.iter_mut() {
|
||||
if msg.role != MessageRole::User {
|
||||
continue;
|
||||
}
|
||||
strip_tool_result_parts(&mut msg.content);
|
||||
}
|
||||
|
||||
if messages.len() != before_count {
|
||||
log::info!(
|
||||
"[bedrock] Removed {} orphaned tool_result message(s) (no tool_use in history)",
|
||||
before_count - messages.len()
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove tool_results whose IDs aren't in valid_tool_use_ids.
|
||||
for msg in messages.iter_mut() {
|
||||
if msg.role != MessageRole::User {
|
||||
continue;
|
||||
}
|
||||
strip_orphaned_tool_result_parts(&mut msg.content, &valid_tool_use_ids);
|
||||
}
|
||||
|
||||
// Remove messages that became empty after stripping.
|
||||
messages.retain(|msg| !is_empty_content(&msg.content));
|
||||
}
|
||||
|
||||
fn is_pure_tool_result(content: &MessageContent) -> bool {
|
||||
matches!(content, MessageContent::ToolResult { .. })
|
||||
}
|
||||
|
||||
fn strip_tool_result_parts(content: &mut MessageContent) {
|
||||
if let MessageContent::MultiPart(parts) = content {
|
||||
parts.retain(|p| !matches!(p, ContentPart::ToolResult { .. }));
|
||||
if parts.len() == 1 {
|
||||
let part = parts.remove(0);
|
||||
*content = match part {
|
||||
ContentPart::Text(t) => MessageContent::Text(t),
|
||||
ContentPart::ToolUse { tool_use_id, name, input } => {
|
||||
MessageContent::ToolUse { tool_use_id, name, input }
|
||||
}
|
||||
ContentPart::ToolResult { tool_use_id, content: c, is_error } => {
|
||||
MessageContent::ToolResult { tool_use_id, content: c, is_error }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_orphaned_tool_result_parts(
|
||||
content: &mut MessageContent,
|
||||
valid_ids: &std::collections::HashSet<String>,
|
||||
) {
|
||||
match content {
|
||||
MessageContent::ToolResult { tool_use_id, .. } => {
|
||||
if !valid_ids.contains(tool_use_id) {
|
||||
*content = MessageContent::Text(String::new());
|
||||
}
|
||||
}
|
||||
MessageContent::MultiPart(parts) => {
|
||||
parts.retain(|p| match p {
|
||||
ContentPart::ToolResult { tool_use_id, .. } => valid_ids.contains(tool_use_id),
|
||||
_ => true,
|
||||
});
|
||||
if parts.len() == 1 {
|
||||
let part = parts.remove(0);
|
||||
*content = match part {
|
||||
ContentPart::Text(t) => MessageContent::Text(t),
|
||||
ContentPart::ToolUse { tool_use_id, name, input } => {
|
||||
MessageContent::ToolUse { tool_use_id, name, input }
|
||||
}
|
||||
ContentPart::ToolResult { tool_use_id, content: c, is_error } => {
|
||||
MessageContent::ToolResult { tool_use_id, content: c, is_error }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty_content(content: &MessageContent) -> bool {
|
||||
match content {
|
||||
MessageContent::Text(t) => t.is_empty(),
|
||||
MessageContent::MultiPart(parts) => parts.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_tool_use_ids_into(content: &MessageContent, ids: &mut std::collections::HashSet<String>) {
|
||||
match content {
|
||||
MessageContent::ToolUse { tool_use_id, .. } => {
|
||||
ids.insert(tool_use_id.clone());
|
||||
}
|
||||
MessageContent::MultiPart(parts) => {
|
||||
for p in parts {
|
||||
if let ContentPart::ToolUse { tool_use_id, .. } = p {
|
||||
ids.insert(tool_use_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_starts_with_user_message(messages: &mut Vec<ConversationMessage>) {
|
||||
|
||||
@@ -124,10 +124,10 @@ pub fn bedrock_stream_to_response_events(
|
||||
event_count += 1;
|
||||
match event {
|
||||
StreamEvent::MessageStart(_) => {
|
||||
log::info!("[bedrock-debug] Event #{event_count}: MessageStart");
|
||||
log::debug!("[bedrock] Event #{event_count}: MessageStart");
|
||||
}
|
||||
StreamEvent::ContentBlockStart(block_start) => {
|
||||
log::info!("[bedrock-debug] Event #{event_count}: ContentBlockStart");
|
||||
log::debug!("[bedrock] Event #{event_count}: ContentBlockStart");
|
||||
if let Some(start) = block_start.start() {
|
||||
match start {
|
||||
ContentBlockStart::ToolUse(tool_start) => {
|
||||
@@ -166,11 +166,11 @@ pub fn bedrock_stream_to_response_events(
|
||||
}
|
||||
}
|
||||
StreamEvent::ContentBlockDelta(delta) => {
|
||||
log::info!("[bedrock-debug] Event #{event_count}: ContentBlockDelta");
|
||||
log::trace!("[bedrock] Event #{event_count}: ContentBlockDelta");
|
||||
if let Some(d) = delta.delta() {
|
||||
match d {
|
||||
ContentBlockDelta::Text(text) => {
|
||||
log::info!("[bedrock-debug] Event #{event_count}: TextDelta ({} chars): {:?}", text.len(), &text[..text.len().min(80)]);
|
||||
log::debug!("[bedrock] TextDelta ({} chars)", text.len());
|
||||
history_text.push_str(text);
|
||||
if text_flushed {
|
||||
let msg_id = current_text_message_id.as_ref().unwrap();
|
||||
@@ -182,6 +182,10 @@ pub fn bedrock_stream_to_response_events(
|
||||
yield Ok(append);
|
||||
} else {
|
||||
buffered_text.push_str(text);
|
||||
// Buffer a few initial deltas so the first
|
||||
// AddMessagesToTask carries enough content for
|
||||
// the exchange to be fully registered before
|
||||
// subsequent AppendToMessageContent events arrive.
|
||||
if buffered_text.len() >= 1 {
|
||||
let msg_id = Uuid::new_v4().to_string();
|
||||
current_text_message_id = Some(msg_id.clone());
|
||||
@@ -209,7 +213,7 @@ pub fn bedrock_stream_to_response_events(
|
||||
}
|
||||
}
|
||||
StreamEvent::ContentBlockStop(_) => {
|
||||
log::info!("[bedrock-debug] Event #{event_count}: ContentBlockStop (tool_use_id={:?})", if current_tool_use_id.is_empty() { "none" } else { ¤t_tool_use_id });
|
||||
log::debug!("[bedrock] Event #{event_count}: ContentBlockStop (tool_use_id={:?})", if current_tool_use_id.is_empty() { "none" } else { ¤t_tool_use_id });
|
||||
if !current_tool_use_id.is_empty() {
|
||||
// Skip suggest_next_prompt — its executor hangs forever
|
||||
// waiting for UI interaction that doesn't exist in the
|
||||
@@ -382,7 +386,7 @@ pub fn bedrock_stream_to_response_events(
|
||||
&model_id,
|
||||
);
|
||||
log::info!(
|
||||
"[bedrock] Stream finished: model={model_id}, input_tokens={input_tokens}, output_tokens={output_tokens}, cache_read={cache_read_input_tokens}, cache_write={cache_write_input_tokens}, cost_cents={cost:.4}"
|
||||
"[bedrock] Stream finished: {event_count} events, model={model_id}, input_tokens={input_tokens}, output_tokens={output_tokens}, cache_read={cache_read_input_tokens}, cache_write={cache_write_input_tokens}, cost_cents={cost:.4}"
|
||||
);
|
||||
|
||||
// Build and store the assistant message into bedrock_messages_sent
|
||||
|
||||
Reference in New Issue
Block a user