Skip to content

Commit 0b4f554

Browse files
committed
fix: runtime execution stream
1 parent 97e3fc2 commit 0b4f554

File tree

2 files changed

+89
-68
lines changed

2 files changed

+89
-68
lines changed

README.md

Lines changed: 80 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ Runtime abstractions and contracts for the UiPath Python SDK.
77

88
## Overview
99

10-
`uipath-runtime` provides the foundational interfaces and base classes for building agent runtimes in the UiPath ecosystem.
11-
It defines the contracts that all runtime implementations must follow and provides utilities for execution context, event streaming, tracing, and structured error handling.
10+
`uipath-runtime` provides the foundational interfaces and base contracts for building agent runtimes in the UiPath ecosystem.
11+
It defines the protocols that all runtime implementations must follow and provides utilities for execution context, event streaming, tracing, and structured error handling.
1212

1313
This package is typically used as a dependency by higher-level SDKs such as:
1414
- [`uipath-langchain`](https://pypi.org/project/uipath-langchain)
@@ -24,20 +24,21 @@ You would use this directly only if you're building custom runtime implementatio
2424
uv add uipath-runtime
2525
```
2626

27-
## Runtime Base Class
27+
## Runtime Protocols
2828

29-
All runtimes inherit from `UiPathBaseRuntime` and implement these core methods:
29+
All runtimes implement the `UiPathRuntimeProtocol` (or one of its sub-protocols):
3030

3131
- `get_schema()` — defines input and output JSON schemas.
3232
- `execute(input, options)` — executes the runtime logic and returns a `UiPathRuntimeResult`.
3333
- `stream(input, options)` — optionally streams runtime events for real-time monitoring.
34-
- `cleanup()` — releases resources.
34+
- `dispose()` — releases resources when the runtime is no longer needed.
35+
36+
Any class that structurally implements these methods satisfies the protocol.
3537

3638
```python
3739
from typing import Any, AsyncGenerator, Optional
40+
3841
from uipath.runtime import (
39-
UiPathBaseRuntime,
40-
UiPathRuntimeContext,
4142
UiPathRuntimeResult,
4243
UiPathRuntimeStatus,
4344
UiPathRuntimeSchema,
@@ -46,11 +47,10 @@ from uipath.runtime import (
4647
UiPathStreamOptions,
4748
)
4849
from uipath.runtime.events import UiPathRuntimeStateEvent
49-
from uipath.runtime.errors import UiPathRuntimeError, UiPathErrorCode, UiPathErrorCategory
5050

5151

52-
class MyCustomRuntime(UiPathBaseRuntime):
53-
"""Example runtime demonstrating the base runtime interface."""
52+
class MyRuntime:
53+
"""Example runtime implementing the UiPath runtime protocols."""
5454

5555
async def get_schema(self) -> UiPathRuntimeSchema:
5656
return UiPathRuntimeSchema(
@@ -73,7 +73,7 @@ class MyCustomRuntime(UiPathBaseRuntime):
7373
) -> UiPathRuntimeResult:
7474
message = (input or {}).get("message", "")
7575
return UiPathRuntimeResult(
76-
output={"result": f"Echo: {message}"},
76+
output={'message': 'Hello from MyRuntime'},
7777
status=UiPathRuntimeStatus.SUCCESSFUL,
7878
)
7979

@@ -88,7 +88,7 @@ class MyCustomRuntime(UiPathBaseRuntime):
8888
status=UiPathRuntimeStatus.SUCCESSFUL,
8989
)
9090

91-
async def cleanup(self) -> None:
91+
async def dispose(self) -> None:
9292
pass
9393
```
9494

@@ -98,6 +98,12 @@ class MyCustomRuntime(UiPathBaseRuntime):
9898
Runtimes can optionally emit real-time events during execution:
9999

100100
```python
101+
from uipath.runtime.events import (
102+
UiPathRuntimeStateEvent,
103+
UiPathRuntimeMessageEvent,
104+
)
105+
from uipath.runtime.result import UiPathRuntimeResult
106+
101107
async for event in runtime.stream({"query": "hello"}):
102108
if isinstance(event, UiPathRuntimeStateEvent):
103109
print(f"State update: {event.payload}")
@@ -138,32 +144,41 @@ Resulting JSON contract:
138144

139145
## Runtime Factory
140146

141-
`UiPathRuntimeFactory` provides a consistent way to create and manage runtime instances.
147+
`UiPathRuntimeFactoryProtocol` provides a consistent contract for discovering and creating runtime instances.
142148

143149
Factories decouple runtime construction (configuration, dependencies) from runtime execution, allowing orchestration, discovery, reuse, and tracing across multiple types of runtimes.
144150

145151
```python
146-
from uipath.runtime import UiPathBaseRuntime, UiPathRuntimeFactory
152+
from typing import Any, AsyncGenerator, Optional
147153

148-
class MyRuntime(UiPathBaseRuntime):
149-
async def execute(self):
150-
return {"message": f"Hello from {self.__class__.__name__}"}
154+
from uipath.runtime import (
155+
UiPathRuntimeResult,
156+
UiPathRuntimeStatus,
157+
UiPathRuntimeSchema,
158+
UiPathExecuteOptions,
159+
UiPathStreamOptions,
160+
UiPathRuntimeProtocol,
161+
UiPathRuntimeFactoryProtocol
162+
)
151163

152-
class MyRuntimeFactory(UiPathRuntimeFactory[MyRuntime]):
153-
def new_runtime(self, entrypoint: str) -> MyRuntime:
164+
class MyRuntimeFactory:
165+
async def new_runtime(self, entrypoint: str) -> UiPathRuntimeProtocol:
154166
return MyRuntime()
155167

156-
def discover_runtimes(self) -> list[MyRuntime]:
168+
def discover_runtimes(self) -> list[UiPathRuntimeProtocol]:
169+
return []
170+
171+
def discover_entrypoints(self) -> list[str]:
157172
return []
158173

159-
# Usage
174+
160175
factory = MyRuntimeFactory()
161-
runtime = factory.new_runtime("example")
176+
runtime = await factory.new_runtime("example")
162177

163178
result = await runtime.execute()
164-
print(result) # {'message': 'Hello from MyRuntime'}
165-
179+
print(result.output) # {'message': 'Hello from MyRuntime'}
166180
```
181+
167182
## Execution Context
168183

169184
`UiPathRuntimeContext` manages configuration, file I/O, and logs across runtime execution.
@@ -220,7 +235,7 @@ from uipath.core import UiPathTraceManager
220235
from uipath.runtime import UiPathExecutionRuntime
221236

222237
trace_manager = UiPathTraceManager()
223-
runtime = MyCustomRuntime()
238+
runtime = MyRuntime()
224239
executor = UiPathExecutionRuntime(
225240
runtime,
226241
trace_manager,
@@ -229,41 +244,37 @@ executor = UiPathExecutionRuntime(
229244
)
230245

231246
result = await executor.execute({"message": "hello"})
232-
spans = trace_manager.get_execution_spans("exec-123") # captured spans
233-
logs = executor.log_handler.buffer # captured logs
234-
print(result.output) # {'result': 'Echo: hello'}
247+
spans = trace_manager.get_execution_spans("exec-123") # captured spans
248+
logs = executor.log_handler.buffer # captured logs
249+
print(result.output) # {'message': 'Hello from MyRuntime'}
235250
```
236251

237252
## Example: Runtime Orchestration
238253

239-
This example demonstrates an **orchestrator runtime** that receives a `UiPathRuntimeFactory`, creates child runtimes through it, and executes each one via `UiPathExecutionRuntime`, all within a single shared `UiPathTraceManager` and `UiPathRuntimeContext`.
254+
This example demonstrates an **orchestrator runtime** that receives a `UiPathRuntimeFactoryProtocol`, creates child runtimes through it, and executes each one via `UiPathExecutionRuntime`, all within a single shared `UiPathTraceManager`.
240255

241256
<details>
242257
<summary>Orchestrator Runtime</summary>
243258

244259
```python
245-
from typing import Any, Optional, TypeVar, Generic
260+
from typing import Any, Optional, AsyncGenerator
246261

247262
from uipath.core import UiPathTraceManager
248263
from uipath.runtime import (
249-
UiPathRuntimeContext,
250-
UiPathBaseRuntime,
251264
UiPathExecutionRuntime,
252265
UiPathRuntimeResult,
253266
UiPathRuntimeStatus,
254267
UiPathExecuteOptions,
255-
UiPathRuntimeFactory,
268+
UiPathStreamOptions,
269+
UiPathRuntimeProtocol,
270+
UiPathRuntimeFactoryProtocol
256271
)
257272

258273

259-
class ChildRuntime(UiPathBaseRuntime):
274+
class ChildRuntime:
260275
"""A simple child runtime that echoes its name and input."""
261276

262-
def __init__(
263-
self,
264-
name: str,
265-
):
266-
super().__init__()
277+
def __init__(self, name: str):
267278
self.name = name
268279

269280
async def get_schema(self):
@@ -283,29 +294,38 @@ class ChildRuntime(UiPathBaseRuntime):
283294
status=UiPathRuntimeStatus.SUCCESSFUL,
284295
)
285296

286-
async def cleanup(self) -> None:
297+
async def stream(
298+
self,
299+
input: Optional[dict[str, Any]] = None,
300+
options: Optional[UiPathStreamOptions] = None,
301+
) -> AsyncGenerator[UiPathRuntimeResult, None]:
302+
yield await self.execute(input, options)
303+
304+
async def dispose(self) -> None:
287305
pass
288306

289307

290-
class ChildRuntimeFactory(UiPathRuntimeFactory[ChildRuntime]):
308+
class ChildRuntimeFactory:
291309
"""Factory that creates ChildRuntime instances."""
292310

293-
def new_runtime(self, entrypoint: str) -> ChildRuntime:
311+
async def new_runtime(self, entrypoint: str) -> UiPathRuntimeProtocol:
294312
return ChildRuntime(name=entrypoint)
295313

296-
def discover_runtimes(self) -> list[ChildRuntime]:
314+
def discover_runtimes(self) -> list[UiPathRuntimeProtocol]:
315+
return []
316+
317+
def discover_entrypoints(self) -> list[str]:
297318
return []
298319

299320

300-
class OrchestratorRuntime(UiPathBaseRuntime):
321+
class OrchestratorRuntime:
301322
"""A runtime that orchestrates multiple child runtimes via a factory."""
302323

303324
def __init__(
304325
self,
305-
factory: UiPathRuntimeFactory[ChildRuntime],
326+
factory: UiPathRuntimeFactoryProtocol,
306327
trace_manager: UiPathTraceManager,
307328
):
308-
super().__init__()
309329
self.factory = factory
310330
self.trace_manager = trace_manager
311331

@@ -323,10 +343,10 @@ class OrchestratorRuntime(UiPathBaseRuntime):
323343

324344
for i, child_input in enumerate(child_inputs):
325345
# Use the factory to create a new child runtime
326-
child_runtime = self.factory.new_runtime(entrypoint=f"child-{i}")
346+
child_runtime = await self.factory.new_runtime(entrypoint=f"child-{i}")
327347

328348
# Wrap child runtime with tracing + logs
329-
execution_id = f"{self.context.job_id}-child-{i}" if self.context else f"child-{i}"
349+
execution_id = f"child-{i}"
330350
executor = UiPathExecutionRuntime(
331351
delegate=child_runtime,
332352
trace_manager=self.trace_manager,
@@ -336,11 +356,10 @@ class OrchestratorRuntime(UiPathBaseRuntime):
336356

337357
# Execute child runtime
338358
result = await executor.execute(child_input, options=options)
339-
340359
child_results.append(result.output or {})
341-
child_spans = trace_manager.get_execution_spans(execution_id)
342-
343-
await child_runtime.cleanup()
360+
child_spans = trace_manager.get_execution_spans(execution_id) # Captured spans
361+
# Dispose the child runtime when finished
362+
await child_runtime.dispose()
344363

345364
return UiPathRuntimeResult(
346365
output={
@@ -350,7 +369,14 @@ class OrchestratorRuntime(UiPathBaseRuntime):
350369
status=UiPathRuntimeStatus.SUCCESSFUL,
351370
)
352371

353-
async def cleanup(self) -> None:
372+
async def stream(
373+
self,
374+
input: Optional[dict[str, Any]] = None,
375+
options: Optional[UiPathStreamOptions] = None,
376+
) -> AsyncGenerator[UiPathRuntimeResult, None]:
377+
yield await self.execute(input, options)
378+
379+
async def dispose(self) -> None:
354380
pass
355381

356382

@@ -361,7 +387,7 @@ async def main() -> None:
361387
options = UiPathExecuteOptions()
362388

363389
with UiPathRuntimeContext(job_id="main-job-001") as ctx:
364-
runtime = OrchestratorRuntime(factory=factory, trace_manager=trace_manager, context=ctx)
390+
runtime = OrchestratorRuntime(factory=factory, trace_manager=trace_manager)
365391

366392
input_data = {
367393
"children": [
@@ -384,4 +410,3 @@ async def main() -> None:
384410
```
385411

386412
</details>
387-

src/uipath/runtime/base.py

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Base runtime class and async context manager implementation."""
22

33
import logging
4-
import typing
54
from typing import (
65
Any,
76
AsyncGenerator,
@@ -53,19 +52,19 @@ class UiPathStreamOptions(UiPathExecuteOptions):
5352
pass
5453

5554

56-
class UiPathExecutableProtocol(typing.Protocol):
55+
class UiPathExecutableProtocol(Protocol):
5756
"""UiPath execution interface."""
5857

5958
async def execute(
6059
self,
6160
input: Optional[dict[str, Any]] = None,
6261
options: Optional[UiPathExecuteOptions] = None,
6362
) -> UiPathRuntimeResult:
64-
"""Produce the agent output."""
63+
"""Execute the runtime with the given input and options."""
6564
...
6665

6766

68-
class UiPathStreamableProtocol(typing.Protocol):
67+
class UiPathStreamableProtocol(Protocol):
6968
"""UiPath streaming interface."""
7069

7170
async def stream(
@@ -75,9 +74,6 @@ async def stream(
7574
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
7675
"""Stream execution events in real-time.
7776
78-
This is an optional method that runtimes can implement to support streaming.
79-
If not implemented, only the execute() method will be available.
80-
8177
Yields framework-agnostic BaseEvent instances during execution,
8278
with the final event being UiPathRuntimeResult.
8379
@@ -87,8 +83,7 @@ async def stream(
8783
Final yield: UiPathRuntimeResult (or its subclass UiPathBreakpointResult)
8884
8985
Raises:
90-
UiPathStreamNotSupportedError: If the runtime doesn't support streaming
91-
RuntimeError: If execution fails
86+
UiPathRuntimeError: If execution fails
9287
9388
Example:
9489
async for event in runtime.stream():
@@ -107,12 +102,10 @@ async def stream(
107102
f"{self.__class__.__name__} does not implement streaming. "
108103
"Use execute() instead."
109104
)
110-
# This yield is unreachable but makes this a proper generator function
111-
# Without it, the function wouldn't match the AsyncGenerator return type
112105
yield
113106

114107

115-
class UiPathSchemaProtocol(typing.Protocol):
108+
class UiPathSchemaProtocol(Protocol):
116109
"""Contains runtime input and output schema."""
117110

118111
async def get_schema(self) -> UiPathRuntimeSchema:
@@ -123,7 +116,7 @@ async def get_schema(self) -> UiPathRuntimeSchema:
123116
...
124117

125118

126-
class UiPathDisposableProtocol(typing.Protocol):
119+
class UiPathDisposableProtocol(Protocol):
127120
"""UiPath disposable interface."""
128121

129122
async def dispose(self) -> None:
@@ -220,6 +213,9 @@ async def stream(
220213
):
221214
async for event in self.delegate.stream(input, options=options):
222215
yield event
216+
else:
217+
async for event in self.delegate.stream(input, options=options):
218+
yield event
223219
finally:
224220
self.trace_manager.flush_spans()
225221
if self.log_handler:

0 commit comments

Comments
 (0)