From 022c83d725fb45642ceec63a6bb659281467060e Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Tue, 17 Jun 2025 14:00:06 +0300 Subject: [PATCH 1/2] chore: use hitl methods from core --- pyproject.toml | 4 +- src/uipath_llamaindex/_cli/_runtime/_hitl.py | 197 ------------------ .../_cli/_runtime/_runtime.py | 8 +- uv.lock | 11 +- 4 files changed, 13 insertions(+), 207 deletions(-) delete mode 100644 src/uipath_llamaindex/_cli/_runtime/_hitl.py diff --git a/pyproject.toml b/pyproject.toml index 8e9d1d2..0f92b85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-llamaindex" -version = "0.0.24" +version = "0.0.25" description = "UiPath LlamaIndex SDK" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.10" @@ -9,7 +9,7 @@ dependencies = [ "llama-index-embeddings-azure-openai>=0.3.8", "llama-index-llms-azure-openai>=0.3.2", "openinference-instrumentation-llama-index>=4.3.0", - "uipath>=2.0.64", + "uipath>=2.0.65, <2.1.0", ] classifiers = [ "Development Status :: 3 - Alpha", diff --git a/src/uipath_llamaindex/_cli/_runtime/_hitl.py b/src/uipath_llamaindex/_cli/_runtime/_hitl.py deleted file mode 100644 index 05dfe0c..0000000 --- a/src/uipath_llamaindex/_cli/_runtime/_hitl.py +++ /dev/null @@ -1,197 +0,0 @@ -# TODO: extract this to core - -import json -import uuid -from dataclasses import dataclass -from functools import cached_property -from typing import Any, Optional - -from llama_index.core.workflow import InputRequiredEvent -from uipath import UiPath -from uipath._cli._runtime._contracts import ( - UiPathApiTrigger, - UiPathErrorCategory, - UiPathResumeTrigger, - UiPathResumeTriggerType, - UiPathRuntimeError, - UiPathRuntimeStatus, -) -from uipath.models import CreateAction, InvokeProcess, WaitAction, WaitJob - - -def _try_convert_to_json_format(value: str) -> str: - try: - return json.loads(value) - except json.decoder.JSONDecodeError: - return value - - -async def _get_api_payload(inbox_id: str) -> Any: - """ - Fetch payload data for API triggers. - - Args: - inbox_id: The Id of the inbox to fetch the payload for. - - Returns: - The value field from the API response payload, or None if an error occurs. - """ - response = None - try: - uipath = UiPath() - response = uipath.api_client.request( - "GET", - f"/orchestrator_/api/JobTriggers/GetPayload/{inbox_id}", - include_folder_headers=True, - ) - data = response.json() - return data.get("payload") - except Exception as e: - raise UiPathRuntimeError( - "API_CONNECTION_ERROR", - "Failed to get trigger payload", - f"Error fetching API trigger payload for inbox {inbox_id}: {str(e)}", - UiPathErrorCategory.SYSTEM, - response.status_code if response else None, - ) from e - - -class HitlReader: - @classmethod - async def read(cls, resume_trigger: UiPathResumeTrigger) -> Optional[str]: - uipath = UiPath() - match resume_trigger.trigger_type: - case UiPathResumeTriggerType.ACTION: - if resume_trigger.item_key: - action = await uipath.actions.retrieve_async( - resume_trigger.item_key, - app_folder_key=resume_trigger.folder_key, - app_folder_path=resume_trigger.folder_path, - ) - return action.data - - case UiPathResumeTriggerType.JOB: - if resume_trigger.item_key: - job = await uipath.jobs.retrieve_async( - resume_trigger.item_key, - folder_key=resume_trigger.folder_key, - folder_path=resume_trigger.folder_path, - ) - if ( - job.state - and not job.state.lower() - == UiPathRuntimeStatus.SUCCESSFUL.value.lower() - ): - raise UiPathRuntimeError( - "INVOKED_PROCESS_FAILURE", - "Invoked process did not finish successfully.", - _try_convert_to_json_format(str(job.job_error or job.info)), - ) - return job.output_arguments - - case UiPathResumeTriggerType.API: - if resume_trigger.api_resume and resume_trigger.api_resume.inbox_id: - return await _get_api_payload(resume_trigger.api_resume.inbox_id) - - case _: - raise UiPathRuntimeError( - "UNKNOWN_TRIGGER_TYPE", - "Unexpected trigger type received", - f"Trigger type :{type(resume_trigger.trigger_type)} is invalid", - UiPathErrorCategory.USER, - ) - - raise UiPathRuntimeError( - "HITL_FEEDBACK_FAILURE", - "Failed to receive payload from HITL action", - detail="Failed to receive payload from HITL action", - category=UiPathErrorCategory.SYSTEM, - ) - - -@dataclass -class HitlProcessor: - """Processes events in a Human-(Robot/Agent)-In-The-Loop scenario.""" - - value: Any - - @cached_property - def type(self) -> Optional[UiPathResumeTriggerType]: - """Returns the type of the interrupt value.""" - if isinstance(self.value, CreateAction) or isinstance(self.value, WaitAction): - return UiPathResumeTriggerType.ACTION - if isinstance(self.value, InvokeProcess) or isinstance(self.value, WaitJob): - return UiPathResumeTriggerType.JOB - if isinstance(self.value, InputRequiredEvent): - return UiPathResumeTriggerType.API - return UiPathResumeTriggerType.NONE - - async def create_resume_trigger(self) -> Optional[UiPathResumeTrigger]: - """Returns the resume trigger.""" - uipath = UiPath() - try: - hitl_input = self.value - resume_trigger = UiPathResumeTrigger( - triggerType=self.type, interruptObject=hitl_input.model_dump_json() - ) - match self.type: - case UiPathResumeTriggerType.ACTION: - resume_trigger.folder_path = hitl_input.app_folder_path - resume_trigger.folder_key = hitl_input.app_folder_key - if isinstance(hitl_input, WaitAction): - resume_trigger.item_key = hitl_input.action.key - elif isinstance(hitl_input, CreateAction): - action = await uipath.actions.create_async( - title=hitl_input.title, - app_name=hitl_input.app_name if hitl_input.app_name else "", - app_folder_path=hitl_input.app_folder_path - if hitl_input.app_folder_path - else "", - app_folder_key=hitl_input.app_folder_key - if hitl_input.app_folder_key - else "", - app_key=hitl_input.app_key if hitl_input.app_key else "", - app_version=hitl_input.app_version - if hitl_input.app_version - else 1, - assignee=hitl_input.assignee if hitl_input.assignee else "", - data=hitl_input.data, - ) - if action: - resume_trigger.item_key = action.key - - case UiPathResumeTriggerType.JOB: - resume_trigger.folder_path = hitl_input.process_folder_path - resume_trigger.folder_key = hitl_input.process_folder_key - if isinstance(hitl_input, WaitJob): - resume_trigger.item_key = hitl_input.job.key - elif isinstance(hitl_input, InvokeProcess): - job = await uipath.processes.invoke_async( - name=hitl_input.name, - input_arguments=hitl_input.input_arguments, - folder_path=hitl_input.process_folder_path, - folder_key=hitl_input.process_folder_key, - ) - if job: - resume_trigger.item_key = job.key - - case UiPathResumeTriggerType.API: - resume_trigger.api_resume = UiPathApiTrigger( - inboxId=str(uuid.uuid4()), request=hitl_input.prefix - ) - case _: - raise UiPathRuntimeError( - "UNKNOWN_HITL_MODEL", - "Unexpected model received", - f"{type(hitl_input)} is not a valid Human(Robot/Agent)-In-The-Loop model", - UiPathErrorCategory.USER, - ) - except Exception as e: - raise UiPathRuntimeError( - "HITL_ACTION_CREATION_FAILED", - "Failed to create HITL action", - f"{str(e)}", - UiPathErrorCategory.SYSTEM, - ) from e - - return resume_trigger diff --git a/src/uipath_llamaindex/_cli/_runtime/_runtime.py b/src/uipath_llamaindex/_cli/_runtime/_runtime.py index e423b07..11a99bb 100644 --- a/src/uipath_llamaindex/_cli/_runtime/_runtime.py +++ b/src/uipath_llamaindex/_cli/_runtime/_runtime.py @@ -26,12 +26,12 @@ UiPathRuntimeResult, UiPathRuntimeStatus, ) +from uipath._cli._runtime._hitl import HitlProcessor, HitlReader from uipath.tracing import TracingManager from .._tracing._oteladapter import LlamaIndexExporter from ._context import UiPathLlamaIndexRuntimeContext from ._exception import UiPathLlamaIndexRuntimeError -from ._hitl import HitlProcessor, HitlReader logger = logging.getLogger(__name__) @@ -98,7 +98,8 @@ async def execute(self) -> Optional[UiPathRuntimeResult]: async for event in handler.stream_events(): # log the received event on trace level if isinstance(event, InputRequiredEvent): - hitl_processor = HitlProcessor(value=event) + # for api trigger hitl scenarios only pass the str input for processing + hitl_processor = HitlProcessor(value=event.prefix) if self.context.resume and not response_applied: # If we are resuming, we need to apply the response to the event stream. response_applied = True @@ -296,6 +297,9 @@ async def get_response_event(self) -> Optional[HumanResponseEvent]: if feedback: if isinstance(feedback, dict): feedback = json.dumps(feedback) + elif isinstance(feedback, bool): + # special handling for default escalation scenarios + feedback = str(feedback) return HumanResponseEvent(response=feedback) return None diff --git a/uv.lock b/uv.lock index 8fa173b..167a7f8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12'", @@ -2848,7 +2847,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.0.64" +version = "2.0.66" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-monitor-opentelemetry" }, @@ -2862,14 +2861,14 @@ dependencies = [ { name = "tenacity" }, { name = "tomli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/8e/1f660202ff18c244012e732bc51f23767536c2e5debc119120ea31b7f19e/uipath-2.0.64.tar.gz", hash = "sha256:f77cd254384a03dd9475f0daa152f5d92e409a1c2b76bc866edb982b071fd988", size = 1840220 } +sdist = { url = "https://files.pythonhosted.org/packages/16/51/dd3c07e194d9899ada9abb4bdfd8198bbe18f6db3e1451c670c6ec3484f5/uipath-2.0.66.tar.gz", hash = "sha256:81592b8ac4891220b16dce94eb1f3b5b4ad59fe05102f77a3fd07263e824e1b5", size = 1829440 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/1a/f22e837b8528cf067fb700975a8fdd7403d95c0e7f9ffe9ffae5a276f155/uipath-2.0.64-py3-none-any.whl", hash = "sha256:8ec14ee26a96aa1fad8b19d137013ab5fd599e867f45c11c4643922c0bdcd223", size = 123261 }, + { url = "https://files.pythonhosted.org/packages/e2/82/8c13b337aabf49eddc1d3a8ce315a16f7bab83f339746c8569c61b2ceddc/uipath-2.0.66-py3-none-any.whl", hash = "sha256:4f75b1edcb543ba495c96b2647aed0d64bc707119a3ef7c03948576cb27083b7", size = 125292 }, ] [[package]] name = "uipath-llamaindex" -version = "0.0.24" +version = "0.0.26" source = { editable = "." } dependencies = [ { name = "llama-index" }, @@ -2896,7 +2895,7 @@ requires-dist = [ { name = "llama-index-embeddings-azure-openai", specifier = ">=0.3.8" }, { name = "llama-index-llms-azure-openai", specifier = ">=0.3.2" }, { name = "openinference-instrumentation-llama-index", specifier = ">=4.3.0" }, - { name = "uipath", specifier = ">=2.0.64" }, + { name = "uipath", specifier = ">=2.0.65,<2.1.0" }, ] [package.metadata.requires-dev] From 3b181faf62a2617998aa522893e508e527f01197 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Tue, 17 Jun 2025 14:03:42 +0300 Subject: [PATCH 2/2] ci: add github actions to support test-core-dev-version label --- .github/workflows/lint-custom-version.yml | 92 +++++++++++++++++++ .github/workflows/lint.yml | 13 +++ .github/workflows/test-custom-version.yml | 102 ++++++++++++++++++++++ .github/workflows/test.yml | 11 +++ 4 files changed, 218 insertions(+) create mode 100644 .github/workflows/lint-custom-version.yml create mode 100644 .github/workflows/test-custom-version.yml diff --git a/.github/workflows/lint-custom-version.yml b/.github/workflows/lint-custom-version.yml new file mode 100644 index 0000000..c68f00f --- /dev/null +++ b/.github/workflows/lint-custom-version.yml @@ -0,0 +1,92 @@ +name: Lint Custom Version + +on: + workflow_call: + pull_request: + types: [opened, synchronize, labeled, unlabeled] + +jobs: + lint-with-custom-version: + name: Lint with Custom UiPath Version + runs-on: ubuntu-latest + if: contains(github.event.pull_request.labels.*.name, 'test-core-dev-version') + permissions: + contents: read + pull-requests: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract version from PR + id: extract-version + shell: bash + run: | + # Extract version from PR title only + PR_TITLE="${{ github.event.pull_request.title }}" + + # Search for version pattern in title (any x.y.z.dev version) + VERSION=$(echo "$PR_TITLE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+' | head -1) + + if [ -z "$VERSION" ]; then + echo "No version found in PR title. Please include version in title like: 2.0.65.dev1004030443" + exit 1 + fi + + echo "Extracted version: $VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Modify pyproject.toml for custom UiPath version + shell: bash + run: | + # Backup original pyproject.toml + cp pyproject.toml pyproject.toml.backup + + # Update the uipath dependency to the custom version + sed -i 's|"uipath>=.*"|"uipath==${{ steps.extract-version.outputs.version }}"|' pyproject.toml + + + + # Add or update [tool.uv.sources] section if it doesn't exist + if ! grep -q "\[tool\.uv\.sources\]" pyproject.toml; then + echo "" >> pyproject.toml + echo "[tool.uv.sources]" >> pyproject.toml + echo 'uipath = { index = "testpypi" }' >> pyproject.toml + else + # Update existing sources if needed + if ! grep -q 'uipath = { index = "testpypi" }' pyproject.toml; then + sed -i '/\[tool\.uv\.sources\]/a uipath = { index = "testpypi" }' pyproject.toml + fi + fi + + echo "Modified pyproject.toml to use UiPath version ${{ steps.extract-version.outputs.version }} from testpypi" + echo "=== Modified pyproject.toml content ===" + grep -A5 -B5 "uipath\|testpypi" pyproject.toml || true + + - name: Install dependencies + run: uv sync --all-extras + + - name: Check static types + run: uv run mypy --config-file pyproject.toml . + + - name: Check linting + run: uv run ruff check . + + - name: Check formatting + run: uv run ruff format --check . + + - name: Restore original pyproject.toml + if: always() + shell: bash + run: | + mv pyproject.toml.backup pyproject.toml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c37daeb..83cb59d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,9 +4,22 @@ on: workflow_call jobs: + skip-lint: + name: Skip Lint (Custom Version Testing) + runs-on: ubuntu-latest + if: contains(github.event.pull_request.labels.*.name, 'test-core-dev-version') + permissions: + contents: read + steps: + - name: Skip lint for custom version testing + run: | + echo "Custom version testing enabled - skipping normal lint process" + echo "This job completes successfully to allow PR merging" + lint: name: Lint runs-on: ubuntu-latest + if: "!contains(github.event.pull_request.labels.*.name, 'test-core-dev-version')" permissions: contents: read diff --git a/.github/workflows/test-custom-version.yml b/.github/workflows/test-custom-version.yml new file mode 100644 index 0000000..94a5fdd --- /dev/null +++ b/.github/workflows/test-custom-version.yml @@ -0,0 +1,102 @@ +name: Test Custom Version + +on: + workflow_call: + secrets: + UIPATH_URL: + required: true + UIPATH_CLIENT_ID: + required: true + UIPATH_CLIENT_SECRET: + required: true + pull_request: + types: [opened, synchronize, labeled, unlabeled] + +jobs: + test-core-dev-version: + name: Test Core Dev Version + runs-on: ${{ matrix.os }} + if: contains(github.event.pull_request.labels.*.name, 'test-core-dev-version') + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + os: [ubuntu-latest, windows-latest] + + permissions: + contents: read + pull-requests: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract version from PR + id: extract-version + shell: bash + run: | + # Extract version from PR title only + PR_TITLE="${{ github.event.pull_request.title }}" + + # Search for version pattern in title (any x.y.z.dev version) + VERSION=$(echo "$PR_TITLE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+' | head -1) + + if [ -z "$VERSION" ]; then + echo "No version found in PR title. Please include version in title like: 2.0.65.dev1004030443" + exit 1 + fi + + echo "Extracted version: $VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Modify pyproject.toml for custom UiPath version + shell: bash + run: | + # Backup original pyproject.toml + cp pyproject.toml pyproject.toml.backup + + # Update the uipath dependency to the custom version + sed -i 's|"uipath>=.*"|"uipath==${{ steps.extract-version.outputs.version }}"|' pyproject.toml + + + + # Add or update [tool.uv.sources] section if it doesn't exist + if ! grep -q "\[tool\.uv\.sources\]" pyproject.toml; then + echo "" >> pyproject.toml + echo "[tool.uv.sources]" >> pyproject.toml + echo 'uipath = { index = "testpypi" }' >> pyproject.toml + else + # Update existing sources if needed + if ! grep -q 'uipath = { index = "testpypi" }' pyproject.toml; then + sed -i '/\[tool\.uv\.sources\]/a uipath = { index = "testpypi" }' pyproject.toml + fi + fi + + echo "Modified pyproject.toml to use UiPath version ${{ steps.extract-version.outputs.version }} from testpypi" + echo "=== Modified pyproject.toml content ===" + grep -A5 -B5 "uipath\|testpypi" pyproject.toml || true + + - name: Install dependencies with specific UiPath version + run: uv sync --all-extras + + - name: Run all tests + run: uv run pytest + env: + UIPATH_URL: ${{ secrets.UIPATH_URL }} + UIPATH_CLIENT_ID: ${{ secrets.UIPATH_CLIENT_ID }} + UIPATH_CLIENT_SECRET: ${{ secrets.UIPATH_CLIENT_SECRET }} + + - name: Restore original pyproject.toml + if: always() + shell: bash + run: | + mv pyproject.toml.backup pyproject.toml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e871daf..a7ffd1b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,21 +16,32 @@ jobs: contents: read steps: + # NOTE: Conditions are duplicated on each step instead of using job-level conditionals + # because GitHub expects ALL matrix combinations to report back when using matrix strategies. + # If we use job-level conditionals, matrix jobs won't run at all, leaving them in "pending" + # state and blocking PR merging. Step-level conditionals allow all matrix jobs to start + # and complete successfully, even when steps are skipped. + - name: Checkout + if: "!contains(github.event.pull_request.labels.*.name, 'test-core-dev-version')" uses: actions/checkout@v4 - name: Setup uv + if: "!contains(github.event.pull_request.labels.*.name, 'test-core-dev-version')" uses: astral-sh/setup-uv@v5 - name: Setup Python + if: "!contains(github.event.pull_request.labels.*.name, 'test-core-dev-version')" uses: actions/setup-python@v5 with: python-version-file: ".python-version" - name: Install dependencies + if: "!contains(github.event.pull_request.labels.*.name, 'test-core-dev-version')" run: uv sync --all-extras - name: Run tests + if: "!contains(github.event.pull_request.labels.*.name, 'test-core-dev-version')" run: uv run pytest continue-on-error: true