Skip to content

fix: conversational events - client side tool event responses should be queued#1760

Open
norman-le wants to merge 8 commits into
mainfrom
chore/client-side-tools-queue-client-events
Open

fix: conversational events - client side tool event responses should be queued#1760
norman-le wants to merge 8 commits into
mainfrom
chore/client-side-tools-queue-client-events

Conversation

@norman-le

@norman-le norman-le commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Problem

When the LLM calls multiple tools in one turn, the bridge stored each endToolCall/confirmToolCall response in a single variable, overwriting previous values. If responses arrived before wait_for_resume() consumed them, earlier values were lost — causing the exchange to hang.

Fix: _bridge.py

Replaced single-slot storage with keyed dict matching using 3 data structures:

  1. _expected_tool_call_ids (deque) — ordered queue of tool_call_ids, populated by emit_interrupt_event(), consumed by wait_for_resume() to know which response to match
  2. _tool_resume_results (dict) — responses that arrived before wait_for_resume() was called; checked first for immediate return
  3. _tool_resume_pending (dict of Futures) — created by wait_for_resume() when response hasn't arrived yet; resolved by _resolve_or_store_resume() when it does

Flow: wait_for_resume pops the next expected ID from the deque → checks results (already arrived?) → if yes, return it → if no, create a pending Future → _resolve_or_store_resume resolves it when the response finally comes in.

Key methods changed:

  • emit_interrupt_event() — now registers the trigger's tool_call_id in the deque (still no websocket emit, just using it here since it's naturally passed through with the known tool call ID)
  • wait_for_resume() — pops expected ID from deque, checks results dict, falls back to Future
  • _resolve_or_store_resume() — new helper that routes incoming responses to the correct Future or stores them
  • _handle_conversation_event() — calls _resolve_or_store_resume() instead of setting a single variable

Tests: test_bridge.py

  • Fixed 3 existing tests to register expected tool_call_ids
  • Added 5 concurrent scenario tests (all early, out of order, mixed, confirm+end)
  • Added 8 edge case tests (empty deque, duplicates, malformed events, cleanup)
  • 78 tests total, all passing

Not changed

  • UiPathChatProtocol — same interface
  • runtime.py — untouched

Still works for client side tools, tool confirmation and server side tools:

Screen.Recording.2026-06-24.at.1.31.04.PM.mov

From our react-sdk, there's a natural gap, so to emulate what a client can do I buffered the client side tool responses to be received at the same time in the runtime/bridge before the resume function is called for the interrupts (lines 22-33 in my log file). Can see how the logs were used in the first commit of the PR: 53303a4

Screen.Recording.2026-06-24.at.11.27.56.AM.mov

Copilot AI review requested due to automatic review settings June 24, 2026 17:54
@github-actions github-actions Bot added test:uipath-langchain Triggers tests in the uipath-langchain-python repository test:uipath-integrations labels Jun 24, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the conversational chat bridge so tool resume responses (confirmToolCall / endToolCall) are matched and queued by tool_call_id, preventing earlier responses from being overwritten when multiple tools are invoked in a single LLM turn.

Changes:

  • Reworked SocketIOChatBridge tool-resume handling to track expected tool call IDs and route resume events to the correct waiter.
  • Updated/added bridge tests to register expected tool_call_ids and validate concurrent/out-of-order resume behavior.
  • Bumped package version to 2.11.13 and refreshed uv.lock.

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 4 comments.

File Description
packages/uipath/src/uipath/_cli/_chat/_bridge.py Implements queued/keyed tool resume matching using a deque + results/pending structures.
packages/uipath/tests/cli/chat/test_bridge.py Updates tests for new registration/matching behavior and adds concurrency scenarios.
packages/uipath/pyproject.toml Version bump to 2.11.13.
packages/uipath/uv.lock Lockfile refresh reflecting version and timestamp changes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/uipath/src/uipath/_cli/_chat/_bridge.py
Comment thread packages/uipath/src/uipath/_cli/_chat/_bridge.py
Comment thread packages/uipath/src/uipath/_cli/_chat/_bridge.py
Comment thread packages/uipath/tests/cli/chat/test_bridge.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.

Comment thread packages/uipath/src/uipath/_cli/_chat/_bridge.py
Comment thread packages/uipath/src/uipath/_cli/_chat/_bridge.py
Comment thread packages/uipath/tests/cli/chat/test_bridge.py
@github-actions

Copy link
Copy Markdown

🚨 Heads up: uipath-langchain cross-tests are FAILING 🚨

Your changes may break the uipath-langchain-python integration.

⚠️ These checks are NOT enforced by branch protection rules. Please review the failures before merging.

🔍 Inspect the failed run →

@sonarqubecloud

Copy link
Copy Markdown

Comment on lines +444 to +456
"""Wait for a tool resume event (confirmToolCall or endToolCall).

Pops the next expected tool_call_id (registered by emit_interrupt_event)
and returns the matching response. Two cases:

1. Response already arrived (stored in _tool_resume_results) — return
immediately without blocking.
2. Response hasn't arrived yet — create a Future in _tool_resume_pending,
block until _handle_conversation_event resolves it.

Returns:
The resume data dict, including ``tool_call_id``.
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice! Thanks for the detailed flow + demoes.

Two questions:

  • In the second demo, if you didn't submit the tool results in order (e.g. instead of 1,2,3,4,5 you did 3,5,1,4,2) would it work?
  • More of a design question, let me know if I'm misunderstanding anything: Any reason we need to dequeue just the next expected tool_call_id? If we just had a data-structure list with all N tool-calls, could we simplify by whenever a tool-resume event occurs, checking all the tool-call-ids in that list to see matches?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good questions.

  1. Yes, it works out of order. Responses get stored in _tool_resume_results by tool_call_id regardless of arrival order. When wait_for_resume() runs, it looks up by the expected ID, not by arrival order. The test test_concurrent_tool_calls_out_of_order tests this.

  2. The deque exists because wait_for_resume() has no parameter — it doesn't know which tool_call_id to return. The runtime calls it N times in a sequential loop:
    https://github.com/UiPath/uipath-runtime-python/blob/2b45546dbeb249541869a4fc227cac0839d51ca5/src/uipath/runtime/chat/runtime.py#L106

for trigger in api_triggers:
    emit_interrupt_event(trigger)     # "I'm about to wait for toolcall-3"
    resume_data = wait_for_resume()   # "give me toolcall-3's result"
    resume_map[trigger.interrupt_id] = resume_data

Each call must return the result for that specific trigger. Without the deque, wait_for_resume() could return any available result and pair it with the wrong trigger (e.g. assign toolcall-1's result to toolcall-3, etc.)

One option was to pass tool_call_id as a parameter to wait_for_resume(tool_call_id) — then you'd only need the results/pending dicts, no deque. But that requires changing the UiPathChatProtocol, which I wanted to avoid. The deque bridges that gap without a protocol change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense, thanks!

@maxduu maxduu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall I think I understand? But let's make sure to test all the edge-cases and left 2 questions here

Some other edge-cases I can think of:

  • Parallel tool-calls with some client-side tools and others including/not-including tool-call confirmations (looks like you tested these already)
  • Parallel tool-calls where some tools are Orchestrator process tools which do the suspend/resume - will your data-structures persist?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:uipath-integrations test:uipath-langchain Triggers tests in the uipath-langchain-python repository

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants