From 99563b9b1b6b05f3ad937529d35fc6baa32eea37 Mon Sep 17 00:00:00 2001 From: Cristian Pufu Date: Mon, 10 Nov 2025 20:57:00 +0200 Subject: [PATCH] fix: use runtime factory protocol for entrypoints and schema --- src/uipath/dev/__init__.py | 1 + src/uipath/dev/_demo/mock_context_runtime.py | 240 +++++++++++++++ src/uipath/dev/_demo/mock_greeting_runtime.py | 131 ++++++++ src/uipath/dev/_demo/mock_numbers_runtime.py | 144 +++++++++ src/uipath/dev/_demo/mock_runtime.py | 280 +++--------------- src/uipath/dev/_demo/mock_support_runtime.py | 146 +++++++++ src/uipath/dev/ui/panels/new_run_panel.py | 248 ++++++++++------ 7 files changed, 871 insertions(+), 319 deletions(-) create mode 100644 src/uipath/dev/_demo/mock_context_runtime.py create mode 100644 src/uipath/dev/_demo/mock_greeting_runtime.py create mode 100644 src/uipath/dev/_demo/mock_numbers_runtime.py create mode 100644 src/uipath/dev/_demo/mock_support_runtime.py diff --git a/src/uipath/dev/__init__.py b/src/uipath/dev/__init__.py index 34d42e1..be896e8 100644 --- a/src/uipath/dev/__init__.py +++ b/src/uipath/dev/__init__.py @@ -83,6 +83,7 @@ def compose(self) -> ComposeResult: yield NewRunPanel( id="new-run-panel", classes="new-run-panel", + runtime_factory=self.runtime_factory, ) # Run details panel (initially hidden) diff --git a/src/uipath/dev/_demo/mock_context_runtime.py b/src/uipath/dev/_demo/mock_context_runtime.py new file mode 100644 index 0000000..6d71eac --- /dev/null +++ b/src/uipath/dev/_demo/mock_context_runtime.py @@ -0,0 +1,240 @@ +import asyncio +import logging +from typing import Any, AsyncGenerator, Optional + +from opentelemetry import trace +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeEvent, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.schema import UiPathRuntimeSchema + +ENTRYPOINT_CONTEXT = "agent/context.py:run" + +logger = logging.getLogger(__name__) + + +class MockContextRuntime: + """A mock runtime that simulates a multi-step workflow with rich telemetry.""" + + def __init__(self, entrypoint: str = ENTRYPOINT_CONTEXT) -> None: + self.entrypoint = entrypoint + self.tracer = trace.get_tracer("uipath.dev.mock.context") + + async def get_schema(self) -> UiPathRuntimeSchema: + return UiPathRuntimeSchema( + filePath=self.entrypoint, + uniqueId="mock-runtime", + type="agent", + input={ + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + }, + output={ + "type": "object", + "properties": {"result": {"type": "string"}}, + "required": ["result"], + }, + ) + + async def execute( + self, + input: Optional[dict[str, Any]] = None, + options: Optional[UiPathExecuteOptions] = None, + ) -> UiPathRuntimeResult: + payload = input or {} + + entrypoint = "mock-entrypoint" + message = str(payload.get("message", "")) + message_length = len(message) + + with self.tracer.start_as_current_span( + "mock-runtime.execute", + attributes={ + "uipath.runtime.name": "MockRuntime", + "uipath.runtime.type": "agent", + "uipath.runtime.entrypoint": entrypoint, + "uipath.input.message.length": message_length, + "uipath.input.has_message": "message" in payload, + }, + ) as root_span: + logger.info( + "MockRuntime: starting execution", + extra={ + "uipath.runtime.entrypoint": entrypoint, + }, + ) + print(f"[MockRuntime] Starting execution with payload={payload!r}") + + # Stage 1: Initialization + with self.tracer.start_as_current_span( + "initialize.environment", + attributes={ + "uipath.step.name": "initialize-environment", + "uipath.step.kind": "init", + }, + ): + logger.info("MockRuntime: initializing environment") + print("[MockRuntime] Initializing environment...") + await asyncio.sleep(0.5) + + # Stage 2: Validation + with self.tracer.start_as_current_span( + "validate.input", + attributes={ + "uipath.step.name": "validate-input", + "uipath.step.kind": "validation", + "uipath.input.has_message": "message" in payload, + }, + ) as validate_span: + logger.info("MockRuntime: validating input") + print("[MockRuntime] Validating input...") + await asyncio.sleep(0.5) + + if "message" not in payload: + logger.warning("MockRuntime: missing 'message' in payload") + validate_span.set_attribute( + "uipath.validation.missing_field", "message" + ) + + # Stage 3: Preprocessing + with self.tracer.start_as_current_span( + "preprocess.data", + attributes={ + "uipath.step.name": "preprocess-data", + "uipath.step.kind": "preprocess", + "uipath.input.size.bytes": len(str(payload).encode("utf-8")), + }, + ): + logger.info("MockRuntime: preprocessing data") + print("[MockRuntime] Preprocessing data...") + await asyncio.sleep(0.5) + + # Stage 4: Compute / reasoning + with self.tracer.start_as_current_span( + "compute.result", + attributes={ + "uipath.step.name": "compute-result", + "uipath.step.kind": "compute", + }, + ): + logger.info("MockRuntime: compute phase started") + print("[MockRuntime] Compute phase...") + + # Subtask: embedding computation + with self.tracer.start_as_current_span( + "compute.embeddings", + attributes={ + "uipath.step.name": "compute-embeddings", + "uipath.step.kind": "compute-subtask", + }, + ): + logger.info("MockRuntime: computing embeddings") + print("[MockRuntime] Computing embeddings...") + await asyncio.sleep(0.5) + + # Subtask: KB query + with self.tracer.start_as_current_span( + "query.knowledgebase", + attributes={ + "uipath.step.name": "query-knowledgebase", + "uipath.step.kind": "io", + "uipath.kb.query.length": message_length, + }, + ): + logger.info("MockRuntime: querying knowledge base") + print("[MockRuntime] Querying knowledge base...") + await asyncio.sleep(0.5) + + # Stage 5: Post-processing + with self.tracer.start_as_current_span( + "postprocess.results", + attributes={ + "uipath.step.name": "postprocess-results", + "uipath.step.kind": "postprocess", + }, + ): + logger.info("MockRuntime: post-processing results") + print("[MockRuntime] Post-processing results...") + await asyncio.sleep(0.4) + + with self.tracer.start_as_current_span( + "generate.output", + attributes={ + "uipath.step.name": "generate-output", + "uipath.step.kind": "postprocess-subtask", + }, + ): + logger.info("MockRuntime: generating structured output") + print("[MockRuntime] Generating output...") + await asyncio.sleep(0.4) + + # Stage 6: Persistence + with self.tracer.start_as_current_span( + "persist.artifacts", + attributes={ + "uipath.step.name": "persist-artifacts", + "uipath.step.kind": "io", + "uipath.persistence.enabled": False, + }, + ): + logger.info("MockRuntime: persisting artifacts (mock)") + print("[MockRuntime] Persisting artifacts (mock)...") + await asyncio.sleep(0.4) + + # Stage 7: Cleanup + with self.tracer.start_as_current_span( + "cleanup.resources", + attributes={ + "uipath.step.name": "cleanup-resources", + "uipath.step.kind": "cleanup", + }, + ): + logger.info("MockRuntime: cleaning up resources") + print("[MockRuntime] Cleaning up resources...") + await asyncio.sleep(0.3) + + result_payload = { + "result": f"Mock runtime processed: {payload.get('message', '')}", + "metadata": { + "entrypoint": entrypoint, + "message_length": message_length, + }, + } + + root_span.set_attribute("uipath.runtime.status", "success") + root_span.set_attribute("uipath.runtime.duration.approx", "5s") + root_span.set_attribute("uipath.output.has_error", False) + root_span.set_attribute( + "uipath.output.message_length", len(str(result_payload)) + ) + + logger.info( + "MockRuntime: execution completed successfully", + extra={ + "uipath.runtime.status": "success", + }, + ) + print(f"[MockRuntime] Finished successfully with result={result_payload!r}") + + return UiPathRuntimeResult( + output=result_payload, + status=UiPathRuntimeStatus.SUCCESSFUL, + ) + + async def stream( + self, + input: Optional[dict[str, Any]] = None, + options: Optional[UiPathStreamOptions] = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + logger.info("MockRuntime: stream() invoked") + print("[MockRuntime] stream() invoked") + yield await self.execute(input=input, options=options) + + async def dispose(self) -> None: + logger.info("MockRuntime: dispose() invoked") + print("[MockRuntime] dispose() invoked") diff --git a/src/uipath/dev/_demo/mock_greeting_runtime.py b/src/uipath/dev/_demo/mock_greeting_runtime.py new file mode 100644 index 0000000..5855eb4 --- /dev/null +++ b/src/uipath/dev/_demo/mock_greeting_runtime.py @@ -0,0 +1,131 @@ +import asyncio +import logging +from typing import Any, AsyncGenerator, Optional + +from opentelemetry import trace +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeEvent, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.schema import UiPathRuntimeSchema + +ENTRYPOINT_GREETING = "agent/greeting.py:main" + +logger = logging.getLogger(__name__) + + +class MockGreetingRuntime: + """Mock runtime that builds a greeting and simulates a small pipeline.""" + + def __init__(self, entrypoint: str = ENTRYPOINT_GREETING) -> None: + self.entrypoint = entrypoint + self.tracer = trace.get_tracer("uipath.dev.mock.greeting") + + async def get_schema(self) -> UiPathRuntimeSchema: + return UiPathRuntimeSchema( + filePath=self.entrypoint, + uniqueId="mock-greeting-runtime", + type="agent", + input={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "Who to greet"}, + "excited": { + "type": "boolean", + "description": "Whether to use an excited greeting", + "default": True, + }, + }, + "required": ["name"], + }, + output={ + "type": "object", + "properties": { + "greeting": {"type": "string"}, + "metadata": { + "type": "object", + "properties": { + "uppercase": {"type": "boolean"}, + "length": {"type": "integer"}, + }, + }, + }, + "required": ["greeting"], + }, + ) + + async def execute( + self, + input: Optional[dict[str, Any]] = None, + options: Optional[UiPathExecuteOptions] = None, + ) -> UiPathRuntimeResult: + payload = input or {} + name = str(payload.get("name", "world")).strip() or "world" + excited = bool(payload.get("excited", True)) + + with self.tracer.start_as_current_span( + "greeting.execute", + attributes={ + "uipath.runtime.name": "GreetingRuntime", + "uipath.runtime.type": "agent", + "uipath.runtime.entrypoint": self.entrypoint, + "uipath.input.name": name, + "uipath.input.excited": excited, + }, + ): + logger.info("GreetingRuntime: starting execution") + + # Stage 1 - normalize name + with self.tracer.start_as_current_span( + "greeting.normalize_name", + attributes={"uipath.step.kind": "preprocess"}, + ): + await asyncio.sleep(0.1) + normalized = name.title() + + # Stage 2 - build greeting + with self.tracer.start_as_current_span( + "greeting.build_message", + attributes={"uipath.step.kind": "compute"}, + ): + await asyncio.sleep(0.1) + greeting = f"Hello, {normalized}!" + if excited: + greeting += " Excited to meet you!" + + # Stage 3 - compute metadata + with self.tracer.start_as_current_span( + "greeting.compute_metadata", + attributes={"uipath.step.kind": "postprocess"}, + ): + await asyncio.sleep(0.05) + metadata = { + "uppercase": greeting.isupper(), + "length": len(greeting), + } + + result_payload = { + "greeting": greeting, + "metadata": metadata, + } + + logger.info("GreetingRuntime: execution completed", extra=metadata) + + return UiPathRuntimeResult( + output=result_payload, + status=UiPathRuntimeStatus.SUCCESSFUL, + ) + + async def stream( + self, + input: Optional[dict[str, Any]] = None, + options: Optional[UiPathStreamOptions] = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + logger.info("GreetingRuntime: stream() invoked") + yield await self.execute(input=input, options=options) + + async def dispose(self) -> None: + logger.info("GreetingRuntime: dispose() invoked") diff --git a/src/uipath/dev/_demo/mock_numbers_runtime.py b/src/uipath/dev/_demo/mock_numbers_runtime.py new file mode 100644 index 0000000..2c4aa20 --- /dev/null +++ b/src/uipath/dev/_demo/mock_numbers_runtime.py @@ -0,0 +1,144 @@ +import asyncio +import logging +from typing import Any, AsyncGenerator, Optional + +from opentelemetry import trace +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeEvent, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.schema import UiPathRuntimeSchema + +ENTRYPOINT_ANALYZE_NUMBERS = "analytics/numbers.py:analyze" + +logger = logging.getLogger(__name__) + + +class MockNumberAnalyticsRuntime: + """Mock runtime that analyzes a list of numbers.""" + + def __init__(self, entrypoint: str = ENTRYPOINT_ANALYZE_NUMBERS) -> None: + self.entrypoint = entrypoint + self.tracer = trace.get_tracer("uipath.dev.mock.number-analytics") + + async def get_schema(self) -> UiPathRuntimeSchema: + return UiPathRuntimeSchema( + filePath=self.entrypoint, + uniqueId="mock-number-analytics-runtime", + type="script", + input={ + "type": "object", + "properties": { + "numbers": { + "type": "array", + "items": {"type": "number"}, + "description": "List of numeric values to analyze", + }, + "operation": { + "type": "string", + "enum": ["sum", "avg", "max"], + "default": "sum", + }, + }, + "required": ["numbers"], + }, + output={ + "type": "object", + "properties": { + "operation": {"type": "string"}, + "result": {"type": "number"}, + "count": {"type": "integer"}, + }, + "required": ["operation", "result"], + }, + ) + + async def execute( + self, + input: Optional[dict[str, Any]] = None, + options: Optional[UiPathExecuteOptions] = None, + ) -> UiPathRuntimeResult: + payload = input or {} + numbers = payload.get("numbers") or [] + operation = str(payload.get("operation", "sum")).lower() + + numbers = [float(x) for x in numbers] + + with self.tracer.start_as_current_span( + "number_analytics.execute", + attributes={ + "uipath.runtime.name": "NumberAnalyticsRuntime", + "uipath.runtime.entrypoint": self.entrypoint, + "uipath.input.count": len(numbers), + "uipath.input.operation": operation, + }, + ): + logger.info("NumberAnalyticsRuntime: starting execution") + + # Validation span + with self.tracer.start_as_current_span( + "number_analytics.validate_input", + attributes={"uipath.step.kind": "validation"}, + ): + await asyncio.sleep(0.05) + if not numbers: + logger.warning("NumberAnalyticsRuntime: empty 'numbers' list") + result_payload = { + "operation": operation, + "result": 0, + "count": 0, + } + return UiPathRuntimeResult( + output=result_payload, + status=UiPathRuntimeStatus.SUCCESSFUL, + ) + + # Compute span + with self.tracer.start_as_current_span( + "number_analytics.compute", + attributes={"uipath.step.kind": "compute"}, + ): + await asyncio.sleep(0.1) + if operation == "avg": + result = sum(numbers) / len(numbers) + elif operation == "max": + result = max(numbers) + else: + operation = "sum" + result = sum(numbers) + + # Postprocess span + with self.tracer.start_as_current_span( + "number_analytics.postprocess", + attributes={"uipath.step.kind": "postprocess"}, + ): + await asyncio.sleep(0.05) + result_payload = { + "operation": operation, + "result": result, + "count": len(numbers), + } + + logger.info( + "NumberAnalyticsRuntime: execution completed", + extra={"operation": operation, "result": result}, + ) + + return UiPathRuntimeResult( + output=result_payload, + status=UiPathRuntimeStatus.SUCCESSFUL, + ) + + async def stream( + self, + input: Optional[dict[str, Any]] = None, + options: Optional[UiPathStreamOptions] = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + logger.info("NumberAnalyticsRuntime: stream() invoked") + yield await self.execute(input=input, options=options) + + async def dispose(self) -> None: + logger.info("NumberAnalyticsRuntime: dispose() invoked") diff --git a/src/uipath/dev/_demo/mock_runtime.py b/src/uipath/dev/_demo/mock_runtime.py index 939f8b5..fe6de89 100644 --- a/src/uipath/dev/_demo/mock_runtime.py +++ b/src/uipath/dev/_demo/mock_runtime.py @@ -1,254 +1,62 @@ """Minimal demo script to run UiPathDevTerminal with mock runtimes.""" -import asyncio import logging -from typing import Any, AsyncGenerator, Optional -from opentelemetry import trace from uipath.runtime import ( - UiPathExecuteOptions, - UiPathRuntimeEvent, UiPathRuntimeProtocol, - UiPathRuntimeResult, - UiPathRuntimeStatus, - UiPathStreamOptions, ) -from uipath.runtime.schema import UiPathRuntimeSchema -logger = logging.getLogger(__name__) - - -class MockRuntime: - """A mock runtime that simulates a multi-step workflow with rich telemetry.""" - - async def get_schema(self) -> UiPathRuntimeSchema: - return UiPathRuntimeSchema( - filePath="default", - uniqueId="mock-runtime", - type="agent", - input={ - "type": "object", - "properties": {"message": {"type": "string"}}, - "required": ["message"], - }, - output={ - "type": "object", - "properties": {"result": {"type": "string"}}, - "required": ["result"], - }, - ) - - async def execute( - self, - input: Optional[dict[str, Any]] = None, - options: Optional[UiPathExecuteOptions] = None, - ) -> UiPathRuntimeResult: - payload = input or {} - - tracer = trace.get_tracer("uipath.dev.mock-runtime") - - entrypoint = "mock-entrypoint" - message = str(payload.get("message", "")) - message_length = len(message) - - with tracer.start_as_current_span( - "mock-runtime.execute", - attributes={ - "uipath.runtime.name": "MockRuntime", - "uipath.runtime.type": "agent", - "uipath.runtime.entrypoint": entrypoint, - "uipath.input.message.length": message_length, - "uipath.input.has_message": "message" in payload, - }, - ) as root_span: - logger.info( - "MockRuntime: starting execution", - extra={ - "uipath.runtime.entrypoint": entrypoint, - }, - ) - print(f"[MockRuntime] Starting execution with payload={payload!r}") - - # Stage 1: Initialization - with tracer.start_as_current_span( - "initialize.environment", - attributes={ - "uipath.step.name": "initialize-environment", - "uipath.step.kind": "init", - }, - ): - logger.info("MockRuntime: initializing environment") - print("[MockRuntime] Initializing environment...") - await asyncio.sleep(0.5) - - # Stage 2: Validation - with tracer.start_as_current_span( - "validate.input", - attributes={ - "uipath.step.name": "validate-input", - "uipath.step.kind": "validation", - "uipath.input.has_message": "message" in payload, - }, - ) as validate_span: - logger.info("MockRuntime: validating input") - print("[MockRuntime] Validating input...") - await asyncio.sleep(0.5) - - if "message" not in payload: - logger.warning("MockRuntime: missing 'message' in payload") - validate_span.set_attribute( - "uipath.validation.missing_field", "message" - ) - - # Stage 3: Preprocessing - with tracer.start_as_current_span( - "preprocess.data", - attributes={ - "uipath.step.name": "preprocess-data", - "uipath.step.kind": "preprocess", - "uipath.input.size.bytes": len(str(payload).encode("utf-8")), - }, - ): - logger.info("MockRuntime: preprocessing data") - print("[MockRuntime] Preprocessing data...") - await asyncio.sleep(0.5) - - # Stage 4: Compute / reasoning - with tracer.start_as_current_span( - "compute.result", - attributes={ - "uipath.step.name": "compute-result", - "uipath.step.kind": "compute", - }, - ): - logger.info("MockRuntime: compute phase started") - print("[MockRuntime] Compute phase...") - - # Subtask: embedding computation - with tracer.start_as_current_span( - "compute.embeddings", - attributes={ - "uipath.step.name": "compute-embeddings", - "uipath.step.kind": "compute-subtask", - }, - ): - logger.info("MockRuntime: computing embeddings") - print("[MockRuntime] Computing embeddings...") - await asyncio.sleep(0.5) - - # Subtask: KB query - with tracer.start_as_current_span( - "query.knowledgebase", - attributes={ - "uipath.step.name": "query-knowledgebase", - "uipath.step.kind": "io", - "uipath.kb.query.length": message_length, - }, - ): - logger.info("MockRuntime: querying knowledge base") - print("[MockRuntime] Querying knowledge base...") - await asyncio.sleep(0.5) - - # Stage 5: Post-processing - with tracer.start_as_current_span( - "postprocess.results", - attributes={ - "uipath.step.name": "postprocess-results", - "uipath.step.kind": "postprocess", - }, - ): - logger.info("MockRuntime: post-processing results") - print("[MockRuntime] Post-processing results...") - await asyncio.sleep(0.4) - - with tracer.start_as_current_span( - "generate.output", - attributes={ - "uipath.step.name": "generate-output", - "uipath.step.kind": "postprocess-subtask", - }, - ): - logger.info("MockRuntime: generating structured output") - print("[MockRuntime] Generating output...") - await asyncio.sleep(0.4) - - # Stage 6: Persistence - with tracer.start_as_current_span( - "persist.artifacts", - attributes={ - "uipath.step.name": "persist-artifacts", - "uipath.step.kind": "io", - "uipath.persistence.enabled": False, - }, - ): - logger.info("MockRuntime: persisting artifacts (mock)") - print("[MockRuntime] Persisting artifacts (mock)...") - await asyncio.sleep(0.4) - - # Stage 7: Cleanup - with tracer.start_as_current_span( - "cleanup.resources", - attributes={ - "uipath.step.name": "cleanup-resources", - "uipath.step.kind": "cleanup", - }, - ): - logger.info("MockRuntime: cleaning up resources") - print("[MockRuntime] Cleaning up resources...") - await asyncio.sleep(0.3) - - result_payload = { - "result": f"Mock runtime processed: {payload.get('message', '')}", - "metadata": { - "entrypoint": entrypoint, - "message_length": message_length, - }, - } - - root_span.set_attribute("uipath.runtime.status", "success") - root_span.set_attribute("uipath.runtime.duration.approx", "5s") - root_span.set_attribute("uipath.output.has_error", False) - root_span.set_attribute( - "uipath.output.message_length", len(str(result_payload)) - ) - - logger.info( - "MockRuntime: execution completed successfully", - extra={ - "uipath.runtime.status": "success", - }, - ) - print(f"[MockRuntime] Finished successfully with result={result_payload!r}") - - return UiPathRuntimeResult( - output=result_payload, - status=UiPathRuntimeStatus.SUCCESSFUL, - ) - - async def stream( - self, - input: Optional[dict[str, Any]] = None, - options: Optional[UiPathStreamOptions] = None, - ) -> AsyncGenerator[UiPathRuntimeEvent, None]: - logger.info("MockRuntime: stream() invoked") - print("[MockRuntime] stream() invoked") - yield await self.execute(input=input, options=options) +from uipath.dev._demo.mock_context_runtime import ENTRYPOINT_CONTEXT, MockContextRuntime +from uipath.dev._demo.mock_greeting_runtime import ( + ENTRYPOINT_GREETING, + MockGreetingRuntime, +) +from uipath.dev._demo.mock_numbers_runtime import ( + ENTRYPOINT_ANALYZE_NUMBERS, + MockNumberAnalyticsRuntime, +) +from uipath.dev._demo.mock_support_runtime import ( + ENTRYPOINT_SUPPORT_CHAT, + MockSupportChatRuntime, +) - async def dispose(self) -> None: - logger.info("MockRuntime: dispose() invoked") - print("[MockRuntime] dispose() invoked") +logger = logging.getLogger(__name__) class MockRuntimeFactory: - """Runtime factory compatible with UiPathDevTerminal expectations.""" + """Runtime factory compatible with UiPathRuntimeFactoryProtocol.""" - # This is the method the Textual app calls here: - # runtime = await self.runtime_factory.new_runtime(entrypoint=run.entrypoint) async def new_runtime(self, entrypoint: str) -> UiPathRuntimeProtocol: - return MockRuntime() + """Create a new runtime instance for the given entrypoint.""" + if entrypoint == ENTRYPOINT_GREETING: + return MockGreetingRuntime(entrypoint=entrypoint) + if entrypoint == ENTRYPOINT_ANALYZE_NUMBERS: + return MockNumberAnalyticsRuntime(entrypoint=entrypoint) + if entrypoint == ENTRYPOINT_SUPPORT_CHAT: + return MockSupportChatRuntime(entrypoint=entrypoint) + if entrypoint == ENTRYPOINT_CONTEXT: + return MockContextRuntime(entrypoint=entrypoint) + + # Fallback: still return something so the demo doesn't explode + logger.warning( + "Unknown entrypoint %r, falling back to GreetingRuntime", entrypoint + ) + return MockGreetingRuntime(entrypoint=entrypoint) def discover_runtimes(self) -> list[UiPathRuntimeProtocol]: - return [] + """Return prototype instances for discovery (not really used by the UI).""" + return [ + MockGreetingRuntime(entrypoint=ENTRYPOINT_GREETING), + MockNumberAnalyticsRuntime(entrypoint=ENTRYPOINT_ANALYZE_NUMBERS), + MockSupportChatRuntime(entrypoint=ENTRYPOINT_SUPPORT_CHAT), + MockContextRuntime(entrypoint=ENTRYPOINT_CONTEXT), + ] def discover_entrypoints(self) -> list[str]: - return [] + """Return all available entrypoints.""" + return [ + ENTRYPOINT_CONTEXT, + ENTRYPOINT_GREETING, + ENTRYPOINT_ANALYZE_NUMBERS, + ENTRYPOINT_SUPPORT_CHAT, + ] diff --git a/src/uipath/dev/_demo/mock_support_runtime.py b/src/uipath/dev/_demo/mock_support_runtime.py new file mode 100644 index 0000000..d6fa617 --- /dev/null +++ b/src/uipath/dev/_demo/mock_support_runtime.py @@ -0,0 +1,146 @@ +import asyncio +import logging +from typing import Any, AsyncGenerator, Optional + +from opentelemetry import trace +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeEvent, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.schema import UiPathRuntimeSchema + +ENTRYPOINT_SUPPORT_CHAT = "agent/support.py:chat" + +logger = logging.getLogger(__name__) + + +class MockSupportChatRuntime: + """Mock runtime that simulates a tiny support agent.""" + + def __init__(self, entrypoint: str = ENTRYPOINT_SUPPORT_CHAT) -> None: + self.entrypoint = entrypoint + self.tracer = trace.get_tracer("uipath.dev.mock.support-chat") + + async def get_schema(self) -> UiPathRuntimeSchema: + return UiPathRuntimeSchema( + filePath=self.entrypoint, + uniqueId="mock-support-chat-runtime", + type="agent", + input={ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "User message to the support bot", + }, + "previousIssues": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional prior issues for context", + }, + }, + "required": ["message"], + }, + output={ + "type": "object", + "properties": { + "reply": {"type": "string"}, + "sentiment": {"type": "string"}, + "escalated": {"type": "boolean"}, + }, + "required": ["reply"], + }, + ) + + async def execute( + self, + input: Optional[dict[str, Any]] = None, + options: Optional[UiPathExecuteOptions] = None, + ) -> UiPathRuntimeResult: + payload = input or {} + message = str(payload.get("message", "")).strip() + previous = payload.get("previousIssues") or [] + + with self.tracer.start_as_current_span( + "support_chat.execute", + attributes={ + "uipath.runtime.name": "SupportChatRuntime", + "uipath.runtime.entrypoint": self.entrypoint, + "uipath.input.message.length": len(message), + "uipath.input.previous_issues": len(previous), + }, + ): + logger.info("SupportChatRuntime: starting execution") + + # Classify sentiment + with self.tracer.start_as_current_span( + "support_chat.classify_sentiment", + attributes={"uipath.step.kind": "analysis"}, + ): + await asyncio.sleep(0.1) + lower = message.lower() + if any(word in lower for word in ["error", "crash", "bug", "broken"]): + sentiment = "frustrated" + elif any(word in lower for word in ["thanks", "thank you", "great"]): + sentiment = "positive" + else: + sentiment = "neutral" + + # Generate reply + with self.tracer.start_as_current_span( + "support_chat.generate_reply", + attributes={ + "uipath.step.kind": "generate", + "uipath.sentiment": sentiment, + }, + ): + await asyncio.sleep(0.15) + if sentiment == "frustrated": + reply = ( + "I'm sorry you're having trouble. " + "I've logged this and will escalate it to our engineers. 🔧" + ) + elif sentiment == "positive": + reply = "Happy to hear that everything is working well! 🎉" + else: + reply = ( + "Thanks for reaching out. Could you share a few more details?" + ) + + # Decide escalation + with self.tracer.start_as_current_span( + "support_chat.decide_escalation", + attributes={"uipath.step.kind": "decision"}, + ): + await asyncio.sleep(0.05) + escalated = sentiment == "frustrated" or len(previous) > 3 + + result_payload = { + "reply": reply, + "sentiment": sentiment, + "escalated": escalated, + } + + logger.info( + "SupportChatRuntime: execution completed", + extra={"sentiment": sentiment, "escalated": escalated}, + ) + + return UiPathRuntimeResult( + output=result_payload, + status=UiPathRuntimeStatus.SUCCESSFUL, + ) + + async def stream( + self, + input: Optional[dict[str, Any]] = None, + options: Optional[UiPathStreamOptions] = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + logger.info("SupportChatRuntime: stream() invoked") + yield await self.execute(input=input, options=options) + + async def dispose(self) -> None: + logger.info("SupportChatRuntime: dispose() invoked") diff --git a/src/uipath/dev/ui/panels/new_run_panel.py b/src/uipath/dev/ui/panels/new_run_panel.py index 2dff17f..1a198a5 100644 --- a/src/uipath/dev/ui/panels/new_run_panel.py +++ b/src/uipath/dev/ui/panels/new_run_panel.py @@ -1,41 +1,82 @@ """Panel for creating new runs with entrypoint selection and JSON input.""" import json -import os -from typing import Any, Tuple, cast +from typing import Any, Dict, Tuple, cast from textual.app import ComposeResult from textual.containers import Container, Horizontal, Vertical from textual.reactive import reactive from textual.widgets import Button, Select, TabbedContent, TabPane, TextArea +from uipath.runtime import UiPathRuntimeFactoryProtocol from uipath.dev.ui.widgets.json_input import JsonInput -def mock_json_from_schema(schema: dict[str, Any]) -> dict[str, Any]: - """Generate a mock JSON object based on a given JSON schema.""" - props: dict[str, Any] = schema.get("properties", {}) - required = schema.get("required", []) - mock = {} - for key, info in props.items(): - if "default" in info: - mock[key] = info["default"] - continue - t = info.get("type") +def mock_json_from_schema(schema: dict[str, Any]) -> Any: + """Generate a mock JSON value based on a given JSON schema. + + - For object schemas: returns a dict of mocked properties. + - For arrays: returns a list with one mocked item. + - For primitives: returns a sensible example / default / enum[0]. + """ + + def _mock_value(sub_schema: dict[str, Any], required: bool = True) -> Any: + # 1) Default wins + if "default" in sub_schema: + return sub_schema["default"] + + t = sub_schema.get("type") + + # 2) Enums: pick the first option + enum = sub_schema.get("enum") + if enum and isinstance(enum, list): + return enum[0] + + # 3) Objects: recurse into mock_json_from_schema + if t == "object": + return mock_json_from_schema(sub_schema) + + # 4) Arrays: mock a single item based on "items" schema + if t == "array": + item_schema = sub_schema.get("items", {}) + # If items is not a dict, just return empty list + if not isinstance(item_schema, dict): + return [] + return [_mock_value(item_schema, required=True)] + + # 5) Primitives if t == "string": - mock[key] = f"example_{key}" if key in required else "" - elif t == "integer": - mock[key] = 0 if key in required else None - elif t == "boolean": - mock[key] = True if key in required else False - elif t == "array": - item_schema = info.get("items", {"type": "string"}) - mock[key] = [mock_json_from_schema(item_schema)] - elif t == "object": - mock[key] = mock_json_from_schema(info) - else: - mock[key] = None - return mock + # If there's a format, we could specialize later (email, date, etc.) + return "example" if required else "" + + if t == "integer": + return 0 + + if t == "number": + return 0.0 + + if t == "boolean": + return True if required else False + + # 6) Fallback + return None + + # Top-level: if it's an object with properties, build a dict + if schema.get("type") == "object" and "properties" in schema: + props: dict[str, Any] = schema.get("properties", {}) + required_keys = set(schema.get("required", [])) + result: dict[str, Any] = {} + + for key, prop_schema in props.items(): + if not isinstance(prop_schema, dict): + continue + is_required = key in required_keys + result[key] = _mock_value(prop_schema, required=is_required) + + return result + + # If it's not an object schema, just mock the value directly + return _mock_value(schema, required=True) class NewRunPanel(Container): @@ -43,44 +84,32 @@ class NewRunPanel(Container): selected_entrypoint = reactive("") - def __init__(self, **kwargs): - """Initialize NewRunPanel with entrypoints from uipath.json.""" + def __init__( + self, + runtime_factory: UiPathRuntimeFactoryProtocol, + **kwargs: Any, + ) -> None: + """Initialize NewRunPanel using UiPathRuntimeFactoryProtocol.""" super().__init__(**kwargs) - json_path = os.path.join(os.getcwd(), "uipath.json") - data: dict[str, Any] = {} - if os.path.exists(json_path): - with open(json_path, "r") as f: - data = json.load(f) - - self.entrypoints = data.get("entryPoints", [{"filePath": "default"}]) - self.entrypoint_paths = [ep["filePath"] for ep in self.entrypoints] - self.conversational = False - self.selected_entrypoint = ( - self.entrypoint_paths[0] if self.entrypoint_paths else "" - ) - ep: dict[str, Any] = next( - ( - ep - for ep in self.entrypoints - if ep["filePath"] == self.selected_entrypoint - ), - {}, - ) - self.initial_input = json.dumps( - mock_json_from_schema(ep.get("input", {})), indent=2 - ) + + self._runtime_factory = runtime_factory + + self.entrypoints: list[str] = [] + + self.entrypoint_schemas: Dict[str, dict[str, Any]] = {} + + self.conversational: bool = False + self.initial_input: str = "{}" def compose(self) -> ComposeResult: """Compose the UI layout.""" with TabbedContent(): with TabPane("New run", id="new-tab"): with Vertical(): - options = [(path, path) for path in self.entrypoint_paths] yield Select( - options, + options=[], id="entrypoint-select", - value=self.selected_entrypoint, - allow_blank=False, + allow_blank=True, ) yield JsonInput( @@ -98,45 +127,98 @@ def compose(self) -> ComposeResult: classes="action-btn", ) - async def on_select_changed(self, event: Select.Changed) -> None: - """Update JSON input when user selects an entrypoint.""" - self.selected_entrypoint = cast(str, event.value) - - ep: dict[str, Any] = next( - ( - ep - for ep in self.entrypoints - if ep["filePath"] == self.selected_entrypoint - ), - {}, - ) + async def on_mount(self) -> None: + """Discover entrypoints once, and set the first as default.""" + try: + discovered = self._runtime_factory.discover_entrypoints() + except Exception: + discovered = [] + + self.entrypoints = discovered or [] + + select = self.query_one("#entrypoint-select", Select) + + json_input = self.query_one("#json-input", TextArea) + run_button = self.query_one("#execute-btn", Button) + + if not self.entrypoints: + self.selected_entrypoint = "" + select.set_options([("No entrypoints found", "no-entrypoints")]) + select.value = "no-entrypoints" + select.disabled = True + run_button.disabled = True + json_input.text = "{}" + return + + options = [(ep, ep) for ep in self.entrypoints] + select.set_options(options) + + # Use the first entrypoint as default + self.selected_entrypoint = self.entrypoints[0] + select.value = self.selected_entrypoint + + # Lazily fetch schema and populate input + await self._load_schema_and_update_input(self.selected_entrypoint) + + async def _load_schema_and_update_input(self, entrypoint: str) -> None: + """Ensure schema for entrypoint is loaded, then update JSON input.""" json_input = self.query_one("#json-input", TextArea) + + if not entrypoint or entrypoint == "no-entrypoints": + json_input.text = "{}" + return + + schema = self.entrypoint_schemas.get(entrypoint) + + if schema is None: + try: + runtime = await self._runtime_factory.new_runtime(entrypoint) + schema_obj = await runtime.get_schema() + + input_schema = schema_obj.input or {} + self.entrypoint_schemas[entrypoint] = input_schema + schema = input_schema + + await runtime.dispose() + except Exception: + schema = {} + self.entrypoint_schemas[entrypoint] = schema + json_input.text = json.dumps( - mock_json_from_schema(ep.get("input", {})), indent=2 + mock_json_from_schema(schema), + indent=2, ) + async def on_select_changed(self, event: Select.Changed) -> None: + """Update JSON input when user selects an entrypoint.""" + self.selected_entrypoint = cast(str, event.value) if event.value else "" + + await self._load_schema_and_update_input(self.selected_entrypoint) + def get_input_values(self) -> Tuple[str, str, bool]: """Get the selected entrypoint and JSON input values.""" json_input = self.query_one("#json-input", TextArea) return self.selected_entrypoint, json_input.text.strip(), self.conversational - def reset_form(self): + def reset_form(self) -> None: """Reset selection and JSON input to defaults.""" - self.selected_entrypoint = ( - self.entrypoint_paths[0] if self.entrypoint_paths else "" - ) select = self.query_one("#entrypoint-select", Select) + json_input = self.query_one("#json-input", TextArea) + + if not self.entrypoints: + self.selected_entrypoint = "" + select.clear() + json_input.text = "{}" + return + + self.selected_entrypoint = self.entrypoints[0] select.value = self.selected_entrypoint - ep: dict[str, Any] = next( - ( - ep - for ep in self.entrypoints - if ep["filePath"] == self.selected_entrypoint - ), - {}, - ) - json_input = self.query_one("#json-input", TextArea) - json_input.text = json.dumps( - mock_json_from_schema(ep.get("input", {})), indent=2 - ) + schema = self.entrypoint_schemas.get(self.selected_entrypoint) + if schema is None: + json_input.text = "{}" + else: + json_input.text = json.dumps( + mock_json_from_schema(schema), + indent=2, + )