feat: real-time tool call streaming indicator with blinking UI

- Add ToolParsingHint enum (Detected/Active/Complete) for UI feedback
- New UiWriter methods: print_tool_streaming_hint(), print_tool_streaming_active()
- Refactor ConsoleUiWriter state to use atomics in ParsingHintState
- Add tool_call_streaming field to CompletionChunk for provider hints
- Anthropic provider sends streaming hints when tool name detected
- New streaming helpers: make_tool_streaming_hint(), make_tool_streaming_active()

Parser improvements:
- Add is_json_invalidated() to detect false positive tool patterns
- Fix tool result poisoning when file contents contain partial JSON
- Unescaped newlines in strings or prose after JSON invalidates detection

User sees ' ● tool_name |' immediately when tool call starts streaming,
with blinking indicator while args are received.
This commit is contained in:
Dhanji R. Prasanna
2026-01-15 12:11:44 +05:30
parent d68f059acf
commit 0ae1a13cdb
18 changed files with 271 additions and 42 deletions

View File

@@ -112,7 +112,7 @@ use tracing::{debug, error};
use crate::{
streaming::{
decode_utf8_streaming, make_final_chunk, make_final_chunk_with_reason, make_text_chunk,
make_tool_chunk,
make_tool_chunk, make_tool_streaming_active, make_tool_streaming_hint,
},
CompletionChunk, CompletionRequest, CompletionResponse, CompletionStream, LLMProvider, Message,
MessageRole, Tool, ToolCall, Usage,
@@ -512,6 +512,12 @@ impl AnthropicProvider {
} else {
// Arguments are empty, we'll accumulate them from partial_json
debug!("Tool call has empty args, will accumulate from partial_json");
// Send a streaming hint so the UI can show the tool name immediately
let hint_chunk = make_tool_streaming_hint(name.clone());
if tx.send(Ok(hint_chunk)).await.is_err() {
debug!("Receiver dropped, stopping stream");
return accumulated_usage;
}
current_tool_calls.push(tool_call);
partial_tool_json.clear();
}
@@ -550,6 +556,12 @@ impl AnthropicProvider {
"Accumulated tool JSON: {}",
partial_tool_json
);
// Send an active hint to trigger UI blink
let active_chunk = make_tool_streaming_active();
if tx.send(Ok(active_chunk)).await.is_err() {
debug!("Receiver dropped, stopping stream");
return accumulated_usage;
}
}
}
}

View File

@@ -494,6 +494,7 @@ impl DatabricksProvider {
usage: None,
tool_calls: None,
stop_reason: None,
tool_call_streaming: None,
};
if tx.send(Ok(text_chunk)).await.is_err() {
debug!("Receiver dropped");

View File

@@ -205,6 +205,8 @@ pub struct CompletionChunk {
pub usage: Option<Usage>, // Add usage tracking for streaming
/// Stop reason from the API (e.g., "end_turn", "max_tokens", "stop_sequence")
pub stop_reason: Option<String>,
/// Tool call currently being streamed (name only, for UI hint)
pub tool_call_streaming: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -158,6 +158,7 @@ impl OpenAIProvider {
tool_calls,
usage: accumulated_usage.clone(),
stop_reason: None, // TODO: Extract from OpenAI response
tool_call_streaming: None,
};
let _ = tx.send(Ok(final_chunk)).await;
}

View File

@@ -62,6 +62,7 @@ pub fn make_final_chunk(tool_calls: Vec<ToolCall>, usage: Option<Usage>) -> Comp
Some(tool_calls)
},
stop_reason: None,
tool_call_streaming: None,
}
}
@@ -77,6 +78,7 @@ pub fn make_final_chunk_with_reason(tool_calls: Vec<ToolCall>, usage: Option<Usa
Some(tool_calls)
},
stop_reason,
tool_call_streaming: None,
}
}
@@ -88,6 +90,7 @@ pub fn make_text_chunk(content: String) -> CompletionChunk {
usage: None,
tool_calls: None,
stop_reason: None,
tool_call_streaming: None,
}
}
@@ -99,5 +102,31 @@ pub fn make_tool_chunk(tool_calls: Vec<ToolCall>) -> CompletionChunk {
usage: None,
tool_calls: Some(tool_calls),
stop_reason: None,
tool_call_streaming: None,
}
}
/// Create a hint chunk indicating a tool call is being streamed.
pub fn make_tool_streaming_hint(tool_name: String) -> CompletionChunk {
CompletionChunk {
content: String::new(),
finished: false,
usage: None,
tool_calls: None,
stop_reason: None,
tool_call_streaming: Some(tool_name),
}
}
/// Create a hint chunk indicating a tool call is still actively streaming.
/// This is used to trigger UI updates (like blinking indicators) during long tool calls.
pub fn make_tool_streaming_active() -> CompletionChunk {
CompletionChunk {
content: String::new(),
finished: false,
usage: None,
tool_calls: None,
stop_reason: None,
tool_call_streaming: Some(String::new()), // Empty string signals "active" vs "detected"
}
}