Skip to content

Conversation

@ayushag-nv
Copy link
Contributor

@ayushag-nv ayushag-nv commented Aug 15, 2025

Overview:

This PR enables parsing of multiple tool calls present inside an array structure.

Details:

r#"<think>
Okay, the user is asking for the weather in San Francisco in Fahrenheit. Let me check the tools available.
</think>

<TOOLCALL>[{"name": "get_weather", "arguments": {"location": "San Francisco, CA", "unit": "fahrenheit"}}, {"name": "get_weather", "arguments": {"location": "New York, NY", "unit": "fahrenheit"}}]</TOOLCALL>"#;

Currently for these kind of tool calls only last one was considered. This PR enables parsing of multiple tool calls present in an array structure and enables end to end.

Where should the reviewer start?

Related Issues: (use one of the action keywords Closes / Fixes / Resolves / Relates to)

  • closes GitHub issue: #xxx

@ayushag-nv ayushag-nv requested a review from a team as a code owner August 15, 2025 22:17
@copy-pr-bot
Copy link

copy-pr-bot bot commented Aug 15, 2025

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions github-actions bot added the chore label Aug 15, 2025
@ayushag-nv ayushag-nv marked this pull request as draft August 15, 2025 22:18
@ayushag-nv ayushag-nv self-assigned this Aug 15, 2025
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 15, 2025

Walkthrough

Public APIs for tool-call parsing now return Vec instead of Option across JSON parser, parsers, and tools modules. Logic shifts from single-item extraction to collecting multiple tool calls. Aggregator updated to consume and set multiple tool calls per choice, adjusting logging and finish_reason handling accordingly.

Changes

Cohort / File(s) Summary
JSON tool-call parser
lib/llm/src/postprocessor/tool_calling/json_parser.rs
Return type changed to anyhow::Result<Vec>. Single inputs now yield a one-element Vec; list inputs are fully iterated and collected. Fallback returns empty Vec. Previously-last-item selection removed.
Parsers API and detection
lib/llm/src/postprocessor/tool_calling/parsers.rs
Public functions now return Vec. Added “default” parser mapping/selection. Callers must check is_empty() instead of is_none(). Tests updated for Vec semantics and multiple-call scenarios.
Tools conversion (aggregate/stream)
lib/llm/src/postprocessor/tool_calling/tools.rs
try_tool_call_parse_aggregate and try_tool_call_parse_stream now return vectors of tool calls/chunks. Streaming assigns incremental indices for all detected calls. Empty results return empty Vec.
OpenAI aggregator handling
lib/llm/src/protocols/openai/chat_completions/aggregator.rs
Aggregation path updated to accept multiple tool calls: assigns entire vector to choice.tool_calls, clears text, and sets finish_reason to ToolCalls when non-empty; logs each call. No public signature changes.

Sequence Diagram(s)

sequenceDiagram
  participant Model as LLM Output
  participant Parser as Parser(detector/json)
  participant Tools as Tools Mapper
  participant Aggregator as OpenAI Aggregator

  Model->>Parser: detect_and_parse_tool_call(message)
  Parser-->>Model: Vec<ToolCallResponse> (possibly empty)

  alt Non-empty
    Parser->>Tools: map to Vec<ChatCompletionMessageToolCall>
    Tools-->>Parser: Vec<ChatCompletionMessageToolCall>
    Parser->>Aggregator: tool_calls Vec
    Aggregator->>Aggregator: set choice.tool_calls, clear text, finish_reason=ToolCalls
  else Empty
    Parser-->>Aggregator: []
    Aggregator->>Aggregator: no changes
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

A whisk of code, a twitching ear,
One call became a chorus clear.
I hop through lists, collect them all,
Carrots counted, none too small. 🥕
Now tools arrive in tidy queues—
A rabbit’s work: more calls to choose!

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🔭 Outside diff range comments (1)
lib/llm/src/postprocessor/tool_calling/json_parser.rs (1)

54-95: Doc comments are stale: update to reflect Vec-return semantics and multi-item handling

The docs still describe Option-return and "last item only", which no longer matches the implementation and public API.

Apply this doc update:

 /// # Return
 ///
-/// - `Ok(Some(ToolCallResponse))` if parsing succeeds
-/// - `Ok(None)` if input format is unrecognized or invalid JSON
-/// - `Err(...)` if JSON is valid but deserialization or argument re-serialization fails
+/// - `Ok(Vec<ToolCallResponse>)` containing zero or more parsed tool calls
+///   - Empty vector if input is unrecognized/invalid for parsing
+/// - `Err(...)` only if argument re-serialization fails
 ///
 /// # Note on List Handling
 ///
-/// When the input contains a list of tool calls (either with `parameters` or `arguments`),
-/// only the **last item** in the list is returned. This design choice assumes that the
-/// most recent tool call in a list is the one to execute.
+/// When the input contains a list of tool calls (either with `parameters` or `arguments`),
+/// all valid items are returned in order.
 ///
 /// # Errors
 ///
-/// Returns a `Result::Err` only if an inner `serde_json::to_string(...)` fails
-/// (e.g., if the arguments are not serializable).
+/// Returns a `Result::Err` only if an inner `serde_json::to_string(...)` fails
+/// (e.g., if the arguments are not serializable).
 ///
 /// # Examples
 ///
 /// ```ignore
 /// let input = r#"<TOOLCALL>[{ "name": "search", "parameters": { "query": "rust" } }]</TOOLCALL>"#;
-/// let result = try_tool_call_parse_json(input)?;
-/// assert!(result.is_some());
+/// let result = try_tool_call_parse_json(input, &JsonParserConfig::default())?;
+/// assert_eq!(result.len(), 1);
 /// ```
🧹 Nitpick comments (10)
lib/llm/src/postprocessor/tool_calling/json_parser.rs (4)

107-111: Avoid panicking on config mismatch; return an error instead

Using assert! will panic at runtime. Prefer a controlled error to aid observability and avoid taking down the process.

-    assert!(
-        tool_call_start_tokens.len() == tool_call_end_tokens.len(),
-        "Tool call start and end tokens must have the same length"
-    );
+    if tool_call_start_tokens.len() != tool_call_end_tokens.len() {
+        anyhow::bail!("Tool call start and end tokens must have the same length");
+    }

172-179: Simplify collection of list items with iterator collect()

Current code builds a Vec and conditionally returns it. You can streamline and let collect() handle empty lists.

-        let mut results = Vec::new();
-        for item in list {
-            results.push(parse(item.name, item.parameters)?);
-        }
-        if !results.is_empty() {
-            return Ok(results);
-        }
+        return list
+            .into_iter()
+            .map(|item| parse(item.name, item.parameters))
+            .collect();

Also, the preceding comment still says “We pop the last item” which is now outdated. Consider updating it to “Collect all items”.


194-200: Mirror the iterator-based collection for arguments lists

Same refactor opportunity as the parameters-variant; also update the stale “take the last item” comment above.

-        let mut results = Vec::new();
-        for item in list {
-            results.push(parse(item.name, item.arguments)?);
-        }
-        if !results.is_empty() {
-            return Ok(results);
-        }
+        return list
+            .into_iter()
+            .map(|item| parse(item.name, item.arguments))
+            .collect();

112-126: Future enhancement: support multiple wrapper blocks, not just the last match

Even with array support, extract_tool_call_content() still takes the last match only (“TODO: Handle multiple tool calls”). If models emit multiple wrapper blocks (e.g., two <tool_call>...</tool_call>), earlier ones will be ignored.

I can propose an approach to return Vec<&str> from the extractor and iterate all blocks if/when you want to tackle this.

lib/llm/src/protocols/openai/chat_completions/aggregator.rs (2)

166-184: Handle multiple tool calls: LGTM; consider redacting/truncating arguments in logs

The multi-call handling (assign vector, clear text, set finish_reason) is correct. However, logging full arguments can leak sensitive data and blow up logs.

Truncate previews and log lengths instead of full payloads:

-                        for tool_call in &tool_calls {
-                            tracing::debug!(
-                                tool_call_id = %tool_call.id,
-                                function_name = %tool_call.function.name,
-                                arguments = %tool_call.function.arguments,
-                                "Parsed structured tool call from aggregated content"
-                            );
-                        }
+                        for tool_call in &tool_calls {
+                            let args = &tool_call.function.arguments;
+                            let preview = if args.len() > 1024 {
+                                format!("{}… (truncated, {} bytes)", &args[..1024], args.len())
+                            } else {
+                                args.clone()
+                            };
+                            tracing::debug!(
+                                tool_call_id = %tool_call.id,
+                                function_name = %tool_call.function.name,
+                                arguments_preview = %preview,
+                                arguments_len = args.len(),
+                                "Parsed structured tool call from aggregated content"
+                            );
+                        }

163-187: Add targeted tests for multi-call extraction to prevent regressions

Tests currently validate text aggregation only. Add a case where choice.text contains a wrapper with two tool calls and assert:

  • choice.message.tool_calls length is 2
  • choice.message.content is None
  • finish_reason == ToolCalls

I can provide a ready-to-run test if helpful.

lib/llm/src/postprocessor/tool_calling/tools.rs (2)

15-35: Unconditional map+collect simplifies control flow

The empty-vec branch is redundant; mapping an empty parsed already yields an empty vec.

-    let parsed = detect_and_parse_tool_call(message, parser_str)?;
-    if !parsed.is_empty() {
-        Ok(parsed
-            .into_iter()
-            .map(
-                |parsed| async_openai::types::ChatCompletionMessageToolCall {
-                    id: parsed.id,
-                    r#type: async_openai::types::ChatCompletionToolType::Function,
-                    function: async_openai::types::FunctionCall {
-                        name: parsed.function.name,
-                        arguments: parsed.function.arguments,
-                    },
-                },
-            )
-            .collect())
-    } else {
-        Ok(vec![])
-    }
+    let parsed = detect_and_parse_tool_call(message, parser_str)?;
+    Ok(parsed
+        .into_iter()
+        .map(|parsed| async_openai::types::ChatCompletionMessageToolCall {
+            id: parsed.id,
+            r#type: async_openai::types::ChatCompletionToolType::Function,
+            function: async_openai::types::FunctionCall {
+                name: parsed.function.name,
+                arguments: parsed.function.arguments,
+            },
+        })
+        .collect())

42-66: Same simplification applies to streaming variant

Enumerate + map is good; remove the conditional for cleaner code.

-    let parsed = detect_and_parse_tool_call(message, parser_str)?;
-    if !parsed.is_empty() {
-        Ok(parsed
-            .into_iter()
-            .enumerate()
-            .map(
-                |(idx, parsed)| async_openai::types::ChatCompletionMessageToolCallChunk {
-                    index: idx as u32,
-                    id: Some(parsed.id),
-                    r#type: Some(async_openai::types::ChatCompletionToolType::Function),
-                    function: Some(async_openai::types::FunctionCallStream {
-                        name: Some(parsed.function.name),
-                        arguments: Some(parsed.function.arguments),
-                    }),
-                    // Add other fields as needed if required by the struct definition
-                },
-            )
-            .collect())
-    } else {
-        Ok(vec![])
-    }
+    let parsed = detect_and_parse_tool_call(message, parser_str)?;
+    Ok(parsed
+        .into_iter()
+        .enumerate()
+        .map(|(idx, parsed)| async_openai::types::ChatCompletionMessageToolCallChunk {
+            index: idx as u32,
+            id: Some(parsed.id),
+            r#type: Some(async_openai::types::ChatCompletionToolType::Function),
+            function: Some(async_openai::types::FunctionCallStream {
+                name: Some(parsed.function.name),
+                arguments: Some(parsed.function.arguments),
+            }),
+        })
+        .collect())
lib/llm/src/postprocessor/tool_calling/parsers.rs (2)

256-268: Rename tests to match Vec semantics

Test names still say “returns_none…”, but the contract is now “empty Vec”. Renaming avoids confusion.

-    fn returns_none_on_invalid_input() {
+    fn returns_empty_vec_on_invalid_input() {
@@
-    fn returns_none_on_valid_json_wrong_shape() {
+    fn returns_empty_vec_on_valid_json_wrong_shape() {

430-453: Fix comments in error-handling test to reflect empty-Vec behavior

Comments still mention Ok(None). Update to Ok(empty Vec) to avoid drift.

-        // Known parser, but invalid input (not JSON) should return Ok(None)
+        // Known parser, but invalid input (not JSON) should return Ok(empty Vec)
@@
-        // Known parser, but valid JSON with wrong shape should return Ok(None)
+        // Known parser, but valid JSON with wrong shape should return Ok(empty Vec)
📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 922850a and 02ea64c.

📒 Files selected for processing (4)
  • lib/llm/src/postprocessor/tool_calling/json_parser.rs (4 hunks)
  • lib/llm/src/postprocessor/tool_calling/parsers.rs (21 hunks)
  • lib/llm/src/postprocessor/tool_calling/tools.rs (2 hunks)
  • lib/llm/src/protocols/openai/chat_completions/aggregator.rs (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (2)
lib/llm/src/postprocessor/tool_calling/tools.rs (1)
lib/llm/src/postprocessor/tool_calling/parsers.rs (1)
  • detect_and_parse_tool_call (136-157)
lib/llm/src/protocols/openai/chat_completions/aggregator.rs (1)
lib/llm/src/postprocessor/tool_calling/tools.rs (1)
  • try_tool_call_parse_aggregate (14-36)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Build and Test - dynamo
  • GitHub Check: pre-merge-rust (lib/runtime/examples)
  • GitHub Check: pre-merge-rust (lib/bindings/python)
  • GitHub Check: pre-merge-rust (.)
🔇 Additional comments (5)
lib/llm/src/postprocessor/tool_calling/json_parser.rs (2)

95-101: Signature change to Result<Vec<…>> looks good

Returning a Vec aligns the JSON parser with the rest of the tool-calling stack and enables multi-call support. Logging the effective config is also helpful for diagnostics.


150-164: Single-object branches correctly wrap in a Vec

The shift from Option to Vec with single-element vectors is consistent and avoids special-casing at call sites.

lib/llm/src/postprocessor/tool_calling/parsers.rs (3)

113-133: Vec-based return for try_tool_call_parse is consistent and future-proof

Dispatching to the JSON parser and returning Vec harmonizes the surface API and enables multiple tool calls. Clear error semantics for unimplemented formats look good.


136-157: Default parser fallback improves ergonomics

Defaulting to "default" when parser_str is None/empty is a nice usability improvement. Error message for unknown parsers is explicit.


170-176: Tests comprehensively cover single- and multi-call flows across configs

Good coverage for parameters vs arguments fields, wrapper tags, and default parser behavior. The array-of-calls tests validate the primary PR objective.

Also applies to: 184-189, 195-206, 209-220, 222-232, 235-254, 270-306, 308-320, 405-427, 513-535

Copy link
Contributor

@elyasmnvidian elyasmnvidian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good, please address coderabbit feedback

@ayushag-nv ayushag-nv force-pushed the ayushag/multi-tool-call-parsing branch from 5385993 to cfeb800 Compare August 18, 2025 15:35
@ayushag-nv ayushag-nv marked this pull request as ready for review August 18, 2025 15:41
@ayushag-nv ayushag-nv enabled auto-merge (squash) August 18, 2025 16:38
@ayushag-nv ayushag-nv merged commit 41f095c into main Aug 18, 2025
9 checks passed
@ayushag-nv ayushag-nv deleted the ayushag/multi-tool-call-parsing branch August 18, 2025 17:03
hhzhang16 pushed a commit that referenced this pull request Aug 27, 2025
Signed-off-by: Hannah Zhang <hannahz@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants