Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-runtime"
version = "0.11.0"
version = "0.12.0"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we can get away with a patch increase to avoid incrementing the ranges in all the repos

description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
7 changes: 6 additions & 1 deletion src/uipath/runtime/chat/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,20 @@ def __init__(
self,
delegate: UiPathRuntimeProtocol,
chat_bridge: UiPathChatProtocol,
end_exchange: bool = True,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I wouldn't add this here - the event needs to be emitted, whether you honor it or not, that should be in the bridge implementation

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@andrewwan-uipath uipath-runtime is a very low-level abstraction. CAS/Maestro-specific logic belongs in the implementation layer - it shouldn't be pushed down into the underlying abstractions.

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.

Makes sense, I will move this to the implementation layer

@maxduu maxduu Jun 17, 2026

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.

@cristipufu [Not-blocking] But wanted to ask:
My intuition was leaning towards the original approach Andrew had as well; can you elaborate on what you mean by "the exchange-event needs to be emitted" always? To me, emit_exchange_end_event() is already CAS specific - its a CAS-defined event that says "emit the event that marks the end of the current turn/exchange".

This new feature is to ensure the agent-runtime doesn't always end the exchange (so downstream agents/messages that have more messages to add can end the exchange themselves). This means that for Flow, we can call a conversational agent without having it emit end-exchange event, so that the chat-UI still shows "responding..." rather than that the turn was over.

so my intuition is saying to use the original approach where the runtime-layer doesn't call the emit_exchange_end_event function depending on if a flag is passed-in. Also was thinking it could avoid the weirdness that the function called emit_exchange_end_event but the bridge-implementation doesn't actually send the event conditionally.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Right now the runtime's event contract changes based on a constructor arg, sometimes it ends the exchange, sometimes it doesn't, so you can't reason about what events come out of the runtime without knowing how it was built. The "don't end the turn so the UI keeps showing responding…" logic is pure CAS/Flow orchestration concern, and we're pushing it down into a low-level abstraction that shouldn't know Flow exists.
The runtime calling emit_exchange_end_event() unconditionally and the bridge deciding what that means isn't weird, it's the layering working: runtime reports "my turn is done," bridge owns the CAS semantics of whether that translates to a terminal event on the wire. If Flow wants to suppress it, the bridge is the thing that knows that. The runtime doesn't.
So same outcome you want, Flow can keep the exchange open, just with the branch living in the layer that already owns the CAS protocol rather than as a flag on the generic runtime.

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.

Sounds good, thank you @cristipufu ! I guess its more of a naming thing then - emit_exchange_end_event should probably be changed to emit_turn_complete_event but its not a big issue and totally okay in my opinion.

I'm good to get this method (extracted to bridge layer) merged.

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.

):
"""Initialize the UiPathChatRuntime.

Args:
delegate: The underlying runtime to wrap
chat_bridge: Bridge for chat event communication
end_exchange: Whether to emit the exchange end event. When False, the exchange is left open so a
downstream consumer can continue it and end it later.
"""
super().__init__()
self.delegate = delegate
self.chat_bridge = chat_bridge
self.end_exchange = end_exchange

async def execute(
self,
Expand Down Expand Up @@ -167,7 +171,8 @@ async def stream(
else:
yield event
execution_completed = True
await self.chat_bridge.emit_exchange_end_event()
if self.end_exchange:
await self.chat_bridge.emit_exchange_end_event()
else:
yield event

Expand Down
5 changes: 5 additions & 0 deletions src/uipath/runtime/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ class UiPathRuntimeContext(BaseModel):
)
exchange_id: str | None = Field(None, description="Exchange identifier for CAS")
message_id: str | None = Field(None, description="Message identifier for CAS")
end_exchange: bool = Field(
True,
description="Whether to emit the exchange end event for CAS",
)
voice_mode: Literal["session"] | None = Field(
None, description="Voice job type for CAS"
)
Expand Down Expand Up @@ -364,6 +368,7 @@ def from_config(
"conversationalService.conversationId": "conversation_id",
"conversationalService.exchangeId": "exchange_id",
"conversationalService.messageId": "message_id",
"conversationalService.endExchange": "end_exchange",
"mcpServer.id": "mcp_server_id",
"mcpServer.slug": "mcp_server_slug",
"voice.mode": "voice_mode",
Expand Down
64 changes: 64 additions & 0 deletions tests/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,70 @@ async def test_chat_runtime_stream_yields_all_events():
assert cast(AsyncMock, bridge.emit_message_event).await_count == 2


@pytest.mark.asyncio
async def test_chat_runtime_emits_exchange_end_by_default():
"""Without end_exchange specified, exchange end is emitted."""

runtime_impl = StreamingMockRuntime(messages=["Hello"])
bridge = make_chat_bridge_mock()

chat_runtime = UiPathChatRuntime(
delegate=runtime_impl,
chat_bridge=bridge,
)

result = await chat_runtime.execute({})

await chat_runtime.dispose()

assert result.status == UiPathRuntimeStatus.SUCCESSFUL
cast(AsyncMock, bridge.emit_exchange_end_event).assert_awaited_once()


@pytest.mark.asyncio
async def test_chat_runtime_emits_exchange_end_when_end_exchange_true():
"""end_exchange=True emits the exchange end event."""

runtime_impl = StreamingMockRuntime(messages=["Hello"])
bridge = make_chat_bridge_mock()

chat_runtime = UiPathChatRuntime(
delegate=runtime_impl,
chat_bridge=bridge,
end_exchange=True,
)

result = await chat_runtime.execute({})

await chat_runtime.dispose()

assert result.status == UiPathRuntimeStatus.SUCCESSFUL
cast(AsyncMock, bridge.emit_exchange_end_event).assert_awaited_once()


@pytest.mark.asyncio
async def test_chat_runtime_skips_exchange_end_when_end_exchange_false():
"""end_exchange=False suppresses the exchange end event but completes normally."""

runtime_impl = StreamingMockRuntime(messages=["Hello", "World"])
bridge = make_chat_bridge_mock()

chat_runtime = UiPathChatRuntime(
delegate=runtime_impl,
chat_bridge=bridge,
end_exchange=False,
)

result = await chat_runtime.execute({})

await chat_runtime.dispose()

# Execution completes normally; only the exchange end emission is skipped
assert result.status == UiPathRuntimeStatus.SUCCESSFUL
assert cast(AsyncMock, bridge.emit_message_event).await_count == 2
cast(AsyncMock, bridge.emit_exchange_end_event).assert_not_awaited()


@pytest.mark.asyncio
async def test_chat_runtime_handles_errors():
"""On unexpected errors, UiPathChatRuntime should propagate them."""
Expand Down
32 changes: 32 additions & 0 deletions tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,38 @@ def test_from_config_loads_runtime_and_fps_properties(tmp_path: Path) -> None:
assert ctx.mcp_server_slug == "test-server-slug"


def test_from_config_maps_end_exchange_fps_property(tmp_path: Path) -> None:
"""conversationalService.endExchange should map onto end_exchange."""
cfg = {
"fpsProperties": {
"conversationalService.conversationId": "conv-123",
"conversationalService.exchangeId": "ex-456",
"conversationalService.endExchange": False,
}
}
config_path = tmp_path / "uipath.json"
config_path.write_text(json.dumps(cfg))

ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))

assert ctx.end_exchange is False


def test_end_exchange_defaults_true_when_fps_property_absent(tmp_path: Path) -> None:
"""end_exchange defaults to True (legacy behavior) when the fps key is missing."""
cfg = {
"fpsProperties": {
"conversationalService.conversationId": "conv-123",
}
}
config_path = tmp_path / "uipath.json"
config_path.write_text(json.dumps(cfg))

ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))

assert ctx.end_exchange is True


def test_result_file_written_on_faulted_trigger_error(tmp_path: Path) -> None:
runtime_dir = tmp_path / "runtime"
ctx = UiPathRuntimeContext(
Expand Down
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading