Addressing PR comments + fix Google Gemini tool schema compatibility#33
Addressing PR comments + fix Google Gemini tool schema compatibility#33
Conversation
Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/4bd8efe6-088e-4b25-b342-718e946f8616 Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
…larify () in mcp.rs Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/4bd8efe6-088e-4b25-b342-718e946f8616 Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/6360078b-d91a-4dc7-ad53-5ac9731492ed Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/6360078b-d91a-4dc7-ad53-5ac9731492ed Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
…te chars Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/6372ade1-2611-4191-b41e-3fc2cc0ffc3d Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/01101b1f-1d74-4909-93ce-54b627cbb9fc Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/01101b1f-1d74-4909-93ce-54b627cbb9fc Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
…e command Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/cd17f8d2-7623-44ce-abb2-e20c98e10342 Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
When moonshotai/kimi-k2.5 (or similar models) returns its internal
<|tool_calls_section_begin|> syntax inside the `content` field instead
of populating the `tool_calls` API field, the agent loop incorrectly
treats the response as a final text answer and streams the raw marker
text to the user.
Add `parse_kimi_tool_calls()` in src/llm.rs that:
- Detects the <|tool_calls_section_begin|> marker
- Parses each <|tool_call_begin|>...<|tool_call_end|> block into a
proper ToolCall struct (name from `functions.{name}:{index}`,
arguments from the JSON after <|tool_call_argument_begin|>)
- Falls back to `{}` for invalid JSON arguments
Apply the fallback in `chat_with_model()` after deserialisation: when
tool_calls is empty but content contains the marker, parse, replace
content with None, set tool_calls, and emit a tracing::warn! so the
condition is visible in logs.
Five new unit tests cover: single call, multiple calls, no markers,
invalid JSON fallback, and ID embedding the call index.
Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/0baad72a-3574-4838-bfdb-10b3b2f76965
Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
…ed responses The second LLM API call (chat_stream) made after the agentic loop's non-streaming call could silently return Ok(partial) when the SSE connection was dropped mid-stream (e.g. after an 11-minute kimi-k2.5 response). This caused Telegram to receive a truncated answer that was also persisted to memory as the final response. Fix: add LlmClient::stream_text() which pipes the already-complete content string through the mpsc channel in 30-char Unicode-safe chunks with 2ms inter-chunk delay, maintaining the progressive Telegram UX without any network call. Remove: chat_stream(), parse_sse_content(), StreamRequest, and their 7 associated tests (now dead code). futures_util::StreamExt import also removed. Add: 2 TDD tests for stream_text — sends_all_content and stops_when_receiver_dropped. Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/52d8cfd3-deb1-45fd-9118-7345eeb0eb7b Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
…m_text tests Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/52d8cfd3-deb1-45fd-9118-7345eeb0eb7b Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
|
Claude finished @chinkan's task in 3m 30s —— View job Code ReviewTodo List:
SummaryThe PR's stated fix — replacing the second Bundled features not mentioned in the PR description:
This is a common Copilot PR pattern — the agent stacks several related improvements in one branch. Each feature is individually reasonable, but the PR scope is much larger than the description suggests. Issues
|
There was a problem hiding this comment.
Pull request overview
This PR aims to prevent silently truncated Telegram replies by removing the post-hoc “replay via second LLM streaming call” approach and instead streaming the already-complete final content locally. It also adds several related platform/config enhancements (Telegram entity formatting, MCP HTTP transport support, query rewrite toggles, tool-name humanisation, and Kimi tool-call parsing).
Changes:
- Replace the “second LLM SSE streaming call” with local streaming of the already-complete response text (
LlmClient::stream_text), avoiding truncation on dropped streams. - Switch Telegram final message formatting to an entity-based pipeline (
markdown_to_entities+split_entities) while keeping intermediate edits as plain text. - Extend MCP configuration/connection to support Streamable HTTP servers, add query rewrite toggle support, and improve tool status/name formatting.
Reviewed changes
Copilot reviewed 12 out of 14 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/agent.rs | Streams final content via stream_text; adds query rewrite enablement logic; injects tool status into token stream. |
| src/llm.rs | Adds stream_text; removes SSE streaming; adds Kimi tool-call text parsing/fix-up. |
| src/platform/telegram.rs | Uses MarkdownV2 escaping for command replies; final flush now uses entity-based formatting + chunking. |
| src/utils/markdown_entities.rs | New Markdown→(plain text, Telegram entities) converter + splitter for Telegram length limits. |
| src/utils/telegram_markdown.rs | New MarkdownV2 escaping/conversion helpers (used for command responses via escape_text). |
| src/utils/mod.rs | Exposes the new utils modules. |
| src/platform/tool_notifier.rs | Adds friendly tool-name mapping, tool status line builder, and updates args preview formatting/tests. |
| src/mcp.rs | Adds HTTP transport path (Streamable HTTP) and refactors connect/register flow. |
| src/config.rs | Makes MCP command optional and adds url/auth_token; adds memory.query_rewriter_enabled. |
| src/bin/setup.rs | Allows parsing HTTP MCP fields in existing configs without breaking the setup wizard. |
| config.example.toml | Documents query rewriting and HTTP MCP server configuration examples. |
| README.md | Notes the new Markdown parsing dependency for Telegram entity formatting. |
| Cargo.toml | Adds pulldown-cmark and enables rmcp HTTP transport feature. |
| Cargo.lock | Locks new dependencies pulled in by pulldown-cmark and rmcp HTTP transport. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/platform/tool_notifier.rs
Outdated
| if let Some(rest) = label.strip_prefix("mcp_") { | ||
| // Find the server name — everything up to the second underscore-separated segment | ||
| if let Some(sep) = rest.find('_') { | ||
| let server = &rest[..sep]; | ||
| let func = &rest[sep + 1..]; |
There was a problem hiding this comment.
friendly_tool_name parses MCP tool names by splitting on the first _ after mcp_, but MCP tool names are constructed as mcp_{server_name}_{tool_name} and server_name can itself contain underscores. In that case the server/icon detection will be wrong and the function name humanisation will be applied to part of the server name. Consider parsing by matching against the configured/connected server names, or splitting from the right (last _) if tool names cannot contain underscores, or storing server/tool metadata alongside the tool log.
src/utils/markdown_entities.rs
Outdated
| // Inline code: emit as a Code entity | ||
| let start_utf16 = plain.encode_utf16().count(); | ||
| plain.push_str(&text); | ||
| let end_utf16 = plain.encode_utf16().count(); | ||
| let length = end_utf16 - start_utf16; |
There was a problem hiding this comment.
This repeatedly computes plain.encode_utf16().count() to get offsets/lengths. Because encode_utf16().count() scans the whole accumulated plain each time, the conversion can become O(n^2) on long LLM outputs. Consider tracking a running plain_utf16_len counter and updating it incrementally as you append text/newlines, so start/end offsets are O(1).
| TagEnd::Paragraph => { | ||
| // Add blank line after each paragraph | ||
| plain.push('\n'); | ||
| } |
There was a problem hiding this comment.
The comment says “Add blank line after each paragraph”, but only a single \n is appended here, which collapses Markdown paragraphs separated by a blank line into a single newline in plain. If the intent is to preserve paragraph breaks, append \n\n (or otherwise preserve the original spacing) and adjust the trailing-newline trimming accordingly.
src/utils/markdown_entities.rs
Outdated
| let split_utf16 = find_split_point( | ||
| text, | ||
| &char_utf16_boundaries, | ||
| chunk_utf16_start, | ||
| chunk_utf16_end_ideal, | ||
| ); | ||
|
|
There was a problem hiding this comment.
split_entities assumes find_split_point always returns a UTF-16 offset strictly greater than chunk_utf16_start. If max_utf16_len is smaller than a single character’s UTF-16 length (e.g. 1 with an emoji/surrogate pair), find_split_point can return start_utf16, causing chunk_utf16_start to never advance and the loop to spin forever. Add a guard to ensure progress (e.g. if split_utf16 == chunk_utf16_start, advance to the next char boundary).
| let split_utf16 = find_split_point( | |
| text, | |
| &char_utf16_boundaries, | |
| chunk_utf16_start, | |
| chunk_utf16_end_ideal, | |
| ); | |
| let mut split_utf16 = find_split_point( | |
| text, | |
| &char_utf16_boundaries, | |
| chunk_utf16_start, | |
| chunk_utf16_end_ideal, | |
| ); | |
| // Ensure progress even if `find_split_point` cannot find a boundary inside | |
| // the requested window (for example, when `max_utf16_len` is smaller than | |
| // a single character's UTF-16 length, such as an emoji/surrogate pair). | |
| if split_utf16 <= chunk_utf16_start { | |
| split_utf16 = char_utf16_boundaries | |
| .iter() | |
| .map(|&(_, utf16)| utf16) | |
| .find(|&utf16| utf16 > chunk_utf16_start) | |
| .unwrap_or(total_utf16); | |
| } |
| /// Command to run for stdio-based MCP servers (e.g. "uvx", "npx"). | ||
| /// Required for stdio servers; omit for HTTP servers. | ||
| #[serde(default)] | ||
| pub command: Option<String>, | ||
| #[serde(default)] |
There was a problem hiding this comment.
This PR’s title/description focuses on removing a second LLM streaming API call, but the changeset also introduces multiple additional features/behavior changes (e.g. MCP HTTP transport config fields, query rewriting toggle, Telegram entity-based formatting). Please either update the PR description to cover these scope expansions or split the unrelated changes into separate PRs to keep review/rollback risk manageable.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
Claude finished @chinkan's task in 2m 43s —— View job Code ReviewTodo List:
SummaryThe core fix — replacing Issues
|
|
@copilot apply changes based on the comments in this thread |
|
@claude[agent] fix all issue in your comments |
|
@chinkan Unfortunately I hit an unexpected error while processing your comment. I've automatically reported this to GitHub. You can ask me to try again later by mentioning me in a new comment. If you want to contact GitHub about this error, please mention the following identifier so they can better serve you: Sorry for the inconvenience! |
4 similar comments
|
@chinkan Unfortunately I hit an unexpected error while processing your comment. I've automatically reported this to GitHub. You can ask me to try again later by mentioning me in a new comment. If you want to contact GitHub about this error, please mention the following identifier so they can better serve you: Sorry for the inconvenience! |
|
@chinkan Unfortunately I hit an unexpected error while processing your comment. I've automatically reported this to GitHub. You can ask me to try again later by mentioning me in a new comment. If you want to contact GitHub about this error, please mention the following identifier so they can better serve you: Sorry for the inconvenience! |
|
@chinkan Unfortunately I hit an unexpected error while processing your comment. I've automatically reported this to GitHub. You can ask me to try again later by mentioning me in a new comment. If you want to contact GitHub about this error, please mention the following identifier so they can better serve you: Sorry for the inconvenience! |
|
@chinkan Unfortunately I hit an unexpected error while processing your comment. I've automatically reported this to GitHub. You can ask me to try again later by mentioning me in a new comment. If you want to contact GitHub about this error, please mention the following identifier so they can better serve you: Sorry for the inconvenience! |
…h spacing, infinite loop guard Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/e5ec895d-11cf-4fa9-a1a5-2640661e5955 Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
|
@copilot for google model, it has error when call tools: 2026-04-09T15:31:13.442829Z DEBUG rustfox::agent: Trying iteration 0: messages length: 2 use superpower to create plan to research deeply on web and context 7 and fix above issue |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 14 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| while plain.ends_with('\n') && plain_utf16_len > 0 { | ||
| plain.pop(); | ||
| plain_utf16_len -= 1; |
There was a problem hiding this comment.
The comment says this trims “at most two” trailing newlines, but the while plain.ends_with('\n') loop will remove all trailing newlines. Either cap the trim to two newlines or update the comment to match the current behavior (also consider whether trimming beyond two can remove intentional block spacing at EOF).
| while plain.ends_with('\n') && plain_utf16_len > 0 { | |
| plain.pop(); | |
| plain_utf16_len -= 1; | |
| for _ in 0..2 { | |
| if plain.ends_with('\n') && plain_utf16_len > 0 { | |
| plain.pop(); | |
| plain_utf16_len -= 1; | |
| } else { | |
| break; | |
| } |
| // Stream tool status into the Telegram message only when | ||
| // tool-progress notifications are enabled, to avoid | ||
| // prepending status lines to otherwise silent/final output. | ||
| if tool_event_tx.is_some() { | ||
| if let Some(ref tx) = stream_status_tx { | ||
| let status = | ||
| crate::platform::tool_notifier::format_tool_status_line( | ||
| &tool_call.function.name, | ||
| &args_preview, | ||
| ); | ||
| tx.try_send(status).ok(); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
When verbose mode is enabled, tool progress is already shown via ToolCallNotifier (separate “⏳ Working…” message). This additional streaming of tool status lines into the main response token stream will duplicate the tool UI and permanently prepend status lines to the final assistant message. Consider choosing a single mechanism (either keep the notifier message, or stream status into the main message and disable the notifier for that mode).
| // Stream tool status into the Telegram message only when | |
| // tool-progress notifications are enabled, to avoid | |
| // prepending status lines to otherwise silent/final output. | |
| if tool_event_tx.is_some() { | |
| if let Some(ref tx) = stream_status_tx { | |
| let status = | |
| crate::platform::tool_notifier::format_tool_status_line( | |
| &tool_call.function.name, | |
| &args_preview, | |
| ); | |
| tx.try_send(status).ok(); | |
| } | |
| } |
| const CHUNK_SIZE: usize = 30; | ||
|
|
||
| debug!( | ||
| url = %url, | ||
| model = %model, | ||
| message_count = messages.len(), | ||
| "Starting streaming request to OpenRouter" | ||
| ); | ||
| let chars: Vec<char> = content.chars().collect(); | ||
| let mut start = 0; | ||
|
|
||
| let response = self | ||
| .client | ||
| .post(&url) | ||
| .header("Authorization", format!("Bearer {}", self.config.api_key)) | ||
| .header("Content-Type", "application/json") | ||
| .header("Accept", "text/event-stream") | ||
| .json(&request) | ||
| .send() | ||
| .await | ||
| .context("Failed to send streaming request to OpenRouter")?; | ||
|
|
||
| let status = response.status(); | ||
| if !status.is_success() { | ||
| let error_body = response.text().await.unwrap_or_default(); | ||
| anyhow::bail!( | ||
| "OpenRouter streaming API error ({}): {}", | ||
| status, | ||
| error_body | ||
| ); | ||
| } | ||
| while start < chars.len() { | ||
| let end = (start + CHUNK_SIZE).min(chars.len()); | ||
| let chunk: String = chars[start..end].iter().collect(); | ||
| start = end; |
There was a problem hiding this comment.
stream_text collects the entire response into a Vec<char> before chunking, which duplicates memory and adds O(n) upfront cost for large responses. This can be chunked without allocating a char vector by iterating over char_indices() and slicing the original String on UTF-8 boundaries.
| // Known server names (with both hyphen and underscore variants) | ||
| // Sorted by length descending to match longest first (handles server names with underscores) | ||
| let known_servers = [ | ||
| ("google-workspace", "📧"), | ||
| ("google_workspace", "📧"), |
There was a problem hiding this comment.
The comment says known_servers is “Sorted by length descending to match longest first”, but the array isn’t actually sorted/enforced. Either sort it (e.g., build a vec and sort by name length) or drop/adjust the comment so future edits don’t rely on a property that isn’t guaranteed.
Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/b4300837-9cd9-4b0b-85b1-b3b64da49677 Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
Fixed in commit 1e79161. The root cause was Google AI Studio's strict JSON Schema validation: any field listed in Added
This is applied transparently in |
…pty required, anyOf null, recursive Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/68449364-dec2-4365-95fc-82932e6ca6cd Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
…hinking placeholder Issue 1: ToolCallNotifier::finish() now always deletes the status message so only the final LLM response remains visible. Issue 2: When verbose is off, a "⏳ Thinking..." placeholder is sent immediately and the stream handle edits it in-place with LLM tokens. Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/4984103d-598b-45f5-a692-d3368ef80c75 Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 14 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
src/platform/telegram.rs:310
- Seeding
current_msg_idwith the non-verbose placeholder interacts badly with the split branch: whenbuffer.len() > TELEGRAM_STREAM_SPLIT(likely before the first 500ms edit), the code sends a new message and overwritescurrent_msg_id, losing the placeholderMessageId. That can leave the original "⏳ Thinking..." message orphaned (never edited or deleted) and can also duplicate content across messages. Consider tracking the placeholder id separately and, on split, either (a) edit the current message to the chunk and then resetcurrent_msg_id = Nonefor the next chunk, or (b) delete the placeholder before sending the first real chunk, so the placeholder cannot remain visible.
let mut buffer = String::new();
// Seed current_msg_id with the placeholder so the first edit reuses it.
let mut current_msg_id: Option<teloxide::types::MessageId> = placeholder_msg_id;
let mut last_action = Instant::now();
let mut rx = stream_token_rx;
while let Some(token) = rx.recv().await {
buffer.push_str(&token);
// When buffer exceeds split threshold, send a NEW message and reset
if buffer.len() > TELEGRAM_STREAM_SPLIT {
match stream_bot.send_message(stream_chat_id, &buffer).await {
Ok(new_msg) => {
current_msg_id = Some(new_msg.id);
buffer.clear();
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/llm.rs
Outdated
| /// - `required: []` (empty array) is rejected; the key must be omitted entirely. | ||
| /// - `anyOf`/`oneOf`/`allOf` variants with `{"type": "null"}` are stripped because | ||
| /// Gemini does not support nullable types expressed as `null` union members. | ||
| /// If stripping removes all but one variant, the schema is inlined (unwrapped). |
There was a problem hiding this comment.
The doc comment says that if stripping nullable union members leaves only one anyOf/oneOf/allOf variant, the schema is "inlined (unwrapped)", but the implementation only removes the {"type":"null"} variants and never unwraps a single remaining variant. Either implement the unwrapping logic or adjust the comment to match the current behavior.
| /// If stripping removes all but one variant, the schema is inlined (unwrapped). |
| // Recurse into anyOf / oneOf / allOf variant schemas and strip null variants. | ||
| // Gemini does not support {"type": "null"} as a union member. | ||
| for key in &["anyOf", "oneOf", "allOf"] { | ||
| if let Some(variants) = obj.get_mut(*key) { | ||
| if let Some(arr) = variants.as_array_mut() { | ||
| // Recurse into each variant first. | ||
| for v in arr.iter_mut() { | ||
| sanitize_parameters(v); | ||
| } | ||
| // Remove variants that are purely {"type": "null"}. | ||
| arr.retain(|v| { | ||
| v.get("type") | ||
| .and_then(|t| t.as_str()) | ||
| .map(|t| t != "null") | ||
| .unwrap_or(true) | ||
| }); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
After removing {"type":"null"} variants from anyOf/oneOf/allOf, the array can become empty (e.g., if a schema was only [ {"type":"null"} ]). Leaving an empty union in the schema is typically invalid and may still trigger provider-side validation errors. Consider removing the key entirely when the variants array becomes empty (and possibly unwrapping when it becomes length 1).
| /// Given a `(byte_pos, cumulative_utf16)` table, convert a UTF-16 offset to a byte offset. | ||
| /// Returns the byte position of the first char whose UTF-16 start is >= `utf16_off`. | ||
| fn utf16_to_byte(boundaries: &[(usize, usize)], utf16_off: usize) -> usize { | ||
| match boundaries.binary_search_by_key(&utf16_off, |&(_, u)| u) { | ||
| Ok(idx) => boundaries[idx].0, | ||
| Err(idx) => { | ||
| // idx is the insertion point — the byte just before | ||
| if idx == 0 { | ||
| 0 | ||
| } else { | ||
| boundaries[idx - 1].0 | ||
| } | ||
| } |
There was a problem hiding this comment.
utf16_to_byte's doc comment says it returns the byte position of the first char whose UTF-16 start is >= utf16_off, but the Err(idx) branch returns boundaries[idx - 1].0 (the previous boundary, i.e. <= utf16_off). Either adjust the implementation to match the stated semantics or update the comment so future callers don’t rely on the wrong behavior.
|
@copilot apply changes based on the comments in this thread |
…o_byte doc Address PR review comments: - sanitize_parameters() now unwraps single-variant anyOf/oneOf/allOf into parent object and removes empty union arrays after stripping null variants - Update doc comment to match actual implementation - Fix utf16_to_byte doc comment to accurately describe Err branch behavior - Update tests to verify new unwrapping behavior Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/3507bae1-0633-4432-97cb-838823cd3e43 Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/3507bae1-0633-4432-97cb-838823cd3e43 Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
Applied all three review comments in
|
finish()in tool_notifier.rs to always delete the status message