diff --git a/demo/mock_factory.py b/demo/mock_factory.py index a4af61b..8e7c0c4 100644 --- a/demo/mock_factory.py +++ b/demo/mock_factory.py @@ -9,13 +9,17 @@ UiPathRuntimeStorageProtocol, ) -from demo.mock_greeting_runtime import ( - ENTRYPOINT_GREETING, - MockGreetingRuntime, +from demo.mock_invoice_runtime import ( + ENTRYPOINT_INVOICE, + MockInvoiceRuntime, ) -from demo.mock_numbers_runtime import ( - ENTRYPOINT_ANALYZE_NUMBERS, - MockNumberAnalyticsRuntime, +from demo.mock_movies_runtime import ( + ENTRYPOINT_MOVIES, + MockMoviesRuntime, +) +from demo.mock_pharma_runtime import ( + ENTRYPOINT_PHARMA, + MockPharmaRuntime, ) from demo.mock_support_runtime import ( ENTRYPOINT_SUPPORT_CHAT, @@ -32,12 +36,7 @@ logger = logging.getLogger(__name__) # Template mappings: entrypoint -> (events_file, schema_file) -TEMPLATE_RUNTIMES = { - "chat/movies.py:graph": ( - "chat_agent/events.json", - "chat_agent/entry-points.json", - ), -} +TEMPLATE_RUNTIMES: dict[str, tuple[str, str]] = {} class MockRuntimeFactory: @@ -51,14 +50,16 @@ async def new_runtime( self, entrypoint: str, runtime_id: str, **kwargs ) -> UiPathRuntimeProtocol: """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_PHARMA: + return MockPharmaRuntime(entrypoint=entrypoint) + if entrypoint == ENTRYPOINT_INVOICE: + return MockInvoiceRuntime(entrypoint=entrypoint) if entrypoint == ENTRYPOINT_SUPPORT_CHAT: return MockSupportChatRuntime(entrypoint=entrypoint) if entrypoint == ENTRYPOINT_TELEMETRY: return MockTelemetryRuntime(entrypoint=entrypoint) + if entrypoint == ENTRYPOINT_MOVIES: + return MockMoviesRuntime(entrypoint=entrypoint) if entrypoint in TEMPLATE_RUNTIMES: events_file, schema_file = TEMPLATE_RUNTIMES[entrypoint] @@ -73,9 +74,9 @@ async def new_runtime( # Fallback: still return something so the demo doesn't explode logger.warning( - "Unknown entrypoint %r, falling back to GreetingRuntime", entrypoint + "Unknown entrypoint %r, falling back to PharmaRuntime", entrypoint ) - return MockGreetingRuntime(entrypoint=entrypoint) + return MockPharmaRuntime(entrypoint=entrypoint) async def get_settings(self) -> UiPathRuntimeFactorySettings | None: """Return factory settings (no-op for mock).""" @@ -89,9 +90,10 @@ def discover_entrypoints(self) -> list[str]: """Return all available entrypoints.""" return [ ENTRYPOINT_TELEMETRY, - ENTRYPOINT_GREETING, - ENTRYPOINT_ANALYZE_NUMBERS, + ENTRYPOINT_PHARMA, + ENTRYPOINT_INVOICE, ENTRYPOINT_SUPPORT_CHAT, + ENTRYPOINT_MOVIES, *TEMPLATE_RUNTIMES.keys(), ] diff --git a/demo/mock_greeting_runtime.py b/demo/mock_greeting_runtime.py deleted file mode 100644 index 4d1d381..0000000 --- a/demo/mock_greeting_runtime.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Mock Greeting Runtime for demonstration purposes.""" - -import asyncio -import logging -from typing import Any, AsyncGenerator - -from opentelemetry import trace -from uipath.runtime import ( - UiPathExecuteOptions, - UiPathRuntimeEvent, - UiPathRuntimeResult, - UiPathRuntimeStatus, - UiPathStreamOptions, -) -from uipath.runtime.schema import ( - UiPathRuntimeEdge, - UiPathRuntimeGraph, - UiPathRuntimeNode, - 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: - """Initialize the MockGreetingRuntime.""" - self.entrypoint = entrypoint - self.tracer = trace.get_tracer("uipath.dev.mock.greeting") - - async def get_schema(self) -> UiPathRuntimeSchema: - """Get the schema for the greeting runtime.""" - 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"], - }, - graph=UiPathRuntimeGraph( - nodes=[ - UiPathRuntimeNode( - id="__start__", name="__start__", type="__start__" - ), - UiPathRuntimeNode( - id="normalize_name", name="normalize_name", type="node" - ), - UiPathRuntimeNode( - id="build_message", - name="build_message", - type="model", - metadata={"model_name": "greeting-builder"}, - ), - UiPathRuntimeNode( - id="compute_metadata", name="compute_metadata", type="node" - ), - UiPathRuntimeNode(id="__end__", name="__end__", type="__end__"), - ], - edges=[ - UiPathRuntimeEdge(source="__start__", target="normalize_name"), - UiPathRuntimeEdge(source="normalize_name", target="build_message"), - UiPathRuntimeEdge( - source="build_message", target="compute_metadata" - ), - UiPathRuntimeEdge(source="compute_metadata", target="__end__"), - ], - ), - ) - - async def execute( - self, - input: dict[str, Any] | None = None, - options: UiPathExecuteOptions | None = None, - ) -> UiPathRuntimeResult: - """Execute the greeting runtime.""" - 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: dict[str, Any] | None = None, - options: UiPathStreamOptions | None = None, - ) -> AsyncGenerator[UiPathRuntimeEvent, None]: - """Stream events from the greeting runtime.""" - logger.info("GreetingRuntime: stream() invoked") - yield await self.execute(input=input, options=options) - - async def dispose(self) -> None: - """Dispose of any resources used by the greeting runtime.""" - logger.info("GreetingRuntime: dispose() invoked") diff --git a/demo/mock_invoice_runtime.py b/demo/mock_invoice_runtime.py new file mode 100644 index 0000000..9ffeb30 --- /dev/null +++ b/demo/mock_invoice_runtime.py @@ -0,0 +1,409 @@ +"""Mock Financial Invoice Processing runtime for demonstration purposes.""" + +import asyncio +import logging +from typing import Any, AsyncGenerator + +from opentelemetry import trace +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeEvent, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.events import ( + UiPathRuntimeStateEvent, + UiPathRuntimeStatePhase, +) +from uipath.runtime.schema import ( + UiPathRuntimeEdge, + UiPathRuntimeGraph, + UiPathRuntimeNode, + UiPathRuntimeSchema, +) + +ENTRYPOINT_INVOICE = "agent/invoice_processing.py:process" + +logger = logging.getLogger(__name__) + +S = UiPathRuntimeStatePhase.STARTED +C = UiPathRuntimeStatePhase.COMPLETED + + +def _state( + node: str, + phase: UiPathRuntimeStatePhase, + payload: dict[str, Any] | None = None, +) -> UiPathRuntimeStateEvent: + return UiPathRuntimeStateEvent(node_name=node, phase=phase, payload=payload or {}) + + +class MockInvoiceRuntime: + """Mock runtime that simulates a financial invoice processing pipeline.""" + + def __init__(self, entrypoint: str = ENTRYPOINT_INVOICE) -> None: + """Initialize the MockInvoiceRuntime.""" + self.entrypoint = entrypoint + self.tracer = trace.get_tracer("uipath.dev.mock.invoice-processing") + + async def get_schema(self) -> UiPathRuntimeSchema: + """Get the schema for the invoice processing runtime.""" + return UiPathRuntimeSchema( + filePath=self.entrypoint, + uniqueId="mock-invoice-processing-runtime", + type="agent", + input={ + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "Invoice ID to process (e.g. INV-2024-08731)", + }, + "currency": { + "type": "string", + "enum": ["USD", "EUR", "GBP"], + "default": "USD", + "description": "Invoice currency", + }, + }, + "required": ["invoice_id"], + }, + output={ + "type": "object", + "properties": { + "status": {"type": "string"}, + "total_amount": {"type": "number"}, + "line_items_count": {"type": "integer"}, + "fraud_score": {"type": "number"}, + "approval_level": {"type": "string"}, + "payment_reference": {"type": "string"}, + }, + "required": ["status", "total_amount"], + }, + graph=UiPathRuntimeGraph( + nodes=[ + UiPathRuntimeNode( + id="__start__", name="__start__", type="__start__" + ), + UiPathRuntimeNode( + id="ingest_document", + name="ingest_document", + type="node", + ), + UiPathRuntimeNode( + id="extract_line_items", + name="extract_line_items", + type="model", + metadata={"model_name": "invoice-extractor-v3"}, + ), + UiPathRuntimeNode( + id="validate_amounts", + name="validate_amounts", + type="node", + ), + UiPathRuntimeNode( + id="fraud_check", + name="fraud_check", + type="model", + metadata={"model_name": "fraud-detection-ensemble"}, + ), + UiPathRuntimeNode( + id="approval_routing", + name="approval_routing", + type="node", + ), + UiPathRuntimeNode( + id="generate_payment", + name="generate_payment", + type="node", + ), + UiPathRuntimeNode(id="__end__", name="__end__", type="__end__"), + ], + edges=[ + UiPathRuntimeEdge(source="__start__", target="ingest_document"), + UiPathRuntimeEdge( + source="ingest_document", target="extract_line_items" + ), + UiPathRuntimeEdge( + source="extract_line_items", target="validate_amounts" + ), + # Cycle: validation mismatch triggers re-extraction + UiPathRuntimeEdge( + source="validate_amounts", + target="extract_line_items", + label="mismatch", + ), + UiPathRuntimeEdge(source="validate_amounts", target="fraud_check"), + # Cycle: fraud flag triggers re-validation with stricter rules + UiPathRuntimeEdge( + source="fraud_check", + target="validate_amounts", + label="re_validate", + ), + UiPathRuntimeEdge(source="fraud_check", target="approval_routing"), + UiPathRuntimeEdge( + source="approval_routing", target="generate_payment" + ), + UiPathRuntimeEdge(source="generate_payment", target="__end__"), + ], + ), + ) + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + """Execute the invoice processing runtime by consuming stream().""" + result: UiPathRuntimeResult | None = None + async for event in self.stream(input=input): + if isinstance(event, UiPathRuntimeResult): + result = event + return result or UiPathRuntimeResult( + output={}, status=UiPathRuntimeStatus.SUCCESSFUL + ) + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Stream events from the invoice processing pipeline.""" + payload = input or {} + invoice_id = str(payload.get("invoice_id", "INV-2024-08731")) + currency = str(payload.get("currency", "USD")) + + with self.tracer.start_as_current_span( + "invoice_processing.execute", + attributes={ + "uipath.runtime.name": "InvoiceProcessingRuntime", + "uipath.runtime.type": "agent", + "uipath.runtime.entrypoint": self.entrypoint, + "uipath.input.invoice_id": invoice_id, + "uipath.input.currency": currency, + }, + ): + # 1. Ingest document — parse the invoice PDF + yield _state("ingest_document", S) + with self.tracer.start_as_current_span( + "ingest_document", + attributes={ + "uipath.step.kind": "preprocess", + "uipath.input.invoice_id": invoice_id, + "uipath.input.format": "PDF", + }, + ) as span: + await asyncio.sleep(0.4) + vendor = "Merck KGaA" + vendor_id = "VND-00412" + pages = 3 + doc_metadata = { + "vendor": vendor, + "vendor_id": vendor_id, + "invoice_date": "2024-11-15", + "due_date": "2024-12-15", + "pages": pages, + } + span.set_attribute("uipath.output.vendor", vendor) + span.set_attribute("uipath.output.vendor_id", vendor_id) + span.set_attribute("uipath.output.pages", pages) + yield _state("ingest_document", C, doc_metadata) + + # 2. Extract line items (first pass) — AI reads the invoice + yield _state("extract_line_items", S) + with self.tracer.start_as_current_span( + "extract_line_items", + attributes={ + "uipath.step.kind": "model", + "uipath.input.vendor": vendor, + "uipath.input.pages": pages, + "uipath.input.pass": 1, + }, + ) as span: + await asyncio.sleep(0.7) + line_items = [ + { + "description": "API-Grade Reagent Lot #R-2024-881", + "qty": 500, + "unit_price": 42.50, + "amount": 21250.00, + }, + { + "description": "HPLC Column (C18, 250mm)", + "qty": 12, + "unit_price": 890.00, + "amount": 10680.00, + }, + { + "description": "Lab Consumables Bundle Q4", + "qty": 1, + "unit_price": 3450.00, + "amount": 3450.00, + }, + ] + subtotal = 35380.00 + tax = 2830.40 + extracted_total = 38210.40 + span.set_attribute("uipath.output.line_items_count", len(line_items)) + span.set_attribute("uipath.output.subtotal", subtotal) + span.set_attribute("uipath.output.extracted_total", extracted_total) + yield _state( + "extract_line_items", + C, + { + "line_items_count": len(line_items), + "subtotal": subtotal, + "tax": tax, + "total": extracted_total, + }, + ) + + # 3. Validate amounts (first pass) — totals don't match + yield _state("validate_amounts", S) + with self.tracer.start_as_current_span( + "validate_amounts", + attributes={ + "uipath.step.kind": "validation", + "uipath.input.extracted_total": extracted_total, + "uipath.input.expected_tax_rate": 0.08, + "uipath.input.pass": 1, + }, + ) as span: + await asyncio.sleep(0.4) + expected_tax = subtotal * 0.08 + mismatch = abs(tax - expected_tax) > 0.01 + span.set_attribute("uipath.output.expected_tax", expected_tax) + span.set_attribute("uipath.output.actual_tax", tax) + span.set_attribute("uipath.output.mismatch", mismatch) + yield _state( + "validate_amounts", + C, + {"mismatch": mismatch, "expected_tax": expected_tax}, + ) + + # 4. Cycle back: re-extract line items (tax correction) + yield _state("extract_line_items", S) + with self.tracer.start_as_current_span( + "extract_line_items", + attributes={ + "uipath.step.kind": "model", + "uipath.input.reason": "tax_mismatch_correction", + "uipath.input.pass": 2, + }, + ) as span: + await asyncio.sleep(0.5) + tax = expected_tax # 2830.40 → correct + corrected_total = subtotal + tax + span.set_attribute("uipath.output.corrected_tax", tax) + span.set_attribute("uipath.output.corrected_total", corrected_total) + yield _state( + "extract_line_items", + C, + {"corrected_total": corrected_total, "tax": tax}, + ) + + # 5. Validate amounts (second pass) — now correct + yield _state("validate_amounts", S) + with self.tracer.start_as_current_span( + "validate_amounts", + attributes={ + "uipath.step.kind": "validation", + "uipath.input.corrected_total": corrected_total, + "uipath.input.pass": 2, + }, + ) as span: + await asyncio.sleep(0.3) + span.set_attribute("uipath.output.valid", True) + span.set_attribute("uipath.output.total_verified", corrected_total) + yield _state("validate_amounts", C, {"valid": True}) + + # 6. Fraud check + yield _state("fraud_check", S) + with self.tracer.start_as_current_span( + "fraud_check", + attributes={ + "uipath.step.kind": "model", + "uipath.input.vendor_id": vendor_id, + "uipath.input.total_amount": corrected_total, + "uipath.input.line_items_count": len(line_items), + }, + ) as span: + await asyncio.sleep(0.6) + fraud_score = 0.12 + fraud_flags = ["vendor_new_banking_details"] + span.set_attribute("uipath.output.fraud_score", fraud_score) + span.set_attribute("uipath.output.flags_count", len(fraud_flags)) + span.set_attribute("uipath.output.flags", ",".join(fraud_flags)) + span.set_attribute("uipath.output.passed", True) + yield _state( + "fraud_check", + C, + {"fraud_score": fraud_score, "flags": fraud_flags}, + ) + + # 7. Approval routing + yield _state("approval_routing", S) + with self.tracer.start_as_current_span( + "approval_routing", + attributes={ + "uipath.step.kind": "routing", + "uipath.input.total_amount": corrected_total, + "uipath.input.fraud_score": fraud_score, + "uipath.input.vendor": vendor, + }, + ) as span: + await asyncio.sleep(0.3) + # Amount > 25K requires manager approval + approval_level = ( + "manager" if corrected_total > 25000 else "auto_approved" + ) + approver = "Sarah Chen (Finance Director)" + span.set_attribute("uipath.output.approval_level", approval_level) + span.set_attribute("uipath.output.approver", approver) + span.set_attribute( + "uipath.output.threshold_exceeded", corrected_total > 25000 + ) + yield _state( + "approval_routing", + C, + {"approval_level": approval_level, "approver": approver}, + ) + + # 8. Generate payment + yield _state("generate_payment", S) + with self.tracer.start_as_current_span( + "generate_payment", + attributes={ + "uipath.step.kind": "postprocess", + "uipath.input.approval_level": approval_level, + "uipath.input.total_amount": corrected_total, + "uipath.input.currency": currency, + }, + ) as span: + await asyncio.sleep(0.4) + payment_ref = "PAY-2024-11-08731" + span.set_attribute("uipath.output.payment_reference", payment_ref) + span.set_attribute("uipath.output.payment_method", "SWIFT") + span.set_attribute("uipath.output.scheduled_date", "2024-12-10") + yield _state( + "generate_payment", + C, + {"payment_reference": payment_ref}, + ) + + yield UiPathRuntimeResult( + output={ + "status": "approved_pending_payment", + "total_amount": corrected_total, + "line_items_count": len(line_items), + "fraud_score": fraud_score, + "approval_level": approval_level, + "payment_reference": payment_ref, + }, + status=UiPathRuntimeStatus.SUCCESSFUL, + ) + + async def dispose(self) -> None: + """Dispose of any resources used by the invoice processing runtime.""" + pass diff --git a/demo/mock_movies_runtime.py b/demo/mock_movies_runtime.py new file mode 100644 index 0000000..b1f3b3d --- /dev/null +++ b/demo/mock_movies_runtime.py @@ -0,0 +1,496 @@ +"""Mock runtime that simulates a movie recommendation chat agent.""" + +import asyncio +import logging +from datetime import datetime +from typing import Any, AsyncGenerator +from uuid import uuid4 + +from opentelemetry import trace +from uipath.core.chat import UiPathConversationMessageEvent +from uipath.core.chat.content import ( + UiPathConversationContentPartChunkEvent, + UiPathConversationContentPartEndEvent, + UiPathConversationContentPartEvent, + UiPathConversationContentPartStartEvent, +) +from uipath.core.chat.message import ( + UiPathConversationMessageEndEvent, + UiPathConversationMessageStartEvent, +) +from uipath.core.chat.tool import ( + UiPathConversationToolCallEndEvent, + UiPathConversationToolCallEvent, + UiPathConversationToolCallStartEvent, +) +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeEvent, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.events import ( + UiPathRuntimeMessageEvent, + UiPathRuntimeStateEvent, + UiPathRuntimeStatePhase, +) +from uipath.runtime.schema import ( + UiPathRuntimeEdge, + UiPathRuntimeGraph, + UiPathRuntimeNode, + UiPathRuntimeSchema, +) + +ENTRYPOINT_MOVIES = "chat/movies.py:graph" + +logger = logging.getLogger(__name__) + +S = UiPathRuntimeStatePhase.STARTED +C = UiPathRuntimeStatePhase.COMPLETED + + +# --- Turn definitions ------------------------------------------------------- + +_TURNS: list[dict[str, Any]] = [ + # Turn: action movies + { + "keywords": ["action", "thriller", "fight", "explosion"], + "tools": [ + { + "name": "tavily_search", + "input": {"query": "best action movies 2024 2025 rated"}, + "output": "Top results: 1) Furiosa: A Mad Max Saga (2024) - 90% RT, 2) The Fall Guy (2024) - 81% RT, 3) Rebel Ridge (2024) - 95% RT", + }, + ], + "response": ( + "I searched for the latest highly-rated action movies. " + "Here are my top picks:\n\n" + "1. **Furiosa: A Mad Max Saga** (2024) — 90% on Rotten Tomatoes. " + "Anya Taylor-Joy stars in this epic prequel to Fury Road.\n\n" + "2. **Rebel Ridge** (2024) — 95% on RT. A tense thriller about " + "a former Marine facing a corrupt small-town police force.\n\n" + "3. **The Fall Guy** (2024) — 81% on RT. Ryan Gosling in a fun " + "action-comedy about Hollywood stunt performers.\n\n" + "Want me to look up more details about any of these, " + "or search for a different genre?" + ), + }, + # Turn: comedy + { + "keywords": ["comedy", "funny", "laugh", "humor"], + "tools": [ + { + "name": "tavily_search", + "input": {"query": "best comedy movies 2024 2025 critically acclaimed"}, + "output": "Top results: 1) Hit Man (2024) - 96% RT, 2) A Real Pain (2024) - 92% RT, 3) Hundreds of Beavers (2024) - 96% RT", + }, + ], + "response": ( + "Great choice! Here are some critically acclaimed comedies:\n\n" + "1. **Hit Man** (2024) — 96% on RT. Glen Powell in a hilarious " + "and surprisingly romantic story about a fake hitman.\n\n" + "2. **A Real Pain** (2024) — 92% on RT. Jesse Eisenberg and " + "Kieran Culkin in a bittersweet comedy about cousins touring Poland.\n\n" + "3. **Hundreds of Beavers** (2024) — 96% on RT. A wildly creative " + "low-budget slapstick comedy that critics are calling a modern classic.\n\n" + "Would you like me to find showtimes or streaming availability?" + ), + }, + # Turn: sci-fi / horror + { + "keywords": ["sci-fi", "horror", "scary", "space", "alien", "robot"], + "tools": [ + { + "name": "tavily_search", + "input": {"query": "best sci-fi horror movies recent highly rated"}, + "output": "Top results: 1) Alien: Romulus (2024) - 80% RT, 2) A Quiet Place: Day One (2024) - 87% RT, 3) The Substance (2024) - 89% RT", + }, + ], + "response": ( + "Here are some great sci-fi and horror picks:\n\n" + "1. **The Substance** (2024) — 89% on RT. Demi Moore in a " + "body-horror satire that's both terrifying and thought-provoking.\n\n" + "2. **A Quiet Place: Day One** (2024) — 87% on RT. The prequel " + "showing how the alien invasion first began in New York City.\n\n" + "3. **Alien: Romulus** (2024) — 80% on RT. A return to the " + "claustrophobic horror of the original Alien franchise.\n\n" + "Want me to search for something more specific?" + ), + }, + # Turn: details / specific movie + { + "keywords": [ + "tell me more", + "details", + "about", + "where", + "stream", + "watch", + "showtimes", + ], + "tools": [ + { + "name": "tavily_search", + "input": {"query": "where to stream watch movies 2024"}, + "output": "Streaming availability: Netflix has Hit Man, Amazon Prime has The Fall Guy, Apple TV+ has Killers of the Flower Moon, Disney+ has Furiosa", + }, + ], + "response": ( + "Here's where you can find these movies:\n\n" + "- **Netflix** — Hit Man, Rebel Ridge\n" + "- **Amazon Prime** — The Fall Guy, A Quiet Place: Day One\n" + "- **Apple TV+** — Killers of the Flower Moon\n" + "- **Disney+** — Furiosa: A Mad Max Saga\n" + "- **Hulu** — Alien: Romulus\n\n" + "Most of these are included with a subscription. " + "Would you like me to look up anything else?" + ), + }, + # Turn: thanks / positive + { + "keywords": ["thanks", "thank", "great", "awesome", "perfect", "bye"], + "tools": [ + { + "name": "tavily_search", + "input": {"query": "upcoming movies 2025 most anticipated"}, + "output": "Most anticipated: Mission Impossible 8 (May 2025), Jurassic World Rebirth (Jul 2025), Superman (Jul 2025)", + }, + ], + "response": ( + "You're welcome! Before you go, here are some exciting " + "upcoming releases to look forward to:\n\n" + "- **Mission: Impossible 8** — May 2025\n" + "- **Superman** — July 2025\n" + "- **Jurassic World Rebirth** — July 2025\n\n" + "Enjoy your movie night! Feel free to come back anytime " + "for more recommendations." + ), + }, + # Default / catch-all + { + "keywords": [], + "tools": [ + { + "name": "tavily_search", + "input": {"query": "best movies 2024 2025 all genres top rated"}, + "output": "Overall top: 1) Anora (2024) - 95% RT Palme d'Or, 2) Conclave (2024) - 93% RT, 3) Dune: Part Two (2024) - 92% RT, 4) The Brutalist (2024) - 89% RT", + }, + ], + "response": ( + "I'd be happy to help you find a great movie! " + "I searched for the top-rated films across all genres:\n\n" + "1. **Anora** (2024) — 95% on RT, Palme d'Or winner. " + "A captivating story about a Brooklyn sex worker who marries " + "the son of a Russian oligarch.\n\n" + "2. **Conclave** (2024) — 93% on RT. Ralph Fiennes leads a " + "gripping thriller set inside the Vatican's papal election.\n\n" + "3. **Dune: Part Two** (2024) — 92% on RT. The epic continuation " + "of Denis Villeneuve's sci-fi masterpiece.\n\n" + "4. **The Brutalist** (2024) — 89% on RT. Adrien Brody in a " + "sweeping drama about a Hungarian architect in post-war America.\n\n" + "What genre are you in the mood for? I can search for " + "action, comedy, sci-fi, horror, drama, or anything else!" + ), + }, +] + + +_movies_turn_index = 0 + + +def _next_turn() -> dict[str, Any]: + """Return the next turn in sequence, cycling through all turns.""" + global _movies_turn_index # noqa: PLW0603 + turn = _TURNS[_movies_turn_index % len(_TURNS)] + _movies_turn_index += 1 + return turn + + +class MockMoviesRuntime: + """Mock runtime for movie recommendation chat with streaming + telemetry + tools.""" + + def __init__(self, entrypoint: str = ENTRYPOINT_MOVIES) -> None: + """Initialize the MockMoviesRuntime.""" + self.entrypoint = entrypoint + self.tracer = trace.get_tracer("uipath.dev.mock.movies") + + async def get_schema(self) -> UiPathRuntimeSchema: + """Get the schema for the movies runtime.""" + return UiPathRuntimeSchema( + filePath=self.entrypoint, + uniqueId="mock-movies-runtime", + type="agent", + input={ + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": {"type": "string"}, + "content": {"type": "string"}, + }, + }, + "description": "Conversation history", + }, + }, + "required": ["messages"], + }, + output={ + "type": "object", + "properties": { + "reply": {"type": "string"}, + }, + }, + graph=UiPathRuntimeGraph( + nodes=[ + UiPathRuntimeNode( + id="__start__", name="__start__", type="__start__" + ), + UiPathRuntimeNode( + id="model", + name="model", + type="model", + metadata={ + "model_name": "claude-3-7-sonnet-latest", + "max_tokens": 64000, + }, + ), + UiPathRuntimeNode( + id="tools", + name="tools", + type="tool", + metadata={ + "tool_names": ["tavily_search"], + "tool_count": 1, + }, + ), + UiPathRuntimeNode(id="__end__", name="__end__", type="__end__"), + ], + edges=[ + UiPathRuntimeEdge(source="__start__", target="model"), + UiPathRuntimeEdge(source="model", target="__end__"), + UiPathRuntimeEdge(source="model", target="tools"), + UiPathRuntimeEdge(source="tools", target="model"), + ], + ), + ) + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + """Execute the movies runtime by consuming stream().""" + result: UiPathRuntimeResult | None = None + async for event in self.stream(input=input): + if isinstance(event, UiPathRuntimeResult): + result = event + return result or UiPathRuntimeResult( + output={}, status=UiPathRuntimeStatus.SUCCESSFUL + ) + + async def _emit_streaming_response( + self, text: str + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Emit chat streaming events: start → chunks → end.""" + message_id = str(uuid4()) + content_part_id = str(uuid4()) + + # message_start + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + start=UiPathConversationMessageStartEvent( + role="assistant", + timestamp=datetime.now().isoformat(), + ), + ), + ) + + # content_part_start + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + content_part=UiPathConversationContentPartEvent( + content_part_id=content_part_id, + start=UiPathConversationContentPartStartEvent( + mime_type="text/plain", + ), + ), + ), + ) + + # Stream word by word + words = text.split(" ") + for i, word in enumerate(words): + chunk = word if i == 0 else f" {word}" + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + content_part=UiPathConversationContentPartEvent( + content_part_id=content_part_id, + chunk=UiPathConversationContentPartChunkEvent(data=chunk), + ), + ), + ) + await asyncio.sleep(0.04) + + # content_part_end + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + content_part=UiPathConversationContentPartEvent( + content_part_id=content_part_id, + end=UiPathConversationContentPartEndEvent(), + ), + ), + ) + + # message_end + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + end=UiPathConversationMessageEndEvent(), + ), + ) + + async def _emit_tool_calls( + self, message_id: str, tools: list[dict[str, Any]] + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Emit tool call start/end events.""" + for tool in tools: + tool_call_id = f"call_{uuid4().hex[:12]}" + + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=tool_call_id, + start=UiPathConversationToolCallStartEvent( + tool_name=tool["name"], + timestamp=datetime.now().isoformat(), + input=tool["input"], + ), + ), + ), + ) + await asyncio.sleep(0.4) + + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=tool_call_id, + end=UiPathConversationToolCallEndEvent( + timestamp=datetime.now().isoformat(), + output=tool["output"], + ), + ), + ), + ) + + def _node_state( + self, node: str, phase: UiPathRuntimeStatePhase + ) -> UiPathRuntimeStateEvent: + return UiPathRuntimeStateEvent(node_name=node, phase=phase, payload={}) + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Stream events with tool calls, token streaming, and telemetry.""" + payload = input or {} + + # Extract user message from messages array + messages = payload.get("messages") or [] + message = "" + for msg in reversed(messages): + if msg.get("role") == "user": + message = str(msg.get("content", "")) + break + + turn = _next_turn() + reply = turn["response"] + tool_defs: list[dict[str, Any]] = turn["tools"] + + message_id = str(uuid4()) + + with self.tracer.start_as_current_span( + "movies.execute", + attributes={ + "uipath.runtime.name": "MoviesRuntime", + "uipath.runtime.entrypoint": self.entrypoint, + "uipath.input.message": message[:200], + "uipath.input.message.length": len(message), + "uipath.input.turn_keywords": ",".join(turn.get("keywords", [])), + }, + ): + # --- Model (first call — decides to search) --- + yield self._node_state("model", S) + with self.tracer.start_as_current_span( + "model", + attributes={ + "uipath.step.kind": "model", + "uipath.input.model": "claude-3-7-sonnet-latest", + "uipath.output.tool_calls_count": len(tool_defs), + "uipath.output.tool_names": ",".join(t["name"] for t in tool_defs), + }, + ): + # message_start for tool-call message + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + start=UiPathConversationMessageStartEvent( + role="assistant", + timestamp=datetime.now().isoformat(), + ), + ), + ) + # Emit tool call events + async for evt in self._emit_tool_calls(message_id, tool_defs): + yield evt + yield self._node_state("model", C) + + # --- Tools (execute search) --- + yield self._node_state("tools", S) + with self.tracer.start_as_current_span( + "tools", + attributes={ + "uipath.step.kind": "tool", + "uipath.input.tool_count": len(tool_defs), + "uipath.input.search_query": tool_defs[0] + .get("input", {}) + .get("query", ""), + "uipath.output.results": tool_defs[0].get("output", "")[:200], + }, + ): + await asyncio.sleep(0.5) + yield self._node_state("tools", C) + + # --- Model (second call — streaming response with search results) --- + yield self._node_state("model", S) + with self.tracer.start_as_current_span( + "model.final", + attributes={ + "uipath.step.kind": "model", + "uipath.input.has_tool_results": True, + "uipath.output.reply.length": len(reply), + "uipath.output.finish_reason": "end_turn", + }, + ): + async for evt in self._emit_streaming_response(reply): + yield evt + yield self._node_state("model", C) + + yield UiPathRuntimeResult( + output={"reply": reply}, + status=UiPathRuntimeStatus.SUCCESSFUL, + ) + + async def dispose(self) -> None: + """Dispose of any resources used by the movies runtime.""" + pass diff --git a/demo/mock_numbers_runtime.py b/demo/mock_numbers_runtime.py deleted file mode 100644 index f628cf6..0000000 --- a/demo/mock_numbers_runtime.py +++ /dev/null @@ -1,177 +0,0 @@ -"""An example mock runtime that analyzes numbers.""" - -import asyncio -import logging -from typing import Any, AsyncGenerator - -from opentelemetry import trace -from uipath.runtime import ( - UiPathExecuteOptions, - UiPathRuntimeEvent, - UiPathRuntimeResult, - UiPathRuntimeStatus, - UiPathStreamOptions, -) -from uipath.runtime.schema import ( - UiPathRuntimeEdge, - UiPathRuntimeGraph, - UiPathRuntimeNode, - UiPathRuntimeSchema, -) - -ENTRYPOINT_ANALYZE_NUMBERS = "agent/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: - """Initialize the MockNumberAnalyticsRuntime.""" - self.entrypoint = entrypoint - self.tracer = trace.get_tracer("uipath.dev.mock.number-analytics") - - async def get_schema(self) -> UiPathRuntimeSchema: - """Get the schema for the number analytics runtime.""" - 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"], - }, - graph=UiPathRuntimeGraph( - nodes=[ - UiPathRuntimeNode( - id="__start__", name="__start__", type="__start__" - ), - UiPathRuntimeNode( - id="validate_input", name="validate_input", type="node" - ), - UiPathRuntimeNode(id="compute", name="compute", type="node"), - UiPathRuntimeNode( - id="postprocess", name="postprocess", type="node" - ), - UiPathRuntimeNode(id="__end__", name="__end__", type="__end__"), - ], - edges=[ - UiPathRuntimeEdge(source="__start__", target="validate_input"), - UiPathRuntimeEdge(source="validate_input", target="compute"), - UiPathRuntimeEdge(source="compute", target="postprocess"), - UiPathRuntimeEdge(source="postprocess", target="__end__"), - ], - ), - ) - - async def execute( - self, - input: dict[str, Any] | None = None, - options: UiPathExecuteOptions | None = None, - ) -> UiPathRuntimeResult: - """Execute the number analytics runtime.""" - 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: dict[str, Any] | None = None, - options: UiPathStreamOptions | None = None, - ) -> AsyncGenerator[UiPathRuntimeEvent, None]: - """Stream events from the number analytics runtime.""" - logger.info("NumberAnalyticsRuntime: stream() invoked") - yield await self.execute(input=input, options=options) - - async def dispose(self) -> None: - """Dispose of any resources used by the number analytics runtime.""" - logger.info("NumberAnalyticsRuntime: dispose() invoked") diff --git a/demo/mock_pharma_runtime.py b/demo/mock_pharma_runtime.py new file mode 100644 index 0000000..4da6dc3 --- /dev/null +++ b/demo/mock_pharma_runtime.py @@ -0,0 +1,367 @@ +"""Mock Pharma Compliance Review runtime for demonstration purposes.""" + +import asyncio +import logging +from typing import Any, AsyncGenerator + +from opentelemetry import trace +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeEvent, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.events import ( + UiPathRuntimeStateEvent, + UiPathRuntimeStatePhase, +) +from uipath.runtime.schema import ( + UiPathRuntimeEdge, + UiPathRuntimeGraph, + UiPathRuntimeNode, + UiPathRuntimeSchema, +) + +ENTRYPOINT_PHARMA = "agent/pharma_compliance.py:review" + +logger = logging.getLogger(__name__) + +S = UiPathRuntimeStatePhase.STARTED +C = UiPathRuntimeStatePhase.COMPLETED + + +def _state( + node: str, + phase: UiPathRuntimeStatePhase, + payload: dict[str, Any] | None = None, +) -> UiPathRuntimeStateEvent: + return UiPathRuntimeStateEvent(node_name=node, phase=phase, payload=payload or {}) + + +class MockPharmaRuntime: + """Mock runtime that simulates a pharma compliance document review pipeline.""" + + def __init__(self, entrypoint: str = ENTRYPOINT_PHARMA) -> None: + """Initialize the MockPharmaRuntime.""" + self.entrypoint = entrypoint + self.tracer = trace.get_tracer("uipath.dev.mock.pharma-compliance") + + async def get_schema(self) -> UiPathRuntimeSchema: + """Get the schema for the pharma compliance review runtime.""" + return UiPathRuntimeSchema( + filePath=self.entrypoint, + uniqueId="mock-greeting-runtime", + type="agent", + input={ + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Document ID to review (e.g. SOP-2024-0142)", + }, + "review_type": { + "type": "string", + "enum": ["initial", "amendment", "periodic"], + "default": "initial", + "description": "Type of compliance review", + }, + }, + "required": ["document_id"], + }, + output={ + "type": "object", + "properties": { + "status": {"type": "string"}, + "findings": { + "type": "array", + "items": {"type": "string"}, + }, + "risk_level": {"type": "string"}, + "recommendation": {"type": "string"}, + }, + "required": ["status", "findings"], + }, + graph=UiPathRuntimeGraph( + nodes=[ + UiPathRuntimeNode( + id="__start__", name="__start__", type="__start__" + ), + UiPathRuntimeNode( + id="extract_metadata", + name="extract_metadata", + type="node", + ), + UiPathRuntimeNode( + id="classify_document", + name="classify_document", + type="model", + metadata={"model_name": "pharma-doc-classifier-v2"}, + ), + UiPathRuntimeNode( + id="check_references", + name="check_references", + type="tool", + metadata={ + "tool_names": [ + "fda_database_lookup", + "ema_guideline_search", + ], + "tool_count": 2, + }, + ), + UiPathRuntimeNode( + id="compliance_review", + name="compliance_review", + type="model", + metadata={"model_name": "gxp-compliance-reviewer"}, + ), + UiPathRuntimeNode( + id="risk_assessment", + name="risk_assessment", + type="node", + ), + UiPathRuntimeNode( + id="generate_report", + name="generate_report", + type="node", + ), + UiPathRuntimeNode(id="__end__", name="__end__", type="__end__"), + ], + edges=[ + UiPathRuntimeEdge(source="__start__", target="extract_metadata"), + UiPathRuntimeEdge( + source="extract_metadata", target="classify_document" + ), + UiPathRuntimeEdge( + source="classify_document", target="check_references" + ), + UiPathRuntimeEdge( + source="check_references", target="compliance_review" + ), + # Cycle: compliance review can request more references + UiPathRuntimeEdge( + source="compliance_review", + target="check_references", + label="needs_more_refs", + ), + UiPathRuntimeEdge( + source="compliance_review", target="risk_assessment" + ), + # Cycle: high risk triggers re-review with different parameters + UiPathRuntimeEdge( + source="risk_assessment", + target="compliance_review", + label="re_review", + ), + UiPathRuntimeEdge( + source="risk_assessment", target="generate_report" + ), + UiPathRuntimeEdge(source="generate_report", target="__end__"), + ], + ), + ) + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + """Execute the pharma compliance review by consuming stream().""" + result: UiPathRuntimeResult | None = None + async for event in self.stream(input=input): + if isinstance(event, UiPathRuntimeResult): + result = event + return result or UiPathRuntimeResult( + output={}, status=UiPathRuntimeStatus.SUCCESSFUL + ) + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Stream events from the pharma compliance review pipeline.""" + payload = input or {} + doc_id = str(payload.get("document_id", "SOP-2024-0142")) + review_type = str(payload.get("review_type", "initial")) + + with self.tracer.start_as_current_span( + "pharma_compliance.execute", + attributes={ + "uipath.runtime.name": "PharmaComplianceReview", + "uipath.runtime.type": "agent", + "uipath.runtime.entrypoint": self.entrypoint, + "uipath.input.document_id": doc_id, + "uipath.input.review_type": review_type, + }, + ): + # 1. Extract metadata + yield _state("extract_metadata", S) + with self.tracer.start_as_current_span( + "extract_metadata", + attributes={ + "uipath.step.kind": "preprocess", + "uipath.input.document_id": doc_id, + }, + ) as span: + await asyncio.sleep(0.4) + title = "Standard Operating Procedure: Batch Release Testing" + version = "3.1" + department = "Quality Assurance" + metadata = { + "title": title, + "version": version, + "department": department, + "effective_date": "2024-03-15", + } + span.set_attribute("uipath.output.title", title) + span.set_attribute("uipath.output.version", version) + yield _state("extract_metadata", C, metadata) + + # 2. Classify document + yield _state("classify_document", S) + with self.tracer.start_as_current_span( + "classify_document", + attributes={ + "uipath.step.kind": "model", + "uipath.input.title": title, + "uipath.input.department": department, + }, + ) as span: + await asyncio.sleep(0.6) + category = "GMP" + regulation = "21 CFR Part 211" + confidence = 0.94 + classification = { + "category": category, + "sub_category": "Quality Control", + "regulation": regulation, + "confidence": confidence, + } + span.set_attribute("uipath.output.category", category) + span.set_attribute("uipath.output.regulation", regulation) + span.set_attribute("uipath.output.confidence", confidence) + yield _state("classify_document", C, classification) + + # 3. Check references (first pass) + yield _state("check_references", S) + with self.tracer.start_as_current_span( + "check_references", + attributes={ + "uipath.step.kind": "tool", + "uipath.input.regulation": regulation, + "uipath.input.databases": "FDA,EMA", + }, + ) as span: + await asyncio.sleep(0.7) + ref_results = { + "fda_references": 3, + "ema_references": 2, + "outdated_refs": 1, + } + span.set_attribute("uipath.output.total_refs", 5) + span.set_attribute("uipath.output.outdated_refs", 1) + yield _state("check_references", C, ref_results) + + # 4. Compliance review (first pass) + yield _state("compliance_review", S) + with self.tracer.start_as_current_span( + "compliance_review", + attributes={ + "uipath.step.kind": "model", + "uipath.input.category": category, + "uipath.input.ref_count": 5, + "uipath.input.pass": 1, + }, + ) as span: + await asyncio.sleep(0.8) + findings = [ + "Section 4.2: Missing temperature range for stability testing", + "Section 7.1: Reference to withdrawn FDA guidance (2019-G-0041)", + "Appendix B: Sampling plan does not meet ICH Q7 requirements", + ] + needs_more = True + span.set_attribute("uipath.output.findings_count", len(findings)) + span.set_attribute("uipath.output.needs_more_refs", needs_more) + yield _state("compliance_review", C, {"findings": findings}) + + # 5. Cycle back: check_references (second pass — triggered by review) + yield _state("check_references", S) + with self.tracer.start_as_current_span( + "check_references", + attributes={ + "uipath.step.kind": "tool", + "uipath.input.query": "ICH Q7 sampling requirements", + "uipath.input.pass": 2, + }, + ) as span: + await asyncio.sleep(0.5) + span.set_attribute("uipath.output.additional_refs", 2) + yield _state("check_references", C, {"additional_refs": 2}) + + # 6. Compliance review (second pass — with additional refs) + yield _state("compliance_review", S) + with self.tracer.start_as_current_span( + "compliance_review", + attributes={ + "uipath.step.kind": "model", + "uipath.input.pass": 2, + "uipath.input.additional_refs": 2, + }, + ) as span: + await asyncio.sleep(0.6) + findings.append( + "Section 5.3: Deviation handling procedure lacks CAPA timeline" + ) + span.set_attribute("uipath.output.findings_count", len(findings)) + span.set_attribute("uipath.output.review_complete", True) + yield _state("compliance_review", C, {"findings": findings}) + + # 7. Risk assessment + yield _state("risk_assessment", S) + with self.tracer.start_as_current_span( + "risk_assessment", + attributes={ + "uipath.step.kind": "compute", + "uipath.input.findings_count": len(findings), + "uipath.input.review_type": review_type, + }, + ) as span: + await asyncio.sleep(0.4) + risk_level = "medium" + span.set_attribute("uipath.output.risk_level", risk_level) + span.set_attribute("uipath.output.risk_score", 6.2) + yield _state("risk_assessment", C, {"risk_level": risk_level}) + + # 8. Generate report + yield _state("generate_report", S) + with self.tracer.start_as_current_span( + "generate_report", + attributes={ + "uipath.step.kind": "postprocess", + "uipath.input.risk_level": risk_level, + "uipath.input.findings_count": len(findings), + }, + ) as span: + await asyncio.sleep(0.3) + recommendation = ( + "Revise document to address 4 findings before approval. " + "Priority: update withdrawn FDA reference and add CAPA timeline." + ) + span.set_attribute("uipath.output.recommendation", recommendation) + yield _state("generate_report", C) + + yield UiPathRuntimeResult( + output={ + "status": "review_complete", + "findings": findings, + "risk_level": risk_level, + "recommendation": recommendation, + }, + status=UiPathRuntimeStatus.SUCCESSFUL, + ) + + async def dispose(self) -> None: + """Dispose of any resources used by the pharma compliance runtime.""" + pass diff --git a/demo/mock_support_runtime.py b/demo/mock_support_runtime.py index 2a556e0..041b85b 100644 --- a/demo/mock_support_runtime.py +++ b/demo/mock_support_runtime.py @@ -1,10 +1,31 @@ -"""Mock runtime that simulates a support chat agent.""" +"""Mock runtime that simulates a support chat agent with token streaming and tool calls.""" import asyncio import logging +from datetime import datetime from typing import Any, AsyncGenerator +from uuid import uuid4 from opentelemetry import trace +from uipath.core.chat import UiPathConversationMessageEvent +from uipath.core.chat.content import ( + UiPathConversationContentPartChunkEvent, + UiPathConversationContentPartEndEvent, + UiPathConversationContentPartEvent, + UiPathConversationContentPartStartEvent, +) +from uipath.core.chat.interrupt import ( + UiPathConversationToolCallConfirmationValue, +) +from uipath.core.chat.message import ( + UiPathConversationMessageEndEvent, + UiPathConversationMessageStartEvent, +) +from uipath.core.chat.tool import ( + UiPathConversationToolCallEndEvent, + UiPathConversationToolCallEvent, + UiPathConversationToolCallStartEvent, +) from uipath.runtime import ( UiPathExecuteOptions, UiPathRuntimeEvent, @@ -12,6 +33,15 @@ UiPathRuntimeStatus, UiPathStreamOptions, ) +from uipath.runtime.events import ( + UiPathRuntimeMessageEvent, + UiPathRuntimeStateEvent, + UiPathRuntimeStatePhase, +) +from uipath.runtime.resumable.trigger import ( + UiPathResumeTrigger, + UiPathResumeTriggerType, +) from uipath.runtime.schema import ( UiPathRuntimeEdge, UiPathRuntimeGraph, @@ -19,18 +49,153 @@ UiPathRuntimeSchema, ) -ENTRYPOINT_SUPPORT_CHAT = "agent/support.py:main" +ENTRYPOINT_SUPPORT_CHAT = "chat/support.py:main" logger = logging.getLogger(__name__) +S = UiPathRuntimeStatePhase.STARTED +C = UiPathRuntimeStatePhase.COMPLETED + + +# --- Turn routing ----------------------------------------------------------- +# Each turn: (tool_name, tool_input, tool_output, response_text) +# We always call at least one tool per turn for a realistic demo. + +_TURNS: list[dict[str, Any]] = [ + # Turn 1: greeting / first contact — look up account + { + "keywords": [], # default / catch-all for first message + "tools": [ + { + "name": "tavily_search", + "input": {"query": "UiPath support knowledge base"}, + "output": "Found 3 relevant articles: KB-1024 'Getting Started', KB-2048 'Troubleshooting', KB-4096 'Best Practices'", + }, + ], + "needs_approval": False, + "response": ( + "Welcome to UiPath Support! I've searched our knowledge base for " + "relevant articles. Here's what I found:\n\n" + "- **KB-1024** — Getting Started with UiPath Studio\n" + "- **KB-2048** — Troubleshooting Common Issues\n" + "- **KB-4096** — Automation Best Practices\n\n" + "How can I help you today? Feel free to describe your issue " + "and I'll look into it right away." + ), + }, + # Turn 2: error / bug report — grep logs + create ticket (needs approval) + { + "keywords": ["error", "crash", "bug", "broken", "fail", "issue", "problem"], + "tools": [ + { + "name": "grep", + "input": {"pattern": "ERROR|FATAL", "path": "/var/log/uipath/"}, + "output": "[2026-02-17 09:12:33] ERROR OrchestratorConnection: timeout after 30s\n[2026-02-17 09:12:34] ERROR RetryPolicy: max retries exceeded", + }, + ], + "needs_approval": True, + "approval_tool": { + "name": "execute", + "input": { + "command": "create-ticket --priority high --category connection-timeout" + }, + "input_schema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Shell command to execute", + }, + }, + "required": ["command"], + }, + "output": "Ticket SUP-4821 created successfully. Assigned to engineering team.", + }, + "response": ( + "I found the issue in the logs — there's a connection timeout " + "to Orchestrator that's causing the failures:\n\n" + "```\n" + "[2026-02-17 09:12:33] ERROR OrchestratorConnection: timeout after 30s\n" + "[2026-02-17 09:12:34] ERROR RetryPolicy: max retries exceeded\n" + "```\n\n" + "I've created ticket **SUP-4821** and assigned it to our engineering team " + "with high priority. In the meantime, try these steps:\n\n" + "1. Check your network connectivity to Orchestrator\n" + "2. Verify the Orchestrator URL in your config\n" + "3. Restart the UiPath Robot service\n\n" + "Would you like me to check anything else?" + ), + }, + # Turn 3: follow-up / how-to — read docs + write summary + { + "keywords": ["how", "help", "guide", "docs", "config", "setup", "restart"], + "tools": [ + { + "name": "read_file", + "input": {"path": "/docs/troubleshooting/connection-timeout.md"}, + "output": "# Connection Timeout Troubleshooting\n\n1. Open UiPath Assistant\n2. Go to Settings > Connections\n3. Set timeout to 60s\n4. Click 'Test Connection'\n5. If still failing, check proxy settings", + }, + ], + "needs_approval": False, + "response": ( + "Here's a step-by-step guide to fix connection timeouts:\n\n" + "1. Open **UiPath Assistant** on your machine\n" + "2. Navigate to **Settings → Connections**\n" + "3. Increase the timeout value to **60 seconds**\n" + "4. Click **Test Connection** to verify\n" + "5. If it's still failing, check your proxy settings — " + "the connection might be going through a corporate proxy\n\n" + "Let me know if that resolves your issue, or if you need " + "further assistance!" + ), + }, + # Turn 4+: positive / thanks — update ticket + { + "keywords": ["thanks", "thank", "great", "awesome", "works", "fixed", "solved"], + "tools": [ + { + "name": "execute", + "input": { + "command": "update-ticket SUP-4821 --status resolved --note 'Customer confirmed fix'" + }, + "output": "Ticket SUP-4821 updated: status → resolved", + }, + ], + "needs_approval": False, + "response": ( + "Glad to hear it's working now! I've updated ticket **SUP-4821** " + "to resolved status.\n\n" + "Here's a summary of what we did:\n" + "- Identified connection timeout errors in the logs\n" + "- Increased the Orchestrator connection timeout to 60s\n" + "- Verified the connection is stable\n\n" + "If you run into any issues in the future, don't hesitate to reach out. " + "Have a great day!" + ), + }, +] + + +_support_turn_index = 0 + + +def _next_turn() -> dict[str, Any]: + """Return the next turn in sequence, cycling through all turns.""" + global _support_turn_index # noqa: PLW0603 + turn = _TURNS[_support_turn_index % len(_TURNS)] + _support_turn_index += 1 + return turn + class MockSupportChatRuntime: - """Mock runtime that simulates a tiny support agent.""" + """Mock runtime that simulates a support agent with chat streaming and tool calls.""" def __init__(self, entrypoint: str = ENTRYPOINT_SUPPORT_CHAT) -> None: """Initialize the MockSupportChatRuntime.""" self.entrypoint = entrypoint self.tracer = trace.get_tracer("uipath.dev.mock.support-chat") + self._suspended_turn: dict[str, Any] | None = None + self._suspended_message_id: str | None = None async def get_schema(self) -> UiPathRuntimeSchema: """Get the schema for the support chat runtime.""" @@ -45,10 +210,16 @@ async def get_schema(self) -> UiPathRuntimeSchema: "type": "string", "description": "User message to the support bot", }, - "previousIssues": { + "messages": { "type": "array", - "items": {"type": "string"}, - "description": "Optional prior issues for context", + "items": { + "type": "object", + "properties": { + "role": {"type": "string"}, + "content": {"type": "string"}, + }, + }, + "description": "Conversation history for chat mode", }, }, "required": ["message"], @@ -57,8 +228,6 @@ async def get_schema(self) -> UiPathRuntimeSchema: "type": "object", "properties": { "reply": {"type": "string"}, - "sentiment": {"type": "string"}, - "escalated": {"type": "boolean"}, }, "required": ["reply"], }, @@ -80,18 +249,12 @@ async def get_schema(self) -> UiPathRuntimeSchema: type="tool", metadata={ "tool_names": [ - "write_todos", - "ls", + "tavily_search", "read_file", - "write_file", - "edit_file", - "glob", "grep", "execute", - "task", - "tavily_search", ], - "tool_count": 10, + "tool_count": 4, }, ), UiPathRuntimeNode( @@ -148,90 +311,381 @@ async def execute( input: dict[str, Any] | None = None, options: UiPathExecuteOptions | None = None, ) -> UiPathRuntimeResult: - """Execute the support chat runtime.""" + """Execute the support chat runtime by consuming stream().""" + result: UiPathRuntimeResult | None = None + async for event in self.stream(input=input): + if isinstance(event, UiPathRuntimeResult): + result = event + return result or UiPathRuntimeResult( + output={}, status=UiPathRuntimeStatus.SUCCESSFUL + ) + + async def _emit_streaming_response( + self, text: str + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Emit chat streaming events for a response: start → chunks → end.""" + message_id = str(uuid4()) + content_part_id = str(uuid4()) + + # message_start + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + start=UiPathConversationMessageStartEvent( + role="assistant", + timestamp=datetime.now().isoformat(), + ), + ), + ) + + # content_part_start + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + content_part=UiPathConversationContentPartEvent( + content_part_id=content_part_id, + start=UiPathConversationContentPartStartEvent( + mime_type="text/plain", + ), + ), + ), + ) + + # Stream word by word + words = text.split(" ") + for i, word in enumerate(words): + chunk = word if i == 0 else f" {word}" + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + content_part=UiPathConversationContentPartEvent( + content_part_id=content_part_id, + chunk=UiPathConversationContentPartChunkEvent(data=chunk), + ), + ), + ) + await asyncio.sleep(0.04) + + # content_part_end + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + content_part=UiPathConversationContentPartEvent( + content_part_id=content_part_id, + end=UiPathConversationContentPartEndEvent(), + ), + ), + ) + + # message_end + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + end=UiPathConversationMessageEndEvent(), + ), + ) + + async def _emit_tool_calls( + self, message_id: str, tools: list[dict[str, Any]] + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Emit tool call start/end events for each tool in the list.""" + for tool in tools: + tool_call_id = f"call_{uuid4().hex[:12]}" + + # tool_call start + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=tool_call_id, + start=UiPathConversationToolCallStartEvent( + tool_name=tool["name"], + timestamp=datetime.now().isoformat(), + input=tool["input"], + ), + ), + ), + ) + await asyncio.sleep(0.3) + + # tool_call end + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=tool_call_id, + end=UiPathConversationToolCallEndEvent( + timestamp=datetime.now().isoformat(), + output=tool["output"], + ), + ), + ), + ) + + def _node_state( + self, node: str, phase: UiPathRuntimeStatePhase + ) -> UiPathRuntimeStateEvent: + return UiPathRuntimeStateEvent(node_name=node, phase=phase, payload={}) + + async def _stream_phase2( + self, turn: dict[str, Any] + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Phase 2: after tool approval — tools → Summarization → model response → TodoList.""" + reply = turn["response"] + approval_tool = turn.get("approval_tool", {}) + + # --- Tools (execute approved tool call) --- + yield self._node_state("tools", S) + with self.tracer.start_as_current_span( + "tools", + attributes={ + "uipath.step.kind": "tool", + "uipath.input.tool_name": approval_tool.get("name", ""), + "uipath.output.result": approval_tool.get("output", ""), + }, + ): + await asyncio.sleep(0.5) + yield self._node_state("tools", C) + + # --- Middleware: Summarization (with tool results) --- + yield self._node_state("SummarizationMiddleware.before_model", S) + with self.tracer.start_as_current_span( + "SummarizationMiddleware.before_model.2", + attributes={"uipath.step.kind": "middleware"}, + ): + await asyncio.sleep(0.15) + yield self._node_state("SummarizationMiddleware.before_model", C) + + # --- Model (streaming final response) --- + yield self._node_state("model", S) + with self.tracer.start_as_current_span( + "model.2", + attributes={ + "uipath.step.kind": "model", + "uipath.output.reply.length": len(reply), + }, + ): + async for evt in self._emit_streaming_response(reply): + yield evt + yield self._node_state("model", C) + + # --- Middleware: TodoList (routes to __end__) --- + yield self._node_state("TodoListMiddleware.after_model", S) + with self.tracer.start_as_current_span( + "TodoListMiddleware.after_model.2", + attributes={ + "uipath.step.kind": "middleware", + "uipath.output.route": "__end__", + }, + ): + await asyncio.sleep(0.1) + yield self._node_state("TodoListMiddleware.after_model", C) + + yield UiPathRuntimeResult( + output={"reply": reply}, + status=UiPathRuntimeStatus.SUCCESSFUL, + ) + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Stream events from the support chat runtime with tool calls and token streaming.""" + is_resuming = options.resume if options else False + + # --- Resume after tool approval --- + if is_resuming and self._suspended_turn is not None: + turn = self._suspended_turn + self._suspended_turn = None + self._suspended_message_id = None + + with self.tracer.start_as_current_span( + "support_chat.resume", + attributes={ + "uipath.runtime.name": "SupportChatRuntime", + "uipath.step.kind": "resume", + }, + ): + async for evt in self._stream_phase2(turn): + yield evt + return + + # --- Normal flow --- payload = input or {} + + # Extract message from either 'message' or last user message in 'messages' + messages = payload.get("messages") or [] message = str(payload.get("message", "")).strip() - previous = payload.get("previousIssues") or [] + if not message and messages: + for msg in reversed(messages): + if msg.get("role") == "user": + message = str(msg.get("content", "")) + break + + turn = _next_turn() + reply = turn["response"] + tool_defs: list[dict[str, Any]] = turn["tools"] + needs_approval = turn.get("needs_approval", False) + + # Shared message_id for the assistant turn (tool calls + response) + message_id = str(uuid4()) with self.tracer.start_as_current_span( "support_chat.execute", attributes={ "uipath.runtime.name": "SupportChatRuntime", "uipath.runtime.entrypoint": self.entrypoint, + "uipath.input.message": message[:200], "uipath.input.message.length": len(message), - "uipath.input.previous_issues": len(previous), + "uipath.input.turn_keywords": ",".join(turn.get("keywords", [])), }, ): - logger.info("SupportChatRuntime: starting execution") + # --- Middleware: PatchToolCalls --- + yield self._node_state("PatchToolCallsMiddleware.before_agent", S) + with self.tracer.start_as_current_span( + "PatchToolCallsMiddleware.before_agent", + attributes={ + "uipath.step.kind": "middleware", + "uipath.output.tools_patched": len(tool_defs), + }, + ): + await asyncio.sleep(0.15) + yield self._node_state("PatchToolCallsMiddleware.before_agent", C) - # Classify sentiment + # --- Middleware: Summarization (first) --- + yield self._node_state("SummarizationMiddleware.before_model", S) with self.tracer.start_as_current_span( - "support_chat.classify_sentiment", - attributes={"uipath.step.kind": "analysis"}, + "SummarizationMiddleware.before_model", + attributes={ + "uipath.step.kind": "middleware", + "uipath.input.message_count": len(messages) + 1, + }, + ): + await asyncio.sleep(0.2) + yield self._node_state("SummarizationMiddleware.before_model", C) + + # --- Model (first call — decides to use tools) --- + yield self._node_state("model", S) + with self.tracer.start_as_current_span( + "model", + attributes={ + "uipath.step.kind": "model", + "uipath.output.tool_calls_count": len(tool_defs), + "uipath.output.tool_names": ",".join(t["name"] for t in tool_defs), + }, + ): + # message_start for tool-call message + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + start=UiPathConversationMessageStartEvent( + role="assistant", + timestamp=datetime.now().isoformat(), + ), + ), + ) + # Emit tool call events from the model + async for evt in self._emit_tool_calls(message_id, tool_defs): + yield evt + yield self._node_state("model", C) + + # --- Middleware: TodoList (routes to tools) --- + yield self._node_state("TodoListMiddleware.after_model", S) + with self.tracer.start_as_current_span( + "TodoListMiddleware.after_model", + attributes={ + "uipath.step.kind": "middleware", + "uipath.output.route": "tools", + }, ): 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 + yield self._node_state("TodoListMiddleware.after_model", C) + + # --- Tools (execute the tool calls) --- + yield self._node_state("tools", S) with self.tracer.start_as_current_span( - "support_chat.generate_reply", + "tools", attributes={ - "uipath.step.kind": "generate", - "uipath.sentiment": sentiment, + "uipath.step.kind": "tool", + "uipath.input.tool_count": len(tool_defs), + "uipath.output.results": "; ".join( + t.get("output", "")[:100] for t in tool_defs + ), }, + ): + await asyncio.sleep(0.5) + yield self._node_state("tools", C) + + # --- Check if this turn needs tool approval --- + if needs_approval: + approval_tool = turn["approval_tool"] + interrupt_id = str(uuid4()) + tool_call_id = f"call_{uuid4().hex[:12]}" + + # Store state for resume + self._suspended_turn = turn + self._suspended_message_id = message_id + + yield UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUSPENDED, + output={"paused_for_approval": approval_tool["name"]}, + triggers=[ + UiPathResumeTrigger( + interrupt_id=interrupt_id, + trigger_type=UiPathResumeTriggerType.API, + payload=UiPathConversationToolCallConfirmationValue( + tool_call_id=tool_call_id, + tool_name=approval_tool["name"], + input_schema=approval_tool["input_schema"], + input_value=approval_tool["input"], + ), + ), + ], + ) + return + + # --- No approval needed — continue with phase 2 inline --- + + # --- Middleware: Summarization (second pass with tool results) --- + yield self._node_state("SummarizationMiddleware.before_model", S) + with self.tracer.start_as_current_span( + "SummarizationMiddleware.before_model.2", + attributes={"uipath.step.kind": "middleware"}, ): 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 + yield self._node_state("SummarizationMiddleware.before_model", C) + + # --- Model (second call — streaming final response with tool results) --- + yield self._node_state("model", S) with self.tracer.start_as_current_span( - "support_chat.decide_escalation", - attributes={"uipath.step.kind": "decision"}, + "model.2", + attributes={ + "uipath.step.kind": "model", + "uipath.output.reply.length": len(reply), + }, ): - 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}, - ) + async for evt in self._emit_streaming_response(reply): + yield evt + yield self._node_state("model", C) - return UiPathRuntimeResult( - output=result_payload, + # --- Middleware: TodoList (routes to __end__) --- + yield self._node_state("TodoListMiddleware.after_model", S) + with self.tracer.start_as_current_span( + "TodoListMiddleware.after_model.2", + attributes={ + "uipath.step.kind": "middleware", + "uipath.output.route": "__end__", + }, + ): + await asyncio.sleep(0.1) + yield self._node_state("TodoListMiddleware.after_model", C) + + yield UiPathRuntimeResult( + output={"reply": reply}, status=UiPathRuntimeStatus.SUCCESSFUL, ) - async def stream( - self, - input: dict[str, Any] | None = None, - options: UiPathStreamOptions | None = None, - ) -> AsyncGenerator[UiPathRuntimeEvent, None]: - """Stream events from the support chat runtime.""" - logger.info("SupportChatRuntime: stream() invoked") - yield await self.execute(input=input, options=options) - async def dispose(self) -> None: """Dispose of any resources used by the support chat runtime.""" - logger.info("SupportChatRuntime: dispose() invoked") + pass diff --git a/demo/mock_telemetry_runtime.py b/demo/mock_telemetry_runtime.py index 1deb19a..67f1670 100644 --- a/demo/mock_telemetry_runtime.py +++ b/demo/mock_telemetry_runtime.py @@ -1,8 +1,8 @@ -"""Mock runtime that simulates a multi-step workflow with rich telemetry.""" +"""Mock runtime that simulates a multi-agent supervisor workflow with rich telemetry.""" import asyncio import logging -from typing import Any, AsyncGenerator +from typing import Any, AsyncGenerator, cast from opentelemetry import trace from uipath.runtime import ( @@ -13,6 +13,10 @@ UiPathStreamOptions, ) from uipath.runtime.debug import UiPathBreakpointResult +from uipath.runtime.events import ( + UiPathRuntimeStateEvent, + UiPathRuntimeStatePhase, +) from uipath.runtime.schema import ( UiPathRuntimeEdge, UiPathRuntimeGraph, @@ -25,27 +29,32 @@ logger = logging.getLogger(__name__) +def _state( + node: str, + phase: UiPathRuntimeStatePhase, + payload: dict[str, Any] | None = None, + qualified: str | None = None, +) -> UiPathRuntimeStateEvent: + return UiPathRuntimeStateEvent( + node_name=node, + qualified_node_name=qualified, + phase=phase, + payload=payload or {}, + ) + + +S = UiPathRuntimeStatePhase.STARTED +C = UiPathRuntimeStatePhase.COMPLETED + + class MockTelemetryRuntime: - """A mock runtime that simulates a multi-step workflow with rich telemetry.""" + """A mock runtime that simulates a supervisor/researcher/coder multi-agent loop.""" def __init__(self, entrypoint: str = ENTRYPOINT_TELEMETRY) -> None: """Initialize the MockTelemetryRuntime.""" self.entrypoint = entrypoint self.tracer = trace.get_tracer("uipath.dev.mock.context") - # State tracking for breakpoints self.current_step_index: int = 0 - self.steps = [ - ("initialize.environment", "initialize-environment", "init"), - ("validate.input", "validate-input", "validation"), - ("preprocess.data", "preprocess-data", "preprocess"), - ("compute.result", "compute-result", "compute"), - ("compute.embeddings", "compute-embeddings", "compute-subtask"), - ("query.knowledgebase", "query-knowledgebase", "io"), - ("postprocess.results", "postprocess-results", "postprocess"), - ("generate.output", "generate-output", "postprocess-subtask"), - ("persist.artifacts", "persist-artifacts", "io"), - ("cleanup.resources", "cleanup-resources", "cleanup"), - ] async def get_schema(self) -> UiPathRuntimeSchema: """Get the schema for the mock telemetry runtime.""" @@ -177,405 +186,231 @@ async def execute( input: dict[str, Any] | None = None, options: UiPathExecuteOptions | None = None, ) -> UiPathRuntimeResult: - """Execute the mock telemetry runtime.""" + """Execute the mock telemetry runtime by consuming stream().""" + result: UiPathRuntimeResult | None = None + async for event in self.stream( + input=input, options=cast(UiPathStreamOptions, options) + ): + if isinstance(event, UiPathRuntimeResult): + result = event + if isinstance(event, UiPathBreakpointResult): + return event + return result or UiPathRuntimeResult( + output={}, status=UiPathRuntimeStatus.SUCCESSFUL + ) + + async def _run_subgraph( + self, parent: str, span_prefix: str + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Simulate a subgraph execution: model → tools → model.""" + tool_name = "tavily_search" if parent == "researcher" else "python_repl_tool" + + # model call 1 + yield _state("model", S, qualified=f"{parent}/model") + with self.tracer.start_as_current_span( + f"{span_prefix}.model", + attributes={ + "uipath.step.kind": "model", + "uipath.input.model": "claude-3-7-sonnet-latest", + "uipath.input.max_tokens": 64000, + }, + ) as span: + await asyncio.sleep(0.8) + span.set_attribute("uipath.output.tool_calls", 1) + span.set_attribute("uipath.output.tool_name", tool_name) + yield _state("model", C, qualified=f"{parent}/model") + + # tools + yield _state("tools", S, qualified=f"{parent}/tools") + with self.tracer.start_as_current_span( + f"{span_prefix}.tools", + attributes={ + "uipath.step.kind": "tool", + "uipath.input.tool_name": tool_name, + }, + ) as span: + await asyncio.sleep(0.6) + span.set_attribute("uipath.output.success", True) + span.set_attribute( + "uipath.output.result_length", + 256 if parent == "researcher" else 128, + ) + yield _state("tools", C, qualified=f"{parent}/tools") + + # model call 2 + yield _state("model", S, qualified=f"{parent}/model") + with self.tracer.start_as_current_span( + f"{span_prefix}.model_final", + attributes={ + "uipath.step.kind": "model", + "uipath.input.has_tool_results": True, + }, + ) as span: + await asyncio.sleep(0.5) + span.set_attribute("uipath.output.finish_reason", "end_turn") + span.set_attribute("uipath.output.response_length", 512) + yield _state("model", C, qualified=f"{parent}/model") + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Stream events simulating supervisor/researcher/coder multi-agent loop.""" payload = input or {} - entrypoint = "mock-entrypoint" message = str(payload.get("message", "")) - message_length = len(message) - # Check breakpoints breakpoints = options.breakpoints if options else None should_break_all = breakpoints == "*" should_break_on = set(breakpoints) if isinstance(breakpoints, list) else set() is_resuming = options.resume if options else False - # If resuming, skip to next step + # Steps: (node_name, next_node_for_breakpoint) + steps = [ + "input", + "supervisor", + "researcher", + "supervisor", + "coder", + "supervisor", + "output", + ] + if is_resuming: self.current_step_index += 1 - # Root span for entire execution 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, + "uipath.runtime.entrypoint": self.entrypoint, + "uipath.input.message.length": len(message), }, - ) as root_span: - # Execute from current step - while self.current_step_index < len(self.steps): - span_name, step_name, step_kind = self.steps[self.current_step_index] + ): + while self.current_step_index < len(steps): + step = steps[self.current_step_index] - # Create nested spans based on step - if step_name == "initialize-environment": + if step == "input": + yield _state("input", S) with self.tracer.start_as_current_span( - span_name, + "input", attributes={ - "uipath.step.name": step_name, - "uipath.step.kind": step_kind, + "uipath.step.kind": "input", + "uipath.input.message": message[:200], + "uipath.input.message.length": len(message), }, - ): - logger.info(f"MockRuntime: executing {step_name}") - print(f"[MockRuntime] ▶️ Executing: {step_name}") - - # Nested: Load config - with self.tracer.start_as_current_span( - "init.load_config", - attributes={"uipath.config.source": "default"}, - ): - await asyncio.sleep(2) - - # Nested: Setup resources - with self.tracer.start_as_current_span( - "init.setup_resources", - attributes={"uipath.resources.count": 3}, - ): - await asyncio.sleep(2) - - # Nested: Initialize connections - with self.tracer.start_as_current_span( - "init.connections", - attributes={"uipath.connections.type": "http"}, - ): - await asyncio.sleep(2) - - elif step_name == "validate-input": + ) as span: + await asyncio.sleep(0.3) + span.set_attribute( + "uipath.output.tokens_estimated", len(message) // 4 + ) + yield _state("input", C, {"message": message}) + + elif step == "supervisor": + yield _state("supervisor", S) + # Determine routing decision + next_idx = self.current_step_index + 1 + next_step = steps[next_idx] if next_idx < len(steps) else "output" with self.tracer.start_as_current_span( - span_name, + "supervisor", attributes={ - "uipath.step.name": step_name, - "uipath.step.kind": step_kind, + "uipath.step.kind": "model", + "uipath.input.step_index": self.current_step_index, + "uipath.input.candidates": "researcher,coder,output", }, - ): - logger.info(f"MockRuntime: executing {step_name}") - print(f"[MockRuntime] ▶️ Executing: {step_name}") - - # Nested: Schema validation - with self.tracer.start_as_current_span( - "validate.schema", - attributes={"uipath.schema.valid": True}, - ): - await asyncio.sleep(2) - - # Nested: Type checking - with self.tracer.start_as_current_span( - "validate.types", - attributes={"uipath.types.checked": 2}, - ): - await asyncio.sleep(2) - - elif step_name == "preprocess-data": + ) as span: + await asyncio.sleep(0.5) + span.set_attribute("uipath.output.decision", next_step) + span.set_attribute("uipath.output.confidence", 0.92) + yield _state("supervisor", C, {"next": next_step}) + + elif step == "researcher": + yield _state("researcher", S) with self.tracer.start_as_current_span( - span_name, + "researcher", attributes={ - "uipath.step.name": step_name, - "uipath.step.kind": step_kind, + "uipath.step.kind": "subgraph", + "uipath.input.task": "web_research", + "uipath.input.query": message[:100], }, - ): - logger.info(f"MockRuntime: executing {step_name}") - print(f"[MockRuntime] ▶️ Executing: {step_name}") - - # Nested: Normalize text - with self.tracer.start_as_current_span( - "preprocess.normalize", - attributes={"uipath.text.normalized": True}, - ): - await asyncio.sleep(2) - - # Nested: Tokenization - with self.tracer.start_as_current_span( - "preprocess.tokenize", - attributes={"uipath.tokens.count": 42}, - ): - await asyncio.sleep(2) - - elif step_name == "compute-result": + ) as span: + async for evt in self._run_subgraph("researcher", "researcher"): + yield evt + span.set_attribute("uipath.output.sources_found", 3) + span.set_attribute("uipath.output.summary.length", 450) + yield _state("researcher", C) + + elif step == "coder": + yield _state("coder", S) with self.tracer.start_as_current_span( - span_name, + "coder", attributes={ - "uipath.step.name": step_name, - "uipath.step.kind": step_kind, + "uipath.step.kind": "subgraph", + "uipath.input.task": "code_generation", + "uipath.input.language": "python", }, - ): - logger.info(f"MockRuntime: executing {step_name}") - print(f"[MockRuntime] ▶️ Executing: {step_name}") - - # Nested: Feature extraction - with self.tracer.start_as_current_span( - "compute.extract_features", - attributes={"uipath.features.count": 128}, - ): - await asyncio.sleep(2) - - # Nested: Model inference - with self.tracer.start_as_current_span( - "compute.inference", - attributes={ - "uipath.model.name": "mock-model-v1", - "uipath.inference.batch_size": 1, - }, - ): - # Deeply nested: Load weights - with self.tracer.start_as_current_span( - "inference.load_weights", - attributes={"uipath.weights.size_mb": 150}, - ): - await asyncio.sleep(2) - - # Deeply nested: Forward pass - with self.tracer.start_as_current_span( - "inference.forward_pass", - attributes={"uipath.layers.executed": 12}, - ): - await asyncio.sleep(2) - - elif step_name == "compute-embeddings": + ) as span: + async for evt in self._run_subgraph("coder", "coder"): + yield evt + span.set_attribute("uipath.output.lines_generated", 47) + span.set_attribute("uipath.output.tests_passed", True) + yield _state("coder", C) + + elif step == "output": + yield _state("output", S) with self.tracer.start_as_current_span( - span_name, + "output", attributes={ - "uipath.step.name": step_name, - "uipath.step.kind": step_kind, + "uipath.step.kind": "output", + "uipath.input.message": message[:100], }, - ): - logger.info(f"MockRuntime: executing {step_name}") - print(f"[MockRuntime] ▶️ Executing: {step_name}") - - # Nested: Encode text - with self.tracer.start_as_current_span( - "embeddings.encode", - attributes={"uipath.embedding.dim": 768}, - ): - await asyncio.sleep(2) - - # Nested: Normalize vectors - with self.tracer.start_as_current_span( - "embeddings.normalize", - attributes={"uipath.normalization.method": "l2"}, - ): - await asyncio.sleep(2) - - elif step_name == "query-knowledgebase": - with self.tracer.start_as_current_span( - span_name, - attributes={ - "uipath.step.name": step_name, - "uipath.step.kind": step_kind, - }, - ): - logger.info(f"MockRuntime: executing {step_name}") - print(f"[MockRuntime] ▶️ Executing: {step_name}") - - # Nested: Build query - with self.tracer.start_as_current_span( - "kb.build_query", - attributes={"uipath.query.type": "vector_search"}, - ): - await asyncio.sleep(2) - - # Nested: Execute search - with self.tracer.start_as_current_span( - "kb.search", - attributes={ - "uipath.kb.index": "documents-v2", - "uipath.kb.top_k": 5, - }, - ): - # Deeply nested: Vector similarity - with self.tracer.start_as_current_span( - "search.vector_similarity", - attributes={"uipath.similarity.metric": "cosine"}, - ): - await asyncio.sleep(2) - - # Deeply nested: Rank results - with self.tracer.start_as_current_span( - "search.rank_results", - attributes={"uipath.ranking.algorithm": "bm25"}, - ): - await asyncio.sleep(2) - - # Nested: Filter results - with self.tracer.start_as_current_span( - "kb.filter_results", - attributes={"uipath.results.filtered": 3}, - ): - await asyncio.sleep(2) - - elif step_name == "postprocess-results": - with self.tracer.start_as_current_span( - span_name, - attributes={ - "uipath.step.name": step_name, - "uipath.step.kind": step_kind, - }, - ): - logger.info(f"MockRuntime: executing {step_name}") - print(f"[MockRuntime] ▶️ Executing: {step_name}") - - # Nested: Format output - with self.tracer.start_as_current_span( - "postprocess.format", - attributes={"uipath.format.type": "json"}, - ): - await asyncio.sleep(2) - - # Nested: Apply templates - with self.tracer.start_as_current_span( - "postprocess.templates", - attributes={"uipath.template.name": "standard_response"}, - ): - await asyncio.sleep(2) - - elif step_name == "generate-output": - with self.tracer.start_as_current_span( - span_name, - attributes={ - "uipath.step.name": step_name, - "uipath.step.kind": step_kind, - }, - ): - logger.info(f"MockRuntime: executing {step_name}") - print(f"[MockRuntime] ▶️ Executing: {step_name}") - - # Nested: Serialize data - with self.tracer.start_as_current_span( - "output.serialize", - attributes={"uipath.serialization.format": "json"}, - ): - await asyncio.sleep(2) - - # Nested: Add metadata - with self.tracer.start_as_current_span( - "output.add_metadata", - attributes={"uipath.metadata.fields": 5}, - ): - await asyncio.sleep(2) - - elif step_name == "persist-artifacts": - with self.tracer.start_as_current_span( - span_name, - attributes={ - "uipath.step.name": step_name, - "uipath.step.kind": step_kind, - }, - ): - logger.info(f"MockRuntime: executing {step_name}") - print(f"[MockRuntime] ▶️ Executing: {step_name}") - - # Nested: Compress data - with self.tracer.start_as_current_span( - "persist.compress", - attributes={"uipath.compression.algorithm": "gzip"}, - ): - await asyncio.sleep(2) - - # Nested: Write to storage - with self.tracer.start_as_current_span( - "persist.write", - attributes={"uipath.storage.backend": "s3"}, - ): - await asyncio.sleep(2) - - elif step_name == "cleanup-resources": - with self.tracer.start_as_current_span( - span_name, - attributes={ - "uipath.step.name": step_name, - "uipath.step.kind": step_kind, - }, - ): - logger.info(f"MockRuntime: executing {step_name}") - print(f"[MockRuntime] ▶️ Executing: {step_name}") - - # Nested: Close connections - with self.tracer.start_as_current_span( - "cleanup.close_connections", - attributes={"uipath.connections.closed": 3}, - ): - await asyncio.sleep(2) - - # Nested: Free memory - with self.tracer.start_as_current_span( - "cleanup.free_memory", - attributes={"uipath.memory.freed_mb": 512}, - ): - await asyncio.sleep(2) - - else: - # Default simple span for any other steps - with self.tracer.start_as_current_span( - span_name, - attributes={ - "uipath.step.name": step_name, - "uipath.step.kind": step_kind, - }, - ): - logger.info(f"MockRuntime: executing {step_name}") - print(f"[MockRuntime] ▶️ Executing: {step_name}") - await asyncio.sleep(2) - - # Check if we should break AFTER executing this step - if should_break_all or step_name in should_break_on: - logger.info(f"MockRuntime: hitting breakpoint at {step_name}") - print(f"[MockRuntime] 🔴 Breakpoint hit: {step_name}") - - # Determine next nodes + ) as span: + await asyncio.sleep(0.3) + span.set_attribute("uipath.output.format", "markdown") + span.set_attribute("uipath.output.length", 820) + yield _state("output", C) + + # Check breakpoints after each top-level step + if should_break_all or step in should_break_on: next_nodes = [] - if self.current_step_index + 1 < len(self.steps): - next_nodes = [self.steps[self.current_step_index + 1][1]] + if self.current_step_index + 1 < len(steps): + next_nodes = [steps[self.current_step_index + 1]] - return UiPathBreakpointResult( + yield UiPathBreakpointResult( status=UiPathRuntimeStatus.SUSPENDED, - breakpoint_node=step_name, + breakpoint_node=step, breakpoint_type="after", current_state={ - "paused_at": step_name, + "paused_at": step, "step_index": self.current_step_index, "payload": payload, "message": message, }, next_nodes=next_nodes, output={ - "paused_at": step_name, + "paused_at": step, "step_index": self.current_step_index, }, ) + return - # Move to next step self.current_step_index += 1 - # All steps completed - reset state + # All steps completed — reset self.current_step_index = 0 - root_span.set_attribute("uipath.runtime.status", "success") - root_span.set_attribute("uipath.runtime.steps_executed", len(self.steps)) - - result_payload = { - "result": f"Mock runtime processed: {payload.get('message', '')}", + yield UiPathRuntimeResult( + output={ + "result": f"Mock runtime processed: {message or ''}", "metadata": { - "entrypoint": entrypoint, - "message_length": message_length, + "entrypoint": self.entrypoint, + "message_length": len(message), }, - } - - return UiPathRuntimeResult( - output=result_payload, - status=UiPathRuntimeStatus.SUCCESSFUL, - ) - - async def stream( - self, - input: dict[str, Any] | None = None, - options: UiPathStreamOptions | None = None, - ) -> AsyncGenerator[UiPathRuntimeEvent, None]: - """Stream events from the mock telemetry runtime.""" - logger.info("MockRuntime: stream() invoked") - print("[MockRuntime] stream() invoked") - yield await self.execute(input=input, options=options) + }, + status=UiPathRuntimeStatus.SUCCESSFUL, + ) async def dispose(self) -> None: """Dispose of any resources used by the mock telemetry runtime.""" - logger.info("MockRuntime: dispose() invoked") - print("[MockRuntime] dispose() invoked") + pass diff --git a/demo/mock_template_runtime.py b/demo/mock_template_runtime.py index 25984b9..beac510 100644 --- a/demo/mock_template_runtime.py +++ b/demo/mock_template_runtime.py @@ -171,7 +171,7 @@ async def stream( async def dispose(self) -> None: """Cleanup resources.""" - logger.info("MockTemplateRuntime: dispose() invoked") + pass def create_template_runtime( diff --git a/pyproject.toml b/pyproject.toml index dbfb8cc..80d5dfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-dev" -version = "0.0.51" +version = "0.0.52" description = "UiPath Developer Console" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/dev/server/frontend/src/App.tsx b/src/uipath/dev/server/frontend/src/App.tsx index 086d44c..4b2f891 100644 --- a/src/uipath/dev/server/frontend/src/App.tsx +++ b/src/uipath/dev/server/frontend/src/App.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRunStore } from "./store/useRunStore"; import { useWebSocket } from "./store/useWebSocket"; import { listRuns, listEntrypoints, getRun } from "./api/client"; import type { RunDetail } from "./types/run"; import { useHashRoute } from "./hooks/useHashRoute"; +import { useIsMobile } from "./hooks/useIsMobile"; import Sidebar from "./components/layout/Sidebar"; import NewRunPanel from "./components/runs/NewRunPanel"; import SetupView from "./components/runs/SetupView"; @@ -12,6 +13,8 @@ import ReloadToast from "./components/shared/ReloadToast"; export default function App() { const ws = useWebSocket(); + const isMobile = useIsMobile(); + const [sidebarOpen, setSidebarOpen] = useState(false); const { runs, selectedRunId, @@ -149,24 +152,44 @@ export default function App() { const handleRunCreated = (runId: string) => { navigate(`#/runs/${runId}/traces`); selectRun(runId); + setSidebarOpen(false); }; const handleSelectRun = (runId: string) => { navigate(`#/runs/${runId}/traces`); selectRun(runId); + setSidebarOpen(false); }; const handleNewRun = () => { navigate("#/new"); + setSidebarOpen(false); }; return ( -
+
+ {/* Mobile hamburger button */} + {isMobile && !sidebarOpen && ( + + )} setSidebarOpen(false)} />
{view === "new" ? ( @@ -177,9 +200,10 @@ export default function App() { mode={setupMode} ws={ws} onRunCreated={handleRunCreated} + isMobile={isMobile} /> ) : selectedRun ? ( - + ) : (
Select a run or create a new one diff --git a/src/uipath/dev/server/frontend/src/components/debug/DebugControls.tsx b/src/uipath/dev/server/frontend/src/components/debug/DebugControls.tsx index 35feb4d..4158279 100644 --- a/src/uipath/dev/server/frontend/src/components/debug/DebugControls.tsx +++ b/src/uipath/dev/server/frontend/src/components/debug/DebugControls.tsx @@ -22,7 +22,7 @@ export default function DebugControls({ runId, status, ws, breakpointNode }: Pro return (
diff --git a/src/uipath/dev/server/frontend/src/components/graph/GraphPanel.tsx b/src/uipath/dev/server/frontend/src/components/graph/GraphPanel.tsx index 3b20a8b..11b8bd8 100644 --- a/src/uipath/dev/server/frontend/src/components/graph/GraphPanel.tsx +++ b/src/uipath/dev/server/frontend/src/components/graph/GraphPanel.tsx @@ -12,7 +12,8 @@ import ReactFlow, { type ReactFlowInstance, } from "reactflow"; import "reactflow/dist/style.css"; -import ELK, { type ElkNode, type ElkExtendedEdge } from "elkjs/lib/elk.bundled.js"; +import type { ElkNode, ElkExtendedEdge } from "elkjs/lib/elk.bundled.js"; +import type ELKType from "elkjs/lib/elk.bundled.js"; import type { TraceSpan } from "../../types/run"; import type { GraphData } from "../../types/graph"; import { getEntrypointGraph } from "../../api/client"; @@ -67,8 +68,15 @@ function computeNodeHeight( return h; } -// ─── ELK layout engine ────────────────────────────────────────────── -const elk = new ELK(); +// ─── ELK layout engine (lazy-loaded) ──────────────────────────────── +let elk: InstanceType | null = null; +async function getElk() { + if (!elk) { + const { default: ELK } = await import("elkjs/lib/elk.bundled.js"); + elk = new ELK(); + } + return elk; +} const ELK_OPTIONS: Record = { "elk.algorithm": "layered", @@ -226,7 +234,8 @@ async function runElkLayout( graphData: GraphData, ): Promise<{ nodes: Node[]; edges: Edge[] }> { const elkGraph = buildElkGraph(graphData); - const layout = await elk.layout(elkGraph); + const elkInstance = await getElk(); + const layout = await elkInstance.layout(elkGraph); // Build lookup: prefixed-id → { type, data } from original graph data const nodeInfo = new Map< diff --git a/src/uipath/dev/server/frontend/src/components/layout/Sidebar.tsx b/src/uipath/dev/server/frontend/src/components/layout/Sidebar.tsx index 2e90ddd..a293454 100644 --- a/src/uipath/dev/server/frontend/src/components/layout/Sidebar.tsx +++ b/src/uipath/dev/server/frontend/src/components/layout/Sidebar.tsx @@ -7,9 +7,12 @@ interface Props { selectedRunId: string | null; onSelectRun: (id: string) => void; onNewRun: () => void; + isMobile?: boolean; + isOpen?: boolean; + onClose?: () => void; } -export default function Sidebar({ runs, selectedRunId, onSelectRun, onNewRun }: Props) { +export default function Sidebar({ runs, selectedRunId, onSelectRun, onNewRun, isMobile, isOpen, onClose }: Props) { const { theme, toggleTheme } = useTheme(); const sorted = [...runs].sort( @@ -18,10 +21,108 @@ export default function Sidebar({ runs, selectedRunId, onSelectRun, onNewRun }: new Date(a.start_time ?? 0).getTime(), ); + // On mobile: hidden unless open, renders as overlay drawer + if (isMobile) { + if (!isOpen) return null; + return ( + <> + {/* Backdrop */} +
+ {/* Drawer */} + + + ); + } + + // Desktop: unchanged return (
+ ); + } + + // Desktop layout (unchanged) + const sidebarTabs: { id: SidebarTab; label: string; count?: number }[] = [ + { id: "primary", label: primaryLabel }, + { id: "io", label: "I/O" }, + { id: "logs", label: "Logs", count: logs.length }, + ]; + return (
{/* Main content: graph + trace tree */} @@ -165,6 +261,7 @@ export default function RunDetailsPanel({ run, ws }: Props) { {/* Drag handle */}
@@ -178,6 +275,7 @@ export default function RunDetailsPanel({ run, ws }: Props) { {/* Sidebar drag handle */}
@@ -190,7 +288,7 @@ export default function RunDetailsPanel({ run, ws }: Props) { > {/* Sidebar tab bar */}
{sidebarTabs.map((tab) => ( @@ -232,12 +330,14 @@ export default function RunDetailsPanel({ run, ws }: Props) {
{sidebarTab === "primary" && ( isChatMode ? ( - + Loading chat...
}> + + ) : ( ) diff --git a/src/uipath/dev/server/frontend/src/components/runs/SetupView.tsx b/src/uipath/dev/server/frontend/src/components/runs/SetupView.tsx index 8421bac..f146808 100644 --- a/src/uipath/dev/server/frontend/src/components/runs/SetupView.tsx +++ b/src/uipath/dev/server/frontend/src/components/runs/SetupView.tsx @@ -11,9 +11,10 @@ interface Props { mode: "run" | "chat"; ws: WsClient; onRunCreated: (runId: string) => void; + isMobile?: boolean; } -export default function SetupView({ entrypoint, mode, ws, onRunCreated }: Props) { +export default function SetupView({ entrypoint, mode, ws, onRunCreated, isMobile }: Props) { const [inputJson, setInputJson] = useState("{}"); const [mockInput, setMockInput] = useState>({}); const [loading, setLoading] = useState(false); @@ -127,19 +128,22 @@ export default function SetupView({ entrypoint, mode, ws, onRunCreated }: Props) // Textarea top-border drag resize const onTextareaDragStart = useCallback( - (e: React.MouseEvent) => { + (e: React.MouseEvent | React.TouchEvent) => { e.preventDefault(); - const startY = e.clientY; + const startY = "touches" in e ? e.touches[0].clientY : e.clientY; const startH = textareaHeight; - const onMove = (ev: MouseEvent) => { - const newH = Math.max(60, startH + (startY - ev.clientY)); + const onMove = (ev: MouseEvent | TouchEvent) => { + const clientY = "touches" in ev ? ev.touches[0].clientY : ev.clientY; + const newH = Math.max(60, startH + (startY - clientY)); setTextareaHeight(newH); }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); + document.removeEventListener("touchmove", onMove); + document.removeEventListener("touchend", onUp); document.body.style.cursor = ""; document.body.style.userSelect = ""; localStorage.setItem("setupTextareaHeight", String(textareaHeight)); @@ -149,28 +153,33 @@ export default function SetupView({ entrypoint, mode, ws, onRunCreated }: Props) document.body.style.userSelect = "none"; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); + document.addEventListener("touchmove", onMove, { passive: false }); + document.addEventListener("touchend", onUp); }, [textareaHeight], ); // Panel resize const onPanelResizeStart = useCallback( - (e: React.MouseEvent) => { + (e: React.MouseEvent | React.TouchEvent) => { e.preventDefault(); - const startX = e.clientX; + const startX = "touches" in e ? e.touches[0].clientX : e.clientX; const startW = panelWidth; - const onMove = (ev: MouseEvent) => { + const onMove = (ev: MouseEvent | TouchEvent) => { const container = containerRef.current; if (!container) return; + const clientX = "touches" in ev ? ev.touches[0].clientX : ev.clientX; const maxW = container.clientWidth - 300; - const newW = Math.max(280, Math.min(maxW, startW + (startX - ev.clientX))); + const newW = Math.max(280, Math.min(maxW, startW + (startX - clientX))); setPanelWidth(newW); }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); + document.removeEventListener("touchmove", onMove); + document.removeEventListener("touchend", onUp); document.body.style.cursor = ""; document.body.style.userSelect = ""; localStorage.setItem("setupPanelWidth", String(panelWidth)); @@ -181,6 +190,8 @@ export default function SetupView({ entrypoint, mode, ws, onRunCreated }: Props) document.body.style.userSelect = "none"; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); + document.addEventListener("touchmove", onMove, { passive: false }); + document.addEventListener("touchend", onUp); }, [panelWidth], ); @@ -188,213 +199,241 @@ export default function SetupView({ entrypoint, mode, ws, onRunCreated }: Props) const modeLabel = isRunMode ? "Autonomous" : "Conversational"; const modeColor = isRunMode ? "var(--success)" : "var(--accent)"; - return ( -
- {/* Graph */} -
- -
- - {/* Drag handle */} + // Shared input panel content + const inputPanel = ( +
+ {/* Header */}
-
+ + {modeLabel}
- {/* Side panel */} -
- {/* Header */} -
+ - - {modeLabel} -
- - {/* Placeholder area */} -
- + + + + ) : ( + + )} + +
+

+ {isRunMode ? "Ready to execute" : "Ready to chat"} +

+

+ Click nodes to set breakpoints {isRunMode ? ( - <> - - - + <>,
configure input below, then run ) : ( - + <>,
then send your first message )} - -

-

- {isRunMode ? "Ready to execute" : "Ready to chat"} -

-

- Click nodes to set breakpoints - {isRunMode ? ( - <>,
configure input below, then run - ) : ( - <>,
then send your first message - )} -

-
+

+
- {/* Bottom input section */} - {isRunMode ? ( - /* Autonomous: JSON textarea + Execute */ -
- {/* Drag handle (the top border) */} + {/* Bottom input section */} + {isRunMode ? ( + /* Autonomous: JSON textarea + Execute */ +
+ {/* Drag handle (the top border) — hidden on mobile */} + {!isMobile && (
-
- {schemaError ? ( -
+ {schemaError ? ( +
+ {schemaError} +
+ ) : ( + <> + +