diff --git a/.github/labeler.yml b/.github/labeler.yml index ee2f4de14..d83420502 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -6,9 +6,13 @@ test:uipath-langchain: - changed-files: - any-glob-to-any-file: ['packages/uipath-core/src/**/*.py'] -test:uipath-llamaindex: +test:uipath-integrations: - changed-files: - any-glob-to-any-file: ['packages/uipath/src/**/*.py'] + - changed-files: + - any-glob-to-any-file: ['packages/uipath-platform/src/**/*.py'] + - changed-files: + - any-glob-to-any-file: ['packages/uipath-core/src/**/*.py'] test:uipath-runtime: - changed-files: diff --git a/.github/scripts/check_dependency_version_bumps.py b/.github/scripts/check_dependency_version_bumps.py new file mode 100644 index 000000000..fef305dea --- /dev/null +++ b/.github/scripts/check_dependency_version_bumps.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Enforce minimum-version bumps between co-changed internal packages. + +The monorepo ships several packages that depend on one another +(``uipath`` -> ``uipath-platform`` -> ``uipath-core``). When a PR changes +the *source* of a dependency package (say ``uipath-core``) **and** the +source of one of its dependents (say ``uipath``), the dependent is almost +certainly relying on the new behaviour. If the dependent does not also +raise the lower bound of its requirement on the dependency, then anyone who +installs the dependent on its own can resolve an older dependency that +predates the new behaviour — a silent runtime break. + +This check fails such a PR. For every pair of co-changed (dependency, +dependent) packages it requires the dependent's lower-bound constraint on +the dependency (the ``>=`` part of e.g. ``uipath-core>=0.5.8, <0.6.0``) to +be at least the dependency's new version declared in this PR. + +The internal dependency graph is discovered from the pyproject files, so no +hard-coded list needs maintaining as packages are added. +""" + +import re +import sys +from pathlib import Path +from typing import TypedDict + +from check_version_uniqueness import get_changed_packages + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib # type: ignore[no-redef] + +PACKAGES_DIR = Path("packages") + + +class PackageInfo(TypedDict): + """Resolved metadata for a single monorepo package.""" + + dir: str + name: str + version: str + dependencies: list[str] + + +def normalize_name(name: str) -> str: + """Normalize a PyPI project name (PEP 503): case-insensitive, -/_/. + treated as equivalent.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +def version_key(version: str) -> tuple[int, ...]: + """Numeric sort key so ``0.5.17`` > ``0.5.8`` (``0.5.18rc1`` -> ``(0, 5, 18)``).""" + parts: list[int] = [] + for component in version.split("."): + digits = "" + for ch in component: + if ch.isdigit(): + digits += ch + else: + break + parts.append(int(digits) if digits else 0) + return tuple(parts) + + +def parse_requirement(requirement: str) -> tuple[str | None, str | None]: + """Extract (normalized name, lower-bound version) from a requirement string. + + Returns the lower bound found in a ``>=`` clause, or ``None`` if there is + no ``>=`` constraint. The name is ``None`` if the string is unparseable. + """ + name_match = re.match(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)", requirement) + if not name_match: + return None, None + name = normalize_name(name_match.group(1)) + + lower: str | None = None + lower_match = re.search(r">=\s*([0-9][0-9A-Za-z._-]*)", requirement) + if lower_match: + lower = lower_match.group(1) + return name, lower + + +def load_package(package_dir: str) -> PackageInfo | None: + """Read a package's name, version and dependency list from pyproject.toml.""" + pyproject = PACKAGES_DIR / package_dir / "pyproject.toml" + if not pyproject.exists(): + return None + with open(pyproject, "rb") as f: + data = tomllib.load(f) + project = data.get("project", {}) + name = project.get("name") + version = project.get("version") + if not name or not version: + return None + return PackageInfo( + dir=package_dir, + name=name, + version=version, + dependencies=list(project.get("dependencies", [])), + ) + + +def get_all_packages() -> dict[str, PackageInfo]: + """Map package directory name -> package info for every package.""" + packages: dict[str, PackageInfo] = {} + if not PACKAGES_DIR.is_dir(): + return packages + for item in sorted(PACKAGES_DIR.iterdir()): + if item.is_dir() and (item / "pyproject.toml").exists(): + info = load_package(item.name) + if info: + packages[item.name] = info + return packages + + +def check(packages: dict[str, PackageInfo], changed: set[str]) -> list[str]: + """Return a list of violation messages (empty when the PR is compliant).""" + name_to_dir: dict[str, str] = { + normalize_name(info["name"]): pkg_dir for pkg_dir, info in packages.items() + } + + violations: list[str] = [] + for dependent_dir in sorted(changed): + dependent = packages.get(dependent_dir) + if not dependent: + continue + + for requirement in dependent["dependencies"]: + dep_name, lower = parse_requirement(requirement) + if dep_name is None: + continue + + dep_dir = name_to_dir.get(dep_name) + # Only internal packages that *also* changed in this PR are in scope. + if dep_dir is None or dep_dir == dependent_dir or dep_dir not in changed: + continue + + dep_version = packages[dep_dir]["version"] + dep_display = packages[dep_dir]["name"] + + if lower is None: + violations.append( + f"{dependent['name']} requires '{requirement}' but has no '>=' lower bound on " + f"{dep_display}; pin it to >={dep_version} (both packages changed in this PR)." + ) + elif version_key(lower) < version_key(dep_version): + violations.append( + f"{dependent['name']} pins {dep_display}>={lower}, but {dep_display} was bumped to " + f"{dep_version} in this PR. Raise the minimum to >={dep_version}." + ) + else: + print(f"OK: {dependent['name']} requires {dep_display}>={lower} (>= new {dep_version})") + + return violations + + +def main() -> int: + packages = get_all_packages() + if not packages: + print("No packages found.") + return 0 + + changed = set(get_changed_packages()) + if not changed: + print("No source changes to internal packages detected.") + return 0 + + print(f"Changed packages: {', '.join(sorted(changed))}") + + violations = check(packages, changed) + if violations: + print("\nDependency version bump check FAILED:\n", file=sys.stderr) + for v in violations: + print(f" - {v}", file=sys.stderr) + print( + "\nWhen you change an internal package and a dependent of it in the same PR, " + "the dependent must require the dependency's new version so a standalone install " + "cannot resolve an older, incompatible release.", + file=sys.stderr, + ) + return 1 + + print("\nAll co-changed internal dependencies have an up-to-date minimum version.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/.github/scripts/test_check_dependency_version_bumps.py b/.github/scripts/test_check_dependency_version_bumps.py new file mode 100644 index 000000000..e4135e89f --- /dev/null +++ b/.github/scripts/test_check_dependency_version_bumps.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Tests for check_dependency_version_bumps.py.""" + +from unittest import mock + +from check_dependency_version_bumps import ( + PackageInfo, + check, + normalize_name, + parse_requirement, + version_key, +) + + +def pkg(name: str, version: str, dependencies: list[str] | None = None) -> PackageInfo: + return PackageInfo( + dir=name, + name=name, + version=version, + dependencies=dependencies or [], + ) + + +class TestVersionKey: + def test_numeric_components(self): + assert version_key("0.5.18") == (0, 5, 18) + + def test_compares_numerically_not_lexically(self): + # The trap a string compare would fall into: "0.5.8" > "0.5.17". + assert version_key("0.5.17") > version_key("0.5.8") + + def test_strips_prerelease_suffix(self): + assert version_key("0.5.18rc1") == (0, 5, 18) + + +class TestNormalizeName: + def test_case_and_separators_equivalent(self): + assert normalize_name("UiPath_Core") == normalize_name("uipath-core") + assert normalize_name("uipath.core") == "uipath-core" + + +class TestParseRequirement: + def test_extracts_name_and_lower_bound(self): + assert parse_requirement("uipath-core>=0.5.8, <0.6.0") == ("uipath-core", "0.5.8") + + def test_no_lower_bound(self): + assert parse_requirement("click") == ("click", None) + assert parse_requirement("httpx<1.0") == ("httpx", None) + + def test_whitespace_after_operator(self): + assert parse_requirement("uipath-core >= 0.5.8") == ("uipath-core", "0.5.8") + + +class TestCheck: + def _packages(self) -> dict[str, PackageInfo]: + return { + "uipath-core": pkg("uipath-core", "0.5.18"), + "uipath-platform": pkg( + "uipath-platform", "0.1.60", ["uipath-core>=0.5.8, <0.6.0"] + ), + "uipath": pkg( + "uipath", + "2.10.74", + [ + "uipath-core>=0.5.8, <0.6.0", + "uipath-platform>=0.1.59, <0.2.0", + "click>=8.3.1", + ], + ), + } + + def test_passes_when_only_dependency_changed(self): + # uipath-core changed alone -> dependents not touched, nothing to enforce. + assert check(self._packages(), {"uipath-core"}) == [] + + def test_passes_when_only_dependent_changed(self): + assert check(self._packages(), {"uipath"}) == [] + + def test_fails_when_co_changed_without_min_bump(self): + # uipath-core bumped to 0.5.18 but uipath still pins >=0.5.8. + violations = check(self._packages(), {"uipath-core", "uipath"}) + assert len(violations) == 1 + assert "uipath" in violations[0] + assert "0.5.18" in violations[0] + + def test_passes_when_min_raised_to_new_version(self): + packages = self._packages() + packages["uipath"]["dependencies"] = [ + "uipath-core>=0.5.18, <0.6.0", + "click>=8.3.1", + ] + assert check(packages, {"uipath-core", "uipath"}) == [] + + def test_passes_when_min_already_above_new_version(self): + packages = self._packages() + packages["uipath"]["dependencies"] = ["uipath-core>=0.6.0, <0.7.0"] + assert check(packages, {"uipath-core", "uipath"}) == [] + + def test_fails_when_no_lower_bound_on_co_changed_dep(self): + packages = self._packages() + packages["uipath"]["dependencies"] = ["uipath-core"] + violations = check(packages, {"uipath-core", "uipath"}) + assert len(violations) == 1 + assert "no '>=' lower bound" in violations[0] + + def test_external_dependencies_are_ignored(self): + # click is not an internal package, so it is never in scope. + assert check(self._packages(), {"uipath"}) == [] + + def test_transitive_chain_each_edge_enforced(self): + # All three changed: uipath must bump core AND platform; platform must bump core. + packages = self._packages() + violations = check(packages, {"uipath-core", "uipath-platform", "uipath"}) + # uipath->core (stale), uipath->platform (0.1.59 < 0.1.60), platform->core (stale) + assert len(violations) == 3 + + def test_no_self_reference(self): + packages = {"uipath": pkg("uipath", "2.0.0", ["uipath>=1.0.0"])} + assert check(packages, {"uipath"}) == [] + + +class TestMain: + def _run(self, packages: dict[str, PackageInfo], changed: list[str]) -> int: + from check_dependency_version_bumps import main + + with ( + mock.patch("check_dependency_version_bumps.get_all_packages", return_value=packages), + mock.patch("check_dependency_version_bumps.get_changed_packages", return_value=changed), + ): + return main() + + def test_returns_zero_when_compliant(self): + packages = { + "uipath-core": pkg("uipath-core", "0.5.18"), + "uipath": pkg("uipath", "2.0.0", ["uipath-core>=0.5.18, <0.6.0"]), + } + assert self._run(packages, ["uipath-core", "uipath"]) == 0 + + def test_returns_one_on_violation(self): + packages = { + "uipath-core": pkg("uipath-core", "0.5.18"), + "uipath": pkg("uipath", "2.0.0", ["uipath-core>=0.5.8, <0.6.0"]), + } + assert self._run(packages, ["uipath-core", "uipath"]) == 1 + + def test_returns_zero_when_no_changes(self): + assert self._run({"uipath-core": pkg("uipath-core", "0.5.18")}, []) == 0 \ No newline at end of file diff --git a/.github/scripts/test_check_version_uniqueness.py b/.github/scripts/test_check_version_uniqueness.py index 94a2b3cc0..af4298af1 100644 --- a/.github/scripts/test_check_version_uniqueness.py +++ b/.github/scripts/test_check_version_uniqueness.py @@ -5,7 +5,6 @@ import urllib.error from unittest import mock -import pytest from check_version_uniqueness import ( get_package_info, diff --git a/.github/scripts/write_uv_overrides.py b/.github/scripts/write_uv_overrides.py new file mode 100644 index 000000000..557f9d26e --- /dev/null +++ b/.github/scripts/write_uv_overrides.py @@ -0,0 +1,50 @@ +"""Write a uv override file forcing the locally built uipath wheels. + +Cross-test workflows build uipath wheels from the PR and run them against +downstream repos (uipath-langchain-python, uipath-integrations-python, +uipath-runtime-python). Those downstreams cap the uipath* version (e.g. +``uipath<2.11.0``), so a backward-compatible minor bump would fail resolution +purely on the cap. uv ``override-dependencies`` ignore the declared version +specifier, so pointing them at the local wheels lets the cross-test exercise the +real new code regardless of the cap. + +The script is layout-agnostic: it overrides whatever ``uipath*`` wheels exist +under ``$GITHUB_WORKSPACE/wheels`` (recursively), so it works for the +three-wheel layout (``wheels//dist/*.whl``) and the single-wheel runtime +layout (``wheels/*.whl``) alike. + +The resulting override file path is appended to ``GITHUB_ENV`` as ``UV_OVERRIDE`` +so every subsequent ``uv`` invocation in the job honors it. +""" + +import glob +import os +import pathlib + + +def main() -> None: + wheels = pathlib.Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve() / "wheels" + + lines = [] + for whl in sorted(glob.glob(str(wheels / "**" / "*.whl"), recursive=True)): + # Wheel filename is ``{distribution}-{version}-...whl`` where the + # distribution escapes hyphens to underscores (uipath_core -> uipath-core). + dist = pathlib.Path(whl).name.split("-", 1)[0].replace("_", "-") + if not dist.startswith("uipath"): + continue + lines.append(f"{dist} @ {pathlib.Path(whl).resolve().as_uri()}") + + if not lines: + raise SystemExit(f"no uipath wheels found under {wheels}") + + out = wheels / "overrides.txt" + out.write_text("\n".join(lines) + "\n") + + with open(os.environ["GITHUB_ENV"], "a") as fh: + fh.write(f"UV_OVERRIDE={out}\n") + + print("\n".join(lines)) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/check-dependency-bumps.yml b/.github/workflows/check-dependency-bumps.yml new file mode 100644 index 000000000..acdff63a6 --- /dev/null +++ b/.github/workflows/check-dependency-bumps.yml @@ -0,0 +1,28 @@ +name: Check Dependency Version Bumps + +on: + workflow_call: + +permissions: + contents: read + +jobs: + check-dependency-bumps: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Enforce min-version bumps for co-changed internal packages + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python .github/scripts/check_dependency_version_bumps.py \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e05e9c73..1dd0471f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,9 @@ on: pull_request: branches: - main + push: + branches: + - main permissions: contents: read @@ -18,6 +21,13 @@ jobs: test: uses: ./.github/workflows/test-packages.yml + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} check-versions: + if: github.event_name == 'pull_request' uses: ./.github/workflows/check-version-availability.yml + + check-dependency-bumps: + if: github.event_name == 'pull_request' + uses: ./.github/workflows/check-dependency-bumps.yml diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index 3c438f75c..d1b218f99 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -109,17 +109,53 @@ jobs: Write-Output "Package $PROJECT_NAME version set to $DEV_VERSION" + # Shared dev suffix for every package built in this run (same PR + run number) + $DEV_SUFFIX = "dev1$PADDED_PR$PADDED_RUN" + + # Intra-repo dependencies per package. A dev build of these is < the package's + # base version (PEP 440 pre-release), so it falls outside the published ">=base" + # constraint and must be forced in via [tool.uv] override-dependencies. + $internalDepsMap = @{ + "uipath" = @("uipath-platform", "uipath-core") + "uipath-platform" = @("uipath-core") + "uipath-core" = @() + } + + # Packages also published in this run (their dev builds exist on testpypi) + $changedPackages = '${{ needs.detect-changed-packages.outputs.packages }}' | ConvertFrom-Json + + $overrideDeps = @() + foreach ($dep in $internalDepsMap[$PROJECT_NAME]) { + if ($changedPackages -contains $dep) { + $depPyproj = Get-Content "../$dep/pyproject.toml" -Raw + $depBaseVersion = ($depPyproj | Select-String -Pattern '(?m)^\[(project|tool\.poetry)\][^\[]*?version\s*=\s*"([^"]*)"' -AllMatches).Matches[0].Groups[2].Value + $overrideDeps += [PSCustomObject]@{ Name = $dep; Version = "$depBaseVersion.$DEV_SUFFIX" } + } + } + + # [tool.uv.sources]: the package itself plus every overridden dep point at testpypi + $sourcesLines = @("$PROJECT_NAME = { index = `"testpypi`" }") + foreach ($d in $overrideDeps) { $sourcesLines += "$($d.Name) = { index = `"testpypi`" }" } + $sourcesBlock = $sourcesLines -join "`n" + + # Optional [tool.uv] override block (omitted when no intra-repo dep was published) + $overrideBlock = "" + if ($overrideDeps.Count -gt 0) { + $overrideItems = ($overrideDeps | ForEach-Object { "`"$($_.Name)==$($_.Version)`"" }) -join ", " + $overrideBlock = "`n[tool.uv]`noverride-dependencies = [$overrideItems]`n" + } + $dependencyMessage = @" ### $PROJECT_NAME ``````toml [project] dependencies = [ - # Exact version: + # Exact version (copy-paste ready): "$PROJECT_NAME==$DEV_VERSION", - # Any version from PR - "$PROJECT_NAME>=$MIN_VERSION,<$MAX_VERSION" + # Any version from this PR (uncomment to use a range instead): + # "$PROJECT_NAME>=$MIN_VERSION,<$MAX_VERSION", ] [[tool.uv.index]] @@ -129,8 +165,8 @@ jobs: explicit = true [tool.uv.sources] - $PROJECT_NAME = { index = "testpypi" } - `````` + $sourcesBlock + $overrideBlock`````` "@ # Get the owner and repo from the GitHub repository diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 79eec3cc1..417cd09c9 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -9,6 +9,10 @@ on: - "packages/uipath/docs/**" - "packages/uipath/mkdocs.yml" - "packages/uipath/pyproject.toml" + - "packages/uipath-platform/src/**" + - "packages/uipath-platform/pyproject.toml" + - "packages/uipath-core/src/**" + - "packages/uipath-core/pyproject.toml" repository_dispatch: types: [publish-docs] diff --git a/.github/workflows/test-cd-scripts.yml b/.github/workflows/test-cd-scripts.yml index a2f1bcdf1..3c6655a9d 100644 --- a/.github/workflows/test-cd-scripts.yml +++ b/.github/workflows/test-cd-scripts.yml @@ -9,8 +9,11 @@ on: - '.github/scripts/test_detect_publishable_packages.py' - '.github/scripts/check_version_uniqueness.py' - '.github/scripts/test_check_version_uniqueness.py' + - '.github/scripts/check_dependency_version_bumps.py' + - '.github/scripts/test_check_dependency_version_bumps.py' - '.github/workflows/cd.yml' - '.github/workflows/check-version-availability.yml' + - '.github/workflows/check-dependency-bumps.yml' permissions: contents: read @@ -32,4 +35,4 @@ jobs: - name: Run tests working-directory: .github/scripts - run: python -m pytest test_detect_publishable_packages.py test_check_version_uniqueness.py -v + run: python -m pytest test_detect_publishable_packages.py test_check_version_uniqueness.py test_check_dependency_version_bumps.py -v diff --git a/.github/workflows/test-packages.yml b/.github/workflows/test-packages.yml index 58e37a42a..f35d77e8f 100644 --- a/.github/workflows/test-packages.yml +++ b/.github/workflows/test-packages.yml @@ -2,6 +2,9 @@ name: Test Packages on: workflow_call: + secrets: + SONAR_TOKEN: + required: false permissions: contents: read @@ -77,10 +80,31 @@ jobs: run: uv sync --all-extras --python ${{ matrix.python-version }} - name: Run tests - if: steps.check.outputs.skip != 'true' + if: steps.check.outputs.skip != 'true' && !(matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13') working-directory: packages/uipath-core run: uv run pytest + - name: Run tests with coverage + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' + working-directory: packages/uipath-core + run: uv run pytest --cov-report=xml --cov-report=html --tb=short + + - name: Upload coverage HTML report + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-html-uipath-core + path: packages/uipath-core/htmlcov/ + retention-days: 30 + + - name: Upload coverage XML report + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-xml-uipath-core + path: packages/uipath-core/coverage.xml + retention-days: 30 + test-uipath-platform: name: Test (uipath-platform, ${{ matrix.python-version }}, ${{ matrix.os }}) needs: detect-changed-packages @@ -126,10 +150,80 @@ jobs: run: uv sync --all-extras --python ${{ matrix.python-version }} - name: Run tests - if: steps.check.outputs.skip != 'true' + if: steps.check.outputs.skip != 'true' && !(matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13') working-directory: packages/uipath-platform run: uv run pytest + - name: Run tests with coverage + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' + working-directory: packages/uipath-platform + run: uv run pytest --cov-report=xml --cov-report=html --tb=short + + - name: Upload coverage HTML report + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-html-uipath-platform + path: packages/uipath-platform/htmlcov/ + retention-days: 30 + + - name: Upload coverage XML report + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-xml-uipath-platform + path: packages/uipath-platform/coverage.xml + retention-days: 30 + + e2e-uipath-platform: + name: E2E (uipath-platform, memory) + needs: detect-changed-packages + runs-on: ubuntu-latest + steps: + - name: Check if package changed + id: check + shell: bash + run: | + if echo '${{ needs.detect-changed-packages.outputs.packages }}' | jq -e 'index("uipath-platform")' > /dev/null; then + echo "skip=false" >> $GITHUB_OUTPUT + else + echo "skip=true" >> $GITHUB_OUTPUT + fi + + - name: Skip + if: steps.check.outputs.skip == 'true' + shell: bash + run: echo "Skipping - no changes to uipath-platform" + + - name: Checkout + if: steps.check.outputs.skip != 'true' + uses: actions/checkout@v4 + + - name: Setup uv + if: steps.check.outputs.skip != 'true' + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + if: steps.check.outputs.skip != 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-platform + run: uv sync --all-extras --python 3.11 + + - name: Run E2E memory tests + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-platform + env: + UIPATH_URL: ${{ secrets.ALPHA_BASE_URL }} + UIPATH_CLIENT_ID: ${{ secrets.ALPHA_TEST_CLIENT_ID }} + UIPATH_CLIENT_SECRET: ${{ secrets.ALPHA_TEST_CLIENT_SECRET }} + UIPATH_FOLDER_KEY: ${{ secrets.UIPATH_MEMORY_FOLDER }} + run: uv run pytest tests/services/test_memory_service_e2e.py -m e2e -v --no-cov + test-uipath: name: Test (uipath, ${{ matrix.python-version }}, ${{ matrix.os }}) needs: detect-changed-packages @@ -176,15 +270,81 @@ jobs: run: uv sync --all-extras --python ${{ matrix.python-version }} - name: Run tests - if: steps.check.outputs.skip != 'true' + if: steps.check.outputs.skip != 'true' && !(matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13') working-directory: packages/uipath run: uv run pytest + - name: Run tests with coverage + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' + working-directory: packages/uipath + run: uv run pytest --cov-report=xml --cov-report=html --tb=short + + - name: Upload coverage HTML report + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-html-uipath + path: packages/uipath/htmlcov/ + retention-days: 30 + + - name: Upload coverage XML report + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-xml-uipath + path: packages/uipath/coverage.xml + retention-days: 30 + continue-on-error: true + sonarcloud: + name: SonarCloud + needs: [test-uipath-core, test-uipath-platform, test-uipath] + runs-on: ubuntu-latest + if: always() && needs.test-uipath-core.result != 'failure' && needs.test-uipath-platform.result != 'failure' && needs.test-uipath.result != 'failure' + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download uipath-core coverage + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: coverage-xml-uipath-core + path: packages/uipath-core + + - name: Download uipath-platform coverage + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: coverage-xml-uipath-platform + path: packages/uipath-platform + + - name: Download uipath coverage + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: coverage-xml-uipath + path: packages/uipath + + - name: Rewrite coverage XML to repo-relative paths + run: | + sed -i 's|src|packages/uipath-core/src|g' packages/uipath-core/coverage.xml || true + sed -i 's|src|packages/uipath-platform/src|g' packages/uipath-platform/coverage.xml || true + sed -i 's|src|packages/uipath/src|g' packages/uipath/coverage.xml || true + + - name: SonarCloud Scan + uses: SonarSource/sonarqube-scan-action@2f77a1ec69fb1d595b06f35ab27e97605bdef703 # v5 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + test-gate: name: Test - needs: [test-uipath-core, test-uipath-platform, test-uipath] + needs: [test-uipath-core, test-uipath-platform, test-uipath, e2e-uipath-platform] runs-on: ubuntu-latest if: always() steps: @@ -196,4 +356,8 @@ jobs: echo "Tests failed" exit 1 fi + # E2E tests are informational — log but don't block + if [[ "${{ needs.e2e-uipath-platform.result }}" == "failure" ]]; then + echo "⚠️ E2E memory tests failed (non-blocking)" + fi echo "All tests passed" diff --git a/.github/workflows/test-uipath-integrations.yml b/.github/workflows/test-uipath-integrations.yml new file mode 100644 index 000000000..95c621941 --- /dev/null +++ b/.github/workflows/test-uipath-integrations.yml @@ -0,0 +1,292 @@ +name: uipath - Test Integrations + +on: + pull_request: + types: [ opened, synchronize, reopened, labeled ] + +jobs: + build-wheels: + runs-on: ubuntu-latest + permissions: + contents: read + if: contains(github.event.pull_request.labels.*.name, 'test:uipath-integrations') + steps: + - name: Checkout uipath-python + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build uipath-core package + working-directory: packages/uipath-core + run: uv build + + - name: Build uipath-platform package + working-directory: packages/uipath-platform + run: uv build + + - name: Build uipath package + working-directory: packages/uipath + run: uv build + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: uipath-wheels + path: packages/*/dist/*.whl + + discover-packages: + needs: [build-wheels] + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + packages: ${{ steps.discover.outputs.packages }} + steps: + - name: Checkout uipath-integrations-python + uses: actions/checkout@v4 + with: + repository: 'UiPath/uipath-integrations-python' + path: 'uipath-integrations-python' + + - name: Discover packages + id: discover + working-directory: uipath-integrations-python + run: | + # Find every package directory under packages/ that has a pyproject.toml + package_dirs=$(find packages -maxdepth 2 -name pyproject.toml -printf '%h\n' | sed 's|^packages/||' | sort) + + echo "Found integration packages:" + echo "$package_dirs" + + packages_json=$(echo "$package_dirs" | jq -R -s -c 'split("\n")[:-1]') + echo "packages=$packages_json" >> $GITHUB_OUTPUT + + test-package: + needs: [build-wheels, discover-packages] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + package: ${{ fromJson(needs.discover-packages.outputs.packages) }} + python-version: [ "3.11", "3.12", "3.13" ] + os: [ ubuntu-latest, windows-latest ] + + name: "${{ matrix.package }} / py${{ matrix.python-version }} / ${{ matrix.os }}" + permissions: + contents: read + + steps: + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Download wheels + uses: actions/download-artifact@v4 + with: + name: uipath-wheels + path: wheels + + - name: Checkout uipath-integrations-python + uses: actions/checkout@v4 + with: + repository: 'UiPath/uipath-integrations-python' + path: 'uipath-integrations-python' + + - name: Checkout uipath-python scripts + uses: actions/checkout@v4 + with: + path: _scripts + sparse-checkout: .github/scripts + + - name: Override uipath packages with local wheels + shell: bash + run: | + PYBIN=$(command -v python || command -v python3) + "$PYBIN" "$GITHUB_WORKSPACE/_scripts/.github/scripts/write_uv_overrides.py" + + - name: Install dependencies and run tests + shell: bash + working-directory: uipath-integrations-python/packages/${{ matrix.package }} + run: | + uv sync + if [ -d tests ]; then + uv run pytest + else + echo "No tests directory found in ${{ matrix.package }}, skipping pytest" + fi + + discover-testcases: + needs: [test-package, discover-packages] + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + matrix: ${{ steps.discover.outputs.matrix }} + has_testcases: ${{ steps.discover.outputs.has_testcases }} + steps: + - name: Checkout uipath-integrations-python + uses: actions/checkout@v4 + with: + repository: 'UiPath/uipath-integrations-python' + path: 'uipath-integrations-python' + + - name: Discover testcases across packages + id: discover + working-directory: uipath-integrations-python + run: | + # For each package with a testcases/ directory, list its testcase folders + # and emit one matrix entry per (package, testcase) pair. + entries="[]" + for pkg_dir in packages/*/; do + pkg=$(basename "$pkg_dir") + tc_dir="$pkg_dir/testcases" + if [ ! -d "$tc_dir" ]; then + continue + fi + testcases=$(find "$tc_dir" -maxdepth 1 -type d -name "*-*" -printf '%f\n' | sort) + if [ -z "$testcases" ]; then + continue + fi + for tc in $testcases; do + entries=$(echo "$entries" | jq --arg p "$pkg" --arg t "$tc" '. + [{package: $p, testcase: $t}]') + done + done + + echo "Discovered testcase matrix:" + echo "$entries" | jq . + + count=$(echo "$entries" | jq 'length') + if [ "$count" -eq 0 ]; then + echo "has_testcases=false" >> $GITHUB_OUTPUT + echo "matrix=[]" >> $GITHUB_OUTPUT + else + echo "has_testcases=true" >> $GITHUB_OUTPUT + echo "matrix=$(echo "$entries" | jq -c .)" >> $GITHUB_OUTPUT + fi + + run-integration-tests: + needs: [build-wheels, discover-testcases] + if: needs.discover-testcases.outputs.has_testcases == 'true' + runs-on: ubuntu-latest + container: + image: ghcr.io/astral-sh/uv:python3.12-bookworm + env: + UIPATH_JOB_KEY: "3a03d5cb-fa21-4021-894d-a8e2eda0afe0" + UIPATH_TRACING_ENABLED: false + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.discover-testcases.outputs.matrix) }} + environment: [alpha, staging] # temporary disable [cloud] + + name: "${{ matrix.package }} / ${{ matrix.testcase }} / ${{ matrix.environment }}" + + steps: + - name: Download wheels + uses: actions/download-artifact@v4 + with: + name: uipath-wheels + path: wheels + + - name: Checkout uipath-integrations-python + uses: actions/checkout@v4 + with: + repository: 'UiPath/uipath-integrations-python' + path: 'uipath-integrations-python' + + - name: Checkout uipath-python scripts + uses: actions/checkout@v4 + with: + path: _scripts + sparse-checkout: .github/scripts + + - name: Override uipath packages with local wheels + shell: bash + run: | + PYBIN=$(command -v python || command -v python3) + "$PYBIN" "$GITHUB_WORKSPACE/_scripts/.github/scripts/write_uv_overrides.py" + + - name: Install dependencies + working-directory: uipath-integrations-python/packages/${{ matrix.package }} + run: uv sync + + - name: Run testcase + env: + CLIENT_ID: ${{ matrix.environment == 'alpha' && secrets.ALPHA_TEST_CLIENT_ID || matrix.environment == 'staging' && secrets.STAGING_TEST_CLIENT_ID || matrix.environment == 'cloud' && secrets.CLOUD_TEST_CLIENT_ID }} + CLIENT_SECRET: ${{ matrix.environment == 'alpha' && secrets.ALPHA_TEST_CLIENT_SECRET || matrix.environment == 'staging' && secrets.STAGING_TEST_CLIENT_SECRET || matrix.environment == 'cloud' && secrets.CLOUD_TEST_CLIENT_SECRET }} + BASE_URL: ${{ matrix.environment == 'alpha' && secrets.ALPHA_BASE_URL || matrix.environment == 'staging' && secrets.STAGING_BASE_URL || matrix.environment == 'cloud' && secrets.CLOUD_BASE_URL }} + UV_PYTHON: "3.12" + working-directory: uipath-integrations-python/packages/${{ matrix.package }}/testcases/${{ matrix.testcase }} + run: | + echo "Package: ${{ matrix.package }}" + echo "Testcase: ${{ matrix.testcase }}" + echo "Environment: ${{ matrix.environment }}" + + bash run.sh + bash ../common/validate_output.sh + + notify-on-failure: + needs: [test-package, run-integration-tests] + if: always() && contains(github.event.pull_request.labels.*.name, 'test:uipath-integrations') && (needs.test-package.result == 'failure' || needs.run-integration-tests.result == 'failure') + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Comment on PR + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const body = [ + marker, + '## :rotating_light: **Heads up: `uipath-integrations` cross-tests are FAILING** :rotating_light:', + '', + 'Your changes may break one or more integrations in **[`uipath-integrations-python`](https://github.com/UiPath/uipath-integrations-python)**:', + '', + '- `uipath-openai-agents`', + '- `uipath-google-adk`', + '- `uipath-agent-framework`', + '- `uipath-llamaindex`', + '- `uipath-pydantic-ai`', + '', + '> :warning: **These checks are NOT enforced by branch protection rules.** Please review the failures before merging.', + '', + `**:mag: [Inspect the failed run →](${runUrl})**`, + ].join('\n'); + + // Delete any prior failure comments for this workflow so the new + // one always lands at the bottom of the PR conversation. + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + for (const c of comments) { + if (c.body && c.body.includes(marker)) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: c.id, + }); + } + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); diff --git a/.github/workflows/test-uipath-langchain.yml b/.github/workflows/test-uipath-langchain.yml index f74135975..25b027633 100644 --- a/.github/workflows/test-uipath-langchain.yml +++ b/.github/workflows/test-uipath-langchain.yml @@ -72,13 +72,17 @@ jobs: repository: 'UiPath/uipath-langchain-python' path: 'uipath-langchain-python' - - name: Update uipath packages + - name: Checkout uipath-python scripts + uses: actions/checkout@v4 + with: + path: _scripts + sparse-checkout: .github/scripts + + - name: Override uipath packages with local wheels shell: bash - working-directory: uipath-langchain-python run: | - uv add ../wheels/uipath-core/dist/*.whl --dev - uv add ../wheels/uipath-platform/dist/*.whl --dev - uv add ../wheels/uipath/dist/*.whl --dev + PYBIN=$(command -v python || command -v python3) + "$PYBIN" "$GITHUB_WORKSPACE/_scripts/.github/scripts/write_uv_overrides.py" - name: Run uipath-langchain tests working-directory: uipath-langchain-python @@ -146,13 +150,17 @@ jobs: repository: 'UiPath/uipath-langchain-python' path: 'uipath-langchain-python' - - name: Update uipath packages + - name: Checkout uipath-python scripts + uses: actions/checkout@v4 + with: + path: _scripts + sparse-checkout: .github/scripts + + - name: Override uipath packages with local wheels shell: bash - working-directory: uipath-langchain-python run: | - uv add ../wheels/uipath-core/dist/*.whl - uv add ../wheels/uipath-platform/dist/*.whl - uv add ../wheels/uipath/dist/*.whl + PYBIN=$(command -v python || command -v python3) + "$PYBIN" "$GITHUB_WORKSPACE/_scripts/.github/scripts/write_uv_overrides.py" - name: Install dependencies working-directory: uipath-langchain-python @@ -175,3 +183,51 @@ jobs: # Execute the testcase run script directly bash run.sh bash ../common/validate_output.sh + + notify-on-failure: + needs: [test-uipath-langchain, run-uipath-langchain-integration-tests] + if: always() && contains(github.event.pull_request.labels.*.name, 'test:uipath-langchain') && (needs.test-uipath-langchain.result == 'failure' || needs.run-uipath-langchain-integration-tests.result == 'failure') + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Comment on PR + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const body = [ + marker, + '## :rotating_light: **Heads up: `uipath-langchain` cross-tests are FAILING** :rotating_light:', + '', + 'Your changes may break the **[`uipath-langchain-python`](https://github.com/UiPath/uipath-langchain-python)** integration.', + '', + '> :warning: **These checks are NOT enforced by branch protection rules.** Please review the failures before merging.', + '', + `**:mag: [Inspect the failed run →](${runUrl})**`, + ].join('\n'); + + // Delete any prior failure comments for this workflow so the new + // one always lands at the bottom of the PR conversation. + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + for (const c of comments) { + if (c.body && c.body.includes(marker)) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: c.id, + }); + } + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); diff --git a/.github/workflows/test-uipath-llamaindex.yml b/.github/workflows/test-uipath-llamaindex.yml deleted file mode 100644 index fcf8d0fb4..000000000 --- a/.github/workflows/test-uipath-llamaindex.yml +++ /dev/null @@ -1,173 +0,0 @@ -name: uipath - Test LlamaIndex - -on: - pull_request: - types: [ opened, synchronize, reopened, labeled ] - -jobs: - build-wheels: - runs-on: ubuntu-latest - permissions: - contents: read - if: contains(github.event.pull_request.labels.*.name, 'test:uipath-llamaindex') - steps: - - name: Checkout uipath-python - uses: actions/checkout@v4 - - - name: Setup uv - uses: astral-sh/setup-uv@v5 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Build uipath-core package - working-directory: packages/uipath-core - run: uv build - - - name: Build uipath-platform package - working-directory: packages/uipath-platform - run: uv build - - - name: Build uipath package - working-directory: packages/uipath - run: uv build - - - name: Upload wheels - uses: actions/upload-artifact@v4 - with: - name: uipath-wheels - path: packages/*/dist/*.whl - - test-uipath-llamaindex: - needs: [build-wheels] - runs-on: ${{ matrix.os }} - strategy: - matrix: - python-version: [ "3.11", "3.12", "3.13" ] - os: [ ubuntu-latest, windows-latest ] - - permissions: - contents: read - - steps: - - name: Setup uv - uses: astral-sh/setup-uv@v5 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Download wheels - uses: actions/download-artifact@v4 - with: - name: uipath-wheels - path: wheels - - - name: Checkout uipath-integrations-python - uses: actions/checkout@v4 - with: - repository: 'UiPath/uipath-integrations-python' - path: 'uipath-integrations-python' - - - name: Update uipath packages - shell: bash - working-directory: uipath-integrations-python/packages/uipath-llamaindex - run: | - uv add ../../../wheels/uipath-core/dist/*.whl --dev - uv add ../../../wheels/uipath-platform/dist/*.whl --dev - uv add ../../../wheels/uipath/dist/*.whl --dev - - - name: Run uipath-llamaindex tests - working-directory: uipath-integrations-python/packages/uipath-llamaindex - run: | - uv sync - uv run pytest - - discover-testcases: - runs-on: ubuntu-latest - permissions: - contents: read - needs: [test-uipath-llamaindex] - outputs: - testcases: ${{ steps.discover.outputs.testcases }} - steps: - - name: Checkout uipath-integrations-python - uses: actions/checkout@v4 - with: - repository: 'UiPath/uipath-integrations-python' - path: 'uipath-integrations-python' - - - name: Discover testcases - id: discover - working-directory: uipath-integrations-python/packages/uipath-llamaindex - run: | - # Find all testcase folders (excluding common folders like README, etc.) - testcase_dirs=$(find testcases -maxdepth 1 -type d -name "*-*" | sed 's|testcases/||' | sort) - - echo "Found testcase directories:" - echo "$testcase_dirs" - - # Convert to JSON array for matrix - testcases_json=$(echo "$testcase_dirs" | jq -R -s -c 'split("\n")[:-1]') - echo "testcases=$testcases_json" >> $GITHUB_OUTPUT - - run-uipath-llamaindex-integration-tests: - runs-on: ubuntu-latest - needs: [build-wheels, discover-testcases] - container: - image: ghcr.io/astral-sh/uv:python3.12-bookworm - env: - UIPATH_JOB_KEY: "3a03d5cb-fa21-4021-894d-a8e2eda0afe0" - UIPATH_TRACING_ENABLED: false - permissions: - contents: read - strategy: - fail-fast: false - matrix: - testcase: ${{ fromJson(needs.discover-testcases.outputs.testcases) }} - environment: [alpha, staging] # temporary disable [cloud] - - name: "${{ matrix.testcase }} / ${{ matrix.environment }}" - - steps: - - name: Download wheels - uses: actions/download-artifact@v4 - with: - name: uipath-wheels - path: wheels - - - name: Checkout uipath-integrations-python - uses: actions/checkout@v4 - with: - repository: 'UiPath/uipath-integrations-python' - path: 'uipath-integrations-python' - - - name: Update uipath packages - shell: bash - working-directory: uipath-integrations-python/packages/uipath-llamaindex - run: | - uv add ../../../wheels/uipath-core/dist/*.whl - uv add ../../../wheels/uipath-platform/dist/*.whl - uv add ../../../wheels/uipath/dist/*.whl - - - name: Install dependencies - working-directory: uipath-integrations-python/packages/uipath-llamaindex - run: uv sync - - - name: Run testcase - env: - CLIENT_ID: ${{ matrix.environment == 'alpha' && secrets.ALPHA_TEST_CLIENT_ID || matrix.environment == 'staging' && secrets.STAGING_TEST_CLIENT_ID || matrix.environment == 'cloud' && secrets.CLOUD_TEST_CLIENT_ID }} - CLIENT_SECRET: ${{ matrix.environment == 'alpha' && secrets.ALPHA_TEST_CLIENT_SECRET || matrix.environment == 'staging' && secrets.STAGING_TEST_CLIENT_SECRET || matrix.environment == 'cloud' && secrets.CLOUD_TEST_CLIENT_SECRET }} - BASE_URL: ${{ matrix.environment == 'alpha' && secrets.ALPHA_BASE_URL || matrix.environment == 'staging' && secrets.STAGING_BASE_URL || matrix.environment == 'cloud' && secrets.CLOUD_BASE_URL }} - UV_PYTHON: "3.12" - working-directory: uipath-integrations-python/packages/uipath-llamaindex/testcases/${{ matrix.testcase }} - run: | - echo "Running testcase: ${{ matrix.testcase }}" - echo "Environment: ${{ matrix.environment }}" - - # Execute the testcase run script directly - bash run.sh - bash ../common/validate_output.sh diff --git a/.github/workflows/test-uipath-runtime.yml b/.github/workflows/test-uipath-runtime.yml index 13ad019ef..200a1b8ca 100644 --- a/.github/workflows/test-uipath-runtime.yml +++ b/.github/workflows/test-uipath-runtime.yml @@ -64,10 +64,17 @@ jobs: repository: 'UiPath/uipath-runtime-python' path: 'uipath-runtime-python' - - name: Update uipath-core version + - name: Checkout uipath-python scripts + uses: actions/checkout@v4 + with: + path: _scripts + sparse-checkout: .github/scripts + + - name: Override uipath packages with local wheels shell: bash - working-directory: uipath-runtime-python - run: uv add ../wheels/*.whl --dev + run: | + PYBIN=$(command -v python || command -v python3) + "$PYBIN" "$GITHUB_WORKSPACE/_scripts/.github/scripts/write_uv_overrides.py" - name: Run uipath-runtime tests working-directory: uipath-runtime-python diff --git a/SETUP.MD b/SETUP.MD new file mode 100644 index 000000000..4e728471b --- /dev/null +++ b/SETUP.MD @@ -0,0 +1,133 @@ +# SETUP.MD + +This file documents how to provision a clean development environment for the three packages in this repo (`uipath-core`, `uipath-platform`, `uipath`), run the build, execute the tests, and validate a sample code change end-to-end. It is intended both as a quick reference for human contributors and as a structured guide for automated environment-setup tooling. + +## Prerequisites + +- Python 3.11+ +- [uv](https://docs.astral.sh/uv/) 0.5+ + +### Supported platforms + +`uv` is shell- and OS-agnostic, so the commands below run unchanged on every supported platform: + +- [x] Linux +- [x] Windows +- [x] macOS + +## Environment Variables + +None required for environment setup, build, or unit tests. The suites under the `Test` section run fully offline and require no external authentication. + +> **All commands below must be run from the repository root.** The `uv --directory packages/` invocations resolve each subpackage relative to the current working directory. The first line of `## Setup` enforces this by `cd`-ing to the git root. + +## Setup + +```bash +cd "$(git rev-parse --show-toplevel)" +python3 -m pip install --upgrade uv + +# Sync all three packages (dependency order: core → platform → main) +uv --directory packages/uipath-core sync --all-extras +uv --directory packages/uipath-platform sync --all-extras +uv --directory packages/uipath sync --all-extras +``` + +## Verify Setup + +```bash +uv --version +uv --directory packages/uipath-core run python --version +uv --directory packages/uipath-core run python -c "import uipath.core; print('uipath-core ok')" +uv --directory packages/uipath-platform run python -c "import uipath.platform; print('uipath-platform ok')" +uv --directory packages/uipath run python -c "import uipath; print('uipath ok')" +``` + +## Build + +N/A + +## Test + +```bash +uv --directory packages/uipath-core run pytest +uv --directory packages/uipath-platform run pytest +uv --directory packages/uipath run pytest +``` + +> Note: `uipath-platform`'s `pyproject.toml` already excludes its E2E tests via `addopts = "... -m 'not e2e'"`. `uipath-core` and `uipath` do not register an `e2e` marker. + +## Sample Code Change + +### The change + +Add a new `size` property to `SpanRegistry` in `packages/uipath-core/src/uipath/core/tracing/span_utils.py`, immediately after the `clear` method and before the `# Global span registry instance` comment: + +```python +@property +def size(self) -> int: + """Return the number of currently registered spans.""" + return len(self._spans) +``` + +Then create `packages/uipath-core/tests/tracing/test_span_registry_size.py` with two pytest tests: + +```python +from unittest.mock import MagicMock + +from uipath.core.tracing.span_utils import SpanRegistry + + +def _make_span(span_id: int) -> MagicMock: + span = MagicMock() + span.get_span_context.return_value.span_id = span_id + span.parent = None # registered as a root span (no parent) + return span + + +def test_size_empty_registry() -> None: + registry = SpanRegistry() + assert registry.size == 0 + + +def test_size_after_registrations() -> None: + registry = SpanRegistry() + registry.register_span(_make_span(1)) + registry.register_span(_make_span(2)) + assert registry.size == 2 +``` + +### Verification + +```bash +uv --directory packages/uipath-core run pytest tests/tracing/test_span_registry_size.py -v +``` + +## Test with a real UiPath Coded Agent + +> This section is for human contributors who want to validate changes end-to-end against the real cloud platform. It is **not executed by the Agentic Inner Loop validation pipeline** — that pipeline only runs the sections above (Setup → Verify → Build → Test → Sample Code Change). + +The unit tests above are necessary but not sufficient — they don't exercise the package end-to-end through a real agent. The flow below validates changes against a live runtime: + +1. Apply the code changes locally. +2. Run the unit tests (see the `Sample Code Change` section above). +3. Scaffold a coded UiPath agent that exercises the changed code path. +4. In the downstream project's `pyproject.toml`, add this local library as an editable dependency (substitute `uipath`, `uipath-platform`, or `uipath-core` depending on which package you changed): + + ```toml + [tool.uv.sources] + uipath = { path = "../path/to/uipath-python/packages/uipath", editable = true } + ``` + +5. Exercise the new behavior end-to-end: + + ```bash + uv run uipath run --input '{...}' + ``` + +6. (Optional) Open a PR and apply the `build:dev` label — this publishes the development version to Test PyPI. +7. The PR description is updated automatically with instructions for pointing the downstream agent at the Test PyPI dev version. +8. Validate the new behavior against the real platform — use either or both of the deploy targets below (Studio Web and Orchestrator are not mutually exclusive): + - **Studio Web**: export the `UIPATH_PROJECT_ID` environment variable pointing to an existing Coded Agent project in your solution, then run [`uipath push`](https://uipath.github.io/uipath-python/cli/#push) to push the dev version to that project. Open it in Studio Web and exercise the changed code path. + - **Orchestrator**: run [`uipath deploy`](https://uipath.github.io/uipath-python/cli/#deploy) to deploy the dev version as a package, then start a job in Orchestrator and exercise the changed code path. +9. Once validation is done, close the dev PR — these PRs are not meant to be merged; their only purpose was to publish a Test PyPI build for end-to-end validation. diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 5604e3938..473d485a3 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.10" +version = "0.5.22" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -95,15 +95,33 @@ warn_required_dynamic_aliases = true [tool.pytest.ini_options] testpaths = ["tests"] python_files = "test_*.py" -addopts = "-ra -q --cov=src/uipath --cov-report=term-missing" +addopts = "-ra -q --cov=src --cov-report=term-missing" asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" +[tool.coverage.run] +source = ["src"] +relative_files = true +omit = [ + "*/tests/*", + "*/__pycache__/*", + "*/site-packages/*", + "*/conftest.py", +] + [tool.coverage.report] show_missing = true +precision = 2 +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "@(abc\\.)?abstractmethod", +] -[tool.coverage.run] -source = ["src"] +[tool.uv] +exclude-newer = "2 days" [[tool.uv.index]] name = "testpypi" diff --git a/packages/uipath-core/src/uipath/core/adapters/__init__.py b/packages/uipath-core/src/uipath/core/adapters/__init__.py new file mode 100644 index 000000000..5906b1b39 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/adapters/__init__.py @@ -0,0 +1,35 @@ +"""Generic adapter contracts for framework integrations. + +This package holds only the abstract contracts — concrete adapter +implementations live in framework-specific plugin packages (e.g. +``uipath-langchain``, ``uipath-openai``) that target the framework they +integrate with. Plugin packages register their concrete adapters with +the global :class:`AdapterRegistry` via the +``uipath.governance.adapters`` entry-point group. + +Public surface: + +- :class:`BaseAdapter` – abstract base every adapter inherits from. +- :class:`GovernedAgentBase` – proxy base for governed agent wrappers. +- :class:`EvaluatorProtocol` – structural protocol the adapter expects + from any policy evaluator. +- :class:`AdapterRegistry` – ordered list of adapters that resolves + the first match for a given agent. +""" + +from .base import BaseAdapter, GovernedAgentBase +from .evaluator import EvaluatorProtocol +from .registry import ( + AdapterRegistry, + get_adapter_registry, + reset_adapter_registry, +) + +__all__ = [ + "BaseAdapter", + "GovernedAgentBase", + "EvaluatorProtocol", + "AdapterRegistry", + "get_adapter_registry", + "reset_adapter_registry", +] diff --git a/packages/uipath-core/src/uipath/core/adapters/base.py b/packages/uipath-core/src/uipath/core/adapters/base.py new file mode 100644 index 000000000..3afaad6a7 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/adapters/base.py @@ -0,0 +1,116 @@ +"""Base adapter contracts for framework-specific integrations. + +An adapter's job: + +1. Detect whether it can handle a given agent object. +2. Attach hooks to that agent (framework-specific). +3. Publish events to a policy evaluator when those hooks fire. + +The evaluator subscribes to events and runs policy checks; it never +knows or cares which adapter fired the event. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any +from uuid import uuid4 + +from .evaluator import EvaluatorProtocol + + +class BaseAdapter(ABC): + """Base class for framework-specific governance adapters.""" + + #: Higher value = more specific = inserted earlier in the registry. + #: Plugin authors should set this above ``0`` on adapters that target + #: a narrower agent type than another already-registered adapter, so + #: the specific one wins ``can_handle`` resolution regardless of the + #: order in which plugins happen to be imported. Among adapters with + #: the same priority, registration order is preserved (stable). + priority: int = 0 + + #: Set to True on a catch-all adapter that should always sort last in + #: the registry. The registry uses this flag (not the class name or + #: :attr:`priority`) to keep the fallback in last position when new + #: adapters register. + is_fallback: bool = False + + @property + def name(self) -> str: + """Return adapter name for logging.""" + return self.__class__.__name__ + + @abstractmethod + def can_handle(self, agent: Any) -> bool: + """Return True if this adapter knows how to hook into this agent type.""" + + @abstractmethod + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + """Attach governance hooks to the agent. + + Args: + agent: The agent to govern. + agent_id: Unique identifier for the agent. + session_id: Session identifier for tracing. + evaluator: Policy evaluator implementing + :class:`EvaluatorProtocol`. + + Returns: + A governed proxy (or the original agent with hooks installed). + """ + + def detach(self, governed: Any) -> Any: + """Detach governance and return the original agent. + + Default implementation uses the public :attr:`GovernedAgentBase.unwrapped` + contract; non-proxy adapters that return the original agent from + :meth:`attach` get back ``governed`` unchanged. + """ + return getattr(governed, "unwrapped", governed) + + def _generate_trace_id(self) -> str: + """Generate a trace ID for governance events.""" + return str(uuid4()) + + +class GovernedAgentBase: + """Base class for governed agent proxies. + + Provides common functionality for all governed agents: + + - Stores reference to original agent + - Forwards unknown attributes to original agent + - Tracks governance metadata + """ + + def __init__( + self, + agent: Any, + adapter: BaseAdapter, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> None: + """Initialize with the wrapped agent and governance metadata.""" + self._agent = agent + self._adapter = adapter + self._agent_id = agent_id + self._session_id = session_id + self._evaluator = evaluator + self._trace_id = adapter._generate_trace_id() + + @property + def unwrapped(self) -> Any: + """Get the original unwrapped agent.""" + return self._agent + + def __getattr__(self, name: str) -> Any: + """Forward attribute access to the original agent.""" + return getattr(self._agent, name) diff --git a/packages/uipath-core/src/uipath/core/adapters/evaluator.py b/packages/uipath-core/src/uipath/core/adapters/evaluator.py new file mode 100644 index 000000000..ee5b92dad --- /dev/null +++ b/packages/uipath-core/src/uipath/core/adapters/evaluator.py @@ -0,0 +1,99 @@ +"""Structural contract for the policy evaluator an adapter talks to. + +Framework adapters call into a policy evaluator at each lifecycle hook. +Concrete evaluator implementations (the native runtime evaluator, a +Microsoft AGT bridge, a composite, …) live in packages outside +``uipath-core`` — adapters depend only on this structural protocol so +they can be swapped against any of them without code change. + +``EvaluatorProtocol`` is a :class:`typing.Protocol` so any class whose +methods match the signatures below satisfies the contract without +inheritance. +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class EvaluatorProtocol(Protocol): + """Structural protocol an adapter expects from a policy evaluator. + + Return types are intentionally :class:`typing.Any`: the concrete + audit record shape lives in the plugin package that owns the + evaluator and the policy model. Adapters in that package cast the + return value back to the concrete type they know. + """ + + def evaluate_before_agent( + self, + agent_input: str, + agent_name: str, + runtime_id: str, + trace_id: str, + model_name: str = "", + **kwargs: Any, + ) -> Any: + """Evaluate BEFORE_AGENT rules.""" + ... + + def evaluate_after_agent( + self, + agent_output: str, + agent_name: str, + runtime_id: str, + trace_id: str, + **kwargs: Any, + ) -> Any: + """Evaluate AFTER_AGENT rules.""" + ... + + def evaluate_before_model( + self, + model_input: str, + agent_name: str, + runtime_id: str, + trace_id: str, + messages: list[dict[str, Any]] | None = None, + model_name: str = "", + **kwargs: Any, + ) -> Any: + """Evaluate BEFORE_MODEL rules.""" + ... + + def evaluate_after_model( + self, + model_output: str, + agent_name: str, + runtime_id: str, + trace_id: str, + **kwargs: Any, + ) -> Any: + """Evaluate AFTER_MODEL rules.""" + ... + + def evaluate_tool_call( + self, + tool_name: str, + tool_args: dict[str, Any], + agent_name: str, + runtime_id: str, + trace_id: str, + session_state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Any: + """Evaluate TOOL_CALL rules.""" + ... + + def evaluate_after_tool( + self, + tool_name: str, + tool_result: str, + agent_name: str, + runtime_id: str, + trace_id: str, + **kwargs: Any, + ) -> Any: + """Evaluate AFTER_TOOL rules.""" + ... diff --git a/packages/uipath-core/src/uipath/core/adapters/registry.py b/packages/uipath-core/src/uipath/core/adapters/registry.py new file mode 100644 index 000000000..adebe780a --- /dev/null +++ b/packages/uipath-core/src/uipath/core/adapters/registry.py @@ -0,0 +1,176 @@ +"""Ordered registry of framework adapters. + +The registry is a pure, implementation-agnostic container — it does +**not** know about any concrete adapter. Plugin packages (e.g. +``uipath-langchain``) populate it by either: + +1. Declaring a ``uipath.governance.adapters`` entry point whose value + is a zero-arg callable that calls :meth:`AdapterRegistry.register`. + These are auto-discovered on first call to + :func:`get_adapter_registry`. +2. Calling :meth:`AdapterRegistry.register` directly at import time + (e.g. side-effect on importing the plugin's governance submodule). + +Adapters are checked in priority order (highest first): more specific +adapters get a higher :attr:`BaseAdapter.priority` so they win +``can_handle`` resolution over generic ones, regardless of the order in +which plugin packages happen to be imported. Among adapters with the +same priority, registration order is preserved. Adapters with +``is_fallback=True`` sort last when registered without an explicit +``position`` — passing ``position`` to :meth:`AdapterRegistry.register` +is an escape hatch that bypasses both priority and fallback ordering, +so callers using it own the resulting list order. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from .base import BaseAdapter + +logger = logging.getLogger(__name__) + +ENTRY_POINT_GROUP = "uipath.governance.adapters" + + +class AdapterRegistry: + """Ordered list of adapters; resolves the first match for an agent.""" + + def __init__(self) -> None: + """Initialize an empty registry.""" + self._adapters: list[BaseAdapter] = [] + + def register(self, adapter: BaseAdapter, position: int | None = None) -> None: + """Register an adapter. + + Args: + adapter: The adapter to register. + position: Explicit insertion index (``0`` = highest priority) + that bypasses both priority-based ordering AND fallback + semantics — the adapter is inserted blindly at the given + index, so callers using ``position`` are responsible for + not placing a fallback before a specific adapter (or a + specific adapter after an existing fallback). When + ``None`` the adapter is inserted by + :attr:`BaseAdapter.priority` (higher first, stable on + ties) and before any adapter marked + :attr:`BaseAdapter.is_fallback`; adapters whose own + ``is_fallback`` is set are appended last. + """ + if position is not None: + self._adapters.insert(position, adapter) + elif adapter.is_fallback: + self._adapters.append(adapter) + else: + insert_at = len(self._adapters) + for i, existing in enumerate(self._adapters): + if existing.is_fallback or existing.priority < adapter.priority: + insert_at = i + break + self._adapters.insert(insert_at, adapter) + logger.debug("Registered adapter: %s", adapter.name) + + def resolve(self, agent: Any) -> BaseAdapter | None: + """Return the first adapter that can handle ``agent`` (or ``None``).""" + for adapter in self._adapters: + try: + if adapter.can_handle(agent): + logger.debug( + "AdapterRegistry: %s -> %s", + type(agent).__name__, + adapter.name, + ) + return adapter + except Exception as exc: + logger.warning( + "Adapter %s.can_handle() failed: %s", + adapter.name, + exc, + ) + continue + return None + + def get_all(self) -> list[BaseAdapter]: + """Return a copy of the registered adapters in priority order.""" + return self._adapters.copy() + + def clear(self) -> None: + """Remove all registered adapters.""" + self._adapters.clear() + + +_registry: AdapterRegistry | None = None + + +def _discover_entry_point_adapters() -> None: + """Load every adapter advertised under the ``uipath.governance.adapters`` group. + + Each entry-point value must be a zero-arg callable (typically a + ``register_*`` function in the plugin package) that calls + :meth:`AdapterRegistry.register`. A failure to load or invoke any + one entry point is logged and skipped — a single broken plugin + must never block governance startup. + """ + try: + from importlib.metadata import entry_points + except ImportError: # pragma: no cover - importlib.metadata is stdlib in py3.11+ + return + + try: + eps = entry_points(group=ENTRY_POINT_GROUP) + except Exception as exc: # noqa: BLE001 - discovery failures must never raise + logger.debug("Adapter entry-point discovery failed: %s", exc, exc_info=True) + return + + for ep in eps: + try: + registrar = ep.load() + except Exception as exc: # noqa: BLE001 - one broken plugin must not block others + logger.debug( + "Failed to load governance adapter entry point '%s' (%s): %s", + ep.name, + ep.value, + exc, + exc_info=True, + ) + continue + if not callable(registrar): + logger.warning( + "Governance adapter entry point '%s' is not callable: %r", + ep.name, + registrar, + ) + continue + try: + registrar() + except Exception as exc: # noqa: BLE001 - one broken plugin must not block others + logger.debug( + "Governance adapter '%s' register call failed: %s", + ep.name, + exc, + exc_info=True, + ) + + +def get_adapter_registry() -> AdapterRegistry: + """Return the process-wide adapter registry singleton. + + On first call, discovers and registers every adapter declared under + the ``uipath.governance.adapters`` entry-point group, so framework + SDKs (``uipath-langchain``, ``uipath-openai``, …) just need to be + installed — no explicit import is required. + """ + global _registry + if _registry is None: + _registry = AdapterRegistry() + _discover_entry_point_adapters() + return _registry + + +def reset_adapter_registry() -> None: + """Drop the singleton registry (intended for tests).""" + global _registry + if _registry is not None: + _registry.clear() + _registry = None diff --git a/packages/uipath-core/src/uipath/core/chat/__init__.py b/packages/uipath-core/src/uipath/core/chat/__init__.py index 476cb9352..ee4a4c674 100644 --- a/packages/uipath-core/src/uipath/core/chat/__init__.py +++ b/packages/uipath-core/src/uipath/core/chat/__init__.py @@ -71,6 +71,7 @@ ) from .event import UiPathConversationEvent, UiPathConversationLabelUpdatedEvent from .exchange import ( + UiPathClientSideToolDeclaration, UiPathConversationExchange, UiPathConversationExchangeData, UiPathConversationExchangeEndEvent, @@ -107,13 +108,22 @@ UiPathSessionStartEvent, ) from .tool import ( + UiPathConversationExecutingToolCallEvent, UiPathConversationToolCall, + UiPathConversationToolCallConfirmation, + UiPathConversationToolCallConfirmationData, + UiPathConversationToolCallConfirmationEvent, UiPathConversationToolCallData, UiPathConversationToolCallEndEvent, UiPathConversationToolCallEvent, UiPathConversationToolCallResult, UiPathConversationToolCallStartEvent, ) +from .voice import ( + UiPathVoiceToolCallMessage, + UiPathVoiceToolCallRequest, + UiPathVoiceToolCallResult, +) __all__ = [ # Root @@ -130,6 +140,7 @@ "UiPathSessionEndingEvent", "UiPathSessionEndEvent", # Exchange + "UiPathClientSideToolDeclaration", "UiPathConversationExchangeStartEvent", "UiPathConversationExchangeEndEvent", "UiPathConversationExchangeEvent", @@ -141,19 +152,6 @@ "UiPathConversationMessageEvent", "UiPathConversationMessageData", "UiPathConversationMessage", - # Interrupt - "InterruptTypeEnum", - "UiPathConversationInterruptStartEvent", - "UiPathConversationInterruptEndEvent", - "UiPathConversationInterruptEvent", - "UiPathConversationToolCallConfirmationValue", - "UiPathConversationToolCallConfirmationEndValue", - "UiPathConversationToolCallConfirmationInterruptStartEvent", - "UiPathConversationToolCallConfirmationInterruptEndEvent", - "UiPathConversationGenericInterruptStartEvent", - "UiPathConversationGenericInterruptEndEvent", - "UiPathConversationInterruptData", - "UiPathConversationInterrupt", # Content "UiPathConversationContentPartChunkEvent", "UiPathConversationContentPartStartEvent", @@ -176,8 +174,12 @@ "UiPathConversationCitationData", "UiPathConversationCitation", # Tool + "UiPathConversationExecutingToolCallEvent", "UiPathConversationToolCallStartEvent", "UiPathConversationToolCallEndEvent", + "UiPathConversationToolCallConfirmation", + "UiPathConversationToolCallConfirmationData", + "UiPathConversationToolCallConfirmationEvent", "UiPathConversationToolCallEvent", "UiPathConversationToolCallResult", "UiPathConversationToolCallData", @@ -189,4 +191,21 @@ "UiPathConversationAsyncInputStreamEvent", # Meta "UiPathConversationMetaEvent", + # Voice + "UiPathVoiceToolCallRequest", + "UiPathVoiceToolCallMessage", + "UiPathVoiceToolCallResult", + # Interrupt (compat shims — deprecated, see interrupt.py) + "InterruptTypeEnum", + "UiPathConversationInterruptStartEvent", + "UiPathConversationInterruptEndEvent", + "UiPathConversationInterruptEvent", + "UiPathConversationInterruptData", + "UiPathConversationInterrupt", + "UiPathConversationGenericInterruptStartEvent", + "UiPathConversationGenericInterruptEndEvent", + "UiPathConversationToolCallConfirmationValue", + "UiPathConversationToolCallConfirmationEndValue", + "UiPathConversationToolCallConfirmationInterruptStartEvent", + "UiPathConversationToolCallConfirmationInterruptEndEvent", ] diff --git a/packages/uipath-core/src/uipath/core/chat/content.py b/packages/uipath-core/src/uipath/core/chat/content.py index cc6300490..4ae8b169c 100644 --- a/packages/uipath-core/src/uipath/core/chat/content.py +++ b/packages/uipath-core/src/uipath/core/chat/content.py @@ -2,6 +2,7 @@ from __future__ import annotations +import uuid from typing import Any, Sequence from pydantic import BaseModel, ConfigDict, Field @@ -95,7 +96,7 @@ class UiPathConversationContentPartData(BaseModel): mime_type: str = Field(..., alias="mimeType") data: InlineOrExternal - citations: Sequence[UiPathConversationCitationData] + citations: Sequence[UiPathConversationCitationData] = Field(default_factory=list) is_transcript: bool | None = Field(None, alias="isTranscript") is_incomplete: bool | None = Field(None, alias="isIncomplete") name: str | None = None @@ -106,11 +107,13 @@ class UiPathConversationContentPartData(BaseModel): class UiPathConversationContentPart(UiPathConversationContentPartData): """Represents a single part of message content.""" - content_part_id: str = Field(..., alias="contentPartId") - created_at: str = Field(..., alias="createdAt") - updated_at: str = Field(..., alias="updatedAt") + content_part_id: str = Field( + default_factory=lambda: str(uuid.uuid4()), alias="contentPartId" + ) + created_at: str | None = Field(None, alias="createdAt") + updated_at: str | None = Field(None, alias="updatedAt") # Override to use full type - citations: Sequence[UiPathConversationCitation] + citations: Sequence[UiPathConversationCitation] = Field(default_factory=list) model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/exchange.py b/packages/uipath-core/src/uipath/core/chat/exchange.py index 788bbe560..835489dfb 100644 --- a/packages/uipath-core/src/uipath/core/chat/exchange.py +++ b/packages/uipath-core/src/uipath/core/chat/exchange.py @@ -28,11 +28,24 @@ ) +class UiPathClientSideToolDeclaration(BaseModel): + """A client-side tool declaration from the SDK client.""" + + name: str + input_schema: dict[str, Any] | None = Field(None, alias="inputSchema") + output_schema: dict[str, Any] | None = Field(None, alias="outputSchema") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + class UiPathConversationExchangeStartEvent(BaseModel): """Signals the start of an exchange of messages within a conversation.""" conversation_sequence: int | None = Field(None, alias="conversationSequence") metadata: dict[str, Any] | None = Field(None, alias="metaData") + client_side_tools: list[UiPathClientSideToolDeclaration] | None = Field( + None, alias="clientSideTools" + ) timestamp: str | None = None model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/interrupt.py b/packages/uipath-core/src/uipath/core/chat/interrupt.py index a2ce3e13f..9094d1597 100644 --- a/packages/uipath-core/src/uipath/core/chat/interrupt.py +++ b/packages/uipath-core/src/uipath/core/chat/interrupt.py @@ -1,4 +1,14 @@ -"""Interrupt events for human-in-the-loop patterns.""" +"""Compatibility shims for legacy interrupt event types. + +The interrupt-based tool-call confirmation flow was replaced by `confirmToolCall` +on the tool call event itself (see PR #1558). The original `interrupt.py` was +removed in `uipath-core` 0.5.13, but published `uipath-runtime` versions still +import these names at module load time, breaking installs that pull the new +`uipath-core` alongside an older runtime. + +These shims keep those imports working. They are not used by current code paths +and should be removed in the next minor bump of `uipath-core`. +""" from enum import Enum from typing import Any, Literal, Union @@ -93,7 +103,7 @@ class UiPathConversationInterruptEvent(BaseModel): class UiPathConversationInterruptData(BaseModel): - """Represents the core data of an interrupt within a message - a pause point where the agent needs external input.""" + """Core data of an interrupt within a message.""" type: str interrupt_value: Any = Field(..., alias="interruptValue") @@ -103,7 +113,7 @@ class UiPathConversationInterruptData(BaseModel): class UiPathConversationInterrupt(UiPathConversationInterruptData): - """Represents an interrupt within a message - a pause point where the agent needs external input.""" + """An interrupt within a message — a pause point where the agent needs external input.""" interrupt_id: str = Field(..., alias="interruptId") created_at: str = Field(..., alias="createdAt") diff --git a/packages/uipath-core/src/uipath/core/chat/message.py b/packages/uipath-core/src/uipath/core/chat/message.py index 48e79171f..37aa2bd76 100644 --- a/packages/uipath-core/src/uipath/core/chat/message.py +++ b/packages/uipath-core/src/uipath/core/chat/message.py @@ -1,5 +1,6 @@ """Message-level events.""" +import uuid from typing import Any, Sequence from pydantic import BaseModel, ConfigDict, Field @@ -10,11 +11,6 @@ UiPathConversationContentPartEvent, ) from .error import UiPathConversationErrorEvent -from .interrupt import ( - UiPathConversationInterrupt, - UiPathConversationInterruptData, - UiPathConversationInterruptEvent, -) from .tool import ( UiPathConversationToolCall, UiPathConversationToolCallData, @@ -53,7 +49,6 @@ class UiPathConversationMessageEvent(BaseModel): None, alias="contentPart" ) tool_call: UiPathConversationToolCallEvent | None = Field(None, alias="toolCall") - interrupt: UiPathConversationInterruptEvent | None = None meta_event: dict[str, Any] | None = Field(None, alias="metaEvent") error: UiPathConversationErrorEvent | None = Field(None, alias="messageError") @@ -67,8 +62,9 @@ class UiPathConversationMessageData(BaseModel): content_parts: Sequence[UiPathConversationContentPartData] = Field( ..., alias="contentParts" ) - tool_calls: Sequence[UiPathConversationToolCallData] = Field(..., alias="toolCalls") - interrupts: Sequence[UiPathConversationInterruptData] + tool_calls: Sequence[UiPathConversationToolCallData] = Field( + default_factory=list, alias="toolCalls" + ) model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) @@ -76,16 +72,19 @@ class UiPathConversationMessageData(BaseModel): class UiPathConversationMessage(UiPathConversationMessageData): """Represents a single message within an exchange.""" - message_id: str = Field(..., alias="messageId") - created_at: str = Field(..., alias="createdAt") - updated_at: str = Field(..., alias="updatedAt") + message_id: str = Field( + default_factory=lambda: str(uuid.uuid4()), alias="messageId" + ) + created_at: str | None = Field(None, alias="createdAt") + updated_at: str | None = Field(None, alias="updatedAt") span_id: str | None = Field(None, alias="spanId") # Overrides to use full types content_parts: Sequence[UiPathConversationContentPart] = Field( ..., alias="contentParts" ) - tool_calls: Sequence[UiPathConversationToolCall] = Field(..., alias="toolCalls") - interrupts: Sequence[UiPathConversationInterrupt] + tool_calls: Sequence[UiPathConversationToolCall] = Field( + default_factory=list, alias="toolCalls" + ) model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/tool.py b/packages/uipath-core/src/uipath/core/chat/tool.py index 9c9e911bd..514e42908 100644 --- a/packages/uipath-core/src/uipath/core/chat/tool.py +++ b/packages/uipath-core/src/uipath/core/chat/tool.py @@ -25,6 +25,10 @@ class UiPathConversationToolCallStartEvent(BaseModel): timestamp: str | None = None input: dict[str, Any] | None = None metadata: dict[str, Any] | None = Field(None, alias="metaData") + require_confirmation: bool | None = Field(None, alias="requireConfirmation") + input_schema: Any | None = Field(None, alias="inputSchema") + is_client_side_tool: bool | None = Field(None, alias="isClientSideTool") + output_schema: Any | None = Field(None, alias="outputSchema") model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) @@ -41,6 +45,47 @@ class UiPathConversationToolCallEndEvent(BaseModel): model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) +class UiPathConversationExecutingToolCallEvent(BaseModel): + """Signals the client that the tool is about to be executed. + + Emitted in all scenarios. For client-side tools, the client should begin + executing its handler upon receiving this event. + """ + + timestamp: str | None = None + input: dict[str, Any] | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationToolCallConfirmationEvent(BaseModel): + """Signals a tool call confirmation (approve/reject) from the client.""" + + approved: bool + input: Any | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationToolCallConfirmationData(BaseModel): + """Represents the core data of a tool call confirmation.""" + + approved: bool + input: Any | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationToolCallConfirmation( + UiPathConversationToolCallConfirmationData +): + """Represents the stored confirmation state on a tool call.""" + + confirmed_at: str | None = Field(None, alias="confirmedAt") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + class UiPathConversationToolCallEvent(BaseModel): """Encapsulates the data related to a tool call event.""" @@ -49,6 +94,12 @@ class UiPathConversationToolCallEvent(BaseModel): None, alias="startToolCall" ) end: UiPathConversationToolCallEndEvent | None = Field(None, alias="endToolCall") + confirm: UiPathConversationToolCallConfirmationEvent | None = Field( + None, alias="confirmToolCall" + ) + executing: UiPathConversationExecutingToolCallEvent | None = Field( + None, alias="executingToolCall" + ) meta_event: dict[str, Any] | None = Field(None, alias="metaEvent") error: UiPathConversationErrorEvent | None = Field(None, alias="toolCallError") @@ -61,6 +112,9 @@ class UiPathConversationToolCallData(BaseModel): name: str input: dict[str, Any] | None = None result: UiPathConversationToolCallResult | None = None + require_confirmation: bool | None = Field(None, alias="requireConfirmation") + input_schema: Any | None = Field(None, alias="inputSchema") + confirmation: UiPathConversationToolCallConfirmationData | None = None model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) @@ -72,5 +126,6 @@ class UiPathConversationToolCall(UiPathConversationToolCallData): timestamp: str | None = None created_at: str = Field(..., alias="createdAt") updated_at: str = Field(..., alias="updatedAt") + confirmation: UiPathConversationToolCallConfirmation | None = None model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/voice.py b/packages/uipath-core/src/uipath/core/chat/voice.py new file mode 100644 index 000000000..7b1adf7e8 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/voice.py @@ -0,0 +1,30 @@ +"""Voice tool-call wire models (CAS socket.io).""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class _VoiceWire(BaseModel): + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathVoiceToolCallRequest(_VoiceWire): + """Single tool call in a batch.""" + + call_id: str = Field(..., alias="callId") + tool_name: str = Field(..., alias="toolName") + args: dict[str, Any] + + +class UiPathVoiceToolCallMessage(_VoiceWire): + """Batch of tool calls from CAS.""" + + calls: list[UiPathVoiceToolCallRequest] = Field(..., min_length=1) + + +class UiPathVoiceToolCallResult(_VoiceWire): + """Result of a single tool call.""" + + result: str + is_error: bool = Field(..., alias="isError") diff --git a/packages/uipath-core/src/uipath/core/governance/__init__.py b/packages/uipath-core/src/uipath/core/governance/__init__.py new file mode 100644 index 000000000..e3dcab741 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/governance/__init__.py @@ -0,0 +1,53 @@ +"""UiPath governance shared contracts. + +Evaluator-agnostic types every governance consumer references — +adapter packages (``uipath-langchain``, ``uipath-openai``, …), the +runtime layer (``uipath.runtime.governance``), and customer code that +catches :class:`GovernanceBlockException`. The full runtime / audit / +native-evaluator implementation lives in ``uipath.runtime.governance``; +this core surface is just the contracts. +""" + +from .config import ( + GOVERNANCE_FEATURE_FLAG, + is_governance_enabled, +) +from .exceptions import ( + GovernanceBlockException, + GovernanceConfigError, + GovernanceViolation, + Severity, +) +from .models import Action, AuditRecord, EnforcementMode, LifecycleHook, RuleEvaluation +from .providers import ( + FiredRule, + GovernanceCompensationProvider, + GovernancePolicyProvider, + GovernRequest, + PolicyContext, + PolicyResponse, +) + +__all__ = [ + # Output models (cross adapter boundary) + "Action", + "AuditRecord", + "EnforcementMode", + "LifecycleHook", + "RuleEvaluation", + # Config + "GOVERNANCE_FEATURE_FLAG", + "is_governance_enabled", + # Exceptions + "GovernanceBlockException", + "GovernanceConfigError", + "GovernanceViolation", + "Severity", + # Provider protocols + wire models + "FiredRule", + "GovernanceCompensationProvider", + "GovernancePolicyProvider", + "GovernRequest", + "PolicyContext", + "PolicyResponse", +] diff --git a/packages/uipath-core/src/uipath/core/governance/config.py b/packages/uipath-core/src/uipath/core/governance/config.py new file mode 100644 index 000000000..7fec33848 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/governance/config.py @@ -0,0 +1,37 @@ +"""Governance configuration. + +Process-level feature-flag gate that decides whether the Python +governance checker runs at all. The +:class:`uipath.core.governance.EnforcementMode` value type is defined +in :mod:`uipath.core.governance.models`; the per-policy runtime state +that selects a mode (backend-supplied via the ``/runtime/policy`` +client) lives in the ``uipath-runtime`` package. +""" + +from __future__ import annotations + +from uipath.core.feature_flags import FeatureFlags + +# Feature flag name controlling whether governance runs. +# Mirrors the gate in ``uipath-runtime`` so the platform-injection path +# and direct callers (agents constructing an evaluator themselves) +# honour the same toggle. +GOVERNANCE_FEATURE_FLAG = "EnablePythonGovernanceChecker" + + +def is_governance_enabled() -> bool: + """Return whether the ``EnablePythonGovernanceChecker`` flag is enabled. + + Governance is **off by default** — the flag must be explicitly set + to ``true`` (programmatically via the ``FeatureFlags`` registry, or + via the ``UIPATH_FEATURE_EnablePythonGovernanceChecker`` env var) + for this function to return ``True``. + + Resolution order: + + 1. :meth:`uipath.core.feature_flags.FeatureFlagsManager.is_flag_enabled` - + the in-process programmatic registry (typically populated from + gitops) and its own ``UIPATH_FEATURE_`` env-var fallback. + 2. Default ``False`` (governance disabled). + """ + return FeatureFlags.is_flag_enabled(GOVERNANCE_FEATURE_FLAG, default=False) diff --git a/packages/uipath-core/src/uipath/core/governance/exceptions.py b/packages/uipath-core/src/uipath/core/governance/exceptions.py new file mode 100644 index 000000000..48f4b178a --- /dev/null +++ b/packages/uipath-core/src/uipath/core/governance/exceptions.py @@ -0,0 +1,114 @@ +"""Governance exception types.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from uipath.core.governance.models import AuditRecord + +_DEFAULT_RULE_ID = "POLICY" +_DEFAULT_RULE_NAME = "Governance Policy" +_MSG_PREFIX = "[Governance Policy Violation]" + + +class Severity(str, Enum): + """Severity classification for a governance violation.""" + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +@dataclass +class GovernanceViolation: + """Details of a governance rule violation.""" + + rule_id: str + rule_name: str + detail: str + severity: Severity = Severity.HIGH + + +def _format_violation_message(rule_id: str, rule_name: str, detail: str) -> str: + return f"{_MSG_PREFIX} {rule_name} ({rule_id}): {detail}" + + +class GovernanceBlockException(Exception): + """Raised when a governance policy blocks an operation. + + This exception indicates that the AI agent's operation was blocked by + a configured governance policy, not an unexpected system error. + + Prefer the classmethod constructors (:meth:`from_violation`, + :meth:`from_audit_record`) when you have structured context — the + default constructor is for raw-message use only. + """ + + # Error code for Orchestrator categorization + error_code: str = "GOVERNANCE_POLICY_VIOLATION" + + def __init__( + self, + message: str | None = None, + *, + violation: GovernanceViolation | None = None, + audit_record: AuditRecord | None = None, + rule_id: str = _DEFAULT_RULE_ID, + rule_name: str = _DEFAULT_RULE_NAME, + ) -> None: + """Construct from a pre-formatted message and optional structured context. + + Most callers should use :meth:`from_violation` or + :meth:`from_audit_record` instead of passing structured context + directly. + """ + self.violation = violation + self.audit_record = audit_record + self.rule_id = rule_id + self.rule_name = rule_name + super().__init__( + message or f"{_MSG_PREFIX} Operation blocked by governance policy." + ) + + @classmethod + def from_violation( + cls, violation: GovernanceViolation + ) -> "GovernanceBlockException": + """Build from a structured :class:`GovernanceViolation`.""" + return cls( + message=_format_violation_message( + violation.rule_id, violation.rule_name, violation.detail + ), + violation=violation, + rule_id=violation.rule_id, + rule_name=violation.rule_name, + ) + + @classmethod + def from_audit_record(cls, audit_record: AuditRecord) -> "GovernanceBlockException": + """Build from an :class:`AuditRecord` — first matched rule wins.""" + matched_rules = [e for e in audit_record.evaluations if e.matched] + if matched_rules: + rule = matched_rules[0] + message = _format_violation_message( + rule.rule_id, rule.rule_name, rule.detail or "Policy violation detected" + ) + return cls( + message=message, + audit_record=audit_record, + rule_id=rule.rule_id, + rule_name=rule.rule_name, + ) + return cls( + message=( + f"{_MSG_PREFIX} Operation blocked. " + f"Rules evaluated: {len(audit_record.evaluations)}" + ), + audit_record=audit_record, + ) + + +class GovernanceConfigError(RuntimeError): + """Raised when governance is misconfigured.""" diff --git a/packages/uipath-core/src/uipath/core/governance/models.py b/packages/uipath-core/src/uipath/core/governance/models.py new file mode 100644 index 000000000..9fc5e2084 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/governance/models.py @@ -0,0 +1,83 @@ +"""Shared governance contracts. + +Two groups of types live here, both kept free of policy-input concepts +(``Rule``/``Check``/``Condition``) so adapter packages don't inherit +the native policy model: + +- **Output types** (:class:`Action`, :class:`LifecycleHook`, + :class:`RuleEvaluation`, :class:`AuditRecord`) — cross the adapter + boundary at evaluation time: every evaluator implementation (native, + AGT, composite, …) produces them, and every adapter consumes them. +- **Configuration value types** (:class:`EnforcementMode`) — describe + governance configuration shared by core, runtime, and consumers. The + runtime state that selects an enforcement mode lives in + ``uipath-runtime``; only the value type lives here. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any + + +class Action(str, Enum): + """Actions that can be taken when a rule matches.""" + + ALLOW = "allow" + DENY = "deny" + AUDIT = "audit" + ESCALATE = "escalate" + + +class LifecycleHook(str, Enum): + """Agent lifecycle hooks where rules can be evaluated.""" + + BEFORE_AGENT = "before_agent" + AFTER_AGENT = "after_agent" + BEFORE_MODEL = "before_model" + AFTER_MODEL = "after_model" + TOOL_CALL = "tool_call" + AFTER_TOOL = "after_tool" + + +class EnforcementMode(str, Enum): + """Governance enforcement modes.""" + + AUDIT = "audit" # Evaluate and log; never block. + ENFORCE = "enforce" # Block on DENY rules. + DISABLED = "disabled" # Skip evaluation entirely. + + +@dataclass +class RuleEvaluation: + """Result of evaluating a single rule.""" + + rule_id: str + rule_name: str + matched: bool + detail: str = "" + pack_name: str = "" + action: Action = Action.ALLOW + description: str = "" + check_results: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class AuditRecord: + """Complete audit record for a governance evaluation.""" + + timestamp: datetime + agent_name: str + runtime_id: str + trace_id: str + hook: LifecycleHook + evaluations: list[RuleEvaluation] + final_action: Action + metadata: dict[str, Any] = field(default_factory=dict) + rules_matched: int = field(init=False) + + def __post_init__(self) -> None: + """Derive rules_matched from the evaluations list.""" + self.rules_matched = sum(1 for e in self.evaluations if e.matched) diff --git a/packages/uipath-core/src/uipath/core/governance/providers.py b/packages/uipath-core/src/uipath/core/governance/providers.py new file mode 100644 index 000000000..29f435edb --- /dev/null +++ b/packages/uipath-core/src/uipath/core/governance/providers.py @@ -0,0 +1,153 @@ +"""Provider protocols for governance backend interactions. + +The runtime needs two backend interactions to function: + +- Fetching the policy pack at startup. +- Firing the compensating ``/runtime/govern`` POST when a + ``guardrail_fallback`` rule matches so the server can run the disabled + centralised guardrail and write the per-rule LLMOps audit records. + +Both have wire formats owned by the ``agenticgovernance_`` ingress. +Defining the contracts here — alongside :class:`EvaluatorProtocol` — +lets runtime consumers depend on stable protocols and receive a +concrete provider via constructor injection. Concrete providers live +outside this package; ``uipath-core`` does not import them. +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from .models import EnforcementMode + +# ---------------------------------------------------------------------- +# Wire-format models +# ---------------------------------------------------------------------- + + +class PolicyContext(BaseModel): + """Caller-supplied selectors for the policy fetch. + + Wrapping the selectors in a model keeps the protocol surface stable + when the server grows new selector dimensions — adding a field here + doesn't change :meth:`GovernancePolicyProvider.get_policy`. + + Today carries only :attr:`is_conversational`; future selectors land + here. + """ + + model_config = ConfigDict(extra="ignore") + + is_conversational: bool | None = None + + +class PolicyResponse(BaseModel): + """Parsed governance backend response. + + Wire envelope:: + + { + "mode": "audit" | "enforce" | "disabled", + "policies": "" + } + + Attributes: + mode: Platform-controlled enforcement mode for the tenant. May + be ``None`` when the backend omits it. A wire value the SDK + doesn't know about parses as ``None`` rather than raising, + so a server-side mode addition can't break agent startup. + policies: Policy pack YAML the caller compiles into its policy + index. May be an empty string when no rules are configured. + """ + + model_config = ConfigDict(extra="ignore") + + mode: EnforcementMode | None = Field(default=None) + policies: str = Field(default="") + + @field_validator("mode", mode="before") + @classmethod + def _coerce_mode(cls, value: object) -> EnforcementMode | None: + if value is None or isinstance(value, EnforcementMode): + return value + try: + return EnforcementMode(value) + except ValueError: + return None + + +class FiredRule(BaseModel): + """Per-rule metadata carried in the ``/runtime/govern`` payload. + + One entry per matching ``guardrail_fallback`` condition. The server + writes one LLMOps trace record per entry, so callers must include + every fired rule even when multiple share the same ``validator``. + """ + + model_config = ConfigDict(populate_by_name=True) + + rule_id: str = Field(alias="ruleId") + rule_name: str = Field(alias="ruleName") + pack_name: str = Field(alias="packName") + validator: str + + +class GovernRequest(BaseModel): + """Request body for the ``/runtime/govern`` compensating governance POST. + + Field aliases match the on-the-wire JSON keys. ``src_timestamp`` is + snake_case on the wire (intentional — preserved verbatim); every + other key is camelCase. + + Job-context fields (``folder_key`` / ``job_key`` / ``process_key`` / + ``reference_id`` / ``agent_version``) are optional; callers omit + them by leaving them ``None``. How unset fields are resolved (e.g. + auto-filled from environment) is the concrete provider's concern, + not part of this wire contract. + """ + + model_config = ConfigDict(populate_by_name=True) + + validators: list[str] = Field(alias="type") + rules: list[FiredRule] + data: dict[str, Any] + hook: str + trace_id: str = Field(alias="traceId") + src_timestamp: str # wire key is intentionally snake_case + agent_name: str = Field(alias="agentName") + runtime_id: str = Field(alias="runtimeId") + + folder_key: str | None = Field(default=None, alias="folderKey") + job_key: str | None = Field(default=None, alias="jobKey") + process_key: str | None = Field(default=None, alias="processKey") + reference_id: str | None = Field(default=None, alias="referenceId") + agent_version: str | None = Field(default=None, alias="agentVersion") + + +# ---------------------------------------------------------------------- +# Provider protocols +# ---------------------------------------------------------------------- + + +@runtime_checkable +class GovernancePolicyProvider(Protocol): + """Contract for fetching the governance policy pack. + + Any object exposing a ``get_policy(context) -> PolicyResponse`` + method satisfies this protocol. + """ + + def get_policy(self, context: PolicyContext) -> PolicyResponse: + """Fetch the policy pack for the active org/tenant.""" + ... + + +@runtime_checkable +class GovernanceCompensationProvider(Protocol): + """Contract for firing the compensating ``/runtime/govern`` POST.""" + + def compensate(self, request: GovernRequest) -> None: + """Fire the compensating governance POST. Fire-and-forget.""" + ... diff --git a/packages/uipath-core/src/uipath/core/guardrails/guardrails.py b/packages/uipath-core/src/uipath/core/guardrails/guardrails.py index fe651c35b..576cc437a 100644 --- a/packages/uipath-core/src/uipath/core/guardrails/guardrails.py +++ b/packages/uipath-core/src/uipath/core/guardrails/guardrails.py @@ -1,7 +1,7 @@ """Guardrails models for UiPath Platform.""" from enum import Enum -from typing import Annotated, Any, Callable, Literal +from typing import Annotated, Any, Callable, Literal, Optional from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -30,6 +30,8 @@ class GuardrailValidationResult(BaseModel): Attributes: result: The validation result type. reason: Textual explanation describing why the validation passed or failed. + span_id: Span ID from the guardrail service response, formatted as a GUID + for trace correlation. None when the response omits the header. """ model_config = ConfigDict(populate_by_name=True) @@ -40,6 +42,11 @@ class GuardrailValidationResult(BaseModel): reason: str = Field( alias="reason", description="Explanation for the validation result." ) + span_id: Optional[str] = Field( + default=None, + alias="spanId", + description="Span ID returned by the guardrail service for trace correlation.", + ) class FieldSource(str, Enum): @@ -227,7 +234,7 @@ class BaseGuardrail(BaseModel): name: str description: str | None = None enabled_for_evals: bool = Field(True, alias="enabledForEvals") - selector: GuardrailSelector + selector: GuardrailSelector | None = None model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/packages/uipath-core/src/uipath/core/triggers/__init__.py b/packages/uipath-core/src/uipath/core/triggers/__init__.py index 400462277..fb4ee6ae5 100644 --- a/packages/uipath-core/src/uipath/core/triggers/__init__.py +++ b/packages/uipath-core/src/uipath/core/triggers/__init__.py @@ -4,11 +4,13 @@ "UiPathResumeTrigger", "UiPathResumeTriggerType", "UiPathApiTrigger", + "UiPathIntegrationTrigger", "UiPathResumeTriggerName", ] from uipath.core.triggers.trigger import ( UiPathApiTrigger, + UiPathIntegrationTrigger, UiPathResumeTrigger, UiPathResumeTriggerName, UiPathResumeTriggerType, diff --git a/packages/uipath-core/src/uipath/core/triggers/trigger.py b/packages/uipath-core/src/uipath/core/triggers/trigger.py index 424245079..c897acd28 100644 --- a/packages/uipath-core/src/uipath/core/triggers/trigger.py +++ b/packages/uipath-core/src/uipath/core/triggers/trigger.py @@ -53,6 +53,25 @@ class UiPathApiTrigger(BaseModel): model_config = ConfigDict(validate_by_name=True) +class UiPathIntegrationTrigger(BaseModel): + """Integration Services (Inbox) resume trigger request. + + Mirrors Orchestrator's `IntegrationResumeDto`: the configuration needed to + register a remote event trigger through the Connections service and + correlate the eventual payload back to the suspended job via `inbox_id`. + """ + + connector: str = Field(alias="connector") + connection_id: str = Field(alias="connectionId") + operation: str = Field(alias="operation") + object_name: str = Field(alias="objectName") + filter_expression: str | None = Field(default=None, alias="filterExpression") + parameters: dict[str, str] | None = Field(default=None, alias="parameters") + inbox_id: str = Field(alias="inboxId") + + model_config = ConfigDict(validate_by_name=True) + + class UiPathResumeTrigger(BaseModel): """Information needed to resume execution.""" @@ -65,6 +84,9 @@ class UiPathResumeTrigger(BaseModel): ) item_key: str | None = Field(default=None, alias="itemKey") api_resume: UiPathApiTrigger | None = Field(default=None, alias="apiResume") + integration_resume: UiPathIntegrationTrigger | None = Field( + default=None, alias="integrationResume" + ) folder_path: str | None = Field(default=None, alias="folderPath") folder_key: str | None = Field(default=None, alias="folderKey") payload: Any | None = Field(default=None, alias="interruptObject", exclude=True) diff --git a/packages/uipath-core/src/uipath/core/workspace/__init__.py b/packages/uipath-core/src/uipath/core/workspace/__init__.py new file mode 100644 index 000000000..c05992aae --- /dev/null +++ b/packages/uipath-core/src/uipath/core/workspace/__init__.py @@ -0,0 +1,8 @@ +"""UiPath workspace hydration shared contracts.""" + +from .protocols import AttachmentsProtocol, JobsProtocol + +__all__ = [ + "AttachmentsProtocol", + "JobsProtocol", +] diff --git a/packages/uipath-core/src/uipath/core/workspace/protocols.py b/packages/uipath-core/src/uipath/core/workspace/protocols.py new file mode 100644 index 000000000..c3f39c170 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/workspace/protocols.py @@ -0,0 +1,56 @@ +"""Service protocols for workspace hydration backend interactions.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable +from uuid import UUID + + +@runtime_checkable +class AttachmentsProtocol(Protocol): + """Subset of the UiPath attachments service used by workspace hydration.""" + + async def download_async( + self, + *, + key: UUID, + destination_path: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> str: + """Download an attachment to a local path.""" + + async def upload_async( + self, + *, + name: str, + content: str | bytes | None = None, + source_path: str | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> UUID: + """Upload content or a local file and return its attachment key.""" + + +@runtime_checkable +class JobsProtocol(Protocol): + """Subset of the UiPath jobs service used by workspace hydration.""" + + async def list_attachments_async( + self, + *, + job_key: UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> list[str]: + """List the attachment ids linked to a job.""" + + async def link_attachment_async( + self, + *, + job_key: UUID, + attachment_key: UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> None: + """Link an existing attachment to a job.""" diff --git a/packages/uipath-core/tests/adapters/__init__.py b/packages/uipath-core/tests/adapters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/uipath-core/tests/adapters/test_base.py b/packages/uipath-core/tests/adapters/test_base.py new file mode 100644 index 000000000..9be6346ed --- /dev/null +++ b/packages/uipath-core/tests/adapters/test_base.py @@ -0,0 +1,163 @@ +"""Tests for BaseAdapter defaults and GovernedAgentBase proxy behavior.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from uipath.core.adapters import BaseAdapter, EvaluatorProtocol +from uipath.core.adapters.base import GovernedAgentBase + + +class _StubEvaluator: + """No-op evaluator that structurally matches EvaluatorProtocol.""" + + def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_after_agent(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_before_model(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_after_model(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_tool_call(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_after_tool(self, *args: Any, **kwargs: Any) -> Any: + return None + + +class _MinimalAdapter(BaseAdapter): + """Concrete adapter that does NOT override ``name`` — exercises the default.""" + + def can_handle(self, agent: Any) -> bool: + return True + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _Agent: + """Simple stand-in for a framework agent with one attribute and one method.""" + + foo = "bar" + + def greet(self) -> str: + return "hello" + + +# --------------------------------------------------------------------------- +# BaseAdapter defaults +# --------------------------------------------------------------------------- + + +def test_default_name_is_class_name(): + """The default ``name`` property returns the class name.""" + assert _MinimalAdapter().name == "_MinimalAdapter" + + +def test_detach_returns_unwrapped_when_present(): + """``detach`` honours the ``unwrapped`` contract on a governed proxy.""" + adapter = _MinimalAdapter() + original = object() + + class _Proxy: + unwrapped = original + + assert adapter.detach(_Proxy()) is original + + +def test_detach_returns_input_when_no_unwrapped_attribute(): + """For non-proxy adapters, ``detach`` returns the input unchanged.""" + adapter = _MinimalAdapter() + raw = object() + assert adapter.detach(raw) is raw + + +def test_generate_trace_id_returns_unique_uuid_string(): + """``_generate_trace_id`` returns a string UUID; consecutive calls differ.""" + adapter = _MinimalAdapter() + a = adapter._generate_trace_id() + b = adapter._generate_trace_id() + assert isinstance(a, str) + assert a != b + assert len(a) == 36 # canonical UUID4 form: 32 hex + 4 dashes + + +# --------------------------------------------------------------------------- +# GovernedAgentBase proxy +# --------------------------------------------------------------------------- + + +def test_governed_agent_base_stores_metadata_and_generates_trace_id(): + """Constructor wires every governance field and pulls a trace id from the adapter.""" + agent = _Agent() + adapter = _MinimalAdapter() + evaluator = _StubEvaluator() + + governed = GovernedAgentBase( + agent=agent, + adapter=adapter, + agent_id="agent-123", + session_id="session-abc", + evaluator=evaluator, + ) + + assert governed._agent is agent + assert governed._adapter is adapter + assert governed._agent_id == "agent-123" + assert governed._session_id == "session-abc" + assert governed._evaluator is evaluator + assert isinstance(governed._trace_id, str) + assert len(governed._trace_id) == 36 + + +def test_governed_agent_base_unwrapped_returns_original_agent(): + agent = _Agent() + governed = GovernedAgentBase( + agent=agent, + adapter=_MinimalAdapter(), + agent_id="a", + session_id="s", + evaluator=_StubEvaluator(), + ) + assert governed.unwrapped is agent + + +def test_governed_agent_base_forwards_attribute_access_to_agent(): + """Unknown attributes fall through to the wrapped agent via __getattr__.""" + governed = GovernedAgentBase( + agent=_Agent(), + adapter=_MinimalAdapter(), + agent_id="a", + session_id="s", + evaluator=_StubEvaluator(), + ) + + assert governed.foo == "bar" + assert governed.greet() == "hello" + + +def test_governed_agent_base_attribute_miss_raises_attribute_error(): + """If the wrapped agent also lacks the attribute, AttributeError surfaces.""" + governed = GovernedAgentBase( + agent=_Agent(), + adapter=_MinimalAdapter(), + agent_id="a", + session_id="s", + evaluator=_StubEvaluator(), + ) + + with pytest.raises(AttributeError): + _ = governed.does_not_exist diff --git a/packages/uipath-core/tests/adapters/test_evaluator.py b/packages/uipath-core/tests/adapters/test_evaluator.py new file mode 100644 index 000000000..5c9e5c9e5 --- /dev/null +++ b/packages/uipath-core/tests/adapters/test_evaluator.py @@ -0,0 +1,104 @@ +"""Tests for EvaluatorProtocol. + +The protocol is a structural type. These tests verify two things: + +1. A class whose method shapes match the protocol passes ``isinstance`` + against the ``runtime_checkable`` Protocol. +2. Subclassing the Protocol and calling ``super().`` actually + executes the stub bodies — this both documents that the stubs are + safely callable (they return ``None``) and brings the contract module + to full line coverage. +""" + +from __future__ import annotations + +from typing import Any + +from uipath.core.adapters import EvaluatorProtocol + + +class _MissingMethodEvaluator: + """Only implements one method — fails the structural check.""" + + def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: + return None + + +class _CompleteEvaluator: + """All six methods present with the expected names — passes ``isinstance``.""" + + def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: + return "before-agent" + + def evaluate_after_agent(self, *args: Any, **kwargs: Any) -> Any: + return "after-agent" + + def evaluate_before_model(self, *args: Any, **kwargs: Any) -> Any: + return "before-model" + + def evaluate_after_model(self, *args: Any, **kwargs: Any) -> Any: + return "after-model" + + def evaluate_tool_call(self, *args: Any, **kwargs: Any) -> Any: + return "tool-call" + + def evaluate_after_tool(self, *args: Any, **kwargs: Any) -> Any: + return "after-tool" + + +class _ProtocolSubclass(EvaluatorProtocol): + """Subclass that delegates to ``super()`` — exercises the stub bodies. + + Each override calls ``super().(...)`` so the ``...`` body of + the Protocol method actually executes (returns ``None``). + """ + + def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_before_agent(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_after_agent(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_after_agent(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_before_model(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_before_model(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_after_model(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_after_model(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_tool_call(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_tool_call(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_after_tool(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_after_tool(*args, **kwargs) # type: ignore[safe-super] + + +# --------------------------------------------------------------------------- +# Structural conformance +# --------------------------------------------------------------------------- + + +def test_complete_evaluator_is_recognized_by_runtime_check(): + """A class with all six methods passes ``isinstance`` against the protocol.""" + assert isinstance(_CompleteEvaluator(), EvaluatorProtocol) + + +def test_partial_evaluator_is_rejected_by_runtime_check(): + """A class missing methods does NOT pass the structural check.""" + assert not isinstance(_MissingMethodEvaluator(), EvaluatorProtocol) + + +# --------------------------------------------------------------------------- +# Stub-body execution (line coverage for the ``...`` placeholders) +# --------------------------------------------------------------------------- + + +def test_protocol_subclass_methods_execute_stub_bodies(): + """Calling each method via ``super()`` executes the stub body and returns None.""" + e = _ProtocolSubclass() + + assert e.evaluate_before_agent("input", "agent", "rt", "trace") is None + assert e.evaluate_after_agent("output", "agent", "rt", "trace") is None + assert e.evaluate_before_model("input", "agent", "rt", "trace") is None + assert e.evaluate_after_model("output", "agent", "rt", "trace") is None + assert e.evaluate_tool_call("tool", {"arg": 1}, "agent", "rt", "trace") is None + assert e.evaluate_after_tool("tool", "result", "agent", "rt", "trace") is None diff --git a/packages/uipath-core/tests/adapters/test_registry.py b/packages/uipath-core/tests/adapters/test_registry.py new file mode 100644 index 000000000..b16b29b1e --- /dev/null +++ b/packages/uipath-core/tests/adapters/test_registry.py @@ -0,0 +1,492 @@ +"""Tests for AdapterRegistry — ordering, resolution, entry-point discovery.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from uipath.core.adapters import BaseAdapter, EvaluatorProtocol +from uipath.core.adapters.registry import ( + AdapterRegistry, + _discover_entry_point_adapters, + get_adapter_registry, + reset_adapter_registry, +) + +# --------------------------------------------------------------------------- +# Test adapters +# --------------------------------------------------------------------------- + + +class _SpecificAdapter(BaseAdapter): + """Matches only objects with a ``__specific__`` marker.""" + + @property + def name(self) -> str: + return "specific" + + def can_handle(self, agent: Any) -> bool: + return hasattr(agent, "__specific__") + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _FallbackAdapter(BaseAdapter): + """Matches anything — must always sort last.""" + + is_fallback = True + + @property + def name(self) -> str: + return "fallback" + + def can_handle(self, agent: Any) -> bool: + return True + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _SecondaryAdapter(BaseAdapter): + """Another specific adapter, used to test ordering between two specifics.""" + + @property + def name(self) -> str: + return "secondary" + + def can_handle(self, agent: Any) -> bool: + return hasattr(agent, "__secondary__") + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _HighPriorityAdapter(BaseAdapter): + """Specific adapter with an elevated priority.""" + + priority = 100 + + @property + def name(self) -> str: + return "high" + + def can_handle(self, agent: Any) -> bool: + return True + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _LowPriorityAdapter(BaseAdapter): + """Generic adapter that should yield to higher-priority specifics.""" + + priority = -10 + + @property + def name(self) -> str: + return "low" + + def can_handle(self, agent: Any) -> bool: + return True + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _BrokenAdapter(BaseAdapter): + """``can_handle`` raises — must be skipped, not crash resolution.""" + + @property + def name(self) -> str: + return "broken" + + def can_handle(self, agent: Any) -> bool: + raise RuntimeError("can_handle exploded") + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + raise RuntimeError("attach exploded") + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _isolate_global_registry(): + """Each test starts with no singleton registry.""" + reset_adapter_registry() + yield + reset_adapter_registry() + + +# --------------------------------------------------------------------------- +# register / resolve / get_all / clear +# --------------------------------------------------------------------------- + + +def test_empty_registry_resolves_to_none(): + reg = AdapterRegistry() + assert reg.resolve(object()) is None + assert reg.get_all() == [] + + +def test_register_appends_in_order(): + reg = AdapterRegistry() + a, b = _SpecificAdapter(), _SecondaryAdapter() + reg.register(a) + reg.register(b) + assert reg.get_all() == [a, b] + + +def test_resolve_returns_first_matching_adapter(): + reg = AdapterRegistry() + reg.register(_SpecificAdapter()) + reg.register(_SecondaryAdapter()) + + agent = MagicMock() + agent.__secondary__ = True # only secondary should match + resolved = reg.resolve(agent) + assert resolved is not None + assert resolved.name == "secondary" + + +def test_resolve_skips_broken_can_handle_and_continues(): + """A can_handle() that raises must not break the whole resolve loop.""" + reg = AdapterRegistry() + reg.register(_BrokenAdapter()) + reg.register(_SpecificAdapter()) + + agent = MagicMock() + agent.__specific__ = True + resolved = reg.resolve(agent) + assert resolved is not None + assert resolved.name == "specific" + + +def test_register_position_inserts_at_index(): + reg = AdapterRegistry() + a, b, c = _SpecificAdapter(), _SecondaryAdapter(), _SpecificAdapter() + reg.register(a) + reg.register(b) + reg.register(c, position=0) # c jumps to head + assert reg.get_all()[0] is c + assert reg.get_all()[1:] == [a, b] + + +def test_higher_priority_adapter_inserted_before_lower_priority(): + """A specific (higher-priority) adapter must sort before a generic one + even when the generic one was registered first.""" + reg = AdapterRegistry() + generic = _LowPriorityAdapter() + specific = _HighPriorityAdapter() + reg.register(generic) + reg.register(specific) # registered later, but higher priority + + adapters = reg.get_all() + assert adapters[0] is specific + assert adapters[1] is generic + + +def test_same_priority_preserves_registration_order(): + """Adapters with equal priority should fall back to insertion order.""" + reg = AdapterRegistry() + a, b = _SpecificAdapter(), _SecondaryAdapter() # both priority=0 + reg.register(a) + reg.register(b) + assert reg.get_all() == [a, b] + + +def test_higher_priority_adapter_inserted_before_fallback(): + """High-priority adapter goes in front of an already-registered fallback.""" + reg = AdapterRegistry() + fallback = _FallbackAdapter() + reg.register(fallback) + reg.register(_HighPriorityAdapter()) + + adapters = reg.get_all() + assert adapters[0].name == "high" + assert adapters[-1] is fallback + + +def test_lower_priority_adapter_inserted_before_fallback_after_specifics(): + """Negative-priority adapter sorts after default-priority specifics but + still before the fallback.""" + reg = AdapterRegistry() + reg.register(_SpecificAdapter()) # priority=0 + reg.register(_FallbackAdapter()) + reg.register(_LowPriorityAdapter()) # priority=-10 + + adapters = reg.get_all() + assert adapters[0].name == "specific" + assert adapters[1].name == "low" + assert adapters[-1].name == "fallback" + + +def test_priority_overrides_registration_order_in_resolve(): + """Resolution must follow priority ordering, not registration order.""" + reg = AdapterRegistry() + reg.register(_LowPriorityAdapter()) # both adapters match every agent, + reg.register(_HighPriorityAdapter()) # so priority decides which wins. + + resolved = reg.resolve(object()) + assert resolved is not None + assert resolved.name == "high" + + +def test_fallback_stays_last_when_new_adapter_registered(): + """When the last entry has ``is_fallback`` set, new adapters insert before it.""" + reg = AdapterRegistry() + fallback = _FallbackAdapter() + reg.register(fallback) + reg.register(_SpecificAdapter()) # this should insert BEFORE fallback + + adapters = reg.get_all() + assert adapters[-1] is fallback + assert adapters[0].name == "specific" + + +def test_fallback_resolves_only_when_no_specific_matches(): + reg = AdapterRegistry() + reg.register(_SpecificAdapter()) + reg.register(_FallbackAdapter()) + + # Agent without the __specific__ marker → fallback wins. + resolved = reg.resolve(object()) + assert resolved is not None + assert resolved.name == "fallback" + + +def test_clear_removes_all_adapters(): + reg = AdapterRegistry() + reg.register(_SpecificAdapter()) + reg.register(_SecondaryAdapter()) + reg.clear() + assert reg.get_all() == [] + assert reg.resolve(object()) is None + + +def test_get_all_returns_copy_not_internal_list(): + """Callers must not be able to mutate the registry through get_all().""" + reg = AdapterRegistry() + reg.register(_SpecificAdapter()) + snapshot = reg.get_all() + snapshot.clear() + assert len(reg.get_all()) == 1 # unaffected + + +# --------------------------------------------------------------------------- +# Singleton + entry-point discovery +# --------------------------------------------------------------------------- + + +def test_get_adapter_registry_returns_singleton(): + reg1 = get_adapter_registry() + reg2 = get_adapter_registry() + assert reg1 is reg2 + + +def test_reset_adapter_registry_drops_singleton(): + first = get_adapter_registry() + reset_adapter_registry() + second = get_adapter_registry() + assert first is not second + + +def test_entry_point_discovery_invokes_registrars(monkeypatch): + """Each entry-point's zero-arg callable must be loaded and called.""" + called: list[str] = [] + + def make_registrar(name: str): + def _register() -> None: + called.append(name) + + return _register + + ep_a = MagicMock() + ep_a.name = "a" + ep_a.value = "pkg_a:register" + ep_a.load.return_value = make_registrar("a") + + ep_b = MagicMock() + ep_b.name = "b" + ep_b.value = "pkg_b:register" + ep_b.load.return_value = make_registrar("b") + + monkeypatch.setattr( + "uipath.core.adapters.registry.entry_points", + lambda group: [ep_a, ep_b] if group == "uipath.governance.adapters" else [], + raising=False, + ) + + # entry_points lives in importlib.metadata; the registry imports it + # lazily inside the function. Patch the import target directly. + import importlib.metadata as importlib_metadata + + monkeypatch.setattr( + importlib_metadata, + "entry_points", + lambda group=None: ( + [ep_a, ep_b] if group == "uipath.governance.adapters" else [] + ), + ) + + _discover_entry_point_adapters() + assert sorted(called) == ["a", "b"] + + +def test_entry_point_discovery_skips_broken_loader(monkeypatch): + """One broken entry-point must not stop the others from registering.""" + called: list[str] = [] + + ep_broken = MagicMock() + ep_broken.name = "broken" + ep_broken.value = "pkg_broken:register" + ep_broken.load.side_effect = ImportError("cannot import") + + ep_ok = MagicMock() + ep_ok.name = "ok" + ep_ok.value = "pkg_ok:register" + ep_ok.load.return_value = lambda: called.append("ok") + + import importlib.metadata as importlib_metadata + + monkeypatch.setattr( + importlib_metadata, + "entry_points", + lambda group=None: ( + [ep_broken, ep_ok] if group == "uipath.governance.adapters" else [] + ), + ) + + _discover_entry_point_adapters() # must not raise + assert called == ["ok"] + + +def test_entry_point_discovery_skips_non_callable(monkeypatch): + """An entry-point that resolves to a non-callable must be logged and skipped.""" + called: list[str] = [] + + ep_bad = MagicMock() + ep_bad.name = "bad" + ep_bad.value = "pkg_bad:NOT_A_FUNCTION" + ep_bad.load.return_value = "not callable" + + ep_ok = MagicMock() + ep_ok.name = "ok" + ep_ok.value = "pkg_ok:register" + ep_ok.load.return_value = lambda: called.append("ok") + + import importlib.metadata as importlib_metadata + + monkeypatch.setattr( + importlib_metadata, + "entry_points", + lambda group=None: ( + [ep_bad, ep_ok] if group == "uipath.governance.adapters" else [] + ), + ) + + _discover_entry_point_adapters() + assert called == ["ok"] + + +def test_entry_point_discovery_swallows_registrar_exception(monkeypatch): + """A registrar that raises mid-call must not stop subsequent registrars.""" + called: list[str] = [] + + def _raises() -> None: + raise RuntimeError("registrar exploded") + + ep_raising = MagicMock() + ep_raising.name = "raises" + ep_raising.value = "pkg:register" + ep_raising.load.return_value = _raises + + ep_ok = MagicMock() + ep_ok.name = "ok" + ep_ok.value = "pkg:register2" + ep_ok.load.return_value = lambda: called.append("ok") + + import importlib.metadata as importlib_metadata + + monkeypatch.setattr( + importlib_metadata, + "entry_points", + lambda group=None: ( + [ep_raising, ep_ok] if group == "uipath.governance.adapters" else [] + ), + ) + + _discover_entry_point_adapters() + assert called == ["ok"] + + +def test_entry_point_discovery_swallows_entry_points_failure(monkeypatch): + """If ``entry_points()`` itself raises, discovery must log and return cleanly.""" + import importlib.metadata as importlib_metadata + + def _boom(group=None): + raise RuntimeError("entry_points API exploded") + + monkeypatch.setattr(importlib_metadata, "entry_points", _boom) + + # Must not raise — and must not register anything. + _discover_entry_point_adapters() + reg = get_adapter_registry() + assert reg.get_all() == [] + + +# --------------------------------------------------------------------------- +# Protocol conformance smoke tests +# --------------------------------------------------------------------------- + + +def test_baseadapter_is_abc(): + """BaseAdapter must be abstract — direct instantiation must fail.""" + with pytest.raises(TypeError): + BaseAdapter() # type: ignore[abstract] + + +def test_concrete_adapter_is_baseadapter(): + """A concrete subclass must be recognized as a BaseAdapter.""" + assert isinstance(_SpecificAdapter(), BaseAdapter) diff --git a/packages/uipath-core/tests/chat/__init__.py b/packages/uipath-core/tests/chat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/uipath-core/tests/chat/test_message.py b/packages/uipath-core/tests/chat/test_message.py new file mode 100644 index 000000000..113d34a66 --- /dev/null +++ b/packages/uipath-core/tests/chat/test_message.py @@ -0,0 +1,59 @@ +"""Tests for `UiPathConversationMessage` input validation. + +Conversational Agent Service contract treats `role` + `contentParts` as +the load-bearing fields for an inbound user message. `messageId` and +`contentPartId` are GUIDs that identify entities in the conversation +hierarchy; when omitted on input, the model fills them with fresh +UUIDs (matching what `uipath dev` does server-side). `createdAt`, +`updatedAt`, `spanId`, and `toolCalls` are server-allocated and absent +from client input. + +These tests pin that behavior so `--input-file` payloads from +`uip codedagent run` validate against the model without requiring +callers to hand-generate UUIDs. +""" + +from __future__ import annotations + +from uipath.core.chat import UiPathConversationMessage + + +def test_minimal_user_message_validates_and_fills_ids() -> None: + msg = UiPathConversationMessage.model_validate( + { + "role": "user", + "contentParts": [ + { + "mimeType": "text/plain", + "data": {"inline": "hello world"}, + } + ], + } + ) + assert msg.role == "user" + assert msg.tool_calls == [] + assert msg.created_at is None + assert msg.updated_at is None + assert msg.message_id # auto-generated UUID + assert msg.content_parts[0].content_part_id # auto-generated UUID + assert msg.content_parts[0].citations == [] + + +def test_explicit_ids_are_preserved() -> None: + msg = UiPathConversationMessage.model_validate( + { + "messageId": "00000000-0000-0000-0000-000000000001", + "role": "user", + "contentParts": [ + { + "contentPartId": "00000000-0000-0000-0000-000000000002", + "mimeType": "text/plain", + "data": {"inline": "hello world"}, + } + ], + } + ) + assert msg.message_id == "00000000-0000-0000-0000-000000000001" + assert ( + msg.content_parts[0].content_part_id == "00000000-0000-0000-0000-000000000002" + ) diff --git a/packages/uipath-core/tests/governance/__init__.py b/packages/uipath-core/tests/governance/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/uipath-core/tests/governance/test_config.py b/packages/uipath-core/tests/governance/test_config.py new file mode 100644 index 000000000..54642a413 --- /dev/null +++ b/packages/uipath-core/tests/governance/test_config.py @@ -0,0 +1,54 @@ +"""Tests for the governance feature-flag gate.""" + +from __future__ import annotations + +import pytest + +from uipath.core.feature_flags import FeatureFlags +from uipath.core.governance.config import ( + GOVERNANCE_FEATURE_FLAG, + is_governance_enabled, +) + + +@pytest.fixture(autouse=True) +def _reset_flags(): + """Each test starts and ends with a clean flags registry.""" + FeatureFlags.reset_flags() + yield + FeatureFlags.reset_flags() + + +def test_governance_flag_name_is_stable(): + """The flag name is a public contract shared with the runtime layer.""" + assert GOVERNANCE_FEATURE_FLAG == "EnablePythonGovernanceChecker" + + +def test_is_governance_enabled_defaults_to_false(): + """With nothing configured, the gate defaults to disabled. + + The platform / host runtime must explicitly opt into governance + (programmatically via :class:`FeatureFlags`, via gitops, or via the + ``UIPATH_FEATURE_EnablePythonGovernanceChecker`` env var). This + keeps the SDK safe-by-default for callers that haven't yet + integrated with the governance backend. + """ + assert is_governance_enabled() is False + + +def test_is_governance_enabled_respects_programmatic_disable(): + """Programmatic ``False`` flips the gate off.""" + FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: False}) + assert is_governance_enabled() is False + + +def test_is_governance_enabled_respects_programmatic_enable(): + """Programmatic ``True`` keeps the gate on.""" + FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: True}) + assert is_governance_enabled() is True + + +def test_is_governance_enabled_reads_env_var_fallback(monkeypatch): + """When nothing is configured programmatically, the env-var fallback wins.""" + monkeypatch.setenv(f"UIPATH_FEATURE_{GOVERNANCE_FEATURE_FLAG}", "false") + assert is_governance_enabled() is False diff --git a/packages/uipath-core/tests/governance/test_exceptions.py b/packages/uipath-core/tests/governance/test_exceptions.py new file mode 100644 index 000000000..257feb3d4 --- /dev/null +++ b/packages/uipath-core/tests/governance/test_exceptions.py @@ -0,0 +1,205 @@ +"""Tests for GovernanceBlockException constructors. + +The classmethod constructors (:meth:`from_violation`, +:meth:`from_audit_record`) form the documented contract that the +evaluator and adapter packages depend on — the evaluator only ever +builds a block via ``from_audit_record``. These tests pin the message +format and attribute population so a future refactor cannot silently +drop the rule id, name, or detail. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from uipath.core.governance.exceptions import ( + GovernanceBlockException, + GovernanceViolation, + Severity, +) +from uipath.core.governance.models import ( + Action, + AuditRecord, + LifecycleHook, + RuleEvaluation, +) + +# --------------------------------------------------------------------------- +# GovernanceViolation +# --------------------------------------------------------------------------- + + +def test_violation_defaults_to_high_severity(): + v = GovernanceViolation(rule_id="A-1", rule_name="No PII", detail="ssn leaked") + assert v.severity == Severity.HIGH + + +def test_violation_severity_can_be_overridden(): + v = GovernanceViolation( + rule_id="A-1", + rule_name="No PII", + detail="ssn leaked", + severity=Severity.CRITICAL, + ) + assert v.severity == Severity.CRITICAL + + +# --------------------------------------------------------------------------- +# GovernanceBlockException base constructor +# --------------------------------------------------------------------------- + + +def test_default_constructor_emits_prefixed_message(): + exc = GovernanceBlockException() + assert "[Governance Policy Violation]" in str(exc) + assert exc.violation is None + assert exc.audit_record is None + + +def test_default_constructor_carries_default_rule_metadata(): + """Constructing without context still gives the documented fallback IDs.""" + exc = GovernanceBlockException() + assert exc.rule_id == "POLICY" + assert exc.rule_name == "Governance Policy" + + +def test_explicit_message_is_used_verbatim(): + exc = GovernanceBlockException("custom message") + assert str(exc) == "custom message" + + +def test_error_code_constant_for_orchestrator_categorization(): + """error_code is a class-level constant the Orchestrator UI reads.""" + assert GovernanceBlockException.error_code == "GOVERNANCE_POLICY_VIOLATION" + exc = GovernanceBlockException() + assert exc.error_code == "GOVERNANCE_POLICY_VIOLATION" + + +# --------------------------------------------------------------------------- +# from_violation +# --------------------------------------------------------------------------- + + +def test_from_violation_populates_rule_metadata(): + v = GovernanceViolation(rule_id="A-1", rule_name="No PII", detail="ssn leaked") + exc = GovernanceBlockException.from_violation(v) + assert exc.rule_id == "A-1" + assert exc.rule_name == "No PII" + assert exc.violation is v + + +def test_from_violation_message_includes_rule_id_name_detail(): + v = GovernanceViolation(rule_id="A-1", rule_name="No PII", detail="ssn leaked") + msg = str(GovernanceBlockException.from_violation(v)) + assert "A-1" in msg + assert "No PII" in msg + assert "ssn leaked" in msg + assert "[Governance Policy Violation]" in msg + + +# --------------------------------------------------------------------------- +# from_audit_record +# --------------------------------------------------------------------------- + + +def _audit_record_with(*evaluations: RuleEvaluation) -> AuditRecord: + return AuditRecord( + timestamp=datetime.now(timezone.utc), + agent_name="agent", + runtime_id="run-1", + trace_id="trace-1", + hook=LifecycleHook.BEFORE_AGENT, + evaluations=list(evaluations), + final_action=Action.DENY, + ) + + +def test_from_audit_record_picks_first_matched_rule(): + """Even when later evaluations matched, the first matched wins the message.""" + audit = _audit_record_with( + RuleEvaluation( + rule_id="UNMATCHED", + rule_name="Did not fire", + matched=False, + detail="", + action=Action.ALLOW, + ), + RuleEvaluation( + rule_id="MATCHED-FIRST", + rule_name="First match", + matched=True, + detail="bad input", + action=Action.DENY, + ), + RuleEvaluation( + rule_id="MATCHED-SECOND", + rule_name="Second match", + matched=True, + detail="also bad", + action=Action.DENY, + ), + ) + + exc = GovernanceBlockException.from_audit_record(audit) + assert exc.rule_id == "MATCHED-FIRST" + assert exc.rule_name == "First match" + assert "bad input" in str(exc) + assert exc.audit_record is audit + + +def test_from_audit_record_falls_back_when_no_match(): + """When the audit has no matches, the exception is still constructible.""" + audit = _audit_record_with( + RuleEvaluation( + rule_id="UNMATCHED", + rule_name="Did not fire", + matched=False, + detail="", + action=Action.ALLOW, + ) + ) + + exc = GovernanceBlockException.from_audit_record(audit) + assert "Rules evaluated: 1" in str(exc) + assert exc.audit_record is audit + + +def test_from_audit_record_matched_detail_default_when_empty(): + """A matched evaluation with empty detail still produces a sensible message.""" + audit = _audit_record_with( + RuleEvaluation( + rule_id="A-1", + rule_name="No PII", + matched=True, + detail="", # empty + action=Action.DENY, + ) + ) + + msg = str(GovernanceBlockException.from_audit_record(audit)) + assert "A-1" in msg + assert "No PII" in msg + # Falls back to a non-empty detail string. + assert "Policy violation detected" in msg + + +# --------------------------------------------------------------------------- +# Exception identity — must be a real Exception so callers can catch broadly +# --------------------------------------------------------------------------- + + +def test_block_exception_is_exception_subclass(): + assert issubclass(GovernanceBlockException, Exception) + + +def test_block_exception_can_be_caught_via_base_exception(): + try: + raise GovernanceBlockException.from_violation( + GovernanceViolation(rule_id="A-1", rule_name="X", detail="d") + ) + except Exception as e: # noqa: BLE001 - intentional broad catch + assert isinstance(e, GovernanceBlockException) + else: + pytest.fail("Did not raise") diff --git a/packages/uipath-core/tests/governance/test_providers.py b/packages/uipath-core/tests/governance/test_providers.py new file mode 100644 index 000000000..083b62663 --- /dev/null +++ b/packages/uipath-core/tests/governance/test_providers.py @@ -0,0 +1,149 @@ +"""Tests for the governance provider protocols + wire-format models.""" + +from __future__ import annotations + +import pytest + +from uipath.core.governance import ( + EnforcementMode, + FiredRule, + GovernanceCompensationProvider, + GovernancePolicyProvider, + GovernRequest, + PolicyContext, + PolicyResponse, +) + + +class _FakePolicyProvider: + def __init__(self) -> None: + self.calls: list[PolicyContext] = [] + + def get_policy(self, context: PolicyContext) -> PolicyResponse: + self.calls.append(context) + return PolicyResponse(mode=EnforcementMode.ENFORCE, policies="rules: []") + + +class _FakeCompensationProvider: + def __init__(self) -> None: + self.calls: list[GovernRequest] = [] + + def compensate(self, request: GovernRequest) -> None: + self.calls.append(request) + + +def _make_request() -> GovernRequest: + return GovernRequest( + validators=["pii_detection"], + rules=[ + FiredRule( + rule_id="ASI-01", + rule_name="Block PII in flight", + pack_name="agent-safety", + validator="pii_detection", + ) + ], + data={"prompt": "hi"}, + hook="before_model", + trace_id="0123456789abcdef0123456789abcdef", + src_timestamp="2026-06-22T10:00:00Z", + agent_name="my-agent", + runtime_id="runtime-1", + ) + + +class TestPolicyContext: + def test_defaults(self) -> None: + ctx = PolicyContext() + assert ctx.is_conversational is None + + def test_ignores_unknown_fields(self) -> None: + ctx = PolicyContext.model_validate( + {"is_conversational": True, "future_selector": "x"} + ) + assert ctx.is_conversational is True + + +class TestPolicyResponse: + def test_defaults(self) -> None: + response = PolicyResponse() + assert response.mode is None + assert response.policies == "" + + @pytest.mark.parametrize( + ("wire_value", "expected"), + [ + ("audit", EnforcementMode.AUDIT), + ("enforce", EnforcementMode.ENFORCE), + ("disabled", EnforcementMode.DISABLED), + ], + ) + def test_parses_known_modes( + self, wire_value: str, expected: EnforcementMode + ) -> None: + response = PolicyResponse.model_validate({"mode": wire_value}) + assert response.mode is expected + + def test_unknown_mode_falls_back_to_none(self) -> None: + # Forward-compat: a server-added mode the SDK doesn't know about + # must not break agent startup. Parses as None so the runtime + # falls back to its safe default rather than raising. + response = PolicyResponse.model_validate({"mode": "ludicrous"}) + assert response.mode is None + + +class TestGovernRequest: + def test_serializes_wire_aliases(self) -> None: + payload = _make_request().model_dump(by_alias=True, exclude_none=True) + assert payload["type"] == ["pii_detection"] + assert payload["traceId"] == "0123456789abcdef0123456789abcdef" + assert payload["agentName"] == "my-agent" + assert payload["runtimeId"] == "runtime-1" + # src_timestamp is intentionally snake_case on the wire. + assert payload["src_timestamp"] == "2026-06-22T10:00:00Z" + # Optional job-context fields left None → excluded. + for absent in ( + "folderKey", + "jobKey", + "processKey", + "referenceId", + "agentVersion", + ): + assert absent not in payload + + +class TestProtocolConformance: + """`runtime_checkable` Protocols should accept structurally-matching objects.""" + + def test_fake_policy_provider_satisfies_protocol(self) -> None: + provider = _FakePolicyProvider() + assert isinstance(provider, GovernancePolicyProvider) + + def test_fake_compensation_provider_satisfies_protocol(self) -> None: + provider = _FakeCompensationProvider() + assert isinstance(provider, GovernanceCompensationProvider) + + def test_object_without_methods_rejected(self) -> None: + class _NotAProvider: + pass + + assert not isinstance(_NotAProvider(), GovernancePolicyProvider) + assert not isinstance(_NotAProvider(), GovernanceCompensationProvider) + + +class TestEndToEndDispatch: + """Caller passes a provider directly to the consumer (no global registry).""" + + def test_policy_round_trip(self) -> None: + provider = _FakePolicyProvider() + response = provider.get_policy(PolicyContext(is_conversational=True)) + + assert response.mode is EnforcementMode.ENFORCE + assert provider.calls == [PolicyContext(is_conversational=True)] + + def test_compensation_round_trip(self) -> None: + provider = _FakeCompensationProvider() + request = _make_request() + provider.compensate(request) + + assert provider.calls == [request] diff --git a/packages/uipath-core/tests/workspace/__init__.py b/packages/uipath-core/tests/workspace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/uipath-core/tests/workspace/test_protocols.py b/packages/uipath-core/tests/workspace/test_protocols.py new file mode 100644 index 000000000..0c9cd11c4 --- /dev/null +++ b/packages/uipath-core/tests/workspace/test_protocols.py @@ -0,0 +1,68 @@ +"""Structural-conformance tests for the workspace hydration protocols.""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +from uipath.core.workspace import AttachmentsProtocol, JobsProtocol + + +class _FakeAttachments: + async def download_async( + self, + *, + key: UUID, + destination_path: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> str: + return destination_path + + async def upload_async( + self, + *, + name: str, + content: str | bytes | None = None, + source_path: str | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> UUID: + return uuid4() + + +class _FakeJobs: + async def list_attachments_async( + self, + *, + job_key: UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> list[str]: + return [] + + async def link_attachment_async( + self, + *, + job_key: UUID, + attachment_key: UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> None: + return None + + +class _NotAService: + pass + + +def test_attachments_service_satisfies_protocol() -> None: + assert isinstance(_FakeAttachments(), AttachmentsProtocol) + + +def test_jobs_service_satisfies_protocol() -> None: + assert isinstance(_FakeJobs(), JobsProtocol) + + +def test_unrelated_object_does_not_satisfy_protocols() -> None: + assert not isinstance(_NotAService(), AttachmentsProtocol) + assert not isinstance(_NotAService(), JobsProtocol) diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 2544216df..cfb903454 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -2,6 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.11" +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P2D" + [[package]] name = "annotated-types" version = "0.7.0" @@ -1007,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.10" +version = "0.5.22" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index ba5634ef1..cfe85a61e 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.18" +version = "0.1.76" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -8,7 +8,7 @@ dependencies = [ "httpx>=0.28.1", "tenacity>=9.0.0", "truststore>=0.10.1", - "uipath-core>=0.5.8, <0.6.0", + "uipath-core>=0.5.21, <0.6.0", "pydantic-function-models>=0.1.11", "sqlparse>=0.5.5", ] @@ -98,15 +98,39 @@ warn_required_dynamic_aliases = true [tool.pytest.ini_options] testpaths = ["tests"] python_files = "test_*.py" -addopts = "-ra -q --cov=src/uipath --cov-report=term-missing" +addopts = "-ra -q --cov=src --cov-report=term-missing -m 'not e2e'" asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" +markers = [ + "e2e: end-to-end tests against real ECS/LLMOps (requires UIPATH_URL, UIPATH_ACCESS_TOKEN, UIPATH_FOLDER_KEY)", +] + +[tool.coverage.run] +source = ["src"] +relative_files = true +omit = [ + "*/tests/*", + "*/__pycache__/*", + "*/site-packages/*", + "*/conftest.py", +] [tool.coverage.report] show_missing = true +precision = 2 +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "@(abc\\.)?abstractmethod", +] -[tool.coverage.run] -source = ["src"] +[tool.uv] +exclude-newer = "2 days" + +[tool.uv.exclude-newer-package] +uipath-core = false [tool.uv.sources] uipath-core = { path = "../uipath-core", editable = true } diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 87c3a17f0..0083388db 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -8,6 +8,7 @@ from .action_center import TasksService from .agenthub._agenthub_service import AgentHubService from .agenthub._remote_a2a_service import RemoteA2aService +from .automation_ops import AutomationOpsService from .chat import ConversationsService, UiPathLlmChatService, UiPathOpenAIService from .common import ( ApiClient, @@ -21,7 +22,9 @@ from .documents import DocumentsService from .entities import EntitiesService from .errors import BaseUrlMissingError, SecretMissingError +from .governance import GovernanceService from .guardrails import GuardrailsService +from .memory import MemoryService from .orchestrator import ( AssetsService, AttachmentsService, @@ -33,7 +36,9 @@ ProcessesService, QueuesService, ) +from .pii_detection import PiiDetectionService from .resource_catalog import ResourceCatalogService +from .semantic_proxy import SemanticProxyService def _has_valid_client_credentials( @@ -113,6 +118,10 @@ def context_grounding(self) -> ContextGroundingService: self.buckets, ) + @property + def memory(self) -> MemoryService: + return MemoryService(self._config, self._execution_context, self.folders) + @property def documents(self) -> DocumentsService: return DocumentsService(self._config, self._execution_context) @@ -139,7 +148,9 @@ def llm(self) -> UiPathLlmChatService: @property def entities(self) -> EntitiesService: - return EntitiesService(self._config, self._execution_context) + return EntitiesService( + self._config, self._execution_context, folders_service=self.folders + ) @cached_property def resource_catalog(self) -> ResourceCatalogService: @@ -159,6 +170,10 @@ def mcp(self) -> McpService: def guardrails(self) -> GuardrailsService: return GuardrailsService(self._config, self._execution_context) + @cached_property + def governance(self) -> GovernanceService: + return GovernanceService(self._config, self._execution_context) + @property def agenthub(self) -> AgentHubService: return AgentHubService(self._config, self._execution_context, self.folders) @@ -171,6 +186,18 @@ def remote_a2a(self) -> RemoteA2aService: def orchestrator_setup(self) -> OrchestratorSetupService: return OrchestratorSetupService(self._config, self._execution_context) + @property + def automation_ops(self) -> AutomationOpsService: + return AutomationOpsService(self._config, self._execution_context) + + @property + def pii_detection(self) -> PiiDetectionService: + return PiiDetectionService(self._config, self._execution_context) + + @property + def semantic_proxy(self) -> SemanticProxyService: + return SemanticProxyService(self._config, self._execution_context) + @property def automation_tracker(self) -> AutomationTrackerService: return AutomationTrackerService(self._config, self._execution_context) diff --git a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py index 662109ce4..2c4a4bde7 100644 --- a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py +++ b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py @@ -118,10 +118,34 @@ def _create_spec( ), } + _apply_priority_labels_and_actionable_toggle( + json_payload, priority, labels, is_actionable_message_enabled + ) + _apply_task_source(json_payload, source_name) + + return RequestSpec( + method="POST", + endpoint=Endpoint("/orchestrator_/tasks/AppTasks/CreateAppTask"), + json=json_payload, + headers=header_folder(app_folder_key, app_folder_path), + ) + + +def _apply_priority_labels_and_actionable_toggle( + payload: Dict[str, Any], + priority: Optional[str], + labels: Optional[List[str]], + is_actionable_message_enabled: Optional[bool], +) -> None: + """Apply priority / tags / isActionableMessageEnabled to ``payload`` in-place. + + Shared between AppTask and QuickForm spec builders — they handle these three + optional fields identically. + """ if priority and (normalized_priority := _normalize_priority(priority)): - json_payload["priority"] = normalized_priority + payload["priority"] = normalized_priority if labels is not None: - json_payload["tags"] = [ + payload["tags"] = [ { "name": label, "displayName": label, @@ -131,37 +155,29 @@ def _create_spec( for label in labels ] if is_actionable_message_enabled is not None: - json_payload["isActionableMessageEnabled"] = is_actionable_message_enabled + payload["isActionableMessageEnabled"] = is_actionable_message_enabled - project_id = UiPathConfig.project_id - trace_id = UiPathConfig.trace_id - if project_id and trace_id: - folder_key = UiPathConfig.folder_key - job_key = UiPathConfig.job_key - process_key = UiPathConfig.process_uuid +def _apply_task_source(payload: Dict[str, Any], source_name: str) -> None: + """Populate ``payload["taskSource"]`` when UiPathConfig has project_id + trace_id. - task_source_metadata: Dict[str, Any] = { + Shared between AppTask and QuickForm spec builders — the taskSource block is + identical for both task types. + """ + project_id = UiPathConfig.project_id + trace_id = UiPathConfig.trace_id + if not (project_id and trace_id): + return + payload["taskSource"] = { + "sourceName": source_name, + "sourceId": project_id, + "taskSourceMetadata": { "InstanceId": trace_id, - "FolderKey": folder_key, - "JobKey": job_key, - "ProcessKey": process_key, - } - - task_source = { - "sourceName": source_name, - "sourceId": project_id, - "taskSourceMetadata": task_source_metadata, - } - - json_payload["taskSource"] = task_source - - return RequestSpec( - method="POST", - endpoint=Endpoint("/orchestrator_/tasks/AppTasks/CreateAppTask"), - json=json_payload, - headers=header_folder(app_folder_key, app_folder_path), - ) + "FolderKey": UiPathConfig.folder_key, + "JobKey": UiPathConfig.job_key, + "ProcessKey": UiPathConfig.process_uuid, + }, + } def _normalize_priority(priority: str | None) -> str | None: @@ -196,6 +212,62 @@ def _normalize_priority(priority: str | None) -> str | None: return normalized +_TASK_TYPE_QUICKFORM = 6 + + +def _create_quickform_spec( + data: Optional[Dict[str, Any]], + title: str, + task_schema_key: str, + schema: Dict[str, Any], + creator_job_key: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + is_actionable_message_enabled: Optional[bool] = None, + actionable_message_metadata: Optional[Dict[str, Any]] = None, + source_name: str = "Agent", +) -> RequestSpec: + """Build the RequestSpec for Orchestrator's GenericTasks/CreateTask endpoint. + + Sets TaskType=QuickFormTask. Mirrors _create_spec but skips the AppTask-specific + shape (no appId, no action-schema-derived fieldSet/actionSet) and instead sends + taskSchemaKey + inline schema together. + + Both taskSchemaKey AND schema are sent on every call: the Agents runtime has no + Action Center package.uploaded subscriber populating the TaskSchemas table, so + Orchestrator upserts the schema (keyed by taskSchemaKey) and then creates the task + in the same call. + + Wire contract: UiPath/Orchestrator/src/Core/Application/Dto/Tasks/TaskCreateRequest.cs. + """ + json_payload: Dict[str, Any] = { + "type": _TASK_TYPE_QUICKFORM, + "taskSchemaKey": task_schema_key, + "schema": schema, + "title": title, + "data": data if data is not None else {}, + } + + if creator_job_key is not None: + json_payload["creatorJobKey"] = creator_job_key + + _apply_priority_labels_and_actionable_toggle( + json_payload, priority, labels, is_actionable_message_enabled + ) + if actionable_message_metadata is not None: + json_payload["actionableMessageMetaData"] = actionable_message_metadata + _apply_task_source(json_payload, source_name) + + return RequestSpec( + method="POST", + endpoint=Endpoint("/orchestrator_/tasks/GenericTasks/CreateTask"), + json=json_payload, + headers=header_folder(folder_key, folder_path), + ) + + def _retrieve_action_spec( action_key: str, app_folder_key: Optional[str], @@ -233,6 +305,34 @@ async def _assign_task_spec( } ] } + elif task_recipient.type == TaskRecipientType.WORKLOAD: + # This branch covers BOTH agent-side Workload criteria (single + # group, distributed by workload) AND agent-side CustomAssignees + # criteria (explicit email list — already resolved into + # `task_recipient.values` upstream). Both submit to the Action + # Center API as a "Workload" assignment; the difference is whether + # `values` carries one group or N emails. + request_spec.json = { + "taskAssignments": [ + { + "taskId": task_key, + "assignmentCriteria": "Workload", + "assigneeNamesOrEmails": task_recipient.values + or [recipient_value], + } + ] + } + elif task_recipient.type == TaskRecipientType.ROUND_ROBIN: + request_spec.json = { + "taskAssignments": [ + { + "taskId": task_key, + "assignmentCriteria": "RoundRobin", + "assigneeNamesOrEmails": task_recipient.values + or [recipient_value], + } + ] + } else: request_spec.json = { "taskAssignments": [ @@ -506,6 +606,154 @@ def create( ) return Task.model_validate(json_response) + @traced(name="tasks_create_quickform", run_type="uipath") + async def create_quickform_async( + self, + title: str, + task_schema_key: str, + schema: Dict[str, Any], + data: Optional[Dict[str, Any]] = None, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + assignee: Optional[str] = None, + recipient: Optional[TaskRecipient] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + is_actionable_message_enabled: Optional[bool] = None, + actionable_message_metadata: Optional[Dict[str, Any]] = None, + creator_job_key: Optional[str] = None, + source_name: str = "Agent", + ) -> Task: + """Creates a new QuickForm task asynchronously. + + QuickForm tasks are schema-first HITL tasks rendered by FormLib in Action + Center. Both task_schema_key AND schema are required: the Agents runtime + does not pre-populate TaskSchemas via a package.uploaded subscriber, so + Orchestrator upserts the schema (keyed by task_schema_key) and creates + the task in the same call. + + Args: + title: The title of the task. + task_schema_key: UUID key of the schema. Used as the key under which + Orchestrator stores/looks up the schema in TaskSchemas. + schema: The HITL schema body to register/upsert. Sent inline on every + call. + data: Optional dictionary containing input data for the task. + folder_path: Optional folder path for the task. Required by the + Orchestrator controller (RequireOrganizationUnit) unless + folder_key is provided. + folder_key: Optional folder key, alternative to folder_path. + assignee: Optional username or email to assign the task to. + recipient: Optional structured recipient (user id / group id / + email). Resolved via identity service before assignment. + priority: Optional priority. Low / Medium / High / Critical. + labels: Optional list of labels for the task. + is_actionable_message_enabled: Whether actionable notifications are + enabled for this task. + actionable_message_metadata: Optional metadata override. For + QuickForm, when null, Orchestrator derives it from the + referenced TaskSchema. + creator_job_key: Optional. Identifies the job that triggered the + inline schema creation/upsert. + source_name: Source name on TaskSource. Defaults to 'Agent'. + + Returns: + Task: The created task object. + """ + spec = _create_quickform_spec( + title=title, + data=data, + task_schema_key=task_schema_key, + schema=schema, + creator_job_key=creator_job_key, + folder_key=folder_key, + folder_path=folder_path, + priority=priority, + labels=labels, + is_actionable_message_enabled=is_actionable_message_enabled, + actionable_message_metadata=actionable_message_metadata, + source_name=source_name, + ) + + response = await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + json_response = response.json() + if assignee or recipient: + assign_spec = await _assign_task_spec( + self, json_response["id"], assignee, recipient + ) + await self.request_async( + assign_spec.method, + assign_spec.endpoint, + json=assign_spec.json, + content=assign_spec.content, + ) + return Task.model_validate(json_response) + + @traced(name="tasks_create_quickform", run_type="uipath") + def create_quickform( + self, + title: str, + task_schema_key: str, + schema: Dict[str, Any], + data: Optional[Dict[str, Any]] = None, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + assignee: Optional[str] = None, + recipient: Optional[TaskRecipient] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + is_actionable_message_enabled: Optional[bool] = None, + actionable_message_metadata: Optional[Dict[str, Any]] = None, + creator_job_key: Optional[str] = None, + source_name: str = "Agent", + ) -> Task: + """Create a new QuickForm task synchronously. + + See :meth:`create_quickform_async` for parameter docs. + """ + spec = _create_quickform_spec( + title=title, + data=data, + task_schema_key=task_schema_key, + schema=schema, + creator_job_key=creator_job_key, + folder_key=folder_key, + folder_path=folder_path, + priority=priority, + labels=labels, + is_actionable_message_enabled=is_actionable_message_enabled, + actionable_message_metadata=actionable_message_metadata, + source_name=source_name, + ) + + response = self.request( + spec.method, + spec.endpoint, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + json_response = response.json() + if assignee or recipient: + assign_spec = asyncio.run( + _assign_task_spec(self, json_response["id"], assignee, recipient) + ) + self.request( + assign_spec.method, + assign_spec.endpoint, + json=assign_spec.json, + content=assign_spec.content, + ) + return Task.model_validate(json_response) + @resource_override( resource_type="app", resource_identifier="app_name", diff --git a/packages/uipath-platform/src/uipath/platform/action_center/tasks.py b/packages/uipath-platform/src/uipath/platform/action_center/tasks.py index f882cf40f..f1a932cb8 100644 --- a/packages/uipath-platform/src/uipath/platform/action_center/tasks.py +++ b/packages/uipath-platform/src/uipath/platform/action_center/tasks.py @@ -22,18 +22,35 @@ class TaskRecipientType(str, enum.Enum): GROUP_ID = "GroupId" EMAIL = "UserEmail" GROUP_NAME = "GroupName" + WORKLOAD = "Workload" + ROUND_ROBIN = "RoundRobin" class TaskRecipient(BaseModel): - """Model representing a task recipient.""" + """Model representing a task recipient. + + `value` is the single identifier (group name, group id, user id, email, …). + `values` is the multi-assignee form used by Workload-with-custom-emails + assignments; when set it takes precedence over `value` for the + `assigneeNamesOrEmails` payload. + + Note: there is no CustomAssignees member here on purpose. The agent-side + CustomAssignees criteria (AgentEscalationRecipientType.CUSTOM_ASSIGNEES, + type 11) is resolved to a Workload assignment with the explicit email list + in `values` before reaching this layer, so the Action Center + AssignTasks API only ever sees the existing literal types. + """ type: Literal[ TaskRecipientType.USER_ID, TaskRecipientType.GROUP_ID, TaskRecipientType.EMAIL, TaskRecipientType.GROUP_NAME, + TaskRecipientType.WORKLOAD, + TaskRecipientType.ROUND_ROBIN, ] = Field(..., alias="type") value: str = Field(..., alias="value") + values: Optional[List[str]] = Field(default=None, alias="values") display_name: Optional[str] = Field(default=None, alias="displayName") diff --git a/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py b/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py index c9475976c..c8993e3ee 100644 --- a/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py +++ b/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py @@ -9,6 +9,7 @@ from typing import Any, List from ..common._base_service import BaseService +from ..common._bindings import resource_override from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext, header_folder @@ -149,6 +150,7 @@ async def main(): data = response.json() return [RemoteA2aAgent.model_validate(agent) for agent in data.get("value", [])] + @resource_override(resource_type="remoteA2aAgent", resource_identifier="slug") def retrieve( self, slug: str, @@ -190,6 +192,7 @@ def retrieve( ) return RemoteA2aAgent.model_validate(response.json()) + @resource_override(resource_type="remoteA2aAgent", resource_identifier="slug") async def retrieve_async( self, slug: str, @@ -239,6 +242,13 @@ async def main(): def custom_headers(self) -> dict[str, str]: return self.folder_headers + def _resolve_folder_key(self, folder_path: str | None) -> str | None: + """Resolve folder key from folder_path, falling back to FolderContext.""" + if folder_path is not None: + return self._folders_service.retrieve_folder_key(folder_path) + + return self._folder_key + def _list_spec( self, *, @@ -273,7 +283,7 @@ def _retrieve_spec( *, folder_path: str | None, ) -> RequestSpec: - folder_key = self._folders_service.retrieve_folder_key(folder_path) + folder_key = self._resolve_folder_key(folder_path) return RequestSpec( method="GET", endpoint=Endpoint(f"/agenthub_/api/remote-a2a-agents/{slug}"), diff --git a/packages/uipath-platform/src/uipath/platform/automation_ops/__init__.py b/packages/uipath-platform/src/uipath/platform/automation_ops/__init__.py new file mode 100644 index 000000000..87ce420ec --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/automation_ops/__init__.py @@ -0,0 +1,9 @@ +"""AutomationOps service package. + +Provides the ``AutomationOpsService`` client for retrieving deployed AI Trust +Layer policies from AgentHub. +""" + +from ._automation_ops_service import AutomationOpsService + +__all__ = ["AutomationOpsService"] diff --git a/packages/uipath-platform/src/uipath/platform/automation_ops/_automation_ops_service.py b/packages/uipath-platform/src/uipath/platform/automation_ops/_automation_ops_service.py new file mode 100644 index 000000000..b5eac8cdd --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/automation_ops/_automation_ops_service.py @@ -0,0 +1,70 @@ +"""AutomationOps service for UiPath Platform. + +Provides methods for retrieving deployed policies from the AgentHub service. +""" + +from typing import Any + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec + +_DEPLOYED_POLICY_ENDPOINT = Endpoint("agenthub_/api/policies/deployed-policy") + + +class AutomationOpsService(BaseService): + """Service for interacting with UiPath AutomationOps policies via AgentHub.""" + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + + @traced(name="automation_ops_get_deployed_policy", run_type="uipath") + def get_deployed_policy(self) -> dict[str, Any]: + """Retrieve the deployed policy. + + Returns: + The deployed policy response as a dictionary. Returns an empty + dict when no policy is deployed (empty 200 response body). + """ + spec = self._deployed_policy_spec() + response = self.request( + spec.method, + url=spec.endpoint, + headers=spec.headers, + scoped="tenant", + ) + if not response.content: + return {} + return response.json() + + @traced(name="automation_ops_get_deployed_policy", run_type="uipath") + async def get_deployed_policy_async(self) -> dict[str, Any]: + """Retrieve the deployed policy (async). + + Returns: + The deployed policy response as a dictionary. Returns an empty + dict when no policy is deployed (empty 200 response body). + """ + spec = self._deployed_policy_spec() + response = await self.request_async( + spec.method, + url=spec.endpoint, + headers=spec.headers, + scoped="tenant", + ) + if not response.content: + return {} + return response.json() + + def _deployed_policy_spec(self) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=_DEPLOYED_POLICY_ENDPOINT, + ) diff --git a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py index d7c093d0d..cb02d8af3 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py +++ b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py @@ -35,6 +35,7 @@ ToolDefinition, ) from .llm_throttle import get_llm_semaphore +from .llm_trace_context import build_trace_context_headers # Common constants API_VERSION = "2024-10-21" # Standard API version for OpenAI-compatible endpoints @@ -224,7 +225,7 @@ async def embeddings( endpoint, json={"input": input}, params={"api-version": API_VERSION}, - headers=self._llm_headers, + headers={**self._llm_headers, **build_trace_context_headers()}, ) return TextEmbedding.model_validate(response.json()) @@ -355,7 +356,7 @@ class Country(BaseModel): endpoint, json=request_body, params={"api-version": API_VERSION}, - headers=self._llm_headers, + headers={**self._llm_headers, **build_trace_context_headers()}, ) return ChatCompletion.model_validate(response.json()) @@ -400,7 +401,7 @@ async def chat_completions( presence_penalty: float = 0, top_p: float | None = 1, top_k: int | None = None, - tools: list[ToolDefinition] | None = None, + tools: list[ToolDefinition | dict[str, Any]] | None = None, tool_choice: ToolChoice | None = None, response_format: dict[str, Any] | type[BaseModel] | None = None, api_version: str = NORMALIZED_API_VERSION, @@ -435,9 +436,11 @@ async def chat_completions( Controls diversity by considering only the top p probability mass. Defaults to 1. top_k (int, optional): Nucleus sampling parameter. Controls diversity by considering only the top k most probable tokens. Defaults to None. - tools (Optional[List[ToolDefinition]], optional): List of tool definitions that the - model can call. Tools enable the model to perform actions or retrieve information - beyond text generation. Defaults to None. + tools (Optional[List[ToolDefinition | dict]], optional): List of tool definitions + that the model can call. Tools enable the model to perform actions or retrieve + information beyond text generation. A tool given as a dict must already be in + UiPath wire format and is forwarded unchanged, which allows arbitrary nested + JSON schemas in its parameters. Defaults to None. tool_choice (Optional[ToolChoice], optional): Controls which tools the model can call. Can be "auto" (model decides), "none" (no tools), or a specific tool choice. Defaults to None. @@ -582,10 +585,15 @@ class Country(BaseModel): # Use provided dictionary format directly request_body["response_format"] = response_format - # Add tools if provided - convert to UiPath format + # Add tools if provided. A tool already in UiPath wire format (a dict) is + # passed through unchanged so callers can supply an arbitrary JSON schema + # for the parameters; ToolDefinition objects are converted as before. if tools: request_body["tools"] = [ - self._convert_tool_to_uipath_format(tool) for tool in tools + tool + if isinstance(tool, dict) + else self._convert_tool_to_uipath_format(tool) + for tool in tools ] # Handle tool_choice @@ -599,6 +607,7 @@ class Country(BaseModel): headers = { **self._llm_headers, + **build_trace_context_headers(), "X-UiPath-LlmGateway-NormalizedApi-ModelName": model, "X-UiPath-LLMGateway-AllowFull4xxResponse": "true", } diff --git a/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py new file mode 100644 index 000000000..b9c1f74a0 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py @@ -0,0 +1,50 @@ +"""W3C-style trace context headers for LLM Gateway requests.""" + +from opentelemetry import trace +from uipath.core.feature_flags import FeatureFlags +from uipath.core.tracing.span_utils import UiPathSpanUtils + +from ..common._config import UiPathConfig +from ..common._span_utils import _SpanUtils, resolve_project_id + + +def build_trace_context_headers( + extra_baggage: list[str] | None = None, +) -> dict[str, str]: + """Build W3C-style trace context headers for LLM Gateway requests. + + Resolves the current span via ``UiPathSpanUtils.get_external_current_span()`` + (which returns the deepest active span from the LLMOps hierarchy) with a + fallback to ``trace.get_current_span()``. + + Args: + extra_baggage: Additional baggage entries (e.g. ``["source=agents"]``) + that callers can inject alongside the platform-level entries. + + Returns an empty dict when the ``EnableTraceContextHeaders`` feature flag + is not enabled, or when no active span is present. + """ + if not FeatureFlags.is_flag_enabled("EnableTraceContextHeaders"): + return {} + + headers: dict[str, str] = {} + llmops_span = UiPathSpanUtils.get_external_current_span() + span = llmops_span or trace.get_current_span() + config_trace_id = UiPathConfig.trace_id + ctx = span.get_span_context() + if config_trace_id and ctx and ctx.span_id: + trace_id = _SpanUtils.normalize_trace_id(config_trace_id) + span_id = format(ctx.span_id, "032x") + headers["x-uipath-traceparent-id"] = f"00-{trace_id}-{span_id}" + + baggage_parts: list[str] = list(extra_baggage) if extra_baggage else [] + if folder_key := UiPathConfig.folder_key: + baggage_parts.append(f"folderKey={folder_key}") + if agent_id := resolve_project_id(): + baggage_parts.append(f"agentId={agent_id}") + if process_key := UiPathConfig.process_key: + baggage_parts.append(f"processKey={process_key}") + if baggage_parts: + headers["x-uipath-tracebaggage"] = ",".join(baggage_parts) + + return headers diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 40fc1ac34..555d6901d 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -4,9 +4,10 @@ """ from ._api_client import ApiClient -from ._base_service import BaseService +from ._base_service import BaseService, resolve_trace_id from ._bindings import ( ConnectionResourceOverwrite, + EntityResourceOverwrite, GenericResourceOverwrite, ResourceOverwrite, ResourceOverwriteParser, @@ -47,6 +48,7 @@ WaitEphemeralIndex, WaitEphemeralIndexRaw, WaitEscalation, + WaitIntegrationEvent, WaitJob, WaitJobRaw, WaitSystemAgent, @@ -88,6 +90,7 @@ "WaitEphemeralIndexRaw", "DocumentExtractionValidation", "WaitDocumentExtractionValidation", + "WaitIntegrationEvent", "RequestSpec", "Endpoint", "UiPathUrl", @@ -100,6 +103,7 @@ "EndpointManager", "jsonschema_to_pydantic", "ConnectionResourceOverwrite", + "EntityResourceOverwrite", "GenericResourceOverwrite", "ResourceOverwrite", "ResourceOverwriteParser", @@ -108,6 +112,7 @@ "_SpanUtils", "resolve_service_url", "inject_routing_headers", + "resolve_trace_id", ] from .validation import validate_pagination_params diff --git a/packages/uipath-platform/src/uipath/platform/common/_base_service.py b/packages/uipath-platform/src/uipath/platform/common/_base_service.py index d236e4ef5..95035b852 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_base_service.py +++ b/packages/uipath-platform/src/uipath/platform/common/_base_service.py @@ -66,14 +66,87 @@ def _get_caller_component() -> str: _TRACE_PARENT_HEADER = "x-uipath-traceparent-id" +def resolve_trace_id(fallback: str | None = None) -> str | None: + """Resolve the current UiPath trace id as a 32-char hex string. + + Same lookup chain :func:`_inject_trace_context` uses to compose the + ``x-uipath-traceparent-id`` header, exposed as a public helper so + callers can capture the value when they need it in a request body + (e.g. governance compensation) or before hopping to a background + thread that won't inherit the OpenTelemetry context. + + Resolution order (first hit wins): + + 1. :attr:`UiPathConfig.trace_id` (``UIPATH_TRACE_ID`` env var), + normalized via :meth:`_SpanUtils.normalize_trace_id`. This is the + canonical agent trace id the LLMOps exporter binds spans to. + 2. The LLMOps external span trace id, when a provider is registered + via :meth:`UiPathSpanUtils.register_current_span_provider`. + 3. The current OpenTelemetry span trace id. + 4. The caller-supplied ``fallback``. + + Args: + fallback: Returned when nothing above resolves. + + Returns: + Lower-case 32-char hex trace id, or ``fallback`` (which may be + ``None``) when no source yields a usable value. + + Thread Safety: + Steps 2 and 3 read OpenTelemetry's thread-local context. Call this + on the thread that owns the live span (e.g. the agent's hook + thread) and capture the result before submitting work to a + background pool — worker threads do not inherit the context. + """ + from uipath.core.tracing.span_utils import UiPathSpanUtils + + from ._config import UiPathConfig + from ._span_utils import _SpanUtils + + config_trace_id = UiPathConfig.trace_id + if config_trace_id: + try: + return _SpanUtils.normalize_trace_id(config_trace_id) + except ValueError: + # Malformed UIPATH_TRACE_ID — fall through to OTel context. + pass + + llmops_span = UiPathSpanUtils.get_external_current_span() + span = llmops_span or trace.get_current_span() + ctx = span.get_span_context() + if ctx.trace_id: + return format_trace_id(ctx.trace_id) + + return fallback + + def _inject_trace_context(headers: dict[str, str]) -> None: - """Inject UiPath trace context header from the active OTEL span.""" - span = trace.get_current_span() + """Inject UiPath trace context header. + + Trace ID: uses the agent trace ID from UIPATH_TRACE_ID env var (same + remapping the LLMOps exporter applies), falling back to the OTEL trace ID. + Span ID: uses the LLMOps tool span (via external span provider) so the + span ID matches what's visible in the LLMOps trace UI. + """ + from uipath.core.tracing.span_utils import UiPathSpanUtils + + from ._config import UiPathConfig + from ._span_utils import _SpanUtils + + llmops_span = UiPathSpanUtils.get_external_current_span() + span = llmops_span or trace.get_current_span() ctx = span.get_span_context() - if ctx.trace_id and ctx.span_id: - headers[_TRACE_PARENT_HEADER] = ( - f"00-{format_trace_id(ctx.trace_id)}-{format_span_id(ctx.span_id)}-01" - ) + if not (ctx.trace_id and ctx.span_id): + return + + config_trace_id = UiPathConfig.trace_id + trace_id = ( + _SpanUtils.normalize_trace_id(config_trace_id) + if config_trace_id + else format_trace_id(ctx.trace_id) + ) + span_id = format_span_id(ctx.span_id) + headers[_TRACE_PARENT_HEADER] = f"00-{trace_id}-{span_id}-01" class BaseService: diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index 449d2a7ef..a93880896 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -14,7 +14,14 @@ Union, ) -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, TypeAdapter +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + TypeAdapter, + model_validator, +) logger = logging.getLogger(__name__) @@ -45,7 +52,15 @@ def folder_identifier(self) -> str: class GenericResourceOverwrite(ResourceOverwrite): resource_type: Literal[ - "process", "index", "app", "asset", "bucket", "mcpServer", "queue", "entity" + "process", + "index", + "app", + "asset", + "bucket", + "mcpServer", + "queue", + "remoteA2aAgent", + "memorySpace", ] name: str = Field(alias="name") folder_path: str = Field(alias="folderPath") @@ -59,6 +74,29 @@ def folder_identifier(self) -> str: return self.folder_path +class EntityResourceOverwrite(ResourceOverwrite): + resource_type: Literal["entity"] + name: str = Field(alias="name") + folder_id: Optional[str] = Field(default=None, alias="folderId") + folder_path: Optional[str] = Field(default=None, alias="folderPath") + + @model_validator(mode="after") + def validate_folder_identifier(self) -> "EntityResourceOverwrite": + if self.folder_id and self.folder_path: + raise ValueError("Only one of folderId or folderPath may be provided.") + if not self.folder_id and not self.folder_path: + raise ValueError("Either folderId or folderPath must be provided.") + return self + + @property + def resource_identifier(self) -> str: + return self.name + + @property + def folder_identifier(self) -> str: + return self.folder_id or self.folder_path or "" + + class ConnectionResourceOverwrite(ResourceOverwrite): resource_type: Literal["connection"] # In eval context, studio web provides "ConnectionId". @@ -83,7 +121,9 @@ def folder_identifier(self) -> str: ResourceOverwriteUnion = Annotated[ - Union[GenericResourceOverwrite, ConnectionResourceOverwrite], + Union[ + GenericResourceOverwrite, EntityResourceOverwrite, ConnectionResourceOverwrite + ], Field(discriminator="resource_type"), ] @@ -112,9 +152,23 @@ def parse(cls, key: str, value: dict[str, Any]) -> ResourceOverwrite: The appropriate ResourceOverwrite subclass instance """ resource_type = key.split(".")[0] - value_with_type = {"resource_type": resource_type, **value} + normalized_value = cls._normalize_value(resource_type, value) + value_with_type = {"resource_type": resource_type, **normalized_value} return cls._adapter.validate_python(value_with_type) + @staticmethod + def _normalize_value(resource_type: str, value: dict[str, Any]) -> dict[str, Any]: + if resource_type != "entity": + return value + + normalized = dict(value) + if "folderId" in normalized: + normalized["folder_id"] = normalized.pop("folderId") + if "folderPath" in normalized: + normalized["folder_path"] = normalized.pop("folderPath") + + return normalized + _resource_overwrites: ContextVar[Optional[dict[str, ResourceOverwrite]]] = ContextVar( "resource_overwrites", default=None diff --git a/packages/uipath-platform/src/uipath/platform/common/_config.py b/packages/uipath-platform/src/uipath/platform/common/_config.py index b656830f6..40db82214 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_config.py +++ b/packages/uipath-platform/src/uipath/platform/common/_config.py @@ -61,6 +61,24 @@ def project_id(self) -> str | None: return os.getenv(ENV_UIPATH_PROJECT_ID, None) + @property + def agent_id(self) -> str | None: + from uipath.platform.common.constants import ENV_UIPATH_AGENT_ID + + return os.getenv(ENV_UIPATH_AGENT_ID) or self.project_id + + @property + def cloud_user_id(self) -> str | None: + from uipath.platform.common.constants import ENV_UIPATH_CLOUD_USER_ID + + return os.getenv(ENV_UIPATH_CLOUD_USER_ID, None) + + @property + def project_files_source(self) -> str | None: + from uipath.platform.common.constants import ENV_UIPATH_PROJECT_FILES_SOURCE + + return os.getenv(ENV_UIPATH_PROJECT_FILES_SOURCE, None) + @property def project_key(self) -> str | None: from uipath.platform.common.constants import ENV_PROJECT_KEY @@ -103,6 +121,12 @@ def folder_path(self) -> str | None: return os.getenv(ENV_FOLDER_PATH, None) + @property + def process_key(self) -> str | None: + from uipath.platform.common.constants import ENV_PROCESS_KEY + + return os.getenv(ENV_PROCESS_KEY, None) + @property def process_uuid(self) -> str | None: from uipath.platform.common.constants import ENV_UIPATH_PROCESS_UUID diff --git a/packages/uipath-platform/src/uipath/platform/common/_job_context.py b/packages/uipath-platform/src/uipath/platform/common/_job_context.py new file mode 100644 index 000000000..ccbd99f22 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_job_context.py @@ -0,0 +1,13 @@ +from ._config import UiPathConfig +from .constants import HEADER_JOB_KEY + + +def header_job_key() -> dict[str, str]: + """Return the X-UiPath-JobKey header when the orchestrator job key is set. + + Returns an empty dict when ``UiPathConfig.job_key`` is unset or empty. + """ + job_key = UiPathConfig.job_key + if not job_key: + return {} + return {HEADER_JOB_KEY: job_key} diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index cd7e15e23..954fbc038 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -2,9 +2,11 @@ import json import logging import os +import uuid from dataclasses import dataclass, field from datetime import datetime from enum import IntEnum +from functools import lru_cache from os import environ as env from typing import Any, Dict, List, Optional @@ -15,8 +17,42 @@ logger = logging.getLogger(__name__) -# SourceEnum.Robots = 4 (default for Python SDK / coded agents) -DEFAULT_SOURCE = 4 +# SourceEnum.CodedAgents = 10 (default for Python SDK / coded agents) +DEFAULT_SOURCE = 10 + + +@lru_cache(maxsize=1) +def _read_config_id() -> str | None: + """Return a valid GUID ``id`` from ``uipath.json``, cached for the process lifetime.""" + from uipath.platform.common._config import UiPathConfig + + try: + config_file = json.loads(UiPathConfig.config_file_path.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return None + + project_id = config_file.get("id") + if not isinstance(project_id, str): + logger.warning("'id' field not present in uipath.json") + return None + + try: + uuid.UUID(project_id) + except ValueError: + logger.warning("Ignoring uipath.json 'id' %r: not a valid GUID.", project_id) + return None + + return project_id + + +def resolve_project_id() -> str | None: + """Resolve the project id. + + Prefers ``uipath.json#id``, falls back to env vars. + """ + from uipath.platform.common._config import UiPathConfig + + return _read_config_id() or UiPathConfig.agent_id or UiPathConfig.project_key class AttachmentProvider(IntEnum): @@ -29,6 +65,16 @@ class AttachmentDirection(IntEnum): OUT = 2 +class VerbosityLevel(IntEnum): + VERBOSE = 0 + TRACE = 1 + INFORMATION = 2 + WARNING = 3 + ERROR = 4 + CRITICAL = 5 + OFF = 6 + + class SpanAttachment(BaseModel): """Represents an attachment in the UiPath tracing system.""" @@ -87,6 +133,7 @@ class UiPathSpan: # Top-level fields for internal tracing schema execution_type: Optional[int] = None agent_version: Optional[str] = None + verbosity_level: Optional[int] = None attachments: Optional[List[SpanAttachment]] = None def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: @@ -114,7 +161,7 @@ def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: for att in self.attachments ] - return { + result: Dict[str, Any] = { "Id": self.id, "TraceId": self.trace_id, "ParentId": self.parent_id, @@ -138,6 +185,9 @@ def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: "AgentVersion": self.agent_version, "Attachments": attachments_out, } + if self.verbosity_level is not None: + result["VerbosityLevel"] = self.verbosity_level + return result class _SpanUtils: @@ -267,9 +317,11 @@ def otel_span_to_uipath_span( ] attributes_dict["links"] = links_list + if agent_id := resolve_project_id(): + attributes_dict["agentId"] = agent_id + # Add process context attributes from environment variables for env_key, attr_key in ( - ("PROJECT_KEY", "agentId"), ("UIPATH_PROCESS_KEY", "agentName"), ("UIPATH_PROCESS_VERSION", "agentVersion"), ): @@ -283,7 +335,10 @@ def otel_span_to_uipath_span( # Top-level fields for internal tracing schema execution_type = attributes_dict.get("executionType") agent_version = attributes_dict.get("agentVersion") - reference_id = attributes_dict.get("referenceId") + reference_id = attributes_dict.get("agentId") or attributes_dict.get( + "referenceId" + ) + verbosity_level = attributes_dict.get("verbosityLevel") # Source: override via uipath.source attribute, else DEFAULT_SOURCE uipath_source = attributes_dict.get("uipath.source") @@ -334,6 +389,7 @@ def otel_span_to_uipath_span( span_type=span_type, execution_type=execution_type, agent_version=agent_version, + verbosity_level=verbosity_level, reference_id=reference_id, source=source, attachments=attachments, diff --git a/packages/uipath-platform/src/uipath/platform/common/constants.py b/packages/uipath-platform/src/uipath/platform/common/constants.py index 6184e844d..304ef64a6 100644 --- a/packages/uipath-platform/src/uipath/platform/common/constants.py +++ b/packages/uipath-platform/src/uipath/platform/common/constants.py @@ -17,6 +17,9 @@ ENV_TELEMETRY_ENABLED = "UIPATH_TELEMETRY_ENABLED" ENV_TRACING_ENABLED = "UIPATH_TRACING_ENABLED" ENV_UIPATH_PROJECT_ID = "UIPATH_PROJECT_ID" +ENV_UIPATH_AGENT_ID = "UIPATH_AGENT_ID" +ENV_UIPATH_CLOUD_USER_ID = "UIPATH_CLOUD_USER_ID" +ENV_UIPATH_PROJECT_FILES_SOURCE = "UIPATH_PROJECT_FILES_SOURCE" ENV_PROJECT_KEY = "PROJECT_KEY" ENV_PROCESS_KEY = "UIPATH_PROCESS_KEY" ENV_UIPATH_PROCESS_UUID = "UIPATH_PROCESS_UUID" diff --git a/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py b/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py index 100b601bd..3b2468551 100644 --- a/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py +++ b/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py @@ -259,3 +259,23 @@ class WaitDocumentExtractionValidation(BaseModel): extraction_validation: StartExtractionValidationResponse task_url: str | None = None + + +class WaitIntegrationEvent(BaseModel): + """Model representing a wait on an Integration Services event. + + Used to suspend a job until a remote event (e.g. Slack message, Teams reply) + is delivered by Integration Services. The SDK resolves `connection_name` + (scoped to `connection_folder_path` when provided) to the underlying + connection id and generates a fresh `inbox_id` when the trigger is created; + the rest of the fields describe which remote event to subscribe to via + the Connections service. + """ + + connector: str + connection_name: str + connection_folder_path: str | None = None + operation: str + object_name: str + filter_expression: str | None = None + parameters: dict[str, str] | None = None diff --git a/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py b/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py index af3bb4f78..b7c1e9444 100644 --- a/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py +++ b/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py @@ -8,7 +8,7 @@ from ..common._base_service import BaseService from ..common._bindings import resource_override -from ..common._config import UiPathApiConfig +from ..common._config import UiPathApiConfig, UiPathConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import header_folder from ..common._models import Endpoint, RequestSpec @@ -24,6 +24,13 @@ logger: logging.Logger = logging.getLogger("uipath") +HEADER_ORIGINATOR = "x-uipath-originator" +HEADER_SOURCE = "x-uipath-source" +# Sent on outbound Integration Service activity invocations so GenAI activities +# can be stitched back to the parent job for licensing attribution. +HEADER_ACTIVITY_JOB_ID = "x-uipath-job-id" +_ORIGINATOR_VALUE = "uipath-python" + class ConnectionsService(BaseService): """Service for managing UiPath external service connections. @@ -768,11 +775,13 @@ def _build_activity_request_spec( # header parameter handling headers = { - "x-uipath-originator": "uipath-python", - "x-uipath-source": "uipath-python", + HEADER_ORIGINATOR: _ORIGINATOR_VALUE, + HEADER_SOURCE: _ORIGINATOR_VALUE, **header_folder(folder_key, None), **header_params, } + if job_key := UiPathConfig.job_key: + headers[HEADER_ACTIVITY_JOB_ID] = job_key # body and files handling json_data: Dict[str, Any] | None = None @@ -788,12 +797,22 @@ def _build_activity_request_spec( # instead of making assumptions on whether or not it's present, we'll handle it defensively if key == json_section: continue - # files not supported yet supported so this will likely not work - files[key] = ( - key, - val, - None, - ) # probably needs to extract content type from val since IS metadata doesn't provide it + if isinstance(val, tuple): + # Caller supplied httpx's (filename, content[, content_type]) + # shape — pass through verbatim. This is the recommended path + # for file uploads so the multipart Content-Disposition gets + # the real filename instead of the form-field name. + files[key] = val + elif isinstance(val, (bytes, bytearray)) or hasattr(val, "read"): + # Raw file content with no filename — fall back to the + # form-field name (legacy behaviour). Backwards compatible + # with callers that still pass bytes directly. + files[key] = (key, val, "application/octet-stream") + else: + # Scalar (string/number/etc.) — send as a plain multipart + # form field, not a file part. The (None, value) shape tells + # httpx to omit `filename=...` from the Content-Disposition. + files[key] = (None, str(val)) files[json_section] = ( "", diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py index 75e525d3b..6bff3f77f 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py @@ -12,12 +12,15 @@ from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext, header_folder from ..common._http_config import get_httpx_client_kwargs +from ..common._job_context import header_job_key from ..common._models import Endpoint, RequestSpec from ..common.constants import ( ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE, ) from ..errors import ( + BatchTransformFailedException, BatchTransformNotCompleteException, + ContextGroundingIndexNotFoundError, IngestionInProgressException, UnsupportedDataSourceException, ) @@ -219,6 +222,7 @@ def retrieve_across_folders( spec.method, spec.endpoint, params=spec.params, + headers=spec.headers, ).json() return [ @@ -248,6 +252,45 @@ async def retrieve_across_folders_async( spec.method, spec.endpoint, params=spec.params, + headers=spec.headers, + ) + ).json() + + return [ + ContextGroundingIndex.model_validate(item) for item in response["value"] + ] + + @traced(name="contextgrounding_retrieve_system_indexes", run_type="uipath") + def _retrieve_system_indexes( + self, + name: Optional[str] = None, + ) -> List[ContextGroundingIndex]: + spec = self._retrieve_system_indexes_spec(name=name) + + response = self.request( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + return [ + ContextGroundingIndex.model_validate(item) for item in response["value"] + ] + + @traced(name="contextgrounding_retrieve_system_indexes", run_type="uipath") + async def _retrieve_system_indexes_async( + self, + name: Optional[str] = None, + ) -> List[ContextGroundingIndex]: + spec = self._retrieve_system_indexes_spec(name=name) + + response = ( + await self.request_async( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, ) ).json() @@ -262,30 +305,38 @@ def retrieve( name: str, folder_key: Optional[str] = None, folder_path: Optional[str] = None, + include_system_indexes: bool = False, ) -> ContextGroundingIndex: """Retrieve context grounding index information by its name. If no folder_key or folder_path is provided and no folder context is - configured, falls back to searching across all folders. + configured, falls back to searching across all folders. When + ``include_system_indexes`` is True, an additional fallback against + system indexes is attempted before raising not-found. Args: name (str): The name of the context index to retrieve. folder_key (Optional[str]): The key of the folder where the index resides. folder_path (Optional[str]): The path of the folder where the index resides. + include_system_indexes (bool): If True, fall back to system indexes + when the index is not found in the per-folder or across-folders listings. + Defaults to False. Returns: ContextGroundingIndex: The index information, including its configuration and metadata if found. Raises: - Exception: If no index with the given name is found. + ContextGroundingIndexNotFoundError: If no index with the given name is found. """ resolved_folder_key = self._resolve_folder_key(folder_key, folder_path) if not resolved_folder_key: indexes = self.retrieve_across_folders(name=name) try: return next(index for index in indexes if index.name == name) - except StopIteration as e: - raise Exception("ContextGroundingIndex not found") from e + except StopIteration: + if include_system_indexes: + return self._retrieve_from_system_indexes(name) + raise ContextGroundingIndexNotFoundError(name) from None spec = self._retrieve_spec( name, @@ -304,8 +355,10 @@ def retrieve( for item in response["value"] if item["name"] == name ) - except StopIteration as e: - raise Exception("ContextGroundingIndex not found") from e + except StopIteration: + if include_system_indexes: + return self._retrieve_from_system_indexes(name) + raise ContextGroundingIndexNotFoundError(name) from None @resource_override(resource_type="index") @traced(name="contextgrounding_retrieve", run_type="uipath") @@ -314,30 +367,38 @@ async def retrieve_async( name: str, folder_key: Optional[str] = None, folder_path: Optional[str] = None, + include_system_indexes: bool = False, ) -> ContextGroundingIndex: """Asynchronously retrieve context grounding index information by its name. If no folder_key or folder_path is provided and no folder context is - configured, falls back to searching across all folders. + configured, falls back to searching across all folders. When + ``include_system_indexes`` is True, an additional fallback against + system indexes is attempted before raising not-found. Args: name (str): The name of the context index to retrieve. folder_key (Optional[str]): The key of the folder where the index resides. folder_path (Optional[str]): The path of the folder where the index resides. + include_system_indexes (bool): If True, fall back to system indexes when + the index is not found in the per-folder or across-folders listings. + Defaults to False. Returns: ContextGroundingIndex: The index information, including its configuration and metadata if found. Raises: - Exception: If no index with the given name is found. + ContextGroundingIndexNotFoundError: If no index with the given name is found. """ resolved_folder_key = self._resolve_folder_key(folder_key, folder_path) if not resolved_folder_key: indexes = await self.retrieve_across_folders_async(name=name) try: return next(index for index in indexes if index.name == name) - except StopIteration as e: - raise Exception("ContextGroundingIndex not found") from e + except StopIteration: + if include_system_indexes: + return await self._retrieve_from_system_indexes_async(name) + raise ContextGroundingIndexNotFoundError(name) from None spec = self._retrieve_spec( name, @@ -358,8 +419,26 @@ async def retrieve_async( for item in response["value"] if item["name"] == name ) - except StopIteration as e: - raise Exception("ContextGroundingIndex not found") from e + except StopIteration: + if include_system_indexes: + return await self._retrieve_from_system_indexes_async(name) + raise ContextGroundingIndexNotFoundError(name) from None + + def _retrieve_from_system_indexes(self, name: str) -> ContextGroundingIndex: + indexes = self._retrieve_system_indexes(name=name) + try: + return next(index for index in indexes if index.name == name) + except StopIteration: + raise ContextGroundingIndexNotFoundError(name) from None + + async def _retrieve_from_system_indexes_async( + self, name: str + ) -> ContextGroundingIndex: + indexes = await self._retrieve_system_indexes_async(name=name) + try: + return next(index for index in indexes if index.name == name) + except StopIteration: + raise ContextGroundingIndexNotFoundError(name) from None @traced(name="contextgrounding_list", run_type="uipath") def list( @@ -381,7 +460,7 @@ def list( "GET", Endpoint("/ecs_/v2/indexes"), params={"$expand": "dataSource"}, - headers={**header_folder(folder_key, None)}, + headers={**header_folder(folder_key, None), **header_job_key()}, ).json() return [ ContextGroundingIndex.model_validate(item) @@ -409,7 +488,7 @@ async def list_async( "GET", Endpoint("/ecs_/v2/indexes"), params={"$expand": "dataSource"}, - headers={**header_folder(folder_key, None)}, + headers={**header_folder(folder_key, None), **header_job_key()}, ) ).json() return [ @@ -447,6 +526,7 @@ def retrieve_by_id( spec.method, spec.endpoint, params=spec.params, + headers=spec.headers, ).json() @traced(name="contextgrounding_retrieve_by_id", run_type="uipath") @@ -479,6 +559,7 @@ async def retrieve_by_id_async( spec.method, spec.endpoint, params=spec.params, + headers=spec.headers, ) return response.json() @@ -598,20 +679,29 @@ async def create_index_async( @resource_override(resource_type="index") @traced(name="contextgrounding_create_ephemeral_index", run_type="uipath") def create_ephemeral_index( - self, usage: EphemeralIndexUsage, attachments: List[str] + self, + usage: EphemeralIndexUsage, + attachments: List[str], + folder_key: str | None = None, + folder_path: str | None = None, ) -> ContextGroundingIndex: """Create a new ephemeral context grounding index. Args: usage (EphemeralIndexUsage): The task type for the ephemeral index (DeepRAG or BatchRAG) attachments (list[str]): The list of attachments ids from which the ephemeral index will be created + folder_key (Optional[str]): The folder key to scope the ephemeral index to. + folder_path (Optional[str]): The folder path to scope the ephemeral index to (resolved to a key if folder_key is not provided). Returns: ContextGroundingIndex: The created index information. """ + if folder_key is not None or folder_path is not None: + folder_key = self._resolve_folder_key(folder_key, folder_path) spec = self._create_ephemeral_spec( usage, attachments, + folder_key=folder_key, ) response = self.request( @@ -626,20 +716,29 @@ def create_ephemeral_index( @resource_override(resource_type="index") @traced(name="contextgrounding_create_ephemeral_index", run_type="uipath") async def create_ephemeral_index_async( - self, usage: EphemeralIndexUsage, attachments: List[str] + self, + usage: EphemeralIndexUsage, + attachments: List[str], + folder_key: str | None = None, + folder_path: str | None = None, ) -> ContextGroundingIndex: """Create a new ephemeral context grounding index. Args: usage (EphemeralIndexUsage): The task type for the ephemeral index (DeepRAG or BatchRAG) attachments (list[str]): The list of attachments ids from which the ephemeral index will be created + folder_key (Optional[str]): The folder key to scope the ephemeral index to. + folder_path (Optional[str]): The folder path to scope the ephemeral index to (resolved to a key if folder_key is not provided). Returns: ContextGroundingIndex: The created index information. """ + if folder_key is not None or folder_path is not None: + folder_key = self._resolve_folder_key(folder_key, folder_path) spec = self._create_ephemeral_spec( usage, attachments, + folder_key=folder_key, ) response = await self.request_async( @@ -1032,6 +1131,10 @@ def download_batch_transform_result( batch_transform = self.retrieve_batch_transform( id=id, index_name=index_name ) + if batch_transform.last_batch_rag_status == BatchTransformStatus.FAILED: + raise BatchTransformFailedException( + batch_transform_id=id, + ) if batch_transform.last_batch_rag_status != BatchTransformStatus.SUCCESSFUL: raise BatchTransformNotCompleteException( batch_transform_id=id, @@ -1091,6 +1194,10 @@ async def download_batch_transform_result_async( batch_transform = await self.retrieve_batch_transform_async( id=id, index_name=index_name ) + if batch_transform.last_batch_rag_status == BatchTransformStatus.FAILED: + raise BatchTransformFailedException( + batch_transform_id=id, + ) if batch_transform.last_batch_rag_status != BatchTransformStatus.SUCCESSFUL: raise BatchTransformNotCompleteException( batch_transform_id=id, @@ -1456,12 +1563,13 @@ def unified_search( self, name: str, query: str, - search_mode: SearchMode = SearchMode.AUTO, + search_mode: SearchMode = SearchMode.SEMANTIC, number_of_results: int = 10, threshold: float = 0.0, scope: Optional[UnifiedSearchScope] = None, folder_key: Optional[str] = None, folder_path: Optional[str] = None, + include_system_indexes: bool = False, ) -> UnifiedQueryResult: """Perform a unified search on a context grounding index. @@ -1471,17 +1579,25 @@ def unified_search( Args: name (str): The name of the context index to search in. query (str): The search query in natural language. - search_mode (SearchMode): The search mode to use. Defaults to AUTO. + search_mode (SearchMode): The search mode to use. Defaults to SEMANTIC. number_of_results (int): Maximum number of results to return. Defaults to 10. threshold (float): Minimum similarity threshold. Defaults to 0.0. scope (Optional[UnifiedSearchScope]): Optional search scope (folder, extension). folder_key (Optional[str]): The key of the folder where the index resides. folder_path (Optional[str]): The path of the folder where the index resides. + include_system_indexes (bool): If True, fall back to tenant-wide + system indexes when the index is not found in folder or + across-folders listings. Defaults to False. Returns: UnifiedQueryResult: The unified search result containing semantic and/or tabular results. """ - index = self.retrieve(name, folder_key=folder_key, folder_path=folder_path) + index = self.retrieve( + name, + folder_key=folder_key, + folder_path=folder_path, + include_system_indexes=include_system_indexes, + ) folder_key = folder_key or index.folder_key @@ -1511,12 +1627,13 @@ async def unified_search_async( self, name: str, query: str, - search_mode: SearchMode = SearchMode.AUTO, + search_mode: SearchMode = SearchMode.SEMANTIC, number_of_results: int = 10, threshold: float = 0.0, scope: Optional[UnifiedSearchScope] = None, folder_key: Optional[str] = None, folder_path: Optional[str] = None, + include_system_indexes: bool = False, ) -> UnifiedQueryResult: """Asynchronously perform a unified search on a context grounding index. @@ -1526,18 +1643,24 @@ async def unified_search_async( Args: name (str): The name of the context index to search in. query (str): The search query in natural language. - search_mode (SearchMode): The search mode to use. Defaults to AUTO. + search_mode (SearchMode): The search mode to use. Defaults to SEMANTIC. number_of_results (int): Maximum number of results to return. Defaults to 10. threshold (float): Minimum similarity threshold. Defaults to 0.0. scope (Optional[UnifiedSearchScope]): Optional search scope (folder, extension). folder_key (Optional[str]): The key of the folder where the index resides. folder_path (Optional[str]): The path of the folder where the index resides. + include_system_indexes (bool): If True, fall back to tenant-wide + system indexes when the index is not found in folder or + across-folders listings. Defaults to False. Returns: UnifiedQueryResult: The unified search result containing semantic and/or tabular results. """ index = await self.retrieve_async( - name, folder_key=folder_key, folder_path=folder_path + name, + folder_key=folder_key, + folder_path=folder_path, + include_system_indexes=include_system_indexes, ) if index and index.in_progress_ingestion(): raise IngestionInProgressException(index_name=name) @@ -1881,9 +2004,20 @@ def _ingest_spec( endpoint=Endpoint(f"/ecs_/v2/indexes/{key}/ingest"), headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) + @staticmethod + def _odata_name_filter(name: str) -> str: + """Build an OData ``Name eq ''`` filter with single quotes escaped. + + OData string literals escape ``'`` by doubling it. URL encoding of the + resulting filter is handled by the HTTP client when params are passed + as a dict. + """ + return "Name eq '{}'".format(name.replace("'", "''")) + def _retrieve_across_folders_spec( self, name: Optional[str] = None, @@ -1892,12 +2026,30 @@ def _retrieve_across_folders_spec( "$expand": "dataSource", } if name: - params["$filter"] = f"Name eq '{name}'" + params["$filter"] = self._odata_name_filter(name) return RequestSpec( method="GET", endpoint=Endpoint("/ecs_/v2/indexes/allacrossfolders"), params=params, + headers={**header_job_key()}, + ) + + def _retrieve_system_indexes_spec( + self, + name: Optional[str] = None, + ) -> RequestSpec: + params: Dict[str, str] = { + "$expand": "dataSource", + } + if name: + params["$filter"] = self._odata_name_filter(name) + + return RequestSpec( + method="GET", + endpoint=Endpoint("/ecs_/v2/indexes/allsystemindexes"), + params=params, + headers={**header_job_key()}, ) def _list_spec( @@ -1912,6 +2064,7 @@ def _list_spec( }, headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -1927,11 +2080,12 @@ def _retrieve_spec( method="GET", endpoint=Endpoint("/ecs_/v2/indexes"), params={ - "$filter": f"Name eq '{name}'", + "$filter": self._odata_name_filter(name), "$expand": "dataSource", }, headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -1984,6 +2138,7 @@ def _create_spec( json=payload.model_dump(by_alias=True, exclude_none=True), headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -1991,12 +2146,14 @@ def _create_ephemeral_spec( self, usage: str, attachments: List[str], + folder_key: str | None = None, ) -> RequestSpec: """Create request spec for ephemeral index creation. Args: usage (str): The task in which the ephemeral index will be used for attachments (list[str]): The list of attachments ids from which the ephemeral index will be created + folder_key (Optional[str]): The folder key to scope the ephemeral index to. Returns: RequestSpec for the create index request @@ -2012,7 +2169,7 @@ def _create_ephemeral_spec( method="POST", endpoint=Endpoint("/ecs_/v2/indexes/createephemeral"), json=payload.model_dump(by_alias=True, exclude_none=True), - headers={}, + headers={**header_folder(folder_key, None), **header_job_key()}, ) def _build_data_source(self, source: SourceConfig) -> Dict[str, Any]: @@ -2112,6 +2269,7 @@ def _retrieve_by_id_spec( endpoint=Endpoint(f"/ecs_/v2/indexes/{id}"), headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2128,6 +2286,7 @@ def _delete_by_id_spec( endpoint=Endpoint(f"/ecs_/v2/indexes/{id}"), headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2155,6 +2314,7 @@ def _search_spec( }, headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2162,7 +2322,7 @@ def _unified_search_spec( self, index_id: str, query: str, - search_mode: SearchMode = SearchMode.AUTO, + search_mode: SearchMode = SearchMode.SEMANTIC, number_of_results: int = 10, threshold: float = 0.0, scope: Optional[UnifiedSearchScope] = None, @@ -2191,6 +2351,7 @@ def _unified_search_spec( json=json_body, headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2220,6 +2381,7 @@ def _deep_rag_creation_spec( }, headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2243,7 +2405,7 @@ def _deep_rag_ephemeral_creation_spec( params={ "$select": "id,lastDeepRagStatus,createdDate", }, - headers={}, + headers={**header_job_key()}, ) def _batch_transform_creation_spec( @@ -2293,6 +2455,7 @@ def _batch_transform_creation_spec( }, headers={ **header_folder(folder_key, None), + **header_job_key(), }, ) @@ -2319,7 +2482,7 @@ def _batch_transform_ephemeral_creation_spec( column.model_dump(by_alias=True) for column in output_columns ], }, - headers={}, + headers={**header_job_key()}, ) def _deep_rag_retrieve_spec( @@ -2332,6 +2495,7 @@ def _deep_rag_retrieve_spec( params={ "$expand": "content", }, + headers={**header_job_key()}, ) def _batch_transform_retrieve_spec( @@ -2341,6 +2505,7 @@ def _batch_transform_retrieve_spec( return RequestSpec( method="GET", endpoint=Endpoint(f"/ecs_/v2/batchRag/{id}"), + headers={**header_job_key()}, ) def _batch_transform_get_read_uri_spec( @@ -2350,6 +2515,7 @@ def _batch_transform_get_read_uri_spec( return RequestSpec( method="GET", endpoint=Endpoint(f"/ecs_/v2/batchRag/{id}/GetReadUri"), + headers={**header_job_key()}, ) def _batch_transform_download_blob_spec( @@ -2359,6 +2525,7 @@ def _batch_transform_download_blob_spec( return RequestSpec( method="GET", endpoint=Endpoint(f"/ecs_/v2/batchRag/{id}/DownloadBlob"), + headers={**header_job_key()}, ) def _resolve_folder_key(self, folder_key, folder_path): diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py index fdcf5eac9..c5c0915bb 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py @@ -239,7 +239,6 @@ class ContextGroundingSearchResultItem(BaseModel): class SearchMode(str, Enum): """Enum representing possible unified search modes.""" - AUTO = "Auto" SEMANTIC = "Semantic" diff --git a/packages/uipath-platform/src/uipath/platform/entities/__init__.py b/packages/uipath-platform/src/uipath/platform/entities/__init__.py index bbc43cdb7..67572ec87 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/entities/__init__.py @@ -5,36 +5,78 @@ from ._entities_service import EntitiesService from .entities import ( + AggregateRow, + ChoiceSetValue, + DataFabricEntityItem, Entity, + EntityAggregate, + EntityAggregateFunction, + EntityBinning, + EntityCreateFieldOptions, + EntityCreateOptions, EntityField, + EntityFieldDataType, EntityFieldMetadata, + EntityImportRecordsResponse, + EntityJoin, + EntityMetadataUpdateOptions, + EntityQueryFilter, + EntityQueryFilterGroup, + EntityQuerySortOption, EntityRecord, EntityRecordsBatchResponse, + EntityRecordsListResponse, EntityRouting, + EntitySetResolution, ExternalField, ExternalObject, ExternalSourceFields, + FailureRecord, FieldDataType, FieldMetadata, + LogicalOperator, + QueryFilterOperator, QueryRoutingOverrideContext, ReferenceType, + RetrieveEntityRecordsResponse, SourceJoinCriteria, ) __all__ = [ + "AggregateRow", + "ChoiceSetValue", + "DataFabricEntityItem", "EntitiesService", "Entity", + "EntityAggregate", + "EntityAggregateFunction", + "EntityBinning", + "EntityCreateFieldOptions", + "EntityCreateOptions", "EntityField", - "EntityRecord", + "EntityFieldDataType", "EntityFieldMetadata", - "EntityRouting", - "FieldDataType", - "FieldMetadata", + "EntityImportRecordsResponse", + "EntityJoin", + "EntityMetadataUpdateOptions", + "EntityQueryFilter", + "EntityQueryFilterGroup", + "EntityQuerySortOption", + "EntityRecord", "EntityRecordsBatchResponse", + "EntityRecordsListResponse", + "EntityRouting", + "EntitySetResolution", "ExternalField", "ExternalObject", "ExternalSourceFields", + "FailureRecord", + "FieldDataType", + "FieldMetadata", + "LogicalOperator", + "QueryFilterOperator", "QueryRoutingOverrideContext", "ReferenceType", + "RetrieveEntityRecordsResponse", "SourceJoinCriteria", ] diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index f30c9492e..fa3c0da9e 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -1,55 +1,113 @@ +"""Public facade for the Data Fabric entities surface. + +:class:`EntitiesService` keeps the existing ``sdk.entities.*`` API flat and +unchanged from a caller's perspective while delegating each operation to the +appropriate underlying service: + +* :class:`EntitySchemaService` — entity definitions, choice set listings, + create / delete / update-metadata lifecycle. +* :class:`EntityDataService` — record CRUD (single and batch), structured + queries, attachments, choice-set values, bulk import, and federated SQL + queries. + +The facade additionally owns cross-cutting concerns such as agent entity-set +resolution. +""" + +import logging from typing import Any, Dict, List, Optional, Type -import sqlparse from httpx import Response -from sqlparse.sql import Parenthesis, Where -from sqlparse.tokens import DML, Keyword, Wildcard from uipath.core.tracing import traced from ..common._base_service import BaseService +from ..common._bindings import _resource_overwrites from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext -from ..common._models import Endpoint, RequestSpec +from ..orchestrator._folder_service import FolderService +from ._entity_data_service import EntityDataService, FileContent +from ._entity_resolution import ( + build_resolution_service, + create_resolution_plan, + create_resolution_plan_async, + create_routing_strategy, + fetch_resolved_entities, + fetch_resolved_entities_async, +) +from ._entity_schema_service import EntitySchemaService from .entities import ( + ChoiceSetValue, + DataFabricEntityItem, Entity, + EntityAggregate, + EntityBinning, + EntityCreateFieldOptions, + EntityCreateOptions, + EntityImportRecordsResponse, + EntityJoin, + EntityMetadataUpdateOptions, + EntityQueryFilterGroup, + EntityQuerySortOption, EntityRecord, EntityRecordsBatchResponse, + EntityRecordsListResponse, + EntitySetResolution, QueryRoutingOverrideContext, + RetrieveEntityRecordsResponse, ) -_FORBIDDEN_DML = {"INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE"} -_FORBIDDEN_DDL = {"DROP", "ALTER", "CREATE", "TRUNCATE"} -_DISALLOWED_KEYWORDS = [ - "WITH", - "UNION", - "INTERSECT", - "EXCEPT", - "OVER", - "ROLLUP", - "CUBE", - "GROUPING", - "PARTITION", -] +logger = logging.getLogger(__name__) class EntitiesService(BaseService): """Service for managing UiPath Data Service entities. - Entities are database tables in UiPath Data Service that can store - structured data for automation processes. + Entities are database tables in UiPath Data Service that store structured + data for automation processes. This service is the unified entry point for + every entity operation: schema management, record CRUD, structured and + SQL queries, file attachments, choice sets, and bulk import. See Also: https://docs.uipath.com/data-service/automation-cloud/latest/user-guide/introduction !!! warning "Preview Feature" - This function is currently experimental. - Behavior and parameters are subject to change in future versions. + This service is currently experimental. Behavior and parameters are + subject to change in future versions. """ def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Optional[FolderService] = None, + folders_map: Optional[Dict[str, str]] = None, + entity_name_overrides: Optional[Dict[str, str]] = None, + routing_context: Optional[QueryRoutingOverrideContext] = None, ) -> None: + """Initialise the facade and its underlying schema and data services.""" super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + self._routing_strategy = create_routing_strategy( + folders_map=folders_map, + effective_entity_names=entity_name_overrides, + routing_context=routing_context, + folders_service=folders_service, + ) + self._schema = EntitySchemaService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + self._data = EntityDataService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + routing_strategy=self._routing_strategy, + ) + + # ------------------------------------------------------------------ + # Schema operations — delegate to EntitySchemaService + # ------------------------------------------------------------------ @traced(name="entity_retrieve", run_type="uipath") def retrieve(self, entity_key: str) -> Entity: @@ -84,10 +142,7 @@ def retrieve(self, entity_key: str) -> Entity: print(f" Required: {field.is_required}") print(f" Primary Key: {field.is_primary_key}") """ - spec = self._retrieve_spec(entity_key) - response = self.request(spec.method, spec.endpoint) - - return Entity.model_validate(response.json()) + return self._schema.retrieve(entity_key) @traced(name="entity_retrieve", run_type="uipath") async def retrieve_async(self, entity_key: str) -> Entity: @@ -122,11 +177,41 @@ async def retrieve_async(self, entity_key: str) -> Entity: print(f" Required: {field.is_required}") print(f" Primary Key: {field.is_primary_key}") """ - spec = self._retrieve_spec(entity_key) + return await self._schema.retrieve_async(entity_key) + + @traced(name="entity_retrieve_by_name", run_type="uipath") + def retrieve_by_name( + self, entity_name: str, folder_key: Optional[str] = None + ) -> Entity: + """Retrieve an entity by its name. + + The server resolves the entity within the folder identified by + ``folder_key``. When omitted the default folder from the + execution context is used. + + Args: + entity_name: The name of the entity. + folder_key: Optional folder key for disambiguation. + """ + return self._schema.retrieve_by_name(entity_name, folder_key=folder_key) - response = await self.request_async(spec.method, spec.endpoint) + @traced(name="entity_retrieve_by_name", run_type="uipath") + async def retrieve_by_name_async( + self, entity_name: str, folder_key: Optional[str] = None + ) -> Entity: + """Asynchronously retrieve an entity by its name. - return Entity.model_validate(response.json()) + The server resolves the entity within the folder identified by + ``folder_key``. When omitted the default folder from the + execution context is used. + + Args: + entity_name: The name of the entity. + folder_key: Optional folder key for disambiguation. + """ + return await self._schema.retrieve_by_name_async( + entity_name, folder_key=folder_key + ) @traced(name="list_entities", run_type="uipath") def list_entities(self) -> List[Entity]: @@ -165,11 +250,7 @@ def list_entities(self) -> List[Entity]: print(f"Total records: {total_records}") print(f"Total storage: {total_storage:.2f} MB") """ - spec = self._list_entities_spec() - response = self.request(spec.method, spec.endpoint) - - entities_data = response.json() - return [Entity.model_validate(entity) for entity in entities_data] + return self._schema.list_entities() @traced(name="list_entities", run_type="uipath") async def list_entities_async(self) -> List[Entity]: @@ -208,20 +289,295 @@ async def list_entities_async(self) -> List[Entity]: print(f"Total records: {total_records}") print(f"Total storage: {total_storage:.2f} MB") """ - spec = self._list_entities_spec() - response = await self.request_async(spec.method, spec.endpoint) + return await self._schema.list_entities_async() + + @traced(name="list_choicesets", run_type="uipath") + def list_choicesets(self) -> List[Entity]: + """List all choice sets in Data Service. + + Returns: + List[Entity]: A list of all choice set entities. + + Examples: + List all choice sets:: + + choicesets = entities_service.list_choicesets() + for cs in choicesets: + print(f"{cs.display_name} ({cs.id})") + """ + return self._schema.list_choicesets() + + @traced(name="list_choicesets", run_type="uipath") + async def list_choicesets_async(self) -> List[Entity]: + """Asynchronously list all choice sets in Data Service. + + Returns: + List[Entity]: A list of all choice set entities. + """ + return await self._schema.list_choicesets_async() + + @traced(name="entity_create", run_type="uipath") + def create_entity( + self, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> str: + """Create a new entity with the given schema and return its id. + + Args: + name (str): Entity name. Must start with a letter and contain + only letters, digits, and underscores (3-100 characters). + fields (List[EntityCreateFieldOptions]): Field definitions for + the new entity. Each entry declares the field's name, type, + and optional constraints such as ``length_limit``, + ``decimal_precision``, ``is_required``, ``is_unique``, etc. + options (Optional[EntityCreateOptions]): Optional entity-level + settings such as display name, description, folder + placement, and RBAC / analytics flags. + + Returns: + str: The id (UUID) of the newly created entity. + + Raises: + ValueError: If the entity name or any field name fails the + client-side validation (regex / length / reserved names) or + if a per-field constraint is not supported for that field + type or is out of range. + + Examples: + Create a simple entity:: + + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityCreateOptions, + EntityFieldDataType, + ) + + entity_id = entities_service.create_entity( + "ProductCatalog", + [ + EntityCreateFieldOptions( + field_name="product_name", + type=EntityFieldDataType.STRING, + is_required=True, + is_unique=True, + ), + EntityCreateFieldOptions( + field_name="price", + type=EntityFieldDataType.DECIMAL, + decimal_precision=2, + ), + ], + options=EntityCreateOptions( + display_name="Product Catalog", + description="Inventory of available products", + is_rbac_enabled=True, + ), + ) + """ + return self._schema.create_entity(name, fields, options) + + @traced(name="entity_create", run_type="uipath") + async def create_entity_async( + self, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> str: + """Asynchronously create a new entity with the given schema. + + Args: + name (str): Entity name; same validation rules as :meth:`create_entity`. + fields (List[EntityCreateFieldOptions]): Field definitions. + options (Optional[EntityCreateOptions]): Optional entity-level settings. + + Returns: + str: The id (UUID) of the newly created entity. + + Raises: + ValueError: For client-side validation failures. + + Examples: + Create a simple entity:: + + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + entity_id = await entities_service.create_entity_async( + "ProductCatalog", + [ + EntityCreateFieldOptions( + field_name="product_name", + type=EntityFieldDataType.STRING, + is_required=True, + ), + ], + ) + """ + return await self._schema.create_entity_async(name, fields, options) + + @traced(name="entity_delete", run_type="uipath") + def delete_entity(self, entity_id: str) -> None: + """Delete an entity and all of its records. + + Args: + entity_id (str): The unique identifier of the entity to delete. + + Examples: + Delete an entity by id:: + + entities_service.delete_entity("a1b2c3d4-...") + """ + self._schema.delete_entity(entity_id) + + @traced(name="entity_delete", run_type="uipath") + async def delete_entity_async(self, entity_id: str) -> None: + """Asynchronously delete an entity and all of its records. + + Args: + entity_id (str): The unique identifier of the entity to delete. + + Examples: + Delete an entity by id:: + + await entities_service.delete_entity_async("a1b2c3d4-...") + """ + await self._schema.delete_entity_async(entity_id) + + @traced(name="entity_update_metadata", run_type="uipath") + def update_entity_metadata( + self, + entity_id: str, + metadata: EntityMetadataUpdateOptions | Dict[str, Any], + ) -> None: + """Update an entity's display name, description, and/or RBAC flag. + + Args: + entity_id (str): The unique identifier of the entity. + metadata (EntityMetadataUpdateOptions | Dict[str, Any]): + An :class:`EntityMetadataUpdateOptions` instance or a dict + with any of ``display_name``, ``description``, + ``is_rbac_enabled``. Dict keys may be snake_case + (``display_name``) or camelCase (``displayName``); both + serialize correctly to the API. + + Examples: + Rename and update description:: + + from uipath.platform.entities import EntityMetadataUpdateOptions + + entities_service.update_entity_metadata( + "a1b2c3d4-...", + EntityMetadataUpdateOptions( + display_name="New Display Name", + description="Refreshed description", + ), + ) + + From a plain dict:: + + entities_service.update_entity_metadata( + "a1b2c3d4-...", + {"display_name": "X", "is_rbac_enabled": True}, + ) + """ + self._schema.update_entity_metadata(entity_id, metadata) + + @traced(name="entity_update_metadata", run_type="uipath") + async def update_entity_metadata_async( + self, + entity_id: str, + metadata: EntityMetadataUpdateOptions | Dict[str, Any], + ) -> None: + """Asynchronously update an entity's display name, description, and/or RBAC flag. + + Args: + entity_id (str): The unique identifier of the entity. + metadata (EntityMetadataUpdateOptions | Dict[str, Any]): + An :class:`EntityMetadataUpdateOptions` instance or a dict + with any of ``display_name``, ``description``, + ``is_rbac_enabled``. + + Examples: + Rename:: + + from uipath.platform.entities import EntityMetadataUpdateOptions + + await entities_service.update_entity_metadata_async( + "a1b2c3d4-...", + EntityMetadataUpdateOptions(display_name="Renamed Entity"), + ) + """ + await self._schema.update_entity_metadata_async(entity_id, metadata) + + # ------------------------------------------------------------------ + # Data operations — delegate to EntityDataService + # ------------------------------------------------------------------ + + @traced(name="get_choiceset_values", run_type="uipath") + def get_choiceset_values( + self, + choiceset_id: str, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> List[ChoiceSetValue]: + """Get the values of a choice set by its ID. + + Args: + choiceset_id: The unique identifier of the choice set. + start: Optional offset for pagination. + limit: Optional page size for pagination. + + Returns: + List[ChoiceSetValue]: The values in the choice set, each containing + id, name, display_name, and number_id. + + Examples: + Get all values in a choice set:: + + values = entities_service.get_choiceset_values("choiceset-id") + for v in values: + print(f"{v.number_id}: {v.display_name}") + """ + return self._data.get_choiceset_values(choiceset_id, start=start, limit=limit) + + @traced(name="get_choiceset_values", run_type="uipath") + async def get_choiceset_values_async( + self, + choiceset_id: str, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> List[ChoiceSetValue]: + """Asynchronously get the values of a choice set by its ID. - entities_data = response.json() - return [Entity.model_validate(entity) for entity in entities_data] + Args: + choiceset_id: The unique identifier of the choice set. + start: Optional offset for pagination. + limit: Optional page size for pagination. + + Returns: + List[ChoiceSetValue]: The values in the choice set. + """ + return await self._data.get_choiceset_values_async( + choiceset_id, start=start, limit=limit + ) @traced(name="entity_list_records", run_type="uipath") def list_records( self, entity_key: str, - schema: Optional[Type[Any]] = None, # Optional schema + schema: Optional[Type[Any]] = None, start: Optional[int] = None, limit: Optional[int] = None, - ) -> List[EntityRecord]: + expansion_level: Optional[int] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + select: Optional[List[str]] = None, + expand: Optional[List[str]] = None, + ) -> EntityRecordsListResponse: """List records from an entity with optional pagination and schema validation. The schema parameter enables type-safe access to entity records by validating the @@ -258,11 +614,23 @@ class CustomerRecord: start (Optional[int]): Starting index for pagination (0-based). limit (Optional[int]): Maximum number of records to return. + expansion_level (Optional[int]): Depth of foreign-key expansion in the + response (``0`` means no expansion). Higher values inline related + records up to that many hops. + filter (Optional[str]): OData ``$filter`` expression + (e.g. ``"status eq 'active'"``). + orderby (Optional[str]): OData ``$orderby`` expression + (e.g. ``"created_at desc"``). + select (Optional[List[str]]): Column projection — field names to + include (rendered as ``$select``). + expand (Optional[List[str]]): Relationship names to expand inline + (rendered as ``$expand``). Returns: - List[EntityRecord]: A list of entity records. Each record contains an 'id' field - and all other fields from the entity. Fields can be accessed as attributes - or dictionary keys on the EntityRecord object. + EntityRecordsListResponse: A list-compatible response with + ``total_count``, ``has_next_page`` and ``next_cursor`` pagination + metadata. Iteration, indexing, and ``len()`` continue to work + like a plain list of :class:`EntityRecord`. Raises: ValueError: If schema validation fails for any record, including cases where @@ -280,6 +648,22 @@ class CustomerRecord: # Get first 50 records records = entities_service.list_records("Customers", start=0, limit=50) + print(f"Showing {len(records)} of {records.total_count} total") + if records.has_next_page: + next_page = entities_service.list_records( + "Customers", start=50, limit=50 + ) + + With OData filter, sorting, projection, and expansion:: + + records = entities_service.list_records( + "Customers", + filter="status eq 'active'", + orderby="created_at desc", + select=["name", "email", "status"], + expand=["company"], + expansion_level=1, + ) With schema validation:: @@ -299,28 +683,31 @@ class CustomerRecord: for record in records: print(f"{record.name}: {record.email}") """ - # Example method to generate the API request specification (mocked here) - spec = self._list_records_spec(entity_key, start, limit) - - # Make the HTTP request (assumes self.request exists) - response = self.request(spec.method, spec.endpoint, params=spec.params) - - # Parse the response JSON and extract the "value" field - records_data = response.json().get("value", []) - - # Validate and wrap records - return [ - EntityRecord.from_data(data=record, model=schema) for record in records_data - ] + return self._data.list_records( + entity_key, + schema=schema, + start=start, + limit=limit, + expansion_level=expansion_level, + filter=filter, + orderby=orderby, + select=select, + expand=expand, + ) @traced(name="entity_list_records", run_type="uipath") async def list_records_async( self, entity_key: str, - schema: Optional[Type[Any]] = None, # Optional schema + schema: Optional[Type[Any]] = None, start: Optional[int] = None, limit: Optional[int] = None, - ) -> List[EntityRecord]: + expansion_level: Optional[int] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + select: Optional[List[str]] = None, + expand: Optional[List[str]] = None, + ) -> EntityRecordsListResponse: """Asynchronously list records from an entity with optional pagination and schema validation. The schema parameter enables type-safe access to entity records by validating the @@ -357,11 +744,23 @@ class CustomerRecord: start (Optional[int]): Starting index for pagination (0-based). limit (Optional[int]): Maximum number of records to return. + expansion_level (Optional[int]): Depth of foreign-key expansion in the + response (``0`` means no expansion). Higher values inline related + records up to that many hops. + filter (Optional[str]): OData ``$filter`` expression + (e.g. ``"status eq 'active'"``). + orderby (Optional[str]): OData ``$orderby`` expression + (e.g. ``"created_at desc"``). + select (Optional[List[str]]): Column projection — field names to + include (rendered as ``$select``). + expand (Optional[List[str]]): Relationship names to expand inline + (rendered as ``$expand``). Returns: - List[EntityRecord]: A list of entity records. Each record contains an 'id' field - and all other fields from the entity. Fields can be accessed as attributes - or dictionary keys on the EntityRecord object. + EntityRecordsListResponse: A list-compatible response with + ``total_count``, ``has_next_page`` and ``next_cursor`` pagination + metadata. Iteration, indexing, and ``len()`` continue to work + like a plain list of :class:`EntityRecord`. Raises: ValueError: If schema validation fails for any record, including cases where @@ -379,6 +778,22 @@ class CustomerRecord: # Get first 50 records records = await entities_service.list_records_async("Customers", start=0, limit=50) + print(f"Showing {len(records)} of {records.total_count} total") + if records.has_next_page: + next_page = await entities_service.list_records_async( + "Customers", start=50, limit=50 + ) + + With OData filter, sorting, projection, and expansion:: + + records = await entities_service.list_records_async( + "Customers", + filter="status eq 'active'", + orderby="created_at desc", + select=["name", "email", "status"], + expand=["company"], + expansion_level=1, + ) With schema validation:: @@ -398,100 +813,292 @@ class CustomerRecord: for record in records: print(f"{record.name}: {record.email}") """ - spec = self._list_records_spec(entity_key, start, limit) - - # Make the HTTP request (assumes self.request exists) - response = await self.request_async( - spec.method, spec.endpoint, params=spec.params + return await self._data.list_records_async( + entity_key, + schema=schema, + start=start, + limit=limit, + expansion_level=expansion_level, + filter=filter, + orderby=orderby, + select=select, + expand=expand, ) - # Parse the response JSON and extract the "value" field - records_data = response.json().get("value", []) + @traced(name="entity_insert_record", run_type="uipath") + def insert_record( + self, + entity_key: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Insert a single record into an entity and return the inserted row. - # Validate and wrap records - return [ - EntityRecord.from_data(data=record, model=schema) for record in records_data - ] + Note: + Unlike :meth:`insert_records` (batch), this single-record endpoint + fires Data Fabric trigger events. Use this method when triggers + attached to the entity must run. - @traced(name="entity_query_records", run_type="uipath") - def query_entity_records( + Args: + entity_key (str): The unique key/identifier of the entity. + data (Any): Record payload — a dict, a Pydantic model, an + :class:`EntityRecord`, or any object exposing ``__dict__``. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). + + Returns: + EntityRecord: The inserted record with its server-assigned ``Id`` + plus any expanded relationships. + + Examples: + Insert from a dict:: + + record = entities_service.insert_record( + "Customers", + {"name": "Alice", "email": "alice@example.com"}, + ) + print(record.id) + + Insert from a Pydantic model:: + + class CustomerInput(BaseModel): + name: str + email: str + + record = entities_service.insert_record( + "Customers", + CustomerInput(name="Bob", email="bob@example.com"), + expansion_level=1, + ) + """ + return self._data.insert_record( + entity_key, data, expansion_level=expansion_level + ) + + @traced(name="entity_insert_record", run_type="uipath") + async def insert_record_async( self, - sql_query: str, - routing_context: Optional[QueryRoutingOverrideContext] = None, - ) -> List[Dict[str, Any]]: - """Query entity records using a validated SQL query. + entity_key: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Asynchronously insert a single record into an entity. - PREVIEW: This method is in preview and may change in future releases. + Note: + Unlike :meth:`insert_records_async` (batch), this single-record + endpoint fires Data Fabric trigger events. Use this method when + triggers attached to the entity must run. Args: - sql_query (str): A SQL SELECT query to execute against Data Service entities. - Only SELECT statements are allowed. Queries without WHERE must include - a LIMIT clause. Subqueries and multi-statement queries are not permitted. - routing_context (Optional[QueryRoutingOverrideContext]): Per-entity routing context - for multi-folder queries. When present, included in the request body - and takes precedence over the folder header on the backend. + entity_key (str): The unique key/identifier of the entity. + data (Any): Record payload — a dict, a Pydantic model, an + :class:`EntityRecord`, or any object exposing ``__dict__``. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). Returns: - List[Dict[str, Any]]: A list of result records as dictionaries. + EntityRecord: The inserted record with its server-assigned ``Id``. - Raises: - ValueError: If the SQL query fails validation (e.g., non-SELECT, missing - WHERE/LIMIT, forbidden keywords, subqueries). + Examples: + Insert from a dict:: + + record = await entities_service.insert_record_async( + "Customers", + {"name": "Alice", "email": "alice@example.com"}, + ) + print(record.id) """ - return self._query_entities_for_records( - sql_query, routing_context=routing_context + return await self._data.insert_record_async( + entity_key, data, expansion_level=expansion_level ) - @traced(name="entity_query_records", run_type="uipath") - async def query_entity_records_async( + @traced(name="entity_get_record", run_type="uipath") + def get_record( self, - sql_query: str, - routing_context: Optional[QueryRoutingOverrideContext] = None, - ) -> List[Dict[str, Any]]: - """Asynchronously query entity records using a validated SQL query. + entity_key: str, + record_id: str, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Fetch a single entity record by its id. - PREVIEW: This method is in preview and may change in future releases. + Args: + entity_key (str): The unique key/identifier of the entity. + record_id (str): The unique identifier of the record to fetch. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). + + Returns: + EntityRecord: The record, with optional expanded relationships. + + Examples: + Basic usage:: + + record = entities_service.get_record("Customers", "rec-1") + print(record.id, record.name) + + With FK expansion:: + + # Inline the related Company record on the returned Customer + record = entities_service.get_record( + "Customers", "rec-1", expansion_level=1 + ) + """ + return self._data.get_record( + entity_key, record_id, expansion_level=expansion_level + ) + + @traced(name="entity_get_record", run_type="uipath") + async def get_record_async( + self, + entity_key: str, + record_id: str, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Asynchronously fetch a single entity record by its id. Args: - sql_query (str): A SQL SELECT query to execute against Data Service entities. - Only SELECT statements are allowed. Queries without WHERE must include - a LIMIT clause. Subqueries and multi-statement queries are not permitted. - routing_context (Optional[QueryRoutingOverrideContext]): Per-entity routing context - for multi-folder queries. When present, included in the request body - and takes precedence over the folder header on the backend. + entity_key (str): The unique key/identifier of the entity. + record_id (str): The unique identifier of the record to fetch. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). Returns: - List[Dict[str, Any]]: A list of result records as dictionaries. + EntityRecord: The record. - Raises: - ValueError: If the SQL query fails validation (e.g., non-SELECT, missing - WHERE/LIMIT, forbidden keywords, subqueries). + Examples: + Basic usage:: + + record = await entities_service.get_record_async("Customers", "rec-1") + print(record.id, record.name) """ - return await self._query_entities_for_records_async( - sql_query, routing_context=routing_context + return await self._data.get_record_async( + entity_key, record_id, expansion_level=expansion_level ) - def _query_entities_for_records( + @traced(name="entity_update_record", run_type="uipath") + def update_record( self, - sql_query: str, - *, - routing_context: Optional[QueryRoutingOverrideContext] = None, - ) -> List[Dict[str, Any]]: - self._validate_sql_query(sql_query) - spec = self._query_entity_records_spec(sql_query, routing_context) - response = self.request(spec.method, spec.endpoint, json=spec.json) - return response.json().get("results", []) + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Update a single record by id and return the updated row. - async def _query_entities_for_records_async( + Note: + Unlike :meth:`update_records` (batch), this single-record endpoint + fires Data Fabric trigger events. Use this method when triggers + attached to the entity must run. + + Args: + entity_key (str): The unique key/identifier of the entity. + record_id (str): The unique identifier of the record to update. + data (Any): Fields to update — a dict, a Pydantic model, or any + object exposing ``__dict__``. Fields explicitly set to + ``None`` are sent through; unset fields are omitted. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). + + Returns: + EntityRecord: The updated record. + + Examples: + Partial update from a dict:: + + record = entities_service.update_record( + "Customers", + "rec-1", + {"email": "alice.new@example.com"}, + ) + + Clear a field by passing an explicit ``None``:: + + # Note: unset fields are omitted; explicit None values are sent. + record = entities_service.update_record( + "Customers", + "rec-1", + {"middle_name": None}, + ) + """ + return self._data.update_record( + entity_key, record_id, data, expansion_level=expansion_level + ) + + @traced(name="entity_update_record", run_type="uipath") + async def update_record_async( self, - sql_query: str, - *, - routing_context: Optional[QueryRoutingOverrideContext] = None, - ) -> List[Dict[str, Any]]: - self._validate_sql_query(sql_query) - spec = self._query_entity_records_spec(sql_query, routing_context) - response = await self.request_async(spec.method, spec.endpoint, json=spec.json) - return response.json().get("results", []) + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Asynchronously update a single record by id. + + Note: + Unlike :meth:`update_records_async` (batch), this single-record + endpoint fires Data Fabric trigger events. + + Args: + entity_key (str): The unique key/identifier of the entity. + record_id (str): The unique identifier of the record to update. + data (Any): Fields to update — a dict, a Pydantic model, or any + object exposing ``__dict__``. + expansion_level (Optional[int]): Depth of foreign-key expansion. + + Returns: + EntityRecord: The updated record. + + Examples: + Partial update:: + + record = await entities_service.update_record_async( + "Customers", + "rec-1", + {"email": "alice.new@example.com"}, + ) + """ + return await self._data.update_record_async( + entity_key, record_id, data, expansion_level=expansion_level + ) + + @traced(name="entity_delete_record", run_type="uipath") + def delete_record(self, entity_key: str, record_id: str) -> None: + """Delete a single record by id. + + Note: + Unlike :meth:`delete_records` (batch), this single-record endpoint + fires Data Fabric trigger events. Use this method when triggers + attached to the entity must run on delete. + + Args: + entity_key (str): The unique key/identifier of the entity. + record_id (str): The unique identifier of the record to delete. + + Examples: + Delete by id:: + + entities_service.delete_record("Customers", "rec-1") + """ + self._data.delete_record(entity_key, record_id) + + @traced(name="entity_delete_record", run_type="uipath") + async def delete_record_async(self, entity_key: str, record_id: str) -> None: + """Asynchronously delete a single record by id. + + Note: + Unlike :meth:`delete_records_async` (batch), this single-record + endpoint fires Data Fabric trigger events. + + Args: + entity_key (str): The unique key/identifier of the entity. + record_id (str): The unique identifier of the record to delete. + + Examples: + Delete by id:: + + await entities_service.delete_record_async("Customers", "rec-1") + """ + await self._data.delete_record_async(entity_key, record_id) @traced(name="entity_record_insert_batch", run_type="uipath") def insert_records( @@ -499,20 +1106,29 @@ def insert_records( entity_key: str, records: List[Any], schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, ) -> EntityRecordsBatchResponse: """Insert multiple records into an entity in a single batch operation. Args: entity_key (str): The unique key/identifier of the entity. - records (List[Any]): List of records to insert. Each record should be an object - with attributes matching the entity's field names. + records (List[Any]): List of records to insert. Each record may be + a dict, a Pydantic model, an :class:`EntityRecord`, or any + object exposing ``__dict__``. schema (Optional[Type[Any]]): Optional schema class for validation. When provided, validates that each record in the response matches the schema structure. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). + fail_on_first (Optional[bool]): When ``True``, stop the batch on + the first per-record failure. When ``False`` (default), all + records are attempted and the response lists both + ``success_records`` and ``failure_records``. Returns: EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully inserted EntityRecord objects - - failure_records: List of EntityRecord objects that failed to insert + - success_records: List of successfully inserted :class:`EntityRecord` objects + - failure_records: List of :class:`FailureRecord` describing per-record errors Examples: Insert records without schema:: @@ -536,6 +1152,15 @@ def __init__(self, name, email, age): print(f"Inserted: {len(response.success_records)}") print(f"Failed: {len(response.failure_records)}") + Insert with FK expansion and fail-fast:: + + response = entities_service.insert_records( + "Orders", + [{"product_id": "p-1", "qty": 3}, {"product_id": "p-2", "qty": 1}], + expansion_level=1, # inline the related Product on each response record + fail_on_first=True, # abort the batch at the first error + ) + Insert with schema validation:: class CustomerSchema: @@ -561,10 +1186,13 @@ def __init__(self, name, email, age): for record in response.success_records: print(f"Inserted: {record.name} (ID: {record.id})") """ - spec = self._insert_batch_spec(entity_key, records) - response = self.request(spec.method, spec.endpoint, json=spec.json) - - return self.validate_entity_batch(response, schema) + return self._data.insert_records( + entity_key, + records, + schema=schema, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) @traced(name="entity_record_insert_batch", run_type="uipath") async def insert_records_async( @@ -572,20 +1200,29 @@ async def insert_records_async( entity_key: str, records: List[Any], schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, ) -> EntityRecordsBatchResponse: """Asynchronously insert multiple records into an entity in a single batch operation. Args: entity_key (str): The unique key/identifier of the entity. - records (List[Any]): List of records to insert. Each record should be an object - with attributes matching the entity's field names. + records (List[Any]): List of records to insert. Each record may be + a dict, a Pydantic model, an :class:`EntityRecord`, or any + object exposing ``__dict__``. schema (Optional[Type[Any]]): Optional schema class for validation. When provided, validates that each record in the response matches the schema structure. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). + fail_on_first (Optional[bool]): When ``True``, stop the batch on + the first per-record failure. When ``False`` (default), all + records are attempted and the response lists both + ``success_records`` and ``failure_records``. Returns: EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully inserted EntityRecord objects - - failure_records: List of EntityRecord objects that failed to insert + - success_records: List of successfully inserted :class:`EntityRecord` objects + - failure_records: List of :class:`FailureRecord` describing per-record errors Examples: Insert records without schema:: @@ -634,10 +1271,13 @@ def __init__(self, name, email, age): for record in response.success_records: print(f"Inserted: {record.name} (ID: {record.id})") """ - spec = self._insert_batch_spec(entity_key, records) - response = await self.request_async(spec.method, spec.endpoint, json=spec.json) - - return self.validate_entity_batch(response, schema) + return await self._data.insert_records_async( + entity_key, + records, + schema=schema, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) @traced(name="entity_record_update_batch", run_type="uipath") def update_records( @@ -645,20 +1285,30 @@ def update_records( entity_key: str, records: List[Any], schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, ) -> EntityRecordsBatchResponse: """Update multiple records in an entity in a single batch operation. Args: entity_key (str): The unique key/identifier of the entity. - records (List[Any]): List of records to update. Each record must have an 'Id' field - and should be a Pydantic model with `model_dump()` method or similar object. + records (List[Any]): List of records to update. Each record must + include its ``Id`` field. A record may be a dict, a Pydantic + model, an :class:`EntityRecord`, or any object exposing + ``__dict__``. schema (Optional[Type[Any]]): Optional schema class for validation. When provided, validates that each record in the request and response matches the schema structure. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). + fail_on_first (Optional[bool]): When ``True``, stop the batch on + the first per-record failure. When ``False`` (default), all + records are attempted and the response lists both + ``success_records`` and ``failure_records``. Returns: EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully updated EntityRecord objects - - failure_records: List of EntityRecord objects that failed to update + - success_records: List of successfully updated :class:`EntityRecord` objects + - failure_records: List of :class:`FailureRecord` describing per-record errors Examples: Update records:: @@ -707,15 +1357,13 @@ class CustomerSchema: for record in response.success_records: print(f"Updated: {record.name}") """ - valid_records = [ - EntityRecord.from_data(data=record.model_dump(by_alias=True), model=schema) - for record in records - ] - - spec = self._update_batch_spec(entity_key, valid_records) - response = self.request(spec.method, spec.endpoint, json=spec.json) - - return self.validate_entity_batch(response, schema) + return self._data.update_records( + entity_key, + records, + schema=schema, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) @traced(name="entity_record_update_batch", run_type="uipath") async def update_records_async( @@ -723,20 +1371,30 @@ async def update_records_async( entity_key: str, records: List[Any], schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, ) -> EntityRecordsBatchResponse: """Asynchronously update multiple records in an entity in a single batch operation. Args: entity_key (str): The unique key/identifier of the entity. - records (List[Any]): List of records to update. Each record must have an 'Id' field - and should be a Pydantic model with `model_dump()` method or similar object. + records (List[Any]): List of records to update. Each record must + include its ``Id`` field. A record may be a dict, a Pydantic + model, an :class:`EntityRecord`, or any object exposing + ``__dict__``. schema (Optional[Type[Any]]): Optional schema class for validation. When provided, validates that each record in the request and response matches the schema structure. + expansion_level (Optional[int]): Depth of foreign-key expansion in + the response (``0`` means no expansion). + fail_on_first (Optional[bool]): When ``True``, stop the batch on + the first per-record failure. When ``False`` (default), all + records are attempted and the response lists both + ``success_records`` and ``failure_records``. Returns: EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully updated EntityRecord objects - - failure_records: List of EntityRecord objects that failed to update + - success_records: List of successfully updated :class:`EntityRecord` objects + - failure_records: List of :class:`FailureRecord` describing per-record errors Examples: Update records:: @@ -785,32 +1443,35 @@ class CustomerSchema: for record in response.success_records: print(f"Updated: {record.name}") """ - valid_records = [ - EntityRecord.from_data(data=record.model_dump(by_alias=True), model=schema) - for record in records - ] - - spec = self._update_batch_spec(entity_key, valid_records) - response = await self.request_async(spec.method, spec.endpoint, json=spec.json) - - return self.validate_entity_batch(response, schema) + return await self._data.update_records_async( + entity_key, + records, + schema=schema, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) @traced(name="entity_record_delete_batch", run_type="uipath") def delete_records( self, entity_key: str, record_ids: List[str], + fail_on_first: Optional[bool] = None, ) -> EntityRecordsBatchResponse: """Delete multiple records from an entity in a single batch operation. Args: entity_key (str): The unique key/identifier of the entity. record_ids (List[str]): List of record IDs (GUIDs) to delete. + fail_on_first (Optional[bool]): When ``True``, stop the batch on + the first per-record failure. When ``False`` (default), all + records are attempted and the response lists both + ``success_records`` and ``failure_records``. Returns: EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully deleted EntityRecord objects - - failure_records: List of EntityRecord objects that failed to delete + - success_records: List of successfully deleted :class:`EntityRecord` objects + - failure_records: List of :class:`FailureRecord` describing per-record errors Examples: Delete specific records by ID:: @@ -847,31 +1508,31 @@ def delete_records( ) print(f"Deleted {len(response.success_records)} inactive records") """ - spec = self._delete_batch_spec(entity_key, record_ids) - response = self.request(spec.method, spec.endpoint, json=spec.json) - - delete_records_response = EntityRecordsBatchResponse.model_validate( - response.json() + return self._data.delete_records( + entity_key, record_ids, fail_on_first=fail_on_first ) - return delete_records_response - @traced(name="entity_record_delete_batch", run_type="uipath") async def delete_records_async( self, entity_key: str, record_ids: List[str], + fail_on_first: Optional[bool] = None, ) -> EntityRecordsBatchResponse: """Asynchronously delete multiple records from an entity in a single batch operation. Args: entity_key (str): The unique key/identifier of the entity. record_ids (List[str]): List of record IDs (GUIDs) to delete. + fail_on_first (Optional[bool]): When ``True``, stop the batch on + the first per-record failure. When ``False`` (default), all + records are attempted and the response lists both + ``success_records`` and ``failure_records``. Returns: EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully deleted EntityRecord objects - - failure_records: List of EntityRecord objects that failed to delete + - success_records: List of successfully deleted :class:`EntityRecord` objects + - failure_records: List of :class:`FailureRecord` describing per-record errors Examples: Delete specific records by ID:: @@ -908,212 +1569,661 @@ async def delete_records_async( ) print(f"Deleted {len(response.success_records)} inactive records") """ - spec = self._delete_batch_spec(entity_key, record_ids) - response = await self.request_async(spec.method, spec.endpoint, json=spec.json) - - delete_records_response = EntityRecordsBatchResponse.model_validate( - response.json() + return await self._data.delete_records_async( + entity_key, record_ids, fail_on_first=fail_on_first ) - return delete_records_response - - def validate_entity_batch( + @traced(name="entity_retrieve_records", run_type="uipath") + def retrieve_records( self, - batch_response: Response, - schema: Optional[Type[Any]] = None, - ) -> EntityRecordsBatchResponse: - # Validate the response format - insert_records_response = EntityRecordsBatchResponse.model_validate( - batch_response.json() - ) + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[EntityAggregate]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RetrieveEntityRecordsResponse: + """Retrieve records with structured filters, sorting, expansion, joins, and aggregates. - # Validate individual records - validated_successful_records = [ - EntityRecord.from_data( - data=successful_record.model_dump(by_alias=True), model=schema - ) - for successful_record in insert_records_response.success_records - ] + Routes to the V2 endpoint when ``binnings`` is provided (numeric/date + binning is gated by the ``enable-binning-on-query`` feature flag on + the backend). - validated_failed_records = [ - EntityRecord.from_data( - data=failed_record.model_dump(by_alias=True), model=schema - ) - for failed_record in insert_records_response.failure_records - ] + Args: + entity_key (str): The unique key/identifier of the entity. + filter_group (Optional[EntityQueryFilterGroup]): Nested filter + conditions combined with AND/OR. + sort_options (Optional[List[EntityQuerySortOption]]): Sort fields + and direction. + selected_fields (Optional[List[str]]): Column projection — field + names to include; omit to return all fields. + expansions (Optional[List[Any]]): Foreign-key relationships to + expand inline on each result record. + expansion_level (Optional[int]): Depth of expansion (sent as a + URL query param). + aggregates (Optional[List[EntityAggregate]]): Aggregate + expressions (``COUNT`` / ``SUM`` / ``AVG`` / ``MIN`` / + ``MAX``). Maximum 5 per query. + group_by (Optional[List[str]]): Fields to group aggregate results + by. Maximum 5; required when both ``aggregates`` and + ``selected_fields`` are supplied. + joins (Optional[List[EntityJoin]]): Cross-entity joins. Maximum + 3, all of the same type. + binnings (Optional[List[EntityBinning]]): Bucket numeric or date + group-by fields. Each entry's field must also appear in + ``group_by``. + start (Optional[int]): Records to skip (pagination offset). + limit (Optional[int]): Maximum number of records to return. - return EntityRecordsBatchResponse( - success_records=validated_successful_records, - failure_records=validated_failed_records, - ) + Returns: + RetrieveEntityRecordsResponse: A response with ``items``, + ``total_count``, ``has_next_page``, and ``next_cursor``. + ``items`` is a list of :class:`EntityRecord` for plain + queries, or :class:`AggregateRow` when ``aggregates``, + ``group_by``, or ``binnings`` are used. ``next_cursor`` is + populated only when the backend returns one; otherwise + paginate by passing the next ``start``. - def _retrieve_spec( - self, - entity_key: str, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint(f"datafabric_/api/Entity/{entity_key}"), - ) + Examples: + Filter + sort + projection:: + + from uipath.platform.entities import ( + EntityQueryFilter, + EntityQueryFilterGroup, + EntityQuerySortOption, + LogicalOperator, + QueryFilterOperator, + ) + + result = entities_service.retrieve_records( + "Customers", + filter_group=EntityQueryFilterGroup( + logical_operator=LogicalOperator.And, + query_filters=[ + EntityQueryFilter( + field_name="status", + operator=QueryFilterOperator.Equals, + value="active", + ) + ], + ), + sort_options=[ + EntityQuerySortOption(field_name="created_at", is_descending=True) + ], + selected_fields=["Id", "name", "email"], + start=0, + limit=50, + ) + print(f"Found {result.total_count} customers") - def _list_entities_spec(self) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint("datafabric_/api/Entity"), + Aggregates and group-by (counts per status):: + + from uipath.platform.entities import ( + EntityAggregate, + EntityAggregateFunction, + ) + + result = entities_service.retrieve_records( + "Customers", + selected_fields=["status"], + group_by=["status"], + aggregates=[ + EntityAggregate( + function=EntityAggregateFunction.Count, + field="Id", + alias="total", + ) + ], + ) + for row in result.items: + print(row.status, row.total) + """ + return self._data.retrieve_records( + entity_key, + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + expansion_level=expansion_level, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, ) - def _list_records_spec( + @traced(name="entity_retrieve_records", run_type="uipath") + async def retrieve_records_async( self, entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[EntityAggregate]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, start: Optional[int] = None, limit: Optional[int] = None, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/read" - ), - params=({"start": start, "limit": limit}), + ) -> RetrieveEntityRecordsResponse: + """Asynchronously retrieve records with structured filters, sorting, expansion, joins, and aggregates. + + Routes to the V2 endpoint when ``binnings`` is provided (numeric/date + binning is gated by the ``enable-binning-on-query`` feature flag on + the backend). + + Args: + entity_key (str): The unique key/identifier of the entity. + filter_group (Optional[EntityQueryFilterGroup]): Nested filter + conditions combined with AND/OR. + sort_options (Optional[List[EntityQuerySortOption]]): Sort fields + and direction. + selected_fields (Optional[List[str]]): Column projection — field + names to include; omit to return all fields. + expansions (Optional[List[Any]]): Foreign-key relationships to + expand inline on each result record. + expansion_level (Optional[int]): Depth of expansion. + aggregates (Optional[List[EntityAggregate]]): Aggregate + expressions. Maximum 5 per query. + group_by (Optional[List[str]]): Fields to group aggregate results + by. Maximum 5; required when both ``aggregates`` and + ``selected_fields`` are supplied. + joins (Optional[List[EntityJoin]]): Cross-entity joins. Maximum + 3, all of the same type. + binnings (Optional[List[EntityBinning]]): Bucket numeric or date + group-by fields. + start (Optional[int]): Records to skip (pagination offset). + limit (Optional[int]): Maximum number of records to return. + + Returns: + RetrieveEntityRecordsResponse: A response with ``items``, + ``total_count``, ``has_next_page``, and ``next_cursor``. + + Examples: + Filter + sort + pagination:: + + from uipath.platform.entities import ( + EntityQueryFilter, + EntityQueryFilterGroup, + QueryFilterOperator, + ) + + result = await entities_service.retrieve_records_async( + "Customers", + filter_group=EntityQueryFilterGroup( + query_filters=[ + EntityQueryFilter( + field_name="status", + operator=QueryFilterOperator.Equals, + value="active", + ) + ], + ), + start=0, + limit=25, + ) + print(f"{len(result.items)} of {result.total_count} customers") + """ + return await self._data.retrieve_records_async( + entity_key, + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + expansion_level=expansion_level, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, + ) + + @traced(name="entity_query_records", run_type="uipath") + def query_entity_records(self, sql_query: str) -> List[Dict[str, Any]]: + """Query entity records using a validated SQL query. + + PREVIEW: This method is in preview and may change in future releases. + + Args: + sql_query (str): A SQL SELECT query to execute against Data Service entities. + Only SELECT statements are allowed. Queries without WHERE must include + a LIMIT clause. Subqueries and multi-statement queries are not permitted. + + Notes: + A routing context is always derived from the configured ``folders_map`` + when present and included in the request body. + + Returns: + List[Dict[str, Any]]: A list of result records as dictionaries. + + Raises: + ValueError: If the SQL query fails validation (e.g., non-SELECT, missing + WHERE/LIMIT, forbidden keywords, subqueries). + """ + return self._data.query_entity_records(sql_query) + + @traced(name="entity_query_records", run_type="uipath") + async def query_entity_records_async(self, sql_query: str) -> List[Dict[str, Any]]: + """Asynchronously query entity records using a validated SQL query. + + PREVIEW: This method is in preview and may change in future releases. + + Args: + sql_query (str): A SQL SELECT query to execute against Data Service entities. + Only SELECT statements are allowed. Queries without WHERE must include + a LIMIT clause. Subqueries and multi-statement queries are not permitted. + + Notes: + A routing context is always derived from the configured ``folders_map`` + when present and included in the request body. + + Returns: + List[Dict[str, Any]]: A list of result records as dictionaries. + + Raises: + ValueError: If the SQL query fails validation (e.g., non-SELECT, missing + WHERE/LIMIT, forbidden keywords, subqueries). + """ + return await self._data.query_entity_records_async(sql_query) + + @traced(name="entity_upload_attachment", run_type="uipath") + def upload_attachment( + self, + entity_id: str, + record_id: str, + field_name: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Upload a file attachment to a File-type field on a record. + + Provide exactly one of ``file`` (raw bytes) or ``file_path`` (path on + disk). + + Args: + entity_id (str): The unique identifier of the entity. + record_id (str): The unique identifier of the record whose + attachment field is being set. + field_name (str): Name of the File-type field on the entity. + file (Optional[FileContent]): Raw bytes (``bytes`` / + ``bytearray`` / ``memoryview``) of the file to upload. + Mutually exclusive with ``file_path``. + file_path (Optional[str]): Path to a local file to upload. + Mutually exclusive with ``file``. + expansion_level (Optional[int]): Optional FK expansion depth in + the response (``0`` means no expansion). + + Returns: + Dict[str, Any]: The decoded JSON response (typically the updated + record), or an empty dict when the response has no body. + + Examples: + Upload from raw bytes:: + + with open("contract.pdf", "rb") as f: + data = f.read() + entities_service.upload_attachment( + "Customers", "rec-1", "Contract", file=data + ) + + Upload from a path on disk:: + + entities_service.upload_attachment( + "Customers", "rec-1", "Contract", file_path="./contract.pdf" + ) + """ + return self._data.upload_attachment( + entity_id, + record_id, + field_name, + file=file, + file_path=file_path, + expansion_level=expansion_level, ) - def _query_entity_records_spec( + @traced(name="entity_upload_attachment", run_type="uipath") + async def upload_attachment_async( self, - sql_query: str, - routing_context: Optional[QueryRoutingOverrideContext] = None, - ) -> RequestSpec: - body: Dict[str, Any] = {"query": sql_query} - if routing_context: - body["routingContext"] = routing_context.model_dump( - by_alias=True, exclude_none=True - ) - return RequestSpec( - method="POST", - endpoint=Endpoint("datafabric_/api/v1/query/execute"), - json=body, + entity_id: str, + record_id: str, + field_name: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Asynchronously upload a file attachment to a File-type field on a record. + + Provide exactly one of ``file`` (raw bytes) or ``file_path`` (path on + disk). + + Args: + entity_id (str): The unique identifier of the entity. + record_id (str): The unique identifier of the record whose + attachment field is being set. + field_name (str): Name of the File-type field on the entity. + file (Optional[FileContent]): Raw bytes of the file to upload. + Mutually exclusive with ``file_path``. + file_path (Optional[str]): Path to a local file to upload. + Mutually exclusive with ``file``. + expansion_level (Optional[int]): Optional FK expansion depth in + the response. + + Returns: + Dict[str, Any]: The decoded JSON response. + + Examples: + Upload from a path on disk:: + + await entities_service.upload_attachment_async( + "Customers", "rec-1", "Contract", file_path="./contract.pdf" + ) + """ + return await self._data.upload_attachment_async( + entity_id, + record_id, + field_name, + file=file, + file_path=file_path, + expansion_level=expansion_level, ) - def _insert_batch_spec(self, entity_key: str, records: List[Any]) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/insert-batch" - ), - json=[record.__dict__ for record in records], + @traced(name="entity_download_attachment", run_type="uipath") + def download_attachment( + self, entity_id: str, record_id: str, field_name: str + ) -> bytes: + """Download a file attached to a record and return its raw bytes. + + Args: + entity_id (str): The unique identifier of the entity. + record_id (str): The unique identifier of the record containing + the attachment. + field_name (str): Name of the File-type field on the entity. + + Returns: + bytes: The raw file content. + + Examples: + Save the downloaded bytes to disk:: + + content = entities_service.download_attachment( + "Customers", "rec-1", "Contract" + ) + with open("downloaded.pdf", "wb") as f: + f.write(content) + """ + return self._data.download_attachment(entity_id, record_id, field_name) + + @traced(name="entity_download_attachment", run_type="uipath") + async def download_attachment_async( + self, entity_id: str, record_id: str, field_name: str + ) -> bytes: + """Asynchronously download a file attached to a record. + + Args: + entity_id (str): The unique identifier of the entity. + record_id (str): The unique identifier of the record containing + the attachment. + field_name (str): Name of the File-type field on the entity. + + Returns: + bytes: The raw file content. + + Examples: + Save the downloaded bytes to disk:: + + content = await entities_service.download_attachment_async( + "Customers", "rec-1", "Contract" + ) + with open("downloaded.pdf", "wb") as f: + f.write(content) + """ + return await self._data.download_attachment_async( + entity_id, record_id, field_name ) - def _update_batch_spec( - self, entity_key: str, records: List[EntityRecord] - ) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/update-batch" - ), - json=[record.model_dump(by_alias=True) for record in records], + @traced(name="entity_delete_attachment", run_type="uipath") + def delete_attachment( + self, + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Remove the file attached to a File-type field on a record. + + Args: + entity_id (str): The unique identifier of the entity. + record_id (str): The unique identifier of the record whose + attachment is being removed. + field_name (str): Name of the File-type field on the entity. + expansion_level (Optional[int]): Optional FK expansion depth in + the response (``0`` means no expansion). + + Returns: + Dict[str, Any]: The decoded JSON response (typically the updated + record), or an empty dict when the response has no body. + + Examples: + Clear an attachment:: + + entities_service.delete_attachment( + "Customers", "rec-1", "Contract" + ) + """ + return self._data.delete_attachment( + entity_id, record_id, field_name, expansion_level=expansion_level ) - def _delete_batch_spec(self, entity_key: str, record_ids: List[str]) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/delete-batch" - ), - json=record_ids, + @traced(name="entity_delete_attachment", run_type="uipath") + async def delete_attachment_async( + self, + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Asynchronously remove the file attached to a File-type field. + + Args: + entity_id (str): The unique identifier of the entity. + record_id (str): The unique identifier of the record whose + attachment is being removed. + field_name (str): Name of the File-type field on the entity. + expansion_level (Optional[int]): Optional FK expansion depth. + + Returns: + Dict[str, Any]: The decoded JSON response. + + Examples: + Clear an attachment:: + + await entities_service.delete_attachment_async( + "Customers", "rec-1", "Contract" + ) + """ + return await self._data.delete_attachment_async( + entity_id, record_id, field_name, expansion_level=expansion_level ) - def _validate_sql_query(self, sql_query: str) -> None: - query = sql_query.strip().rstrip(";").strip() - if not query: - raise ValueError("SQL query cannot be empty.") + @traced(name="entity_import_records", run_type="uipath") + def import_records( + self, + entity_id: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + ) -> EntityImportRecordsResponse: + """Bulk-import records into an entity from a CSV file. - statements = sqlparse.parse(query) - if len(statements) != 1 or not statements[0].tokens: - raise ValueError("Only a single SELECT statement is allowed.") + Provide exactly one of ``file`` (raw bytes) or ``file_path`` (path on + disk). - stmt = statements[0] - stmt_type = stmt.get_type() + Args: + entity_id (str): The unique identifier of the entity. + file (Optional[FileContent]): Raw bytes of a CSV file. Mutually + exclusive with ``file_path``. + file_path (Optional[str]): Path to a local CSV file. Mutually + exclusive with ``file``. - if stmt_type != "SELECT": - raise ValueError("Only SELECT statements are allowed.") + Returns: + EntityImportRecordsResponse: Reports the total rows in the file, + the number successfully inserted, and an optional + ``error_file_link`` pointing to a CSV listing rows that + failed validation. - keywords = set() - for token in stmt.flatten(): - if token.ttype in Keyword: - keywords.add(token.normalized) + Examples: + Import from a path on disk:: - for kw in _FORBIDDEN_DML: - if kw in keywords: - raise ValueError(f"SQL keyword '{kw}' is not allowed.") + result = entities_service.import_records( + "Customers", file_path="./customers.csv" + ) + print( + f"Inserted {result.inserted_records} of " + f"{result.total_records} rows" + ) + if result.error_file_link: + print(f"Errors: {result.error_file_link}") + """ + return self._data.import_records(entity_id, file=file, file_path=file_path) + + @traced(name="entity_import_records", run_type="uipath") + async def import_records_async( + self, + entity_id: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + ) -> EntityImportRecordsResponse: + """Asynchronously bulk-import records into an entity from a CSV file. + + Provide exactly one of ``file`` (raw bytes) or ``file_path`` (path on + disk). - for kw in _FORBIDDEN_DDL: - if kw in keywords: - raise ValueError(f"SQL keyword '{kw}' is not allowed.") + Args: + entity_id (str): The unique identifier of the entity. + file (Optional[FileContent]): Raw bytes of a CSV file. + file_path (Optional[str]): Path to a local CSV file. + + Returns: + EntityImportRecordsResponse: Reports the total, inserted, and + ``error_file_link`` for failed rows. - for kw in _DISALLOWED_KEYWORDS: - if kw in keywords: - raise ValueError( - f"SQL construct '{kw}' is not allowed in entity queries." + Examples: + Import from a path on disk:: + + result = await entities_service.import_records_async( + "Customers", file_path="./customers.csv" ) + print( + f"Inserted {result.inserted_records} of " + f"{result.total_records} rows" + ) + """ + return await self._data.import_records_async( + entity_id, file=file, file_path=file_path + ) + + # ------------------------------------------------------------------ + # Public helper retained for backward compatibility — tests call this + # ------------------------------------------------------------------ + + def validate_entity_batch( + self, + batch_response: Response, + schema: Optional[Type[Any]] = None, + ) -> EntityRecordsBatchResponse: + """Parse a batch response, optionally validating success records against ``schema``. + + Failure records are returned as :class:`FailureRecord` instances and + are not validated against the user schema. + """ + return self._data.validate_entity_batch(batch_response, schema=schema) + + # ------------------------------------------------------------------ + # Cross-cutting — entity-set resolution for agent overrides + # ------------------------------------------------------------------ - if self._has_subquery(stmt): - raise ValueError("Subqueries are not allowed.") - - has_where = any(isinstance(t, Where) for t in stmt.tokens) - has_limit = "LIMIT" in keywords - if not has_where and not has_limit: - raise ValueError("Queries without WHERE must include a LIMIT clause.") - - projection = self._projection_tokens(stmt) - has_wildcard = any(t.ttype is Wildcard for t in projection) - if has_wildcard and not has_where: - raise ValueError("SELECT * without filtering is not allowed.") - if not has_where and self._projection_column_count(projection) > 4: - raise ValueError( - "Selecting more than 4 columns without filtering is not allowed." + @traced(name="resolve_entity_set", run_type="uipath") + def resolve_entity_set( + self, + items: List[DataFabricEntityItem], + ) -> EntitySetResolution: + """Resolve an agent entity set, applying resource overwrites.""" + plan = create_resolution_plan( + items, + _resource_overwrites.get() or {}, + lambda folder_path: ( + self._folders_service.retrieve_key(folder_path=folder_path) + if self._folders_service is not None + else None + ), + ) + entities = fetch_resolved_entities( + plan, + self.retrieve, + self.retrieve_by_name, + logger, + ) + resolution_service: EntitiesService = build_resolution_service( # type: ignore[assignment] + config=self._config, + execution_context=self._execution_context, + folders_service=self._folders_service, + plan=plan, + service_factory=EntitiesService, + ) + return EntitySetResolution( + entities=entities, + entities_service=resolution_service, + ) + + @traced(name="resolve_entity_set", run_type="uipath") + async def resolve_entity_set_async( + self, + items: List[DataFabricEntityItem], + ) -> EntitySetResolution: + """Resolve an agent entity set, applying resource overwrites.""" + + async def _resolve_folder_path(folder_path: str) -> Optional[str]: + if self._folders_service is None: + return None + return await self._folders_service.retrieve_key_async( + folder_path=folder_path ) - @staticmethod - def _has_subquery(stmt: sqlparse.sql.Statement) -> bool: - """Recursively walk the AST looking for SELECT inside parentheses.""" - - def _walk(token: sqlparse.sql.Token) -> bool: - if isinstance(token, Parenthesis): - for child in token.flatten(): - if child.ttype is DML and child.normalized == "SELECT": - return True - if hasattr(token, "tokens"): - for child in token.tokens: - if _walk(child): - return True - return False - - for token in stmt.tokens: - if _walk(token): - return True - return False - - @staticmethod - def _projection_tokens( - stmt: sqlparse.sql.Statement, - ) -> list[sqlparse.sql.Token]: - """Extract tokens between the first SELECT and FROM.""" - tokens: list[sqlparse.sql.Token] = [] - collecting = False - for token in stmt.flatten(): - if token.ttype is DML and token.normalized == "SELECT": - collecting = True - continue - if token.ttype is Keyword and token.normalized == "FROM": - break - if collecting: - tokens.append(token) - return tokens - - @staticmethod - def _projection_column_count( - projection: list[sqlparse.sql.Token], - ) -> int: - text = "".join(t.value for t in projection).strip() - if not text: - return 0 - return len([part for part in text.split(",") if part.strip()]) + plan = await create_resolution_plan_async( + items, + _resource_overwrites.get() or {}, + _resolve_folder_path, + ) + entities = await fetch_resolved_entities_async( + plan, + self.retrieve_async, + self.retrieve_by_name_async, + logger, + ) + resolution_service: EntitiesService = build_resolution_service( # type: ignore[assignment] + config=self._config, + execution_context=self._execution_context, + folders_service=self._folders_service, + plan=plan, + service_factory=EntitiesService, + ) + return EntitySetResolution( + entities=entities, + entities_service=resolution_service, + ) + + +# Resolve the forward reference to EntitiesService in EntitySetResolution. +# The model uses TYPE_CHECKING to avoid circular imports in entities.py, +# so we must rebuild it here where EntitiesService is fully defined. +EntitySetResolution.model_rebuild() diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py new file mode 100644 index 000000000..60cfb99b4 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py @@ -0,0 +1,1403 @@ +"""Data-side operations for the Data Fabric entities surface. + +Handles record CRUD (single and batch), structured queries, attachments, +choice-set value lookup, bulk import, and federated SQL queries. Schema +definitions are managed by :class:`EntitySchemaService` and exposed alongside +data operations through :class:`EntitiesService`. +""" + +import json as json_module +import logging +from contextlib import nullcontext +from pathlib import Path +from typing import Any, Dict, List, Optional, Type + +import sqlparse +from httpx import HTTPStatusError, Response +from pydantic import BaseModel +from sqlparse.sql import Function, Identifier, IdentifierList, Parenthesis, Where +from sqlparse.tokens import DML, Keyword, Whitespace, Wildcard + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec +from ..errors._enriched_exception import EnrichedException +from ..orchestrator._folder_service import FolderService +from ._entity_resolution import RoutingStrategy, create_routing_strategy +from .entities import ( + AggregateRow, + ChoiceSetValue, + EntityAggregate, + EntityBinning, + EntityImportRecordsResponse, + EntityJoin, + EntityQueryFilterGroup, + EntityQuerySortOption, + EntityRecord, + EntityRecordsBatchResponse, + EntityRecordsListResponse, + QueryRoutingOverrideContext, + RetrieveEntityRecordsResponse, +) + +logger = logging.getLogger(__name__) + +FileContent = bytes | bytearray | memoryview +"""Acceptable raw bytes types for attachment and CSV uploads.""" + +_FORBIDDEN_DML = {"INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE"} +_FORBIDDEN_DDL = {"DROP", "ALTER", "CREATE", "TRUNCATE"} +_DISALLOWED_KEYWORDS = [ + "WITH", + "UNION", + "INTERSECT", + "EXCEPT", + "OVER", + "ROLLUP", + "CUBE", + "GROUPING", + "PARTITION", +] +_AGGREGATE_FUNCTIONS = ("COUNT", "SUM", "AVG", "MIN", "MAX") + + +class EntityDataService(BaseService): + """HTTP service for entity-record and attachment operations. + + Backend target: ``datafabric_/api/EntityService/...`` plus + ``datafabric_/api/Attachment/...`` for file attachments, and + ``datafabric_/api/v1/query/execute`` for federated SQL queries. + + !!! warning "Preview Feature" + This service is currently experimental. Behavior and parameters are + subject to change in future versions. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Optional[FolderService] = None, + routing_strategy: Optional[RoutingStrategy] = None, + folders_map: Optional[Dict[str, str]] = None, + entity_name_overrides: Optional[Dict[str, str]] = None, + routing_context: Optional[QueryRoutingOverrideContext] = None, + ) -> None: + """Initialise the data service. + + Either pass a pre-built ``routing_strategy`` (the facade does this so + both services share one) or supply the inputs and let this service + construct its own. + """ + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + self._routing_strategy: RoutingStrategy = ( + routing_strategy + if routing_strategy is not None + else create_routing_strategy( + folders_map=folders_map, + effective_entity_names=entity_name_overrides, + routing_context=routing_context, + folders_service=folders_service, + ) + ) + + # ------------------------------------------------------------------ + # Choice-set value lookup + # ------------------------------------------------------------------ + + def get_choiceset_values( + self, + choiceset_id: str, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> List[ChoiceSetValue]: + """Internal implementation; see :meth:`EntitiesService.get_choiceset_values`.""" + spec = self._get_choiceset_values_spec(choiceset_id, start=start, limit=limit) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return self._parse_choiceset_values(response) + + async def get_choiceset_values_async( + self, + choiceset_id: str, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> List[ChoiceSetValue]: + """Async variant of :meth:`get_choiceset_values`.""" + spec = self._get_choiceset_values_spec(choiceset_id, start=start, limit=limit) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return self._parse_choiceset_values(response) + + # ------------------------------------------------------------------ + # List records (multi-record read with OData filters) + # ------------------------------------------------------------------ + + def list_records( + self, + entity_key: str, + schema: Optional[Type[Any]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + expansion_level: Optional[int] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + select: Optional[List[str]] = None, + expand: Optional[List[str]] = None, + ) -> EntityRecordsListResponse: + """Internal implementation; see :meth:`EntitiesService.list_records`.""" + spec = self._list_records_spec( + entity_key, + start=start, + limit=limit, + expansion_level=expansion_level, + filter=filter, + orderby=orderby, + select=select, + expand=expand, + ) + response = self.request(spec.method, spec.endpoint, params=spec.params) + return self._build_records_list_response(response, schema, start, limit) + + async def list_records_async( + self, + entity_key: str, + schema: Optional[Type[Any]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + expansion_level: Optional[int] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + select: Optional[List[str]] = None, + expand: Optional[List[str]] = None, + ) -> EntityRecordsListResponse: + """Async variant of :meth:`list_records`.""" + spec = self._list_records_spec( + entity_key, + start=start, + limit=limit, + expansion_level=expansion_level, + filter=filter, + orderby=orderby, + select=select, + expand=expand, + ) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params + ) + return self._build_records_list_response(response, schema, start, limit) + + # ------------------------------------------------------------------ + # Single-record operations (fire trigger events; batch versions don't) + # ------------------------------------------------------------------ + + def insert_record( + self, + entity_key: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Internal implementation; see :meth:`EntitiesService.insert_record`.""" + spec = self._insert_record_spec(entity_key, data, expansion_level) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return EntityRecord.model_validate(response.json()) + + async def insert_record_async( + self, + entity_key: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Async variant of :meth:`insert_record`.""" + spec = self._insert_record_spec(entity_key, data, expansion_level) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return EntityRecord.model_validate(response.json()) + + def get_record( + self, + entity_key: str, + record_id: str, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Fetch a single record by its id.""" + spec = self._get_record_spec(entity_key, record_id, expansion_level) + response = self.request(spec.method, spec.endpoint, params=spec.params) + return EntityRecord.model_validate(response.json()) + + async def get_record_async( + self, + entity_key: str, + record_id: str, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Async variant of :meth:`get_record`.""" + spec = self._get_record_spec(entity_key, record_id, expansion_level) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params + ) + return EntityRecord.model_validate(response.json()) + + def update_record( + self, + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Internal implementation; see :meth:`EntitiesService.update_record`.""" + spec = self._update_record_spec(entity_key, record_id, data, expansion_level) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return EntityRecord.model_validate(response.json()) + + async def update_record_async( + self, + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Async variant of :meth:`update_record`.""" + spec = self._update_record_spec(entity_key, record_id, data, expansion_level) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return EntityRecord.model_validate(response.json()) + + def delete_record(self, entity_key: str, record_id: str) -> None: + """Delete a single record by id.""" + spec = self._delete_record_spec(entity_key, record_id) + self.request(spec.method, spec.endpoint) + + async def delete_record_async(self, entity_key: str, record_id: str) -> None: + """Async variant of :meth:`delete_record`.""" + spec = self._delete_record_spec(entity_key, record_id) + await self.request_async(spec.method, spec.endpoint) + + # ------------------------------------------------------------------ + # Batch record operations + # ------------------------------------------------------------------ + + def insert_records( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Internal implementation; see :meth:`EntitiesService.insert_records`.""" + spec = self._insert_batch_spec( + entity_key, + records, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) + response = self._request_or_extract_batch( + sync_call=lambda: self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + ) + if isinstance(response, EntityRecordsBatchResponse): + return response + return self.validate_entity_batch(response, schema) + + async def insert_records_async( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Async variant of :meth:`insert_records`.""" + spec = self._insert_batch_spec( + entity_key, + records, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) + + async def _send_batch() -> Response: + return await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + + result = await self._request_or_extract_batch_async(_send_batch) + if isinstance(result, EntityRecordsBatchResponse): + return result + return self.validate_entity_batch(result, schema) + + def update_records( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Internal implementation; see :meth:`EntitiesService.update_records`.""" + normalized = [self._record_to_dict(record) for record in records] + if schema is not None: + for record in normalized: + EntityRecord.from_data(data=record, model=schema) + + spec = self._update_batch_spec( + entity_key, + normalized, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) + response = self._request_or_extract_batch( + sync_call=lambda: self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + ) + if isinstance(response, EntityRecordsBatchResponse): + return response + return self.validate_entity_batch(response, schema) + + async def update_records_async( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Async variant of :meth:`update_records`.""" + normalized = [self._record_to_dict(record) for record in records] + if schema is not None: + for record in normalized: + EntityRecord.from_data(data=record, model=schema) + + spec = self._update_batch_spec( + entity_key, + normalized, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) + + async def _send_batch() -> Response: + return await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + + result = await self._request_or_extract_batch_async(_send_batch) + if isinstance(result, EntityRecordsBatchResponse): + return result + return self.validate_entity_batch(result, schema) + + def delete_records( + self, + entity_key: str, + record_ids: List[str], + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Delete multiple records by id in a single batch.""" + spec = self._delete_batch_spec( + entity_key, record_ids, fail_on_first=fail_on_first + ) + result = self._request_or_extract_batch( + sync_call=lambda: self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + ) + if isinstance(result, EntityRecordsBatchResponse): + return result + return EntityRecordsBatchResponse.model_validate(result.json()) + + async def delete_records_async( + self, + entity_key: str, + record_ids: List[str], + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Async variant of :meth:`delete_records`.""" + spec = self._delete_batch_spec( + entity_key, record_ids, fail_on_first=fail_on_first + ) + + async def _send_batch() -> Response: + return await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + + result = await self._request_or_extract_batch_async(_send_batch) + if isinstance(result, EntityRecordsBatchResponse): + return result + return EntityRecordsBatchResponse.model_validate(result.json()) + + # ------------------------------------------------------------------ + # Structured query (POST /entity/{id}/query) + # ------------------------------------------------------------------ + + def retrieve_records( + self, + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[EntityAggregate]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RetrieveEntityRecordsResponse: + """Internal implementation; see :meth:`EntitiesService.retrieve_records`.""" + spec = self._retrieve_records_spec( + entity_key, + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + expansion_level=expansion_level, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, + ) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return self._parse_query_response(response, start=start, limit=limit) + + async def retrieve_records_async( + self, + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[EntityAggregate]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RetrieveEntityRecordsResponse: + """Async variant of :meth:`retrieve_records`.""" + spec = self._retrieve_records_spec( + entity_key, + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + expansion_level=expansion_level, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, + ) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return self._parse_query_response(response, start=start, limit=limit) + + # ------------------------------------------------------------------ + # Federated SQL query + # ------------------------------------------------------------------ + + def query_entity_records( + self, + sql_query: str, + ) -> List[Dict[str, Any]]: + """Internal implementation; see :meth:`EntitiesService.query_entity_records`.""" + return self._query_entities_for_records(sql_query) + + async def query_entity_records_async( + self, + sql_query: str, + ) -> List[Dict[str, Any]]: + """Async variant of :meth:`query_entity_records`.""" + return await self._query_entities_for_records_async(sql_query) + + # ------------------------------------------------------------------ + # Attachments + # ------------------------------------------------------------------ + + def upload_attachment( + self, + entity_id: str, + record_id: str, + field_name: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Internal implementation; see :meth:`EntitiesService.upload_attachment`.""" + spec = self._attachment_endpoint( + entity_id, record_id, field_name, expansion_level + ) + with self._open_file(file, file_path) as handle: + response = self.request( + "POST", + spec.endpoint, + params=spec.params, + files={"file": handle}, + ) + return response.json() if response.content else {} + + async def upload_attachment_async( + self, + entity_id: str, + record_id: str, + field_name: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Async variant of :meth:`upload_attachment`.""" + spec = self._attachment_endpoint( + entity_id, record_id, field_name, expansion_level + ) + with self._open_file(file, file_path) as handle: + response = await self.request_async( + "POST", + spec.endpoint, + params=spec.params, + files={"file": handle}, + ) + return response.json() if response.content else {} + + def download_attachment( + self, entity_id: str, record_id: str, field_name: str + ) -> bytes: + """Internal implementation; see :meth:`EntitiesService.download_attachment`.""" + spec = self._attachment_endpoint(entity_id, record_id, field_name) + response = self.request("GET", spec.endpoint) + return response.content + + async def download_attachment_async( + self, entity_id: str, record_id: str, field_name: str + ) -> bytes: + """Async variant of :meth:`download_attachment`.""" + spec = self._attachment_endpoint(entity_id, record_id, field_name) + response = await self.request_async("GET", spec.endpoint) + return response.content + + def delete_attachment( + self, + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Internal implementation; see :meth:`EntitiesService.delete_attachment`.""" + spec = self._attachment_endpoint( + entity_id, record_id, field_name, expansion_level + ) + response = self.request("DELETE", spec.endpoint, params=spec.params) + return response.json() if response.content else {} + + async def delete_attachment_async( + self, + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Async variant of :meth:`delete_attachment`.""" + spec = self._attachment_endpoint( + entity_id, record_id, field_name, expansion_level + ) + response = await self.request_async("DELETE", spec.endpoint, params=spec.params) + return response.json() if response.content else {} + + # ------------------------------------------------------------------ + # Bulk import + # ------------------------------------------------------------------ + + def import_records( + self, + entity_id: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + ) -> EntityImportRecordsResponse: + """Internal implementation; see :meth:`EntitiesService.import_records`.""" + spec = self._import_records_spec(entity_id) + with self._open_file(file, file_path) as handle: + response = self.request(spec.method, spec.endpoint, files={"file": handle}) + return EntityImportRecordsResponse.model_validate(response.json() or {}) + + async def import_records_async( + self, + entity_id: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + ) -> EntityImportRecordsResponse: + """Async variant of :meth:`import_records`.""" + spec = self._import_records_spec(entity_id) + with self._open_file(file, file_path) as handle: + response = await self.request_async( + spec.method, spec.endpoint, files={"file": handle} + ) + return EntityImportRecordsResponse.model_validate(response.json() or {}) + + # ------------------------------------------------------------------ + # Public helper for batch response validation + # ------------------------------------------------------------------ + + def validate_entity_batch( + self, + batch_response: Response, + schema: Optional[Type[Any]] = None, + ) -> EntityRecordsBatchResponse: + """Internal implementation; see :meth:`EntitiesService.validate_entity_batch`.""" + parsed = EntityRecordsBatchResponse.model_validate(batch_response.json()) + + validated_successful_records = [] + for successful_record in parsed.success_records: + data = successful_record.model_dump(by_alias=True) + if data.get("Id") is not None: + validated_successful_records.append( + EntityRecord.from_data(data=data, model=schema) + ) + + return EntityRecordsBatchResponse( + success_records=validated_successful_records, + failure_records=parsed.failure_records, + ) + + # ------------------------------------------------------------------ + # Internal helpers — request specs + # ------------------------------------------------------------------ + + def _query_entities_for_records(self, sql_query: str) -> List[Dict[str, Any]]: + """Synchronously run a validated SQL query through the federated query engine.""" + self._validate_sql_query(sql_query) + routing_context = self._routing_strategy.resolve() + spec = self._query_entity_records_spec(sql_query, routing_context) + response = self.request(spec.method, spec.endpoint, json=spec.json) + return response.json().get("results", []) + + async def _query_entities_for_records_async( + self, sql_query: str + ) -> List[Dict[str, Any]]: + """Asynchronously run a validated SQL query through the federated query engine.""" + self._validate_sql_query(sql_query) + routing_context = await self._routing_strategy.resolve_async() + spec = self._query_entity_records_spec(sql_query, routing_context) + response = await self.request_async(spec.method, spec.endpoint, json=spec.json) + return response.json().get("results", []) + + @staticmethod + def _list_records_spec( + entity_key: str, + start: Optional[int] = None, + limit: Optional[int] = None, + expansion_level: Optional[int] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + select: Optional[List[str]] = None, + expand: Optional[List[str]] = None, + ) -> RequestSpec: + """Build the GET spec for the multi-record read endpoint.""" + params: Dict[str, Any] = {} + if start is not None: + params["start"] = start + if limit is not None: + params["limit"] = limit + if expansion_level is not None: + params["expansionLevel"] = expansion_level + if filter is not None: + params["$filter"] = filter + if orderby is not None: + params["$orderby"] = orderby + if select: + params["$select"] = ",".join(select) + if expand: + params["$expand"] = ",".join(expand) + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/read" + ), + params=params, + ) + + @staticmethod + def _insert_record_spec( + entity_key: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> RequestSpec: + """Build the POST spec for inserting a single record.""" + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/insert" + ), + params=params, + json=EntityDataService._record_to_dict(data), + ) + + @staticmethod + def _get_record_spec( + entity_key: str, + record_id: str, + expansion_level: Optional[int] = None, + ) -> RequestSpec: + """Build the GET spec for fetching a single record by id.""" + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/read/{record_id}" + ), + params=params, + ) + + @staticmethod + def _update_record_spec( + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> RequestSpec: + """Build the POST spec for updating a single record by id.""" + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/update/{record_id}" + ), + params=params, + json=EntityDataService._record_to_dict(data), + ) + + @staticmethod + def _delete_record_spec(entity_key: str, record_id: str) -> RequestSpec: + """Build the DELETE spec for removing a single record by id.""" + return RequestSpec( + method="DELETE", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/delete/{record_id}" + ), + ) + + @staticmethod + def _insert_batch_spec( + entity_key: str, + records: List[Any], + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> RequestSpec: + """Build the POST spec for the batch-insert endpoint.""" + params = EntityDataService._batch_params( + expansion_level=expansion_level, fail_on_first=fail_on_first + ) + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/insert-batch" + ), + params=params, + json=[EntityDataService._record_to_dict(record) for record in records], + ) + + @staticmethod + def _update_batch_spec( + entity_key: str, + records: List[Dict[str, Any]], + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> RequestSpec: + """Build the POST spec for the batch-update endpoint.""" + params = EntityDataService._batch_params( + expansion_level=expansion_level, fail_on_first=fail_on_first + ) + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/update-batch" + ), + params=params, + json=records, + ) + + @staticmethod + def _delete_batch_spec( + entity_key: str, + record_ids: List[str], + fail_on_first: Optional[bool] = None, + ) -> RequestSpec: + """Build the POST spec for the batch-delete endpoint.""" + params = EntityDataService._batch_params(fail_on_first=fail_on_first) + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/delete-batch" + ), + params=params, + json=record_ids, + ) + + @staticmethod + def _batch_params( + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> Dict[str, Any]: + """Build the optional URL params common to all batch endpoints.""" + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + if fail_on_first is not None: + params["failOnFirst"] = "true" if fail_on_first else "false" + return params + + @staticmethod + def _retrieve_records_spec( + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[Any]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RequestSpec: + """Build the request spec for the structured-query endpoint. + + Filters, sorting, projection, expansions, aggregates, group-by, joins, + binnings, ``start``, and ``limit`` are placed in the JSON body; + ``expansionLevel`` is a URL query parameter. The V2 endpoint is used + only when ``binnings`` are supplied. + """ + body: Dict[str, Any] = {} + if filter_group is not None: + body["filterGroup"] = filter_group.model_dump( + by_alias=True, exclude_none=True + ) + if sort_options: + body["sortOptions"] = [ + opt.model_dump(by_alias=True, exclude_none=True) for opt in sort_options + ] + if selected_fields: + body["selectedFields"] = list(selected_fields) + if expansions: + body["expansions"] = [ + e.model_dump(by_alias=True, exclude_none=True) + if isinstance(e, BaseModel) + else e + for e in expansions + ] + if aggregates: + body["aggregates"] = [ + a.model_dump(by_alias=True, exclude_none=True) + if isinstance(a, BaseModel) + else a + for a in aggregates + ] + if group_by: + body["groupBy"] = list(group_by) + if joins: + body["joins"] = [ + j.model_dump(by_alias=True, exclude_none=True) for j in joins + ] + if binnings: + body["binnings"] = [ + b.model_dump(by_alias=True, exclude_none=True) for b in binnings + ] + if start is not None: + body["start"] = start + if limit is not None: + body["limit"] = limit + + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + + if binnings: + endpoint = Endpoint( + f"datafabric_/api/v2/EntityService/entity/{entity_key}/query" + ) + else: + endpoint = Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/query" + ) + + return RequestSpec( + method="POST", + endpoint=endpoint, + params=params, + json=body, + ) + + @staticmethod + def _query_entity_records_spec( + sql_query: str, + routing_context: Optional[QueryRoutingOverrideContext] = None, + ) -> RequestSpec: + """Build the POST spec for the federated SQL query endpoint.""" + body: Dict[str, Any] = {"query": sql_query} + if routing_context: + body["routingContext"] = routing_context.model_dump( + by_alias=True, exclude_none=True + ) + return RequestSpec( + method="POST", + endpoint=Endpoint("datafabric_/api/v1/query/execute"), + json=body, + ) + + @staticmethod + def _get_choiceset_values_spec( + choiceset_id: str, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RequestSpec: + """Build the POST spec for the choice-set values endpoint.""" + params: Dict[str, Any] = {} + if start is not None: + params["start"] = start + if limit is not None: + params["limit"] = limit + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{choiceset_id}/query_expansion" + ), + params=params, + json={}, + ) + + @staticmethod + def _attachment_endpoint( + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> RequestSpec: + """Return the attachment endpoint plus any ``expansionLevel`` query param. + + The HTTP verb is supplied by the caller; only the URL and query + parameters depend on these arguments. + """ + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/Attachment/entity/{entity_id}/{record_id}/{field_name}" + ), + params=params, + ) + + @staticmethod + def _import_records_spec(entity_id: str) -> RequestSpec: + """Build the POST spec for the bulk-upload (CSV import) endpoint.""" + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_id}/bulk-upload" + ), + ) + + @staticmethod + def _open_file(file: Optional[FileContent], file_path: Optional[str]) -> Any: + """Yield a file-like object from raw bytes or a path on disk. + + Exactly one of ``file`` and ``file_path`` must be supplied. + """ + if (file is None) == (file_path is None): + raise ValueError( + "Provide exactly one of `file` (bytes) or `file_path` (str path on disk)." + ) + if file_path is not None: + return open(Path(file_path), "rb") + return nullcontext(file) + + # ------------------------------------------------------------------ + # Internal helpers — response parsing and record normalisation + # ------------------------------------------------------------------ + + @staticmethod + def _record_to_dict(record: Any) -> Dict[str, Any]: + """Normalize an input record to a plain dict. + + Accepts dicts, Pydantic ``BaseModel`` (including :class:`EntityRecord`), + or any object exposing ``__dict__``. Explicit ``None`` values are + preserved so callers can clear fields by setting them to ``None`` on a + model instance — only unset fields (whose Pydantic default applies) are + dropped via ``exclude_unset=True``. + """ + if isinstance(record, dict): + return dict(record) + if isinstance(record, BaseModel): + return record.model_dump(by_alias=True, exclude_unset=True) + if hasattr(record, "__dict__"): + return {k: v for k, v in record.__dict__.items() if not k.startswith("_")} + raise TypeError( + f"Cannot convert record of type {type(record).__name__} to dict — " + "pass a dict, an EntityRecord, a Pydantic BaseModel, or an object with __dict__." + ) + + @staticmethod + def _build_records_list_response( + response: Response, + schema: Optional[Type[Any]], + start: Optional[int], + limit: Optional[int], + ) -> EntityRecordsListResponse: + """Build an :class:`EntityRecordsListResponse` from a list-records body.""" + body = response.json() or {} + records_data = body.get("value", []) + total_count = int( + body.get("totalRecordCount", body.get("totalCount", len(records_data))) or 0 + ) + records = [ + EntityRecord.from_data(data=record, model=schema) for record in records_data + ] + + next_cursor = body.get("nextCursor") + if limit is not None and limit > 0: + consumed = (start or 0) + len(records) + has_next_page = consumed < total_count + else: + has_next_page = bool(body.get("hasNextPage", False)) + + return EntityRecordsListResponse( + items=records, + total_count=total_count, + has_next_page=has_next_page, + next_cursor=next_cursor, + ) + + @staticmethod + def _parse_query_response( + response: Response, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RetrieveEntityRecordsResponse: + """Parse a query response into :class:`RetrieveEntityRecordsResponse`. + + Rows that include an ``Id`` field are parsed as :class:`EntityRecord`; + rows that don't (aggregate / group-by / binning results) are parsed as + :class:`AggregateRow`. ``has_next_page`` is derived from + ``start + len(items) < total_count`` whenever ``limit`` is supplied; + ``next_cursor`` is populated only when the backend returns one, + otherwise the caller paginates by passing the next ``start``. + """ + body = response.json() or {} + items_raw = body.get("value", []) or [] + items: List[EntityRecord | AggregateRow] = [] + for raw in items_raw: + if isinstance(raw, dict) and isinstance(raw.get("Id"), str): + items.append(EntityRecord.from_data(data=raw)) + else: + items.append(AggregateRow.model_validate(raw)) + + total_count = int(body.get("totalRecordCount", body.get("totalCount", 0)) or 0) + + next_cursor: Optional[str] = body.get("nextCursor") + has_next_page = bool(body.get("hasNextPage", False)) + if next_cursor is None and limit is not None and limit > 0: + consumed = (start or 0) + len(items) + has_next_page = consumed < total_count + + return RetrieveEntityRecordsResponse( + items=items, + total_count=total_count, + has_next_page=has_next_page, + next_cursor=next_cursor, + ) + + @staticmethod + def _parse_choiceset_values(response: Response) -> List[ChoiceSetValue]: + """Decode and return the choice-set values from a query-expansion response.""" + data = response.json() + raw_values = data.get("jsonValue", "[]") + items = ( + json_module.loads(raw_values) if isinstance(raw_values, str) else raw_values + ) + return [ChoiceSetValue.model_validate(item) for item in items] + + # ------------------------------------------------------------------ + # Internal helpers — batch error recovery + # ------------------------------------------------------------------ + + def _request_or_extract_batch( + self, + sync_call: Any, + ) -> Response | EntityRecordsBatchResponse: + """Run a batch request and recover per-record failures from a 400 body. + + On HTTP 400 with a body that lists both ``successRecords`` and + ``failureRecords``, returns the parsed batch response instead of + raising. All other errors propagate. + """ + try: + return sync_call() + except EnrichedException as exc: + extracted = self._extract_batch_response_from_error(exc) + if extracted is not None: + return extracted + raise + + async def _request_or_extract_batch_async( + self, + async_call: Any, + ) -> Response | EntityRecordsBatchResponse: + """Async variant of :meth:`_request_or_extract_batch`.""" + try: + return await async_call() + except EnrichedException as exc: + extracted = self._extract_batch_response_from_error(exc) + if extracted is not None: + return extracted + raise + + @staticmethod + def _extract_batch_response_from_error( + exc: EnrichedException, + ) -> Optional[EntityRecordsBatchResponse]: + """Return a parsed batch response when the error body matches the per-record-failure shape. + + Recovery is intentionally narrow: only HTTP 400 with a JSON object + containing list-typed ``successRecords`` and ``failureRecords`` keys. + Returns ``None`` for any other status, body shape, or parse failure + so that the original error propagates. + """ + cause = exc.__cause__ + if not isinstance(cause, HTTPStatusError): + return None + if cause.response.status_code != 400: + return None + try: + data = cause.response.json() + except Exception: + return None + if not isinstance(data, dict): + return None + if not ( + isinstance(data.get("successRecords"), list) + and isinstance(data.get("failureRecords"), list) + ): + return None + try: + return EntityRecordsBatchResponse.model_validate(data) + except Exception: + return None + + # ------------------------------------------------------------------ + # Internal helpers — SQL validation (federated query path) + # ------------------------------------------------------------------ + + def _validate_sql_query(self, sql_query: str) -> None: + """Validate a SQL string for the federated query endpoint client-side.""" + query = sql_query.strip().rstrip(";").strip() + if not query: + raise ValueError("SQL query cannot be empty.") + + statements = sqlparse.parse(query) + if len(statements) != 1 or not statements[0].tokens: + raise ValueError("Only a single SELECT statement is allowed.") + + stmt = statements[0] + stmt_type = stmt.get_type() + + if stmt_type != "SELECT": + raise ValueError("Only SELECT statements are allowed.") + + keywords = set() + for token in stmt.flatten(): + if token.ttype in Keyword: + keywords.add(token.normalized) + + for kw in _FORBIDDEN_DML: + if kw in keywords: + raise ValueError(f"SQL keyword '{kw}' is not allowed.") + + for kw in _FORBIDDEN_DDL: + if kw in keywords: + raise ValueError(f"SQL keyword '{kw}' is not allowed.") + + for kw in _DISALLOWED_KEYWORDS: + if kw in keywords: + raise ValueError( + f"SQL construct '{kw}' is not allowed in entity queries." + ) + + if self._has_subquery(stmt): + raise ValueError("Subqueries are not allowed.") + + has_where = any(isinstance(t, Where) for t in stmt.tokens) + has_limit = "LIMIT" in keywords + has_from = "FROM" in keywords + + if not has_from: + raise ValueError("Queries must include a FROM clause.") + + projection = self._projection_tokens(stmt) + + if self._projection_has_count_star(projection): + raise ValueError( + "COUNT(*) is not supported. Use COUNT(column_name) instead." + ) + + has_aggregate = self._projection_has_aggregate(projection) + + if not has_where and not has_limit and not has_aggregate: + raise ValueError("Queries without WHERE must include a LIMIT clause.") + + has_bare_wildcard = self._projection_has_bare_wildcard(projection) + if has_bare_wildcard: + raise ValueError("SELECT * is not allowed. Specify column names instead.") + if not has_where and self._projection_column_count(projection) > 4: + raise ValueError( + "Selecting more than 4 columns without filtering is not allowed." + ) + + @staticmethod + def _projection_has_aggregate( + projection: List[sqlparse.sql.Token], + ) -> bool: + """Return ``True`` when the projection contains an aggregate function call.""" + + def _has_agg(token: sqlparse.sql.Token) -> bool: + if isinstance(token, Function): + return token.get_name().upper() in _AGGREGATE_FUNCTIONS + if isinstance(token, Identifier): + return any(_has_agg(child) for child in token.tokens) + return False + + for node in projection: + if _has_agg(node): + return True + if isinstance(node, IdentifierList): + if any(_has_agg(child) for child in node.tokens): + return True + return False + + @staticmethod + def _projection_has_count_star( + projection: List[sqlparse.sql.Token], + ) -> bool: + """Return ``True`` when the projection contains ``COUNT(*)``.""" + + def _is_count_star(func: Function) -> bool: + if func.get_name().upper() != "COUNT": + return False + return any(t.ttype is Wildcard for t in func.flatten()) + + def _has_count_star(token: sqlparse.sql.Token) -> bool: + if isinstance(token, Function): + return _is_count_star(token) + if isinstance(token, Identifier): + return any(_has_count_star(child) for child in token.tokens) + return False + + for node in projection: + if _has_count_star(node): + return True + if isinstance(node, IdentifierList): + if any(_has_count_star(child) for child in node.tokens): + return True + return False + + @staticmethod + def _projection_has_bare_wildcard( + projection: List[sqlparse.sql.Token], + ) -> bool: + """Return ``True`` for a bare ``*`` or qualified ``table.*`` outside a function.""" + + def _identifier_has_wildcard(ident: Identifier) -> bool: + return any(t.ttype is Wildcard for t in ident.tokens) + + for node in projection: + if node.ttype is Wildcard: + return True + if isinstance(node, Identifier) and _identifier_has_wildcard(node): + return True + if isinstance(node, IdentifierList): + for child in node.tokens: + if child.ttype is Wildcard: + return True + if isinstance(child, Identifier) and _identifier_has_wildcard( + child + ): + return True + return False + + @staticmethod + def _has_subquery(stmt: sqlparse.sql.Statement) -> bool: + """Recursively walk the AST looking for a SELECT inside parentheses.""" + + def _walk(token: sqlparse.sql.Token) -> bool: + if isinstance(token, Parenthesis): + for child in token.flatten(): + if child.ttype is DML and child.normalized == "SELECT": + return True + if hasattr(token, "tokens"): + for child in token.tokens: + if _walk(child): + return True + return False + + for token in stmt.tokens: + if _walk(token): + return True + return False + + @staticmethod + def _projection_tokens( + stmt: sqlparse.sql.Statement, + ) -> List[sqlparse.sql.Token]: + """Return the non-flattened AST nodes between the first SELECT and FROM.""" + tokens: List[sqlparse.sql.Token] = [] + collecting = False + for token in stmt.tokens: + if token.ttype is DML and token.normalized == "SELECT": + collecting = True + continue + if token.ttype is Keyword and token.normalized in ("FROM", "INTO"): + break + if token.ttype is Keyword and token.normalized == "DISTINCT": + continue + if collecting and token.ttype is not Whitespace: + tokens.append(token) + return tokens + + @staticmethod + def _projection_column_count( + projection: List[sqlparse.sql.Token], + ) -> int: + """Return the number of columns referenced by the projection.""" + for node in projection: + if isinstance(node, IdentifierList): + return len(list(node.get_identifiers())) + if isinstance(node, (Identifier, Function)): + return 1 + if node.ttype is Wildcard: + return 1 + return 0 diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_resolution.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_resolution.py new file mode 100644 index 000000000..2477b26b9 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_resolution.py @@ -0,0 +1,526 @@ +from __future__ import annotations + +import abc +import asyncio +import logging +from dataclasses import dataclass +from typing import Awaitable, Callable, Dict, Optional + +from ..common._bindings import ( + EntityResourceOverwrite, + ResourceOverwrite, + _resource_overwrites, +) +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..orchestrator._folder_service import FolderService +from .entities import ( + DataFabricEntityItem, + Entity, + EntityRouting, + QueryRoutingOverrideContext, +) + +FolderPathResolver = Callable[[str], Optional[str]] +AsyncFolderPathResolver = Callable[[str], Awaitable[Optional[str]]] +EntityByKeyFetcher = Callable[[str], Entity] +AsyncEntityByKeyFetcher = Callable[[str], Awaitable[Entity]] +EntityByNameFetcher = Callable[[str, Optional[str]], Entity] +AsyncEntityByNameFetcher = Callable[[str, Optional[str]], Awaitable[Entity]] + + +# --------------------------------------------------------------------------- +# Routing strategy +# --------------------------------------------------------------------------- + + +class RoutingStrategy(abc.ABC): + """Strategy for resolving a ``QueryRoutingOverrideContext`` at query time.""" + + @abc.abstractmethod + def resolve(self) -> Optional[QueryRoutingOverrideContext]: ... + + @abc.abstractmethod + async def resolve_async(self) -> Optional[QueryRoutingOverrideContext]: ... + + +class PreResolvedRoutingStrategy(RoutingStrategy): + """Returns a routing context that was fully resolved at init time. + + Used after ``resolve_entity_set`` where all folder paths have already + been converted to folder keys and the routing context is immutable. + """ + + def __init__( + self, + routing_context: QueryRoutingOverrideContext, + ) -> None: + self._routing_context = routing_context + + def resolve(self) -> Optional[QueryRoutingOverrideContext]: + return self._routing_context + + async def resolve_async(self) -> Optional[QueryRoutingOverrideContext]: + return self._routing_context + + @property + def routing_context(self) -> QueryRoutingOverrideContext: + return self._routing_context + + +class FoldersMapRoutingStrategy(RoutingStrategy): + """Builds a routing context from a pre-populated folders map. + + Used when an ``EntitiesService`` is constructed with an explicit + ``folders_map`` (and optional entity-name overrides) but *without* a + pre-built routing context. Folder paths in the map are resolved to + folder keys lazily at query time via ``FolderService``. + """ + + def __init__( + self, + folders_map: Dict[str, str], + effective_entity_names: Dict[str, str], + folders_service: Optional[FolderService], + ) -> None: + self._folders_map = folders_map + self._effective_entity_names = effective_entity_names + self._folders_service = folders_service + + def resolve(self) -> Optional[QueryRoutingOverrideContext]: + resolved = self._resolve_folder_paths() + return build_resolution_routing_context( + { + name: (resolved or {}).get(path, path) + for name, path in self._folders_map.items() + }, + self._effective_entity_names, + ) + + async def resolve_async(self) -> Optional[QueryRoutingOverrideContext]: + resolved = await self._resolve_folder_paths_async() + return build_resolution_routing_context( + { + name: (resolved or {}).get(path, path) + for name, path in self._folders_map.items() + }, + self._effective_entity_names, + ) + + def _resolve_folder_paths(self) -> Optional[dict[str, str]]: + folder_paths = set(self._folders_map.values()) + if not folder_paths: + return None + + resolved: dict[str, str] = {} + for folder_path in folder_paths: + if self._folders_service is not None: + folder_key = self._folders_service.retrieve_key(folder_path=folder_path) + if folder_key is not None: + resolved[folder_path] = folder_key + continue + resolved[folder_path] = folder_path + return resolved + + async def _resolve_folder_paths_async(self) -> Optional[dict[str, str]]: + folder_paths = set(self._folders_map.values()) + if not folder_paths: + return None + + resolved: dict[str, str] = {} + for folder_path in folder_paths: + if self._folders_service is not None: + folder_key = await self._folders_service.retrieve_key_async( + folder_path=folder_path + ) + if folder_key is not None: + resolved[folder_path] = folder_key + continue + resolved[folder_path] = folder_path + return resolved + + +class ContextOverwriteRoutingStrategy(RoutingStrategy): + """Builds a routing context lazily from ``_resource_overwrites``. + + This is the fallback for direct SDK usage where no ``folders_map`` or + pre-resolved routing context exists. Entity overwrites are read from + the active ``ResourceOverwritesContext`` at query time. + """ + + def __init__(self, folders_service: Optional[FolderService]) -> None: + self._folders_service = folders_service + + def resolve(self) -> Optional[QueryRoutingOverrideContext]: + entity_overwrites = _get_entity_overwrites_from_context() + if not entity_overwrites: + return None + + folder_paths = { + ow.folder_path for ow in entity_overwrites.values() if ow.folder_path + } + resolved = self._resolve_paths(folder_paths) + return self._build(entity_overwrites, resolved) + + async def resolve_async(self) -> Optional[QueryRoutingOverrideContext]: + entity_overwrites = _get_entity_overwrites_from_context() + if not entity_overwrites: + return None + + folder_paths = { + ow.folder_path for ow in entity_overwrites.values() if ow.folder_path + } + resolved = await self._resolve_paths_async(folder_paths) + return self._build(entity_overwrites, resolved) + + def _resolve_paths(self, folder_paths: set[str]) -> dict[str, str]: + resolved: dict[str, str] = {} + for path in folder_paths: + if self._folders_service is not None: + key = self._folders_service.retrieve_key(folder_path=path) + if key is not None: + resolved[path] = key + continue + resolved[path] = path + return resolved + + async def _resolve_paths_async(self, folder_paths: set[str]) -> dict[str, str]: + resolved: dict[str, str] = {} + for path in folder_paths: + if self._folders_service is not None: + key = await self._folders_service.retrieve_key_async(folder_path=path) + if key is not None: + resolved[path] = key + continue + resolved[path] = path + return resolved + + @staticmethod + def _build( + entity_overwrites: Dict[str, EntityResourceOverwrite], + resolved: dict[str, str], + ) -> Optional[QueryRoutingOverrideContext]: + routings: list[EntityRouting] = [] + for original_name, overwrite in entity_overwrites.items(): + override_name = ( + overwrite.resource_identifier + if overwrite.resource_identifier != original_name + else None + ) + folder_id = _resolve_overwrite_folder(overwrite, resolved) + routings.append( + EntityRouting( + entity_name=original_name, + folder_id=folder_id, + override_entity_name=override_name, + ) + ) + + if not routings: + return None + return QueryRoutingOverrideContext(entity_routings=routings) + + +def create_routing_strategy( + *, + folders_map: Optional[Dict[str, str]], + effective_entity_names: Optional[Dict[str, str]], + routing_context: Optional[QueryRoutingOverrideContext], + folders_service: Optional[FolderService], +) -> RoutingStrategy: + """Select the appropriate routing strategy based on init-time state.""" + if routing_context is not None: + return PreResolvedRoutingStrategy(routing_context) + if folders_map: + return FoldersMapRoutingStrategy( + folders_map, + effective_entity_names or {}, + folders_service, + ) + return ContextOverwriteRoutingStrategy(folders_service) + + +# --------------------------------------------------------------------------- +# Helpers shared across strategies +# --------------------------------------------------------------------------- + + +def _get_entity_overwrites_from_context() -> Dict[str, EntityResourceOverwrite]: + """Extract entity overwrites from the active ResourceOverwritesContext.""" + context_overwrites = _resource_overwrites.get() + if not context_overwrites: + return {} + + result: Dict[str, EntityResourceOverwrite] = {} + for key, overwrite in context_overwrites.items(): + if isinstance(overwrite, EntityResourceOverwrite): + original_name = key.split(".", 1)[1] if "." in key else key + result[original_name] = overwrite + return result + + +def _resolve_overwrite_folder( + overwrite: EntityResourceOverwrite, + resolved: dict[str, str], +) -> str: + """Return the folder key for an entity overwrite. + + Uses folder_id directly when present (already a key). + Falls back to resolving folder_path through the resolved map. + """ + if overwrite.folder_id: + return overwrite.folder_id + if overwrite.folder_path and resolved: + return resolved.get(overwrite.folder_path, overwrite.folder_path) + return overwrite.folder_identifier + + +# --------------------------------------------------------------------------- +# Resolution plan (used by resolve_entity_set) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class EntityFetchByKey: + entity_key: str + + +@dataclass(frozen=True) +class EntityFetchByName: + entity_name: str + folder_key: str + + +@dataclass(frozen=True) +class EntityResolutionDraft: + fetch_by_key: list[EntityFetchByKey] + fetch_by_name: list[EntityFetchByName] + folders_map: dict[str, str] + effective_entity_names: dict[str, str] + folder_paths_to_resolve: set[str] + + +@dataclass(frozen=True) +class EntityResolutionPlan: + fetch_by_key: list[EntityFetchByKey] + fetch_by_name: list[EntityFetchByName] + folders_map: dict[str, str] + effective_entity_names: dict[str, str] + routing_context: QueryRoutingOverrideContext | None + + +def create_resolution_draft( + items: list[DataFabricEntityItem], + context_overwrites: dict[str, ResourceOverwrite], +) -> EntityResolutionDraft: + folders_map: dict[str, str] = {} + effective_entity_names: dict[str, str] = {} + folder_paths_to_resolve: set[str] = set() + fetch_by_key: list[EntityFetchByKey] = [] + fetch_by_name: list[EntityFetchByName] = [] + + for item in items: + overwrite = context_overwrites.get( + f"entity.{item.id}" + ) or context_overwrites.get(f"entity.{item.name}") + resolved_folder = item.folder_key + + if isinstance(overwrite, EntityResourceOverwrite): + folder_changed = False + if overwrite.folder_id: + resolved_folder = overwrite.folder_id + folder_changed = resolved_folder != item.folder_key + elif overwrite.folder_path: + resolved_folder = overwrite.folder_path + folder_changed = True + folder_paths_to_resolve.add(overwrite.folder_path) + + if overwrite.name != item.name or folder_changed: + if overwrite.name != item.name: + effective_entity_names[item.name] = overwrite.name + fetch_by_name.append( + EntityFetchByName( + entity_name=overwrite.name, + folder_key=resolved_folder, + ) + ) + folders_map[item.name] = resolved_folder + continue + + fetch_by_key.append(EntityFetchByKey(entity_key=item.entity_key or item.id)) + folders_map[item.name] = resolved_folder + + return EntityResolutionDraft( + fetch_by_key=fetch_by_key, + fetch_by_name=fetch_by_name, + folders_map=folders_map, + effective_entity_names=effective_entity_names, + folder_paths_to_resolve=folder_paths_to_resolve, + ) + + +def finalize_resolution_plan( + draft: EntityResolutionDraft, + resolve_folder_path: Callable[[str], Optional[str]], +) -> EntityResolutionPlan: + resolved_paths: dict[str, str] = {} + for folder_path in draft.folder_paths_to_resolve: + resolved_paths[folder_path] = resolve_folder_path(folder_path) or folder_path + + resolved_folders_map = { + entity_name: resolved_paths.get(folder_key, folder_key) + for entity_name, folder_key in draft.folders_map.items() + } + resolved_fetch_by_name = [ + EntityFetchByName( + entity_name=entry.entity_name, + folder_key=resolved_paths.get(entry.folder_key, entry.folder_key), + ) + for entry in draft.fetch_by_name + ] + + return EntityResolutionPlan( + fetch_by_key=draft.fetch_by_key, + fetch_by_name=resolved_fetch_by_name, + folders_map=resolved_folders_map, + effective_entity_names=draft.effective_entity_names, + routing_context=build_resolution_routing_context( + resolved_folders_map, + draft.effective_entity_names, + ), + ) + + +def build_resolution_routing_context( + folders_map: dict[str, str], + effective_entity_names: dict[str, str], +) -> QueryRoutingOverrideContext | None: + routings = [ + EntityRouting( + entity_name=original_name, + folder_id=folder_id, + override_entity_name=effective_entity_names.get(original_name), + ) + for original_name, folder_id in folders_map.items() + ] + if not routings: + return None + + return QueryRoutingOverrideContext(entity_routings=routings) + + +def create_resolution_plan( + items: list[DataFabricEntityItem], + context_overwrites: dict[str, ResourceOverwrite], + resolve_folder_path: FolderPathResolver, +) -> EntityResolutionPlan: + draft = create_resolution_draft(items, context_overwrites) + return finalize_resolution_plan(draft, resolve_folder_path) + + +async def create_resolution_plan_async( + items: list[DataFabricEntityItem], + context_overwrites: dict[str, ResourceOverwrite], + resolve_folder_path: AsyncFolderPathResolver, +) -> EntityResolutionPlan: + draft = create_resolution_draft(items, context_overwrites) + folder_paths = list(draft.folder_paths_to_resolve) + results = await asyncio.gather(*(resolve_folder_path(fp) for fp in folder_paths)) + resolved_paths = { + fp: result or fp for fp, result in zip(folder_paths, results, strict=True) + } + + return finalize_resolution_plan( + draft, + lambda folder_path: resolved_paths.get(folder_path, folder_path), + ) + + +def fetch_resolved_entities( + plan: EntityResolutionPlan, + retrieve_by_key: EntityByKeyFetcher, + retrieve_by_name: EntityByNameFetcher, + logger: logging.Logger, +) -> list[Entity]: + entities: list[Entity] = [] + for key_entry in plan.fetch_by_key: + try: + entities.append(retrieve_by_key(key_entry.entity_key)) + except Exception: + logger.warning( + "Failed to fetch entity by key '%s', skipping.", + key_entry.entity_key, + exc_info=True, + ) + + for name_entry in plan.fetch_by_name: + try: + entities.append( + retrieve_by_name(name_entry.entity_name, name_entry.folder_key) + ) + except Exception: + logger.warning( + "Failed to fetch entity by name '%s' (folder_key=%s), skipping.", + name_entry.entity_name, + name_entry.folder_key, + exc_info=True, + ) + + return entities + + +async def fetch_resolved_entities_async( + plan: EntityResolutionPlan, + retrieve_by_key: AsyncEntityByKeyFetcher, + retrieve_by_name: AsyncEntityByNameFetcher, + logger: logging.Logger, +) -> list[Entity]: + async def _safe_fetch_by_key(entry: EntityFetchByKey) -> Optional[Entity]: + try: + return await retrieve_by_key(entry.entity_key) + except Exception: + logger.warning( + "Failed to fetch entity by key '%s', skipping.", + entry.entity_key, + exc_info=True, + ) + return None + + async def _safe_fetch_by_name(entry: EntityFetchByName) -> Optional[Entity]: + try: + return await retrieve_by_name( + entry.entity_name, + entry.folder_key, + ) + except Exception: + logger.warning( + "Failed to fetch entity by name '%s' (folder_key=%s), skipping.", + entry.entity_name, + entry.folder_key, + exc_info=True, + ) + return None + + tasks = [_safe_fetch_by_key(entry) for entry in plan.fetch_by_key] + [ + _safe_fetch_by_name(entry) for entry in plan.fetch_by_name + ] + results = await asyncio.gather(*tasks) + return [entity for entity in results if entity is not None] + + +def build_resolution_service( + *, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: FolderService | None, + plan: EntityResolutionPlan, + service_factory: Callable[..., object], +) -> object: + return service_factory( + config=config, + execution_context=execution_context, + folders_service=folders_service, + folders_map=plan.folders_map, + entity_name_overrides=plan.effective_entity_names, + routing_context=plan.routing_context, + ) diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py new file mode 100644 index 000000000..9872969d6 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py @@ -0,0 +1,522 @@ +"""Schema-side operations for the Data Fabric entities surface. + +Handles entity definitions, choice set listings, and the create / delete / +update-metadata lifecycle that targets the backend ``EntityController``. +Record CRUD, queries, attachments, and bulk import live on +:class:`EntityDataService` and are mediated by :class:`EntitiesService`. +""" + +import re +from typing import Any, Dict, List, Optional + +from httpx import Response + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec +from ..common.constants import HEADER_FOLDER_KEY +from ..orchestrator._folder_service import FolderService +from .entities import ( + ENTITY_FIELD_CONSTRAINT_DEFAULTS, + ENTITY_FIELD_CONSTRAINT_SPEC, + ENTITY_SCHEMA_FIELD_TYPE_MAP, + RESERVED_FIELD_NAMES, + Entity, + EntityCreateFieldOptions, + EntityCreateOptions, + EntityFieldDataType, + EntityMetadataUpdateOptions, +) + +DATA_FABRIC_TENANT_FOLDER_ID = "00000000-0000-0000-0000-000000000000" + +_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9]*$") +"""Entity and field name pattern: must start with a letter, then letters and digits only. + +Matches the UI's create-entity / create-field form validators so any name accepted +here can later be displayed or edited through the Data Service UI. +""" + +_ENTITY_NAME_MIN_LENGTH = 1 +_ENTITY_NAME_MAX_LENGTH = 30 +_FIELD_NAME_MIN_LENGTH = 3 +_FIELD_NAME_MAX_LENGTH = 100 + + +class EntitySchemaService(BaseService): + """HTTP service for entity-schema operations. + + Provides retrieval and lifecycle management for entities and choice sets. + Backend target: ``datafabric_/api/Entity``. + + See Also: + https://docs.uipath.com/data-service/automation-cloud/latest/user-guide/introduction + + !!! warning "Preview Feature" + This service is currently experimental. Behavior and parameters are + subject to change in future versions. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Optional[FolderService] = None, + ) -> None: + """Initialise the schema service.""" + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + + def retrieve(self, entity_key: str) -> Entity: + """Internal implementation; see :meth:`EntitiesService.retrieve`.""" + spec = self._retrieve_spec(entity_key) + response = self.request(spec.method, spec.endpoint) + return Entity.model_validate(response.json()) + + async def retrieve_async(self, entity_key: str) -> Entity: + """Async variant of :meth:`retrieve`.""" + spec = self._retrieve_spec(entity_key) + response = await self.request_async(spec.method, spec.endpoint) + return Entity.model_validate(response.json()) + + def retrieve_by_name( + self, entity_name: str, folder_key: Optional[str] = None + ) -> Entity: + """Internal implementation; see :meth:`EntitiesService.retrieve_by_name`.""" + spec = self._retrieve_by_name_spec(entity_name) + headers = self._folder_key_headers(folder_key) + response = self.request(spec.method, spec.endpoint, headers=headers) + return Entity.model_validate(response.json()) + + async def retrieve_by_name_async( + self, entity_name: str, folder_key: Optional[str] = None + ) -> Entity: + """Async variant of :meth:`retrieve_by_name`.""" + spec = self._retrieve_by_name_spec(entity_name) + headers = self._folder_key_headers(folder_key) + response = await self.request_async(spec.method, spec.endpoint, headers=headers) + return Entity.model_validate(response.json()) + + def list_entities(self) -> List[Entity]: + """Internal implementation; see :meth:`EntitiesService.list_entities`.""" + spec = self._list_entities_spec() + response = self.request(spec.method, spec.endpoint) + entities_data = response.json() + return [Entity.model_validate(entity) for entity in entities_data] + + async def list_entities_async(self) -> List[Entity]: + """Async variant of :meth:`list_entities`.""" + spec = self._list_entities_spec() + response = await self.request_async(spec.method, spec.endpoint) + entities_data = response.json() + return [Entity.model_validate(entity) for entity in entities_data] + + def list_choicesets(self) -> List[Entity]: + """Internal implementation; see :meth:`EntitiesService.list_choicesets`.""" + spec = self._list_choicesets_spec() + response = self.request(spec.method, spec.endpoint) + return [Entity.model_validate(item) for item in response.json()] + + async def list_choicesets_async(self) -> List[Entity]: + """Async variant of :meth:`list_choicesets`.""" + spec = self._list_choicesets_spec() + response = await self.request_async(spec.method, spec.endpoint) + return [Entity.model_validate(item) for item in response.json()] + + def create_entity( + self, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> str: + """Internal implementation; see :meth:`EntitiesService.create_entity`.""" + spec = self._create_entity_spec(name, fields, options) + response = self.request(spec.method, spec.endpoint, json=spec.json) + return self._extract_entity_id(response) + + async def create_entity_async( + self, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> str: + """Async variant of :meth:`create_entity`.""" + spec = self._create_entity_spec(name, fields, options) + response = await self.request_async(spec.method, spec.endpoint, json=spec.json) + return self._extract_entity_id(response) + + def delete_entity(self, entity_id: str) -> None: + """Delete an entity and all of its records.""" + spec = self._delete_entity_spec(entity_id) + self.request(spec.method, spec.endpoint) + + async def delete_entity_async(self, entity_id: str) -> None: + """Async variant of :meth:`delete_entity`.""" + spec = self._delete_entity_spec(entity_id) + await self.request_async(spec.method, spec.endpoint) + + def update_entity_metadata( + self, + entity_id: str, + metadata: EntityMetadataUpdateOptions | Dict[str, Any], + ) -> None: + """Internal implementation; see :meth:`EntitiesService.update_entity_metadata`.""" + spec = self._update_entity_metadata_spec(entity_id, metadata) + self.request(spec.method, spec.endpoint, json=spec.json) + + async def update_entity_metadata_async( + self, + entity_id: str, + metadata: EntityMetadataUpdateOptions | Dict[str, Any], + ) -> None: + """Async variant of :meth:`update_entity_metadata`.""" + spec = self._update_entity_metadata_spec(entity_id, metadata) + await self.request_async(spec.method, spec.endpoint, json=spec.json) + + # ------------------------------------------------------------------ + # Request-spec builders + # ------------------------------------------------------------------ + + @staticmethod + def _retrieve_spec(entity_key: str) -> RequestSpec: + """Build the GET spec for fetching an entity by key.""" + return RequestSpec( + method="GET", + endpoint=Endpoint(f"datafabric_/api/Entity/{entity_key}"), + ) + + @staticmethod + def _retrieve_by_name_spec(entity_name: str) -> RequestSpec: + """Build the GET spec for fetching an entity by name.""" + return RequestSpec( + method="GET", + endpoint=Endpoint(f"datafabric_/api/Entity/{entity_name}/metadata"), + ) + + @staticmethod + def _folder_key_headers(folder_key: Optional[str]) -> Dict[str, str]: + """Return the folder-key header dict, empty when no key is supplied.""" + if folder_key: + return {HEADER_FOLDER_KEY: folder_key} + return {} + + @staticmethod + def _list_entities_spec() -> RequestSpec: + """Build the GET spec for listing all entities (non-choice-sets).""" + return RequestSpec( + method="GET", + endpoint=Endpoint("datafabric_/api/Entity"), + ) + + @staticmethod + def _list_choicesets_spec() -> RequestSpec: + """Build the GET spec for listing all choice sets.""" + return RequestSpec( + method="GET", + endpoint=Endpoint("datafabric_/api/Entity/choiceset"), + ) + + @classmethod + def _create_entity_spec( + cls, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> RequestSpec: + """Build the POST spec for creating an entity with its field schema.""" + cls._validate_name(name, "entity") + for field in fields: + cls._validate_name(field.field_name, "field") + opts = options or EntityCreateOptions() + # The user-facing option ``is_analytics_enabled`` maps to the legacy + # backend field name ``isInsightsEnabled`` — the wire name predates + # the "Analytics" UI rename. + payload: Dict[str, Any] = { + "displayName": opts.display_name or name, + "entityDefinition": { + "name": name, + "fields": [cls._build_schema_field_payload(f) for f in fields], + "folderId": opts.folder_key or DATA_FABRIC_TENANT_FOLDER_ID, + "isRbacEnabled": bool(opts.is_rbac_enabled or False), + "isInsightsEnabled": bool(opts.is_analytics_enabled or False), + "externalFields": opts.external_fields or [], + }, + } + if opts.description is not None: + payload["description"] = opts.description + return RequestSpec( + method="POST", + endpoint=Endpoint("datafabric_/api/Entity"), + json=payload, + ) + + @staticmethod + def _delete_entity_spec(entity_id: str) -> RequestSpec: + """Build the DELETE spec for removing an entity.""" + return RequestSpec( + method="DELETE", + endpoint=Endpoint(f"datafabric_/api/Entity/{entity_id}"), + ) + + @staticmethod + def _update_entity_metadata_spec( + entity_id: str, + metadata: EntityMetadataUpdateOptions | Dict[str, Any], + ) -> RequestSpec: + """Build the PATCH spec for updating entity metadata. + + Dict inputs are validated through :class:`EntityMetadataUpdateOptions` + so snake_case keys (``display_name``) and camelCase keys + (``displayName``) both serialise to the API field names the backend + expects. + """ + if not isinstance(metadata, EntityMetadataUpdateOptions): + metadata = EntityMetadataUpdateOptions.model_validate(metadata) + body = metadata.model_dump(by_alias=True, exclude_none=True) + return RequestSpec( + method="PATCH", + endpoint=Endpoint(f"datafabric_/api/Entity/{entity_id}/metadata"), + json=body, + ) + + @classmethod + def _build_schema_field_payload( + cls, field: EntityCreateFieldOptions + ) -> Dict[str, Any]: + """Build the API field payload for a single field on create-entity. + + Maps :class:`EntityFieldDataType` to the backend's ``sqlType.name`` and + ``fieldDisplayType`` (e.g. ``STRING`` becomes ``NVARCHAR`` / ``Basic``). + Caller-supplied constraints are validated against + :data:`ENTITY_FIELD_CONSTRAINT_SPEC`; unsupplied per-type constraints + fall back to :data:`ENTITY_FIELD_CONSTRAINT_DEFAULTS` so the field is + persisted fully and remains editable later. + """ + ftype = field.type or EntityFieldDataType.STRING + cls._validate_name(field.field_name, "field") + cls._validate_field_constraints(ftype, field) + + sql_type_name, field_display_type = ENTITY_SCHEMA_FIELD_TYPE_MAP[ftype] + sql_type: Dict[str, Any] = {"name": sql_type_name} + sql_type.update(cls._build_sql_type_constraints(ftype, field)) + + payload: Dict[str, Any] = { + "name": field.field_name, + "displayName": field.display_name or field.field_name, + "sqlType": sql_type, + "fieldDisplayType": field_display_type, + "description": field.description or "", + "isRequired": bool(field.is_required or False), + "isUnique": bool(field.is_unique or False), + "isRbacEnabled": bool(field.is_rbac_enabled or False), + "isEncrypted": bool(field.is_encrypted or False), + } + if field.default_value is not None: + payload["defaultValue"] = field.default_value + if field.choice_set_id is not None: + payload["choiceSetId"] = field.choice_set_id + if field.reference_entity_name is not None: + payload["referenceEntityName"] = field.reference_entity_name + if field.reference_field_name is not None: + payload["referenceFieldName"] = field.reference_field_name + return payload + + @staticmethod + def _build_sql_type_constraints( + ftype: EntityFieldDataType, field: EntityCreateFieldOptions + ) -> Dict[str, Any]: + """Return the ``sqlType`` constraint fields required for ``ftype``. + + Caller-supplied values override defaults where the type accepts them; + types that take no constraints (UUID, DATETIME, CHOICE_SET_SINGLE, + AUTO_NUMBER) return an empty dict. + """ + d = ENTITY_FIELD_CONSTRAINT_DEFAULTS + if ftype is EntityFieldDataType.STRING: + return {"lengthLimit": field.length_limit or d["STRING_LENGTH_LIMIT"]} + if ftype is EntityFieldDataType.MULTILINE_TEXT: + return { + "lengthLimit": field.length_limit or d["MULTILINE_TEXT_LENGTH_LIMIT"] + } + if ftype is EntityFieldDataType.DECIMAL: + return { + "lengthLimit": d["DECIMAL_LENGTH_LIMIT"], + "decimalPrecision": ( + field.decimal_precision + if field.decimal_precision is not None + else d["DECIMAL_PRECISION"] + ), + "maxValue": ( + field.max_value + if field.max_value is not None + else d["NUMERIC_MAX_VALUE"] + ), + "minValue": ( + field.min_value + if field.min_value is not None + else d["NUMERIC_MIN_VALUE"] + ), + } + if ftype is EntityFieldDataType.BOOLEAN: + return {"lengthLimit": d["BOOLEAN_LENGTH_LIMIT"]} + if ftype in ( + EntityFieldDataType.DATE, + EntityFieldDataType.DATETIME_WITH_TZ, + ): + return {"lengthLimit": d["DATE_LENGTH_LIMIT"]} + if ftype in (EntityFieldDataType.INTEGER, EntityFieldDataType.BIG_INTEGER): + return { + "maxValue": ( + field.max_value + if field.max_value is not None + else d["NUMERIC_MAX_VALUE"] + ), + "minValue": ( + field.min_value + if field.min_value is not None + else d["NUMERIC_MIN_VALUE"] + ), + } + if ftype in (EntityFieldDataType.FLOAT, EntityFieldDataType.DOUBLE): + return { + "decimalPrecision": ( + field.decimal_precision + if field.decimal_precision is not None + else d["DECIMAL_PRECISION"] + ), + "maxValue": ( + field.max_value + if field.max_value is not None + else d["NUMERIC_MAX_VALUE"] + ), + "minValue": ( + field.min_value + if field.min_value is not None + else d["NUMERIC_MIN_VALUE"] + ), + } + if ftype in (EntityFieldDataType.FILE, EntityFieldDataType.RELATIONSHIP): + return {"lengthLimit": d["UNIQUEIDENTIFIER_LENGTH_LIMIT"]} + if ftype is EntityFieldDataType.CHOICE_SET_MULTIPLE: + return {"lengthLimit": d["CHOICE_SET_MULTIPLE_LENGTH_LIMIT"]} + # UUID, DATETIME, CHOICE_SET_SINGLE, AUTO_NUMBER — no constraints + return {} + + @staticmethod + def _validate_name(name: str, context: str) -> None: + r"""Validate an entity or field name against the UI's create-form rules. + + Entity names must be 1-30 characters; field names must be 3-100 + characters. Both must match ``^[a-zA-Z][a-zA-Z0-9]*$`` — start with a + letter, then letters or digits only (underscores are not permitted, to + stay consistent with the UI's entity / field creation forms). + + Field names additionally cannot collide with the system-reserved field + names in :data:`RESERVED_FIELD_NAMES`; the reserved-name check runs + first so that short reserved names produce a more informative error. + """ + if context == "field": + if name in RESERVED_FIELD_NAMES: + reserved = ", ".join(sorted(RESERVED_FIELD_NAMES)) + raise ValueError( + f"Field name {name!r} is reserved. Reserved names: {reserved}." + ) + min_len, max_len = _FIELD_NAME_MIN_LENGTH, _FIELD_NAME_MAX_LENGTH + else: + min_len, max_len = _ENTITY_NAME_MIN_LENGTH, _ENTITY_NAME_MAX_LENGTH + + if not (min_len <= len(name) <= max_len) or not _NAME_RE.match(name): + raise ValueError( + f"Invalid {context} name {name!r}. Must start with a letter, " + f"contain only letters and digits, and be {min_len}-{max_len} " + "characters." + ) + + @staticmethod + def _validate_field_constraints( + ftype: EntityFieldDataType, field: EntityCreateFieldOptions + ) -> None: + """Validate caller-supplied per-field constraints. + + Rejects constraints that ``ftype`` does not accept (e.g. + ``decimal_precision`` on ``STRING``), values outside the inclusive + range declared in :data:`ENTITY_FIELD_CONSTRAINT_SPEC`, and + ``min_value`` greater than or equal to ``max_value`` when both are + supplied. Also enforces type-dependent required references: + ``CHOICE_SET_SINGLE`` and ``CHOICE_SET_MULTIPLE`` need + ``choice_set_id``; ``RELATIONSHIP`` needs ``reference_entity_name``. + """ + if ( + ftype + in ( + EntityFieldDataType.CHOICE_SET_SINGLE, + EntityFieldDataType.CHOICE_SET_MULTIPLE, + ) + and not field.choice_set_id + ): + raise ValueError( + f"Field {field.field_name!r} of type {ftype.value} requires " + "choice_set_id." + ) + if ( + ftype is EntityFieldDataType.RELATIONSHIP + and not field.reference_entity_name + ): + raise ValueError( + f"Field {field.field_name!r} of type {ftype.value} requires " + "reference_entity_name." + ) + + spec = ENTITY_FIELD_CONSTRAINT_SPEC.get(ftype, {}) + provided: Dict[str, Any] = {} + for attr in ("length_limit", "max_value", "min_value", "decimal_precision"): + value = getattr(field, attr) + if value is not None: + provided[attr] = value + + unsupported = [name for name in provided if name not in spec] + if unsupported: + allowed = ", ".join(sorted(spec.keys())) or "none" + raise ValueError( + f"Field {field.field_name!r} of type {ftype.value} does not accept " + f"{', '.join(sorted(unsupported))}. Allowed constraints: {allowed}." + ) + + for name, value in provided.items(): + low, high = spec[name] + if not (low <= value <= high): + raise ValueError( + f"Field {field.field_name!r} of type {ftype.value}: " + f"{name}={value} is out of range [{low}, {high}]." + ) + + if ( + field.min_value is not None + and field.max_value is not None + and field.min_value >= field.max_value + ): + raise ValueError( + f"Field {field.field_name!r}: min_value ({field.min_value}) must be " + f"strictly less than max_value ({field.max_value})." + ) + + @staticmethod + def _extract_entity_id(response: Response) -> str: + """Return the new entity id from a create-entity response. + + Accepts both a bare JSON string id and a JSON object containing + ``id`` or ``entityId``. + """ + try: + body = response.json() + except Exception: + return response.text.strip().strip('"') + if isinstance(body, str): + return body + if isinstance(body, dict): + for key in ("id", "Id", "entityId", "EntityId"): + value = body.get(key) + if isinstance(value, str): + return value + return response.text.strip().strip('"') diff --git a/packages/uipath-platform/src/uipath/platform/entities/entities.py b/packages/uipath-platform/src/uipath/platform/entities/entities.py index b2c49b763..51eea2d1b 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/entities.py +++ b/packages/uipath-platform/src/uipath/platform/entities/entities.py @@ -1,10 +1,34 @@ """Entities models for UiPath Platform API interactions.""" -from enum import Enum -from types import EllipsisType -from typing import Any, Dict, List, Optional, Type, Union, get_args, get_origin +from __future__ import annotations -from pydantic import BaseModel, ConfigDict, Field, create_model +from enum import Enum, IntEnum +from types import EllipsisType +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Iterator, + List, + Optional, + Type, + Union, + get_args, + get_origin, + overload, +) + +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + create_model, + model_validator, +) + +if TYPE_CHECKING: + from ._entities_service import EntitiesService class ReferenceType(Enum): @@ -67,8 +91,8 @@ class ExternalConnection(BaseModel): id: str connection_id: str = Field(alias="connectionId") element_instance_id: str = Field(alias="elementInstanceId") - folder_id: str = Field(alias="folderKey") # named folderKey in TS SDK - connector_id: str = Field(alias="connectorKey") # named connectorKey in TS SDK + folder_id: str = Field(alias="folderKey") + connector_id: str = Field(alias="connectorKey") connector_name: str = Field(alias="connectorName") connection_name: str = Field(alias="connectionName") @@ -125,7 +149,7 @@ class FieldMetadata(BaseModel): reference_field: Optional["EntityField"] = Field( default=None, alias="referenceField" ) - reference_type: ReferenceType = Field(alias="referenceType") + reference_type: Optional[ReferenceType] = Field(default=None, alias="referenceType") sql_type: "FieldDataType" = Field(alias="sqlType") is_required: bool = Field(alias="isRequired") display_name: str = Field(alias="displayName") @@ -197,14 +221,40 @@ class SourceJoinCriteria(BaseModel): model_config = ConfigDict( validate_by_name=True, validate_by_alias=True, + extra="allow", + ) + id: Optional[str] = None + entity_id: Optional[str] = Field(default=None, alias="entityId") + join_field_name: Optional[str] = Field(default=None, alias="joinFieldName") + join_type: Optional[str] = Field(default=None, alias="joinType") + related_source_object_id: Optional[str] = Field( + default=None, alias="relatedSourceObjectId" + ) + related_source_object_field_name: Optional[str] = Field( + default=None, alias="relatedSourceObjectFieldName" + ) + related_source_field_name: Optional[str] = Field( + default=None, alias="relatedSourceFieldName" ) - id: str - entity_id: str = Field(alias="entityId") - join_field_name: str = Field(alias="joinFieldName") - join_type: str = Field(alias="joinType") - related_source_object_id: str = Field(alias="relatedSourceObjectId") - related_source_object_field_name: str = Field(alias="relatedSourceObjectFieldName") - related_source_field_name: str = Field(alias="relatedSourceFieldName") + + +class ChoiceSetValue(BaseModel): + """Model representing a single value within a choice set.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + + id: str = Field(alias="Id") + name: str = Field(alias="Name") + display_name: str = Field(alias="DisplayName") + number_id: int = Field(alias="NumberId") + created_time: str | None = Field(default=None, alias="CreateTime") + updated_time: str | None = Field(default=None, alias="UpdateTime") + created_by: str | None = Field(default=None, alias="CreatedBy") + updated_by: str | None = Field(default=None, alias="UpdatedBy") + record_owner: str | None = Field(default=None, alias="RecordOwner") class EntityRecord(BaseModel): @@ -216,7 +266,7 @@ class EntityRecord(BaseModel): "extra": "allow", } - id: str = Field(alias="Id") # Mandatory field validated by Pydantic + id: str = Field(alias="Id") @classmethod def from_data( @@ -292,11 +342,16 @@ class Entity(BaseModel): entity_type: str = Field(alias="entityType") description: Optional[str] = Field(default=None, alias="description") fields: Optional[List[FieldMetadata]] = Field(default=None, alias="fields") - external_fields: Optional[List[ExternalSourceFields]] = Field( - default=None, alias="externalFields" + external_fields: Optional[ + List[ExternalField | ExternalSourceFields | Dict[str, Any]] + ] = Field( + default=None, + alias="externalFields", ) - source_join_criteria: Optional[List[SourceJoinCriteria]] = Field( - default=None, alias="sourceJoinCriteria" + source_join_criteria: Optional[List[SourceJoinCriteria | Dict[str, Any]]] = Field( + default=None, + validation_alias=AliasChoices("sourceJoinCriteria", "sourceJoinCriterias"), + alias="sourceJoinCriteria", ) record_count: Optional[int] = Field(default=None, alias="recordCount") storage_size_in_mb: Optional[float] = Field(default=None, alias="storageSizeInMB") @@ -310,6 +365,25 @@ class Entity(BaseModel): id: str +class FailureRecord(BaseModel): + """A record that failed to insert/update/delete in a batch operation. + + Backend error responses for failed records do not always include a valid + ``Id`` field — this model accepts arbitrary shapes so the caller can + inspect ``error`` text and the original ``record`` payload. + """ + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + extra="allow", + ) + + id: Optional[str] = Field(default=None, alias="Id") + error: Optional[str] = Field(default=None) + record: Optional[Dict[str, Any]] = Field(default=None) + + class EntityRecordsBatchResponse(BaseModel): """Model representing a batch response of entity records.""" @@ -318,8 +392,421 @@ class EntityRecordsBatchResponse(BaseModel): validate_by_alias=True, ) - success_records: List[EntityRecord] = Field(alias="successRecords") - failure_records: List[EntityRecord] = Field(alias="failureRecords") + success_records: List[EntityRecord] = Field( + default_factory=list, alias="successRecords" + ) + failure_records: List[FailureRecord] = Field( + default_factory=list, alias="failureRecords" + ) + + +class EntityRecordsListResponse(List[EntityRecord]): + """List of EntityRecord with pagination metadata. + + Subclasses ``list`` so existing call sites that iterate, index, or call + ``len()`` continue to work; new fields ``total_count``, ``has_next_page``, + and ``next_cursor`` expose pagination information returned by the backend. + """ + + def __init__( + self, + items: Optional[List[EntityRecord]] = None, + total_count: int = 0, + has_next_page: bool = False, + next_cursor: Optional[str] = None, + ) -> None: + """Construct from a list of records plus pagination metadata.""" + super().__init__(items or []) + self.total_count = total_count + self.has_next_page = has_next_page + self.next_cursor = next_cursor + + +class LogicalOperator(IntEnum): + """Logical operator for combining query filter groups.""" + + And = 0 + Or = 1 + + +class QueryFilterOperator(str, Enum): + """Comparison operators supported by the structured query API.""" + + Equals = "=" + NotEquals = "!=" + GreaterThan = ">" + LessThan = "<" + GreaterThanOrEqual = ">=" + LessThanOrEqual = "<=" + Contains = "contains" + NotContains = "not contains" + StartsWith = "startswith" + EndsWith = "endswith" + In = "in" + NotIn = "not in" + + +class EntityQueryFilter(BaseModel): + """A single filter condition for querying entity records. + + Backend operator/operand rules: + + * ``in`` / ``not in`` — require a non-empty ``value_list`` and reject + ``value``. + * ``=`` / ``!=`` — allow a null ``value`` (becomes ``IS NULL`` / ``IS + NOT NULL``) and reject ``value_list``. + * All other operators (``>``, ``<``, ``>=``, ``<=``, ``contains``, + ``not contains``, ``startswith``, ``endswith``) — require a non-null + ``value`` and reject ``value_list``. + """ + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + field_name: str = Field(alias="fieldName") + operator: QueryFilterOperator + value: Optional[str] = None + value_list: Optional[List[str]] = Field(default=None, alias="valueList") + + @model_validator(mode="after") + def _check_operator_operands(self) -> "EntityQueryFilter": + """Reject operator/operand combinations the backend rejects. + + Implements the same rules the Data Service ``SelectQueryBuilder`` + enforces server-side, so callers see a clear local error instead of + an opaque HTTP 400. + """ + op = self.operator + if op in (QueryFilterOperator.In, QueryFilterOperator.NotIn): + if not self.value_list: + raise ValueError( + f"Operator {op.value!r} requires a non-empty value_list." + ) + if self.value is not None: + raise ValueError( + f"Operator {op.value!r} uses value_list; value must be omitted." + ) + return self + + if self.value_list is not None: + raise ValueError( + f"Operator {op.value!r} uses value; value_list must be omitted." + ) + if ( + op not in (QueryFilterOperator.Equals, QueryFilterOperator.NotEquals) + and self.value is None + ): + raise ValueError(f"Operator {op.value!r} requires a non-null value.") + return self + + +class EntityQueryFilterGroup(BaseModel): + """A group of query filters combined with a logical operator.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + logical_operator: Optional[LogicalOperator] = Field( + default=None, alias="logicalOperator" + ) + continue_logical_operator: Optional[LogicalOperator] = Field( + default=None, alias="continueLogicalOperator" + ) + query_filters: Optional[List[EntityQueryFilter]] = Field( + default=None, alias="queryFilters" + ) + filter_groups: Optional[List["EntityQueryFilterGroup"]] = Field( + default=None, alias="filterGroups" + ) + + +class EntityQuerySortOption(BaseModel): + """Sort option for query results.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + field_name: str = Field(alias="fieldName") + is_descending: Optional[bool] = Field(default=None, alias="isDescending") + + +class EntityAggregateFunction(str, Enum): + """Aggregate functions supported by the Data Fabric query API.""" + + Count = "COUNT" + Sum = "SUM" + Avg = "AVG" + Min = "MIN" + Max = "MAX" + + +class EntityAggregate(BaseModel): + """A single aggregate expression to apply during a query.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + function: EntityAggregateFunction + field: str + alias: Optional[str] = None + + +class EntityJoin(BaseModel): + """Multi-entity JOIN definition for cross-entity queries.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + entity_name: Optional[str] = Field(default=None, alias="entityName") + join_type: Optional[str] = Field(default=None, alias="joinType") + join_field_name: Optional[str] = Field(default=None, alias="joinFieldName") + related_entity_name: Optional[str] = Field(default=None, alias="relatedEntityName") + related_field_name: Optional[str] = Field(default=None, alias="relatedFieldName") + + +class EntityBinning(BaseModel): + """A binning (GROUP BY/aggregation) clause for V2 query endpoint.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + field_name: Optional[str] = Field(default=None, alias="fieldName") + aggregate_function: Optional[EntityAggregateFunction] = Field( + default=None, alias="aggregateFunction" + ) + alias: Optional[str] = None + + +class AggregateRow(BaseModel): + """A row returned by aggregate / group-by / binning queries. + + Aggregate rows do not have an ``Id`` field; columns vary by query + (``selected_fields``, ``aggregates`` aliases, binning aliases) and are + accessible as attributes via ``extra="allow"``. + """ + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + +class RetrieveEntityRecordsResponse(BaseModel): + """Response from :meth:`EntitiesService.retrieve_records`. + + For plain queries, ``items`` is a list of :class:`EntityRecord`. When the + query uses ``aggregates``, ``group_by``, or ``binnings``, the backend + returns rows without an ``Id`` field; those rows are parsed as + :class:`AggregateRow` instances. + """ + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + items: List[EntityRecord | AggregateRow] = Field(default_factory=list) + total_count: int = Field(default=0, alias="totalCount") + has_next_page: bool = Field(default=False, alias="hasNextPage") + next_cursor: Optional[str] = Field(default=None, alias="nextCursor") + + def __iter__(self) -> Iterator[EntityRecord | AggregateRow]: # type: ignore[override] + """Iterate over records (delegates to ``self.items``).""" + return iter(self.items) + + def __len__(self) -> int: + """Return the number of records (delegates to ``self.items``).""" + return len(self.items) + + @overload + def __getitem__(self, index: int) -> EntityRecord | AggregateRow: ... + + @overload + def __getitem__(self, index: slice) -> List[EntityRecord | AggregateRow]: ... + + def __getitem__( + self, index: int | slice + ) -> EntityRecord | AggregateRow | List[EntityRecord | AggregateRow]: + """Index or slice records (delegates to ``self.items``).""" + return self.items[index] + + +class EntityFieldDataType(str, Enum): + """User-facing entity field data type names accepted by ``create_entity``.""" + + UUID = "UUID" + STRING = "STRING" + INTEGER = "INTEGER" + DATETIME = "DATETIME" + DATETIME_WITH_TZ = "DATETIME_WITH_TZ" + DECIMAL = "DECIMAL" + FLOAT = "FLOAT" + DOUBLE = "DOUBLE" + DATE = "DATE" + BOOLEAN = "BOOLEAN" + BIG_INTEGER = "BIG_INTEGER" + MULTILINE_TEXT = "MULTILINE_TEXT" + FILE = "FILE" + CHOICE_SET_SINGLE = "CHOICE_SET_SINGLE" + CHOICE_SET_MULTIPLE = "CHOICE_SET_MULTIPLE" + AUTO_NUMBER = "AUTO_NUMBER" + RELATIONSHIP = "RELATIONSHIP" + + +# Maps the user-facing EntityFieldDataType to the ``(sqlType.name, fieldDisplayType)`` +# tuple expected by the backend when creating an entity. ``sqlType.name`` is +# the raw SQL Server type the backend persists; ``fieldDisplayType`` controls +# how the field renders in the UI. +ENTITY_SCHEMA_FIELD_TYPE_MAP: Dict[EntityFieldDataType, "tuple[str, str]"] = { + EntityFieldDataType.UUID: ("UNIQUEIDENTIFIER", "Basic"), + EntityFieldDataType.STRING: ("NVARCHAR", "Basic"), + EntityFieldDataType.INTEGER: ("INT", "Basic"), + EntityFieldDataType.DATETIME: ("DATETIME2", "Basic"), + EntityFieldDataType.DATETIME_WITH_TZ: ("DATETIMEOFFSET", "Basic"), + EntityFieldDataType.DECIMAL: ("DECIMAL", "Basic"), + EntityFieldDataType.FLOAT: ("FLOAT", "Basic"), + EntityFieldDataType.DOUBLE: ("REAL", "Basic"), + EntityFieldDataType.DATE: ("DATE", "Basic"), + EntityFieldDataType.BOOLEAN: ("BIT", "Basic"), + EntityFieldDataType.BIG_INTEGER: ("BIGINT", "Basic"), + EntityFieldDataType.MULTILINE_TEXT: ("MULTILINE", "Basic"), + EntityFieldDataType.FILE: ("UNIQUEIDENTIFIER", "File"), + EntityFieldDataType.CHOICE_SET_SINGLE: ("INT", "ChoiceSetSingle"), + EntityFieldDataType.CHOICE_SET_MULTIPLE: ("NVARCHAR", "ChoiceSetMultiple"), + EntityFieldDataType.AUTO_NUMBER: ("DECIMAL", "AutoNumber"), + EntityFieldDataType.RELATIONSHIP: ("UNIQUEIDENTIFIER", "Relationship"), +} + +# Default and fixed sqlType constraint values applied when the caller does +# not supply them. The backend requires these on field creation — without +# them the field is stored in an incomplete state and the UI later fails +# with "Field type cannot be changed" when editing advanced options. +ENTITY_FIELD_CONSTRAINT_DEFAULTS: Dict[str, int] = { + "STRING_LENGTH_LIMIT": 200, + "MULTILINE_TEXT_LENGTH_LIMIT": 200, + "DECIMAL_LENGTH_LIMIT": 1000, + "DECIMAL_PRECISION": 2, + "BOOLEAN_LENGTH_LIMIT": 100, + "DATE_LENGTH_LIMIT": 1000, + "UNIQUEIDENTIFIER_LENGTH_LIMIT": 300, + "CHOICE_SET_MULTIPLE_LENGTH_LIMIT": 4000, + "NUMERIC_MAX_VALUE": 1_000_000_000_000, + "NUMERIC_MIN_VALUE": -1_000_000_000_000, +} + +# Per-field-type spec describing which user-supplied constraints are valid +# and their inclusive ranges. Field types absent from this map (BOOLEAN, +# DATE, DATETIME, DATETIME_WITH_TZ, FILE, RELATIONSHIP, UUID, CHOICE_SET_*, +# AUTO_NUMBER) accept no user-supplied constraints — passing one raises +# ``ValueError`` so the caller gets a clear local error before any HTTP call. +_MAX_SAFE_INTEGER = 9_007_199_254_740_991 + +ENTITY_FIELD_CONSTRAINT_SPEC: Dict[ + EntityFieldDataType, Dict[str, "tuple[int, int]"] +] = { + EntityFieldDataType.STRING: { + "length_limit": (1, 4000), + }, + EntityFieldDataType.MULTILINE_TEXT: { + "length_limit": (1, 10000), + }, + EntityFieldDataType.INTEGER: { + "max_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "min_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + }, + EntityFieldDataType.BIG_INTEGER: { + "max_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "min_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + }, + EntityFieldDataType.DECIMAL: { + "max_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "min_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "decimal_precision": (0, 10), + }, + EntityFieldDataType.FLOAT: { + "max_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "min_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "decimal_precision": (0, 10), + }, + EntityFieldDataType.DOUBLE: { + "max_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "min_value": (-_MAX_SAFE_INTEGER, _MAX_SAFE_INTEGER), + "decimal_precision": (0, 10), + }, +} + +RESERVED_FIELD_NAMES = frozenset( + ["Id", "CreatedBy", "CreateTime", "UpdatedBy", "UpdateTime"] +) +"""Field names reserved by the backend — using one as a user field name is rejected.""" + + +class EntityCreateFieldOptions(BaseModel): + """User-facing field definition for creating or updating entity schemas.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + field_name: str = Field(alias="fieldName") + type: Optional[EntityFieldDataType] = Field( + default=EntityFieldDataType.STRING, alias="type" + ) + display_name: Optional[str] = Field(default=None, alias="displayName") + description: Optional[str] = None + is_required: Optional[bool] = Field(default=None, alias="isRequired") + is_unique: Optional[bool] = Field(default=None, alias="isUnique") + is_rbac_enabled: Optional[bool] = Field(default=None, alias="isRbacEnabled") + is_encrypted: Optional[bool] = Field(default=None, alias="isEncrypted") + default_value: Optional[str] = Field(default=None, alias="defaultValue") + length_limit: Optional[int] = Field(default=None, alias="lengthLimit") + max_value: Optional[int] = Field(default=None, alias="maxValue") + min_value: Optional[int] = Field(default=None, alias="minValue") + decimal_precision: Optional[int] = Field(default=None, alias="decimalPrecision") + choice_set_id: Optional[str] = Field(default=None, alias="choiceSetId") + reference_entity_name: Optional[str] = Field( + default=None, alias="referenceEntityName" + ) + reference_field_name: Optional[str] = Field( + default=None, alias="referenceFieldName" + ) + + +class EntityCreateOptions(BaseModel): + """Options for creating a new Data Fabric entity.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + display_name: Optional[str] = Field(default=None, alias="displayName") + description: Optional[str] = None + folder_key: Optional[str] = Field(default=None, alias="folderKey") + is_rbac_enabled: Optional[bool] = Field(default=None, alias="isRbacEnabled") + is_analytics_enabled: Optional[bool] = Field( + default=None, alias="isAnalyticsEnabled" + ) + external_fields: Optional[List[Dict[str, Any]]] = Field( + default=None, alias="externalFields" + ) + + +class EntityMetadataUpdateOptions(BaseModel): + """Options for updating an entity's metadata via PATCH /metadata.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + display_name: Optional[str] = Field(default=None, alias="displayName") + description: Optional[str] = None + is_rbac_enabled: Optional[bool] = Field(default=None, alias="isRbacEnabled") + + +class EntityImportRecordsResponse(BaseModel): + """Response from a bulk import operation.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + total_records: int = Field(default=0, alias="totalRecords") + inserted_records: int = Field(default=0, alias="insertedRecords") + error_file_link: Optional[str] = Field(default=None, alias="errorFileLink") class EntityRouting(BaseModel): @@ -342,4 +829,28 @@ class QueryRoutingOverrideContext(BaseModel): entity_routings: List[EntityRouting] = Field(alias="entityRoutings") +class DataFabricEntityItem(BaseModel): + """A single Data Fabric entity reference from agent configuration.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + id: str + entity_key: Optional[str] = Field(None, alias="referenceKey") + name: str + folder_key: str = Field(alias="folderId") + description: Optional[str] = None + + +class EntitySetResolution(BaseModel): + """Result of resolving an agent entity set with overwrites applied.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + entities: list[Entity] + entities_service: EntitiesService + + Entity.model_rebuild() +EntityQueryFilterGroup.model_rebuild() diff --git a/packages/uipath-platform/src/uipath/platform/errors/__init__.py b/packages/uipath-platform/src/uipath/platform/errors/__init__.py index 3c446fe14..97f7e6d98 100644 --- a/packages/uipath-platform/src/uipath/platform/errors/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/errors/__init__.py @@ -8,6 +8,8 @@ - FolderNotFoundException: Raised when a folder cannot be found - UnsupportedDataSourceException: Raised when an operation is attempted on an unsupported data source type - IngestionInProgressException: Raised when a search is attempted on an index during ingestion +- ContextGroundingIndexNotFoundError: Raised when a context grounding index cannot be resolved by name +- BatchTransformFailedException: Raised when a batch transform has failed - BatchTransformNotCompleteException: Raised when attempting to get results from an incomplete batch transform - OperationNotCompleteException: Raised when attempting to get results from an incomplete operation - OperationFailedException: Raised when an operation has failed @@ -15,7 +17,11 @@ """ from ._base_url_missing_error import BaseUrlMissingError +from ._batch_transform_failed_exception import BatchTransformFailedException from ._batch_transform_not_complete_exception import BatchTransformNotCompleteException +from ._context_grounding_index_not_found_exception import ( + ContextGroundingIndexNotFoundError, +) from ._enriched_exception import EnrichedException, ExtractedErrorInfo from ._folder_not_found_exception import FolderNotFoundException from ._ingestion_in_progress_exception import IngestionInProgressException @@ -26,7 +32,9 @@ __all__ = [ "BaseUrlMissingError", + "BatchTransformFailedException", "BatchTransformNotCompleteException", + "ContextGroundingIndexNotFoundError", "EnrichedException", "ExtractedErrorInfo", "FolderNotFoundException", diff --git a/packages/uipath-platform/src/uipath/platform/errors/_batch_transform_failed_exception.py b/packages/uipath-platform/src/uipath/platform/errors/_batch_transform_failed_exception.py new file mode 100644 index 000000000..67088b0f1 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_batch_transform_failed_exception.py @@ -0,0 +1,10 @@ +class BatchTransformFailedException(Exception): + """Raised when a batch transform has failed. + + This exception is raised when a batch transform task has completed + with a failed status, as opposed to still being in progress. + """ + + def __init__(self, batch_transform_id: str): + self.message = f"Batch transform '{batch_transform_id}' failed." + super().__init__(self.message) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_context_grounding_index_not_found_exception.py b/packages/uipath-platform/src/uipath/platform/errors/_context_grounding_index_not_found_exception.py new file mode 100644 index 000000000..653be92e8 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_context_grounding_index_not_found_exception.py @@ -0,0 +1,13 @@ +from typing import Optional + + +class ContextGroundingIndexNotFoundError(Exception): + """Raised when a context grounding index cannot be resolved by name.""" + + def __init__(self, index_name: Optional[str] = None): + self.index_name = index_name + if index_name: + self.message = f"ContextGroundingIndex '{index_name}' not found" + else: + self.message = "ContextGroundingIndex not found" + super().__init__(self.message) diff --git a/packages/uipath-platform/src/uipath/platform/governance/__init__.py b/packages/uipath-platform/src/uipath/platform/governance/__init__.py new file mode 100644 index 000000000..e1f587606 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/governance/__init__.py @@ -0,0 +1,20 @@ +"""Governance services for the UiPath Platform. + +Exposes the agenticgovernance_ ingress: tenant-controlled policy packs +served centrally so policy decisions can change without redeploying +agents. +""" + +from ._governance_provider import UiPathPlatformGovernanceProvider +from ._governance_service import GovernanceService +from .compensate import FiredRule, GovernRequest +from .policy import PolicyContext, PolicyResponse + +__all__ = [ + "FiredRule", + "GovernRequest", + "GovernanceService", + "PolicyContext", + "PolicyResponse", + "UiPathPlatformGovernanceProvider", +] diff --git a/packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py b/packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py new file mode 100644 index 000000000..23f1464bb --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py @@ -0,0 +1,81 @@ +"""Platform-backed implementation of the core governance provider protocols. + +Thin adapter around :class:`GovernanceService` that exposes only the +methods required by +:class:`uipath.core.governance.GovernancePolicyProvider` and +:class:`uipath.core.governance.GovernanceCompensationProvider`. + +Wrap an existing :class:`GovernanceService` (e.g. +``UiPathPlatformGovernanceProvider(service=UiPath().governance)``) or +pass ``config``/``execution_context`` to construct one inline. +""" + +from __future__ import annotations + +from uipath.core.governance import GovernRequest, PolicyContext, PolicyResponse + +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ._governance_service import GovernanceService + + +class UiPathPlatformGovernanceProvider: + """Platform-backed governance provider. + + Implements both + :class:`uipath.core.governance.GovernancePolicyProvider` and + :class:`uipath.core.governance.GovernanceCompensationProvider` by + delegating to :class:`GovernanceService`. + + Args: + service: Existing :class:`GovernanceService` to delegate to. + Useful for tests and for sharing an SDK service across + consumers. When omitted, a fresh service is built from the + ``config`` and ``execution_context`` kwargs. + config: Required when ``service`` is not supplied. + execution_context: Required when ``service`` is not supplied. + """ + + def __init__( + self, + service: GovernanceService | None = None, + *, + config: UiPathApiConfig | None = None, + execution_context: UiPathExecutionContext | None = None, + ) -> None: + if service is None: + if config is None or execution_context is None: + raise ValueError( + "UiPathPlatformGovernanceProvider requires either a " + "GovernanceService instance or both config and " + "execution_context." + ) + service = GovernanceService( + config=config, execution_context=execution_context + ) + self._service = service + + @property + def service(self) -> GovernanceService: + """The underlying :class:`GovernanceService` instance.""" + return self._service + + # ── GovernancePolicyProvider ───────────────────────────────────── + + def get_policy(self, context: PolicyContext) -> PolicyResponse: + """Fetch the policy pack — delegates to ``GovernanceService``.""" + return self._service.get_policy(context) + + async def get_policy_async(self, context: PolicyContext) -> PolicyResponse: + """Async variant of :meth:`get_policy`.""" + return await self._service.get_policy_async(context) + + # ── GovernanceCompensationProvider ─────────────────────────────── + + def compensate(self, request: GovernRequest) -> None: + """Fire the compensating ``/runtime/govern`` POST.""" + self._service._compensate(request) + + async def compensate_async(self, request: GovernRequest) -> None: + """Async variant of :meth:`compensate`.""" + await self._service._compensate_async(request) diff --git a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py new file mode 100644 index 000000000..5ceabf479 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py @@ -0,0 +1,356 @@ +"""Service for the ``agenticgovernance_`` ingress. + +Wraps the two governance backend endpoints UiPath exposes: + +- ``GET /{org}/agenticgovernance_/api/v1/runtime/policy`` — fetch the + tenant-managed policy pack (see :meth:`GovernanceService.retrieve_policy`). +- ``POST /{org}/agenticgovernance_/api/v1/runtime/govern`` — compensating + governance call fired when a ``guardrail_fallback`` rule matches + (see :meth:`GovernanceService.compensate`). + +Org/tenant scoping is read from :class:`UiPathConfig`; auth, retries, +trace context, and error enrichment come from :class:`BaseService`. +""" + +from typing import Any, Optional + +from uipath.core import traced +from uipath.core.governance import ( + FiredRule, + GovernRequest, + PolicyContext, + PolicyResponse, +) + +from ..common._base_service import BaseService +from ..common._config import UiPathConfig +from ..common._service_url_overrides import ( + inject_routing_headers, + resolve_service_url, +) +from ..common.constants import HEADER_INTERNAL_TENANT_ID + +# The agenticgovernance_ ingress lives at a separate org-scoped path that +# uses the organization UUID (not the slug exposed by ``UIPATH_URL``). +GOVERNANCE_SERVICE_PREFIX = "agenticgovernance_" +POLICY_API_PATH = "api/v1/runtime/policy" +GOVERN_API_PATH = "api/v1/runtime/govern" +AGENT_TYPE_PARAM = "agentType" + + +class GovernanceService(BaseService): + """Service for the agenticgovernance_ ingress. + + Exposes two endpoints: + + - :meth:`retrieve_policy` — GET the tenant-managed policy pack. + - :meth:`compensate` — POST a compensating ``/runtime/govern`` call + so the server can run a disabled centralized guardrail and write + the per-rule LLMOps audit records itself. + + Org and tenant scoping come from :attr:`UiPathConfig.organization_id` + and :attr:`UiPathConfig.tenant_id`; the tenant travels in the + ``x-uipath-internal-tenantid`` header (the URL is org-scoped only). + + !!! info "Version Availability" + This service is available starting from **uipath** version **2.2.13**. + """ + + # ── Policy fetch ───────────────────────────────────────────────── + + @traced(name="governance_retrieve_policy", run_type="uipath") + def retrieve_policy( + self, + *, + is_conversational: Optional[bool] = None, + ) -> PolicyResponse: + """Fetch the governance policy pack for the active org/tenant. + + Args: + is_conversational: When the hosted agent's type is known, + selects the conversational (``True``) or autonomous + (``False``) policy view. ``None`` (default) omits the + ``agentType`` query param so the server applies its + default. + + Returns: + PolicyResponse: ``mode`` and the YAML ``policies`` string. + + Raises: + ValueError: If ``UiPathConfig.organization_id`` or + ``UiPathConfig.tenant_id`` is not set. + EnrichedException: If the backend returns a non-2xx response. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + response = client.governance.retrieve_policy() + print(response.mode, len(response.policies)) + ``` + """ + url, headers = self._build_org_scoped_request(POLICY_API_PATH) + params = self._policy_params(is_conversational) + response = self.request("GET", url=url, params=params, headers=headers) + return PolicyResponse.model_validate(response.json()) + + @traced(name="governance_retrieve_policy", run_type="uipath") + async def retrieve_policy_async( + self, + *, + is_conversational: Optional[bool] = None, + ) -> PolicyResponse: + """Asynchronously fetch the governance policy pack. + + See :meth:`retrieve_policy` for parameter and return semantics. + """ + url, headers = self._build_org_scoped_request(POLICY_API_PATH) + params = self._policy_params(is_conversational) + response = await self.request_async( + "GET", url=url, params=params, headers=headers + ) + return PolicyResponse.model_validate(response.json()) + + # ── Policy provider adapter (GovernancePolicyProvider protocol) ─ + + def get_policy(self, context: PolicyContext) -> PolicyResponse: + """Fetch the policy pack — :class:`GovernancePolicyProvider` adapter. + + Thin wrapper over :meth:`retrieve_policy` that accepts the + context model the core protocol uses. Lets the runtime consume + governance through :class:`uipath.core.governance.GovernancePolicyProvider` + without importing this module. + """ + return self.retrieve_policy(is_conversational=context.is_conversational) + + async def get_policy_async(self, context: PolicyContext) -> PolicyResponse: + """Async variant of :meth:`get_policy`.""" + return await self.retrieve_policy_async( + is_conversational=context.is_conversational + ) + + # ── Compensating governance call ───────────────────────────────── + + def compensate( + self, + *, + hook: str, + validators: list[str], + rules: list[FiredRule], + data: dict[str, Any], + trace_id: str, + src_timestamp: str, + agent_name: str, + runtime_id: str, + folder_key: str | None = None, + job_key: str | None = None, + process_key: str | None = None, + reference_id: str | None = None, + agent_version: str | None = None, + ) -> None: + """POST a compensating ``/runtime/govern`` call. + + Fired when a ``guardrail_fallback`` rule matches: the centralized + guardrail is disabled, so the server is asked to run the + guardrail check server-side and write the per-rule LLMOps audit + records bound to ``trace_id``. The agent does not inspect the + response body. + + Job-context fields (``folder_key`` / ``job_key`` / + ``process_key`` / ``reference_id`` / ``agent_version``) are + auto-populated from :class:`UiPathConfig` when omitted. + Caller-supplied values — including the empty string — take + precedence. + + Args: + hook: Identifier of the agent hook that fired the rule + (e.g. ``"before_model"``). + validators: Validator names attached to the fired rules. + rules: Each rule that fired — one LLMOps audit record is + written per entry. + data: Hook payload the server replays through the + centralized guardrail. + trace_id: Canonical 32-char hex trace id. Capture via + :func:`resolve_trace_id` on the hook thread before + hopping to a background pool. + src_timestamp: ISO-8601 timestamp on the source side. + agent_name: Agent identifier as known to the platform. + runtime_id: Runtime instance identifier. + folder_key: Override the env-backed folder key. + job_key: Override the env-backed job key. + process_key: Override the env-backed process key. + reference_id: Override the env-backed agent id. + agent_version: Override the env-backed agent version. + + Raises: + ValueError: If ``UiPathConfig.organization_id`` or + ``UiPathConfig.tenant_id`` is not set. + EnrichedException: If the backend returns a non-2xx response. + + Threading: + ``trace_id`` must be the agent's canonical trace id, and + OpenTelemetry context is thread-local; capture it on the + hook thread (via :func:`resolve_trace_id`) before hopping + to a background pool. + """ + self._compensate( + GovernRequest( + hook=hook, + validators=validators, + rules=rules, + data=data, + trace_id=trace_id, + src_timestamp=src_timestamp, + agent_name=agent_name, + runtime_id=runtime_id, + folder_key=folder_key, + job_key=job_key, + process_key=process_key, + reference_id=reference_id, + agent_version=agent_version, + ) + ) + + async def compensate_async( + self, + *, + hook: str, + validators: list[str], + rules: list[FiredRule], + data: dict[str, Any], + trace_id: str, + src_timestamp: str, + agent_name: str, + runtime_id: str, + folder_key: str | None = None, + job_key: str | None = None, + process_key: str | None = None, + reference_id: str | None = None, + agent_version: str | None = None, + ) -> None: + """Asynchronously POST a compensating ``/runtime/govern`` call. + + See :meth:`compensate` for parameter semantics. + """ + await self._compensate_async( + GovernRequest( + hook=hook, + validators=validators, + rules=rules, + data=data, + trace_id=trace_id, + src_timestamp=src_timestamp, + agent_name=agent_name, + runtime_id=runtime_id, + folder_key=folder_key, + job_key=job_key, + process_key=process_key, + reference_id=reference_id, + agent_version=agent_version, + ) + ) + + # ── Internal worker for GovernRequest-shaped callers ───────────── + + @traced(name="governance_compensate", run_type="uipath") + def _compensate(self, request: GovernRequest) -> None: + """Fire a compensation call from a pre-built :class:`GovernRequest`. + + Internal helper used by the provider adapter + (:class:`uipath.platform.governance.UiPathPlatformGovernanceProvider`) + to satisfy :class:`uipath.core.governance.GovernanceCompensationProvider` + without unpacking the request. The public ergonomic counterpart + is :meth:`compensate`. + """ + url, headers = self._build_org_scoped_request(GOVERN_API_PATH) + payload = self._build_govern_payload(request) + self.request("POST", url=url, headers=headers, json=payload) + + @traced(name="governance_compensate", run_type="uipath") + async def _compensate_async(self, request: GovernRequest) -> None: + """Async variant of :meth:`_compensate`.""" + url, headers = self._build_org_scoped_request(GOVERN_API_PATH) + payload = self._build_govern_payload(request) + await self.request_async("POST", url=url, headers=headers, json=payload) + + # ── Internals ──────────────────────────────────────────────────── + + def _build_org_scoped_request(self, path: str) -> tuple[str, dict[str, str]]: + """Compose the agenticgovernance_ URL and the tenant header. + + Both governance endpoints share the same URL shape + (``{origin}/{org_id_uuid}/agenticgovernance_/{path}``) and the + same ``x-uipath-internal-tenantid`` header — neither matches + ``UiPathUrl.scope_url`` (slug-based), so the URL is composed + directly here. + + Honors ``UIPATH_SERVICE_URL_AGENTICGOVERNANCE`` for local dev: + when set, redirects to the override and injects routing headers + so the local server sees what the platform router would have + carried. ``BaseService.request`` does this same dance for paths + that fit ``scope_url``; the org-UUID-in-path shape forces us to + run it ourselves before composing the absolute URL. + """ + organization_id = UiPathConfig.organization_id + if not organization_id: + raise ValueError( + "Governance call requires UIPATH_ORGANIZATION_ID " + "to be set in the environment." + ) + tenant_id = UiPathConfig.tenant_id + if not tenant_id: + raise ValueError( + "Governance call requires UIPATH_TENANT_ID " + "to be set in the environment." + ) + + override = resolve_service_url(f"{GOVERNANCE_SERVICE_PREFIX}/{path}") + if override: + headers: dict[str, str] = {} + inject_routing_headers(headers) + return override, headers + + url = ( + f"{self._url.base_url}/{organization_id}/{GOVERNANCE_SERVICE_PREFIX}/{path}" + ) + return url, {HEADER_INTERNAL_TENANT_ID: tenant_id} + + @staticmethod + def _policy_params(is_conversational: Optional[bool]) -> dict[str, str]: + if is_conversational is None: + return {} + return { + AGENT_TYPE_PARAM: "conversational" if is_conversational else "autonomous" + } + + @staticmethod + def _build_govern_payload(request: GovernRequest) -> dict[str, Any]: + """Serialize the request and fill missing job-context from UiPathConfig. + + Auto-fill resolution order for each job-context field: caller + value > ``UiPathConfig`` (env-var-backed) > omit. + + ``model_dump(exclude_none=True)`` already drops fields the caller + left ``None``, so key presence — not truthiness — is the right + "was it supplied?" signal: a caller-supplied empty string is + still a caller value and must not be overridden by the env. + """ + payload = request.model_dump(by_alias=True, exclude_none=True) + for wire_key, config_attr in _JOB_CONTEXT_FIELDS: + if wire_key in payload: + continue + value = getattr(UiPathConfig, config_attr, None) + if value: + payload[wire_key] = value + return payload + + +# Wire-key → UiPathConfig attribute, for compensation payload auto-fill. +_JOB_CONTEXT_FIELDS: tuple[tuple[str, str], ...] = ( + ("folderKey", "folder_key"), + ("jobKey", "job_key"), + ("processKey", "process_uuid"), + ("referenceId", "agent_id"), + ("agentVersion", "process_version"), +) diff --git a/packages/uipath-platform/src/uipath/platform/governance/compensate.py b/packages/uipath-platform/src/uipath/platform/governance/compensate.py new file mode 100644 index 000000000..bad4845f9 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/governance/compensate.py @@ -0,0 +1,10 @@ +"""Re-exports of compensation models from :mod:`uipath.core.governance`. + +The wire-shape models live in ``uipath-core`` so the runtime can depend on +the protocol contract without importing ``uipath-platform``. This module +keeps the existing ``uipath.platform.governance`` import paths working. +""" + +from uipath.core.governance import FiredRule, GovernRequest + +__all__ = ["FiredRule", "GovernRequest"] diff --git a/packages/uipath-platform/src/uipath/platform/governance/policy.py b/packages/uipath-platform/src/uipath/platform/governance/policy.py new file mode 100644 index 000000000..27de1c9e7 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/governance/policy.py @@ -0,0 +1,10 @@ +"""Re-exports of governance policy models from :mod:`uipath.core.governance`. + +The wire-shape models live in ``uipath-core`` so the runtime can depend on +the protocol contract without importing ``uipath-platform``. This module +keeps the existing ``uipath.platform.governance`` import paths working. +""" + +from uipath.core.governance import PolicyContext, PolicyResponse + +__all__ = ["PolicyContext", "PolicyResponse"] diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py index ffab74581..93c60b0a4 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py @@ -14,6 +14,33 @@ ) from ._guardrails_service import GuardrailsService +from .decorators import ( + BlockAction, + BuiltInGuardrailValidator, + CustomGuardrailValidator, + CustomValidator, + GuardrailAction, + GuardrailBlockException, + GuardrailExclude, + GuardrailExecutionStage, + GuardrailTargetAdapter, + GuardrailValidatorBase, + HarmfulContentEntity, + HarmfulContentEntityType, + HarmfulContentValidator, + IntellectualPropertyEntityType, + IntellectualPropertyValidator, + LogAction, + LoggingSeverityLevel, + PIIDetectionEntity, + PIIDetectionEntityType, + PIIValidator, + PromptInjectionValidator, + RuleFunction, + UserPromptAttacksValidator, + guardrail, + register_guardrail_adapter, +) from .guardrails import ( BuiltInValidatorGuardrail, EnumListParameterValue, @@ -22,7 +49,9 @@ ) __all__ = [ + # Service "GuardrailsService", + # Guardrail models "BuiltInValidatorGuardrail", "GuardrailType", "GuardrailValidationResultType", @@ -33,4 +62,30 @@ "GuardrailValidationResult", "EnumListParameterValue", "MapEnumParameterValue", + # Decorator framework + "guardrail", + "GuardrailValidatorBase", + "BuiltInGuardrailValidator", + "CustomGuardrailValidator", + "HarmfulContentValidator", + "IntellectualPropertyValidator", + "PIIValidator", + "PromptInjectionValidator", + "UserPromptAttacksValidator", + "CustomValidator", + "RuleFunction", + "HarmfulContentEntity", + "HarmfulContentEntityType", + "IntellectualPropertyEntityType", + "PIIDetectionEntity", + "PIIDetectionEntityType", + "GuardrailExecutionStage", + "GuardrailAction", + "LogAction", + "BlockAction", + "LoggingSeverityLevel", + "GuardrailBlockException", + "GuardrailExclude", + "GuardrailTargetAdapter", + "register_guardrail_adapter", ] diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py index ebfbaf33d..86856a6b4 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -1,4 +1,5 @@ -from typing import Any +import re +from typing import Any, Optional from httpx import HTTPStatusError from uipath.core.guardrails import ( @@ -7,6 +8,7 @@ ) from uipath.core.tracing import traced +from ..chat.llm_trace_context import build_trace_context_headers from ..common._base_service import BaseService from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext @@ -14,6 +16,13 @@ from ..errors import EnrichedException from .guardrails import BuiltInValidatorGuardrail +# x-uipath-traceparent-id header format: {version}-{trace_id}-{span_id}[-{trace_flags}] +# Based on W3C traceparent but allows 16- or 32-hex span IDs. +_TRACEPARENT_PATTERN = re.compile( + r"^[0-9a-f]{2}-[0-9a-f]{32}-(?P[0-9a-f]{16}|[0-9a-f]{32})(?:-[0-9a-f]{2})?$", + re.IGNORECASE, +) + class GuardrailsService(BaseService): """Service for validating text against UiPath Guardrails. @@ -34,6 +43,31 @@ def __init__( ) -> None: super().__init__(config=config, execution_context=execution_context) + @staticmethod + def _extract_span_id_from_traceparent( + traceparent: Optional[str], + ) -> Optional[str]: + """Extract span ID from x-uipath-traceparent-id header and format as GUID. + + Args: + traceparent: Value from the ``x-uipath-traceparent-id`` response header. + Accepts 3-part ``"00-{trace_id}-{span_id}"`` or 4-part + ``"00-{trace_id}-{span_id}-{trace_flags}"``. Span ID may be + 16 or 32 hex chars. + + Returns: + Span ID formatted as lowercase GUID (8-4-4-4-12), or None if not parseable. + """ + if not traceparent: + return None + match = _TRACEPARENT_PATTERN.match(traceparent) + if not match: + return None + span_id_hex = match.group("span_id").lower() + # Pad to 32 chars for GUID conversion (span IDs may be 16 hex chars) + padded = span_id_hex.zfill(32) + return f"{padded[:8]}-{padded[8:12]}-{padded[12:16]}-{padded[16:20]}-{padded[20:32]}" + @staticmethod def _parse_result(result_str: str) -> GuardrailValidationResultType: """Parse result string from API response to GuardrailValidationResultType. @@ -82,18 +116,26 @@ def evaluate_guardrail( "validator": guardrail.validator_type, "input": input_data if isinstance(input_data, str) else str(input_data), "parameters": parameters, + "guardrailName": guardrail.name, } spec = RequestSpec( method="POST", endpoint=Endpoint("/agentsruntime_/api/execution/guardrails/validate"), json=payload, ) + # Include trace context headers for server-side span correlation + trace_headers = build_trace_context_headers() + request_headers = {**(spec.headers or {}), **trace_headers} + span_id = None try: response = self.request( spec.method, url=spec.endpoint, json=spec.json, - headers=spec.headers, + headers=request_headers, + ) + span_id = self._extract_span_id_from_traceparent( + response.headers.get("x-uipath-traceparent-id") ) response_data = response.json() except EnrichedException as e: @@ -107,6 +149,11 @@ def evaluate_guardrail( and original_error.response ): try: + span_id = self._extract_span_id_from_traceparent( + original_error.response.headers.get( + "x-uipath-traceparent-id" + ) + ) response_data = original_error.response.json() except Exception: # If JSON parsing fails, re-raise the original exception @@ -127,9 +174,11 @@ def evaluate_guardrail( reason = response_data.get("details", "") # Prepare model data - model_data = { + model_data: dict[str, Any] = { "result": result.value, "reason": reason, } + if span_id: + model_data["spanId"] = span_id return GuardrailValidationResult.model_validate(model_data) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py new file mode 100644 index 000000000..e8d692164 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py @@ -0,0 +1,66 @@ +"""Guardrail decorator framework for UiPath Platform. + +Provides the ``@guardrail`` decorator, built-in validators, actions, and an +adapter registry that framework integrations (e.g. *uipath-langchain*) use to +teach the decorator how to wrap their specific object types. +""" + +from ._actions import BlockAction, LogAction, LoggingSeverityLevel +from ._core import GuardrailExclude +from ._enums import ( + GuardrailExecutionStage, + HarmfulContentEntityType, + IntellectualPropertyEntityType, + PIIDetectionEntityType, +) +from ._exceptions import GuardrailBlockException +from ._guardrail import guardrail +from ._models import GuardrailAction, HarmfulContentEntity, PIIDetectionEntity +from ._registry import GuardrailTargetAdapter, register_guardrail_adapter +from .validators import ( + BuiltInGuardrailValidator, + CustomGuardrailValidator, + CustomValidator, + GuardrailValidatorBase, + HarmfulContentValidator, + IntellectualPropertyValidator, + PIIValidator, + PromptInjectionValidator, + RuleFunction, + UserPromptAttacksValidator, +) + +__all__ = [ + # Decorator + "guardrail", + # Validators + "GuardrailValidatorBase", + "BuiltInGuardrailValidator", + "CustomGuardrailValidator", + "HarmfulContentValidator", + "IntellectualPropertyValidator", + "PIIValidator", + "PromptInjectionValidator", + "UserPromptAttacksValidator", + "CustomValidator", + "RuleFunction", + # Models & enums + "HarmfulContentEntity", + "HarmfulContentEntityType", + "IntellectualPropertyEntityType", + "PIIDetectionEntity", + "PIIDetectionEntityType", + "GuardrailExecutionStage", + "GuardrailAction", + # Actions + "LogAction", + "BlockAction", + "LoggingSeverityLevel", + # Exception + "GuardrailBlockException", + # Exclude marker + "GuardrailExclude", + # Adapter registry + "GuardrailTargetAdapter", + "register_guardrail_adapter", +] diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_actions.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_actions.py new file mode 100644 index 000000000..8e6489797 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_actions.py @@ -0,0 +1,82 @@ +"""Built-in GuardrailAction implementations.""" + +import logging +from dataclasses import dataclass +from enum import Enum +from typing import Any, Optional + +from uipath.core.guardrails import ( + GuardrailValidationResult, + GuardrailValidationResultType, +) + +from ._exceptions import GuardrailBlockException +from ._models import GuardrailAction + + +class LoggingSeverityLevel(int, Enum): + """Logging severity level for :class:`LogAction`.""" + + ERROR = logging.ERROR + INFO = logging.INFO + WARNING = logging.WARNING + DEBUG = logging.DEBUG + + +@dataclass +class LogAction(GuardrailAction): + """Log guardrail violations without stopping execution. + + Args: + severity_level: Python logging level. Defaults to ``WARNING``. + message: Custom log message. If omitted, the validation reason is used. + """ + + severity_level: LoggingSeverityLevel = LoggingSeverityLevel.WARNING + message: Optional[str] = None + + def handle_validation_result( + self, + result: GuardrailValidationResult, + data: str | dict[str, Any], + guardrail_name: str, + ) -> str | dict[str, Any] | None: + """Log the violation and return ``None`` (no data modification).""" + if result.result == GuardrailValidationResultType.VALIDATION_FAILED: + msg = self.message or f"Failed: {result.reason}" + logging.getLogger(__name__).log( + self.severity_level, + "[GUARDRAIL] [%s] %s", + guardrail_name, + msg, + ) + return None + + +@dataclass +class BlockAction(GuardrailAction): + """Block execution by raising :class:`GuardrailBlockException`. + + Framework adapters catch ``GuardrailBlockException`` at the wrapper boundary + and convert it to their own runtime error type. + + Args: + title: Exception title. Defaults to a message derived from the guardrail name. + detail: Exception detail. Defaults to the validation reason. + """ + + title: Optional[str] = None + detail: Optional[str] = None + + def handle_validation_result( + self, + result: GuardrailValidationResult, + data: str | dict[str, Any], + guardrail_name: str, + ) -> str | dict[str, Any] | None: + """Raise :class:`GuardrailBlockException` when validation fails.""" + if result.result == GuardrailValidationResultType.VALIDATION_FAILED: + title = self.title or f"Guardrail [{guardrail_name}] blocked execution" + detail = self.detail or result.reason or "Guardrail validation failed" + raise GuardrailBlockException(title=title, detail=detail) + return None diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_core.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_core.py new file mode 100644 index 000000000..ca168a1e0 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_core.py @@ -0,0 +1,302 @@ +"""Core framework-agnostic utilities for guardrail decorators.""" + +import ast +import dataclasses +import inspect +import json +import logging +from typing import Annotated, Any, Callable, get_args, get_origin, get_type_hints + +from uipath.core.guardrails import ( + GuardrailValidationResult, +) + +from ._enums import GuardrailExecutionStage + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# GuardrailExclude marker +# --------------------------------------------------------------------------- + + +class GuardrailExclude: + """Marker to exclude a parameter from guardrail input serialization. + + Use with :data:`typing.Annotated` to prevent a specific function parameter + from being collected into the guardrail evaluation payload:: + + async def process( + text: str, + config: Annotated[dict, GuardrailExclude()], + ) -> str: ... + """ + + +# --------------------------------------------------------------------------- +# Evaluator type alias +# --------------------------------------------------------------------------- + +_EvaluatorFn = Callable[ + [ + "str | dict[str, Any]", # data + GuardrailExecutionStage, # stage + "dict[str, Any] | None", # input_data + "dict[str, Any] | None", # output_data + ], + GuardrailValidationResult, +] +"""Type alias for the unified evaluation callable used by all wrappers.""" + + +# --------------------------------------------------------------------------- +# Evaluator factory +# --------------------------------------------------------------------------- + + +def _make_evaluator( + validator: Any, + name: str, + description: str | None, + enabled_for_evals: bool, +) -> _EvaluatorFn: + """Return a unified evaluation callable. + + Delegates to ``validator.run()`` which each validator subclass implements + (:class:`BuiltInGuardrailValidator` hits the UiPath API; + :class:`CustomGuardrailValidator` runs a local Python rule). + + Args: + validator: :class:`GuardrailValidatorBase` instance. + name: Guardrail name — forwarded to ``validator.run()`` on each call. + description: Optional description — forwarded to ``validator.run()``. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Callable with signature ``(data, stage, input_data, output_data)``. + """ + + def _eval( + data: str | dict[str, Any], + stage: GuardrailExecutionStage, + input_data: dict[str, Any] | None, + output_data: dict[str, Any] | None, + ) -> GuardrailValidationResult: + return validator.run( + name, description, enabled_for_evals, data, stage, input_data, output_data + ) + + return _eval + + +# --------------------------------------------------------------------------- +# Parameter introspection +# --------------------------------------------------------------------------- + + +def _get_excluded_params(func: Any) -> set[str]: + """Return parameter names annotated with :class:`GuardrailExclude`. + + Args: + func: Callable to inspect. + + Returns: + Set of parameter names that should be excluded from guardrail input. + """ + try: + hints = get_type_hints(func, include_extras=True) + except Exception: + return set() + excluded: set[str] = set() + for name, hint in hints.items(): + if get_origin(hint) is Annotated: + for meta in get_args(hint)[1:]: + if isinstance(meta, GuardrailExclude): + excluded.add(name) + return excluded + + +# --------------------------------------------------------------------------- +# Serialization helpers +# --------------------------------------------------------------------------- + + +def _serialize_value(value: Any) -> Any: + """Serialize *value* to a JSON-compatible type for guardrail evaluation. + + Pydantic models → ``model_dump()``, dataclasses → ``asdict()``, + primitives → as-is, everything else → ``str()``. + """ + if value is None: + return None + if isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return {k: _serialize_value(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_serialize_value(v) for v in value] + # Pydantic v2 + if hasattr(value, "model_dump"): + return value.model_dump() + # Pydantic v1 + if hasattr(value, "dict") and callable(value.dict): + try: + return value.dict() + except Exception: + pass + # dataclasses + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return dataclasses.asdict(value) + return str(value) + + +def _collect_input( + bound: inspect.BoundArguments, + excluded: set[str], +) -> dict[str, Any]: + """Collect non-excluded function parameters into a guardrail input dict. + + Args: + bound: Bound arguments from ``inspect.Signature.bind()``. + excluded: Parameter names to skip. + + Returns: + ``{param_name: serialized_value}`` for all non-excluded parameters. + """ + result: dict[str, Any] = {} + for name, value in bound.arguments.items(): + if name in excluded or name in ("self", "cls"): + continue + result[name] = _serialize_value(value) + return result + + +def _collect_output(return_value: Any) -> dict[str, Any]: + """Serialize a function return value into a dict for guardrail evaluation. + + Args: + return_value: The value returned by the wrapped function. + + Returns: + A ``dict`` representation suitable for guardrail evaluation. + """ + serialized = _serialize_value(return_value) + if isinstance(serialized, dict): + return serialized + return {"return": serialized} + + +def _reconstruct_output(original: Any, modified: Any) -> Any: + """Reconstruct a return value from a guardrail-modified payload. + + Args: + original: The original return value (used to determine target type). + modified: The modified value returned by the guardrail action. + + Returns: + Reconstructed value of the same type as *original* where possible. + """ + if modified is None: + return original + # Pydantic v2 model + dict modification → reconstruct via model_validate + if hasattr(original, "model_validate") and isinstance(modified, dict): + try: + return type(original).model_validate(modified) + except Exception: + pass + # Pydantic v1 + if hasattr(original, "parse_obj") and isinstance(modified, dict): + try: + return type(original).parse_obj(modified) + except Exception: + pass + return modified + + +def _apply_pre_modification( + bound: inspect.BoundArguments, + modified: Any, + excluded: set[str], +) -> None: + """Apply guardrail PRE-stage modifications back to bound function arguments. + + If the action returned a modified dict, keys matching non-excluded parameters + are updated in-place. If the action returned a plain string and there is exactly + one non-excluded parameter, that parameter is updated. + + Args: + bound: Bound arguments to mutate in-place. + modified: Value returned by the guardrail action. + excluded: Parameter names that were excluded from evaluation. + """ + if modified is None: + return + non_excluded = [ + n for n in bound.arguments if n not in excluded and n not in ("self", "cls") + ] + if isinstance(modified, dict): + for name in non_excluded: + if name in modified: + bound.arguments[name] = modified[name] + elif isinstance(modified, str) and len(non_excluded) == 1: + bound.arguments[non_excluded[0]] = modified + + +# --------------------------------------------------------------------------- +# Tool I/O normalisation helpers (used by LangChain adapter) +# --------------------------------------------------------------------------- + + +def _is_tool_call_envelope(tool_input: Any) -> bool: + """Return ``True`` if *tool_input* is a LangGraph tool-call envelope dict.""" + return ( + isinstance(tool_input, dict) + and "args" in tool_input + and tool_input.get("type") == "tool_call" + ) + + +def _extract_input(tool_input: Any) -> dict[str, Any]: + """Normalise tool input to a plain dict for rule / guardrail evaluation. + + LangGraph wraps tool inputs as ``{"name": ..., "args": {...}, "type": "tool_call"}``. + This function unwraps ``args`` so rules can access the actual tool arguments. + """ + if _is_tool_call_envelope(tool_input): + args = tool_input["args"] + if isinstance(args, dict): + return args + if isinstance(tool_input, dict): + return tool_input + return {"input": tool_input} + + +def _rewrap_input(original_tool_input: Any, modified_args: dict[str, Any]) -> Any: + """Re-wrap modified args back into the original tool-call envelope (if applicable).""" + if _is_tool_call_envelope(original_tool_input): + import copy + + wrapped = copy.copy(original_tool_input) + wrapped["args"] = modified_args + return wrapped + return modified_args + + +def _extract_output(result: Any) -> dict[str, Any]: + """Normalise tool output to a dict for guardrail / rule evaluation. + + Falls back to ``{"output": content}`` for plain strings and anything else. + """ + if isinstance(result, dict): + return result + if isinstance(result, str): + try: + parsed = json.loads(result) + return parsed if isinstance(parsed, dict) else {"output": parsed} + except ValueError: + try: + parsed = ast.literal_eval(result) + return parsed if isinstance(parsed, dict) else {"output": parsed} + except (ValueError, SyntaxError): + return {"output": result} + return {"output": result} diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py new file mode 100644 index 000000000..c88df5acd --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_enums.py @@ -0,0 +1,93 @@ +"""Enums for guardrail decorators.""" + +from enum import Enum + + +class GuardrailExecutionStage(str, Enum): + """Execution stage for guardrails.""" + + PRE = "pre" + """Evaluate before the target executes.""" + + POST = "post" + """Evaluate after the target executes.""" + + PRE_AND_POST = "pre&post" + """Evaluate both before and after the target executes.""" + + +class PIIDetectionEntityType(str, Enum): + """PII detection entity types supported by UiPath guardrails. + + | Value | + |---| + | `PERSON` | + | `ADDRESS` | + | `DATE` | + | `PHONE_NUMBER` | + | `EUGPS_COORDINATES` | + | `EMAIL` | + | `CREDIT_CARD_NUMBER` | + | `INTERNATIONAL_BANKING_ACCOUNT_NUMBER` | + | `SWIFT_CODE` | + | `ABA_ROUTING_NUMBER` | + | `US_DRIVERS_LICENSE_NUMBER` | + | `UK_DRIVERS_LICENSE_NUMBER` | + | `US_INDIVIDUAL_TAXPAYER_IDENTIFICATION` | + | `UK_UNIQUE_TAXPAYER_NUMBER` | + | `US_BANK_ACCOUNT_NUMBER` | + | `US_SOCIAL_SECURITY_NUMBER` | + | `USUK_PASSPORT_NUMBER` | + | `URL` | + | `IP_ADDRESS` | + """ + + PERSON = "Person" + ADDRESS = "Address" + DATE = "Date" + PHONE_NUMBER = "PhoneNumber" + EUGPS_COORDINATES = "EugpsCoordinates" + EMAIL = "Email" + CREDIT_CARD_NUMBER = "CreditCardNumber" + INTERNATIONAL_BANKING_ACCOUNT_NUMBER = "InternationalBankingAccountNumber" + SWIFT_CODE = "SwiftCode" + ABA_ROUTING_NUMBER = "ABARoutingNumber" + US_DRIVERS_LICENSE_NUMBER = "USDriversLicenseNumber" + UK_DRIVERS_LICENSE_NUMBER = "UKDriversLicenseNumber" + US_INDIVIDUAL_TAXPAYER_IDENTIFICATION = "USIndividualTaxpayerIdentification" + UK_UNIQUE_TAXPAYER_NUMBER = "UKUniqueTaxpayerNumber" + US_BANK_ACCOUNT_NUMBER = "USBankAccountNumber" + US_SOCIAL_SECURITY_NUMBER = "USSocialSecurityNumber" + USUK_PASSPORT_NUMBER = "UsukPassportNumber" + URL = "URL" + IP_ADDRESS = "IPAddress" + + +class HarmfulContentEntityType(str, Enum): + """Harmful content entity types supported by UiPath guardrails. + + | Value | + |---| + | `HATE` | + | `SELF_HARM` | + | `SEXUAL` | + | `VIOLENCE` | + """ + + HATE = "Hate" + SELF_HARM = "SelfHarm" + SEXUAL = "Sexual" + VIOLENCE = "Violence" + + +class IntellectualPropertyEntityType(str, Enum): + """Intellectual property entity types supported by UiPath guardrails. + + | Value | + |---| + | `TEXT` | + | `CODE` | + """ + + TEXT = "Text" + CODE = "Code" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_exceptions.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_exceptions.py new file mode 100644 index 000000000..f4b7672e5 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_exceptions.py @@ -0,0 +1,18 @@ +"""Exceptions for guardrail decorators.""" + + +class GuardrailBlockException(Exception): + """Raised by BlockAction when a guardrail blocks execution. + + Framework adapters (e.g. LangChain) should catch this and convert it to + their own runtime exception type at the outermost wrapper boundary. + + Args: + title: Brief title for the block event. + detail: Detailed reason for the block. + """ + + def __init__(self, title: str, detail: str) -> None: + self.title = title + self.detail = detail + super().__init__(f"{title}: {detail}") diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_guardrail.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_guardrail.py new file mode 100644 index 000000000..d61b41c0d --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_guardrail.py @@ -0,0 +1,224 @@ +"""Single ``@guardrail`` decorator for all guardrail types.""" + +import inspect +import logging +from functools import wraps +from typing import Any + +from ._core import ( + _apply_pre_modification, + _collect_input, + _collect_output, + _EvaluatorFn, + _get_excluded_params, + _make_evaluator, + _reconstruct_output, +) +from ._enums import GuardrailExecutionStage +from ._models import GuardrailAction +from ._registry import is_recognized_by_adapter, wrap_with_adapter +from .validators._base import GuardrailValidatorBase + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _run_pre( + evaluator: _EvaluatorFn, + action: GuardrailAction, + name: str, + bound: inspect.BoundArguments, + excluded: set[str], +) -> None: + """Evaluate PRE guardrail and apply any modifications to *bound* in-place.""" + input_data = _collect_input(bound, excluded) + try: + result = evaluator(input_data, GuardrailExecutionStage.PRE, input_data, None) + except Exception as exc: + logger.error("Error evaluating PRE guardrail %r: %s", name, exc, exc_info=True) + return + from uipath.core.guardrails import GuardrailValidationResultType + + if result.result == GuardrailValidationResultType.VALIDATION_FAILED: + modified = action.handle_validation_result(result, input_data, name) + _apply_pre_modification(bound, modified, excluded) + + +def _run_post( + evaluator: _EvaluatorFn, + action: GuardrailAction, + name: str, + bound: inspect.BoundArguments, + excluded: set[str], + return_value: Any, +) -> Any: + """Evaluate POST guardrail and return (possibly modified) return value.""" + input_data = _collect_input(bound, excluded) + output_data = _collect_output(return_value) + try: + result = evaluator( + output_data, GuardrailExecutionStage.POST, input_data, output_data + ) + except Exception as exc: + logger.error("Error evaluating POST guardrail %r: %s", name, exc, exc_info=True) + return return_value + from uipath.core.guardrails import GuardrailValidationResultType + + if result.result == GuardrailValidationResultType.VALIDATION_FAILED: + modified = action.handle_validation_result(result, output_data, name) + return _reconstruct_output(return_value, modified) + return return_value + + +def _wrap_function( + func: Any, + evaluator: _EvaluatorFn, + action: GuardrailAction, + name: str, + stage: GuardrailExecutionStage, + excluded: set[str], +) -> Any: + """Wrap *func* as a pure Python function with PRE/POST guardrail evaluation.""" + sig = inspect.signature(func) + + def _dispatch_return(return_value: Any) -> Any: + """For factory functions: if the return value is recognized by an adapter, wrap it.""" + if is_recognized_by_adapter(return_value): + return wrap_with_adapter(return_value, evaluator, action, name, stage) + return return_value + + if inspect.iscoroutinefunction(func): + + @wraps(func) + async def _wrapped_async(*args: Any, **kwargs: Any) -> Any: + bound = sig.bind(*args, **kwargs) + bound.apply_defaults() + if stage in ( + GuardrailExecutionStage.PRE, + GuardrailExecutionStage.PRE_AND_POST, + ): + _run_pre(evaluator, action, name, bound, excluded) + return_value = await func(*bound.args, **bound.kwargs) + return_value = _dispatch_return(return_value) + if stage in ( + GuardrailExecutionStage.POST, + GuardrailExecutionStage.PRE_AND_POST, + ): + # Only run POST on plain (non-adapter-wrapped) values + if not is_recognized_by_adapter(return_value): + return_value = _run_post( + evaluator, action, name, bound, excluded, return_value + ) + return return_value + + return _wrapped_async + + @wraps(func) + def _wrapped(*args: Any, **kwargs: Any) -> Any: + bound = sig.bind(*args, **kwargs) + bound.apply_defaults() + if stage in ( + GuardrailExecutionStage.PRE, + GuardrailExecutionStage.PRE_AND_POST, + ): + _run_pre(evaluator, action, name, bound, excluded) + return_value = func(*bound.args, **bound.kwargs) + return_value = _dispatch_return(return_value) + if stage in ( + GuardrailExecutionStage.POST, + GuardrailExecutionStage.PRE_AND_POST, + ): + if not is_recognized_by_adapter(return_value): + return_value = _run_post( + evaluator, action, name, bound, excluded, return_value + ) + return return_value + + return _wrapped + + +# --------------------------------------------------------------------------- +# Public @guardrail decorator +# --------------------------------------------------------------------------- + + +def guardrail( + func: Any = None, + *, + validator: GuardrailValidatorBase, + action: GuardrailAction, + name: str = "Guardrail", + description: str | None = None, + stage: GuardrailExecutionStage = GuardrailExecutionStage.PRE_AND_POST, + enabled_for_evals: bool = True, +) -> Any: + """Apply a guardrail to any callable — tool functions, LLM factories, agent nodes. + + When applied to a plain function or async function, the decorator collects + function parameters (PRE) and return value (POST) and evaluates them against + the guardrail. Use :class:`~._core.GuardrailExclude` to opt individual + parameters out of serialization. + + When applied to a factory function whose return value is recognised by a + registered framework adapter (e.g. a LangChain ``BaseChatModel``), the + returned object is wrapped so every subsequent ``invoke()`` call is guarded. + + Multiple ``@guardrail`` decorators can be stacked on the same callable. + + Args: + func: Callable to decorate. Supplied directly when used without parentheses. + validator: :class:`~.validators.GuardrailValidatorBase` defining what to check. + action: :class:`~._models.GuardrailAction` defining how to respond on violation. + name: Human-readable name for this guardrail instance. + description: Optional description passed to API-based guardrails. + stage: When to evaluate — ``PRE``, ``POST``, or ``PRE_AND_POST``. + Defaults to ``PRE_AND_POST``. + enabled_for_evals: Whether this guardrail is active in evaluation scenarios. + Defaults to ``True``. + + Returns: + The decorated callable (or framework object). + + Raises: + ValueError: If *action* is invalid, or the validator does not support + the requested stage. + GuardrailBlockException: Raised at runtime by :class:`~._actions.BlockAction` + when a violation is detected. + """ + if action is None: + raise ValueError("action must be provided") + if not isinstance(action, GuardrailAction): + raise ValueError("action must be an instance of GuardrailAction") + if not isinstance(enabled_for_evals, bool): + raise ValueError("enabled_for_evals must be a boolean") + + def _apply(obj: Any) -> Any: + # ------------------------------------------------------------------ + # 1. Adapter-recognised direct object (e.g. BaseTool after @tool) + # ------------------------------------------------------------------ + if is_recognized_by_adapter(obj): + validator.validate_stage(stage) + evaluator = _make_evaluator(validator, name, description, enabled_for_evals) + return wrap_with_adapter(obj, evaluator, action, name, stage) + + # ------------------------------------------------------------------ + # 2. Plain callable — wrap as pure function + # ------------------------------------------------------------------ + if callable(obj): + validator.validate_stage(stage) + evaluator = _make_evaluator(validator, name, description, enabled_for_evals) + excluded = _get_excluded_params(obj) + return _wrap_function(obj, evaluator, action, name, stage, excluded) + + raise ValueError( + f"@guardrail cannot be applied to {type(obj)!r}. " + "Target must be a callable or a framework-registered object. " + "Ensure the relevant framework adapter is imported before using @guardrail." + ) + + if func is None: + return _apply + return _apply(func) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_models.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_models.py new file mode 100644 index 000000000..8d86fbf39 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_models.py @@ -0,0 +1,76 @@ +"""Models for guardrail decorators.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any + +from uipath.core.guardrails import GuardrailValidationResult + + +@dataclass +class PIIDetectionEntity: + """PII entity configuration with detection threshold. + + Args: + name: The entity type name (e.g. ``PIIDetectionEntityType.EMAIL``). + threshold: Confidence threshold (0.0 to 1.0) for detection. + """ + + name: str + threshold: float = 0.5 + + def __post_init__(self) -> None: + if not 0.0 <= self.threshold <= 1.0: + raise ValueError( + f"Threshold must be between 0.0 and 1.0, got {self.threshold}" + ) + + +@dataclass +class HarmfulContentEntity: + """Harmful content entity configuration with severity threshold. + + Args: + name: The entity type name (e.g. ``HarmfulContentEntityType.VIOLENCE``). + threshold: Severity threshold (0 to 6) for detection. Defaults to ``2``. + """ + + name: str + threshold: int = 2 + + def __post_init__(self) -> None: + if not 0 <= self.threshold <= 6: + raise ValueError(f"Threshold must be between 0 and 6, got {self.threshold}") + + +class GuardrailAction(ABC): + """Interface for defining custom actions when a guardrail violation is detected. + + Subclass this to implement custom behaviour on validation failure, such as + logging, blocking, or content sanitisation. Built-in implementations are + :class:`LogAction` and :class:`BlockAction`. + """ + + @abstractmethod + def handle_validation_result( + self, + result: GuardrailValidationResult, + data: str | dict[str, Any], + guardrail_name: str, + ) -> "str | dict[str, Any] | None": + """Handle a guardrail validation result. + + Called when guardrail validation fails. May return modified data to + sanitise/filter the validated content before execution continues, or + ``None`` to leave it unchanged. + + Args: + result: The validation result from the guardrails service. + data: The data that was validated (string or dictionary). Depending + on context this can be tool input, tool output, or message text. + guardrail_name: The name of the guardrail that triggered. + + Returns: + Modified data if the action wants to replace the original, or + ``None`` if no modification is needed. + """ diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_registry.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_registry.py new file mode 100644 index 000000000..c4b7773b5 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/_registry.py @@ -0,0 +1,105 @@ +"""Adapter registry for guardrail target recognition and wrapping.""" + +from typing import Any, Protocol, runtime_checkable + +from ._core import _EvaluatorFn +from ._enums import GuardrailExecutionStage +from ._models import GuardrailAction + + +@runtime_checkable +class GuardrailTargetAdapter(Protocol): + """Protocol for framework-specific guardrail adapters. + + Implement this protocol to teach :func:`guardrail` how to handle objects + from a particular framework. Register instances via + :func:`register_guardrail_adapter`. + """ + + def recognize(self, target: Any) -> bool: + """Return ``True`` if this adapter handles *target*. + + Args: + target: Object being decorated or returned by a factory function. + + Returns: + ``True`` if this adapter can wrap *target*, ``False`` otherwise. + """ + ... + + def wrap( + self, + target: Any, + evaluator: _EvaluatorFn, + action: GuardrailAction, + name: str, + stage: GuardrailExecutionStage, + ) -> Any: + """Wrap *target* with guardrail enforcement logic. + + Args: + target: Object to wrap. + evaluator: Unified evaluation callable from :func:`_make_evaluator`. + action: Action to invoke on validation failure. + name: Human-readable guardrail name. + stage: When to evaluate (PRE, POST, or PRE_AND_POST). + + Returns: + Wrapped object, same type or duck-type compatible. + """ + ... + + +# Module-level registry. Later-registered adapters take priority (inserted at 0). +_adapters: list[GuardrailTargetAdapter] = [] + + +def register_guardrail_adapter(adapter: GuardrailTargetAdapter) -> None: + """Register a framework adapter for the ``@guardrail`` decorator. + + Later-registered adapters are tried first. + + Args: + adapter: An instance implementing :class:`GuardrailTargetAdapter`. + """ + _adapters.insert(0, adapter) + + +def is_recognized_by_adapter(target: Any) -> bool: + """Return ``True`` if any registered adapter recognizes *target*. + + Args: + target: The object being decorated. + + Returns: + ``True`` if a registered adapter handles *target*. + """ + for adapter in _adapters: + if adapter.recognize(target): + return True + return False + + +def wrap_with_adapter( + target: Any, + evaluator: _EvaluatorFn, + action: GuardrailAction, + name: str, + stage: GuardrailExecutionStage, +) -> Any: + """Ask the first matching adapter to wrap *target*. + + Args: + target: The object to wrap. + evaluator: Unified evaluation callable. + action: Action on violation. + name: Guardrail name. + stage: Execution stage. + + Returns: + Wrapped object, or *target* unchanged if no adapter handles it. + """ + for adapter in _adapters: + if adapter.recognize(target): + return adapter.wrap(target, evaluator, action, name, stage) + return target diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py new file mode 100644 index 000000000..bbcf29039 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py @@ -0,0 +1,26 @@ +"""Guardrail validators for the ``@guardrail`` decorator.""" + +from ._base import ( + BuiltInGuardrailValidator, + CustomGuardrailValidator, + GuardrailValidatorBase, +) +from .custom import CustomValidator, RuleFunction +from .harmful_content import HarmfulContentValidator +from .intellectual_property import IntellectualPropertyValidator +from .pii import PIIValidator +from .prompt_injection import PromptInjectionValidator +from .user_prompt_attacks import UserPromptAttacksValidator + +__all__ = [ + "GuardrailValidatorBase", + "BuiltInGuardrailValidator", + "CustomGuardrailValidator", + "HarmfulContentValidator", + "IntellectualPropertyValidator", + "PIIValidator", + "PromptInjectionValidator", + "UserPromptAttacksValidator", + "CustomValidator", + "RuleFunction", +] diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/_base.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/_base.py new file mode 100644 index 000000000..a9eaf5afd --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/_base.py @@ -0,0 +1,182 @@ +"""Abstract base classes for guardrail validators.""" + +from abc import ABC, abstractmethod +from typing import Any, ClassVar + +from uipath.core.guardrails import GuardrailValidationResult + +from uipath.platform.guardrails.guardrails import BuiltInValidatorGuardrail + +from .._enums import GuardrailExecutionStage + + +class GuardrailValidatorBase: + """Root base class for guardrail validators. + + Concrete validators should subclass either + :class:`BuiltInGuardrailValidator` (for UiPath API-backed validation) + or :class:`CustomGuardrailValidator` (for in-process Python validation). + """ + + supported_stages: ClassVar[list[GuardrailExecutionStage]] = [] + """Stages this validator supports. Empty list means all stages are allowed.""" + + def validate_stage(self, stage: GuardrailExecutionStage) -> None: + """Raise ``ValueError`` if *stage* is not in :attr:`supported_stages`. + + Args: + stage: Requested execution stage. + + Raises: + ValueError: If :attr:`supported_stages` is non-empty and *stage* is absent. + """ + if self.supported_stages and stage not in self.supported_stages: + raise ValueError( + f"{type(self).__name__} does not support stage {stage!r}. " + f"Supported stages: {[s.value for s in self.supported_stages]}" + ) + + def run( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + data: "str | dict[str, Any]", + stage: GuardrailExecutionStage, + input_data: "dict[str, Any] | None", + output_data: "dict[str, Any] | None", + ) -> GuardrailValidationResult: + """Execute the guardrail evaluation. + + Called by the ``@guardrail`` decorator at each function invocation. + Subclasses override this via :class:`BuiltInGuardrailValidator` or + :class:`CustomGuardrailValidator`. + + Raises: + NotImplementedError: Always — subclass one of the two ABCs instead. + """ + raise NotImplementedError( + f"{type(self).__name__} must subclass BuiltInGuardrailValidator " + "or CustomGuardrailValidator and implement the required abstract method." + ) + + +class BuiltInGuardrailValidator(GuardrailValidatorBase, ABC): + """Base for validators that delegate to the UiPath Guardrails API. + + Subclass this and implement :meth:`get_built_in_guardrail` to create an + API-backed guardrail validator (e.g. PII detection, prompt injection). + + Example:: + + class MyValidator(BuiltInGuardrailValidator): + def get_built_in_guardrail(self, name, description, enabled_for_evals): + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + ... + ) + """ + + @abstractmethod + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build the UiPath API guardrail definition for this validator. + + Args: + name: Name for the guardrail instance. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + :class:`BuiltInValidatorGuardrail` ready to be sent to the API. + """ + ... + + def run( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + data: "str | dict[str, Any]", + stage: GuardrailExecutionStage, + input_data: "dict[str, Any] | None", + output_data: "dict[str, Any] | None", + ) -> GuardrailValidationResult: + """Evaluate via the UiPath Guardrails API. + + Lazily initialises the ``UiPath`` client on the first call and reuses + it for all subsequent invocations. + """ + built_in = self.get_built_in_guardrail(name, description, enabled_for_evals) + if not hasattr(self, "_uipath"): + from uipath.platform import UiPath + + self._uipath: Any = UiPath() + return self._uipath.guardrails.evaluate_guardrail(data, built_in) + + +class CustomGuardrailValidator(GuardrailValidatorBase, ABC): + """Base for validators that run entirely in-process. + + Subclass this and implement :meth:`evaluate` to create a local guardrail + validator that requires no UiPath API call. + + Example:: + + class ProfanityValidator(CustomGuardrailValidator): + BANNED = {"badword"} + + def evaluate(self, data, stage, input_data, output_data): + text = (input_data or {}).get("message", "") + if any(w in text.lower() for w in self.BANNED): + return GuardrailValidationResult( + result=GuardrailValidationResultType.VALIDATION_FAILED, + reason="Profanity detected", + ) + return GuardrailValidationResult(result=GuardrailValidationResultType.PASSED) + """ + + @abstractmethod + def evaluate( + self, + data: "str | dict[str, Any]", + stage: GuardrailExecutionStage, + input_data: "dict[str, Any] | None", + output_data: "dict[str, Any] | None", + ) -> GuardrailValidationResult: + """Perform local validation without a UiPath API call. + + Return a result with ``VALIDATION_FAILED`` to **trigger** the guardrail + (causing the configured :class:`~uipath.platform.guardrails.decorators.GuardrailAction` + to fire), or ``PASSED`` to let execution continue unchanged. + + Args: + data: Primary data being evaluated. + stage: Current execution stage (PRE or POST). + input_data: Normalised function input dict, or ``None``. + output_data: Normalised function output dict, or ``None`` at PRE stage. + + Returns: + :class:`~uipath.core.guardrails.GuardrailValidationResult` — + return ``VALIDATION_FAILED`` to activate the guardrail, + ``PASSED`` to allow execution to continue. + """ + ... + + def run( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + data: "str | dict[str, Any]", + stage: GuardrailExecutionStage, + input_data: "dict[str, Any] | None", + output_data: "dict[str, Any] | None", + ) -> GuardrailValidationResult: + """Delegate to :meth:`evaluate`.""" + return self.evaluate(data, stage, input_data, output_data) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/custom.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/custom.py new file mode 100644 index 000000000..df6549600 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/custom.py @@ -0,0 +1,125 @@ +"""Custom (rule-based) guardrail validator.""" + +import inspect +from typing import Any, Callable + +from uipath.core.guardrails import ( + GuardrailValidationResult, + GuardrailValidationResultType, +) + +from .._enums import GuardrailExecutionStage +from ._base import CustomGuardrailValidator + +RuleFunction = ( + Callable[[dict[str, Any]], bool] | Callable[[dict[str, Any], dict[str, Any]], bool] +) +"""Type alias for custom rule functions passed to :class:`CustomValidator`. + +The rule must return ``True`` to **trigger** the guardrail (i.e. signal a +violation that causes the configured action to fire), or ``False`` to let +execution continue unchanged. + +It accepts either one parameter (the input or output dict) or two parameters +(input dict, output dict — POST stage only). + +Examples:: + + # Triggered when "donkey" appears in the joke argument + CustomValidator(lambda args: "donkey" in args.get("joke", "").lower()) + + # Triggered when the output joke exceeds 500 characters + CustomValidator(lambda args: len(args.get("joke", "")) > 500) + + # Two-parameter form: triggered at POST when output contains input keyword + CustomValidator(lambda inp, out: inp.get("topic", "") in out.get("joke", "")) +""" + + +class CustomValidator(CustomGuardrailValidator): + """Validate function input/output using a local Python rule function. + + No UiPath API call is made. Applicable at any stage. + + The *rule* is called with the collected parameter dict (PRE stage) or the + serialised return-value dict (POST stage). It must return ``True`` to + **activate** the guardrail — i.e. to signal a violation and invoke the + configured :class:`~uipath.platform.guardrails.decorators.GuardrailAction`. + Return ``False`` (or any falsy value) to let execution continue unchanged. + + Args: + rule: A :data:`RuleFunction` that returns ``True`` to trigger the + guardrail. Must accept 1 or 2 parameters. + + Raises: + ValueError: If *rule* is not callable or has an unsupported parameter count. + """ + + def __init__(self, rule: RuleFunction) -> None: + """Initialize CustomValidator with a rule callable.""" + if not callable(rule): + raise ValueError(f"rule must be callable, got {type(rule)}") + sig = inspect.signature(rule) + param_count = len(sig.parameters) + if param_count not in (1, 2): + raise ValueError(f"rule must have 1 or 2 parameters, got {param_count}") + self.rule = rule + self._param_count = param_count + + def evaluate( + self, + data: str | dict[str, Any], + stage: GuardrailExecutionStage, + input_data: dict[str, Any] | None, + output_data: dict[str, Any] | None, + ) -> GuardrailValidationResult: + """Run the rule against the collected input or output dict. + + The rule receives the PRE parameter dict or POST return-value dict and + must return ``True`` to **trigger** the guardrail (VALIDATION_FAILED), + or ``False`` to pass. + + Args: + data: Unused; the rule operates on *input_data* or *output_data*. + stage: Current stage (PRE or POST). + input_data: Collected function input dict. + output_data: Collected function output dict, or ``None`` at PRE stage. + + Returns: + :class:`~uipath.core.guardrails.GuardrailValidationResult` — + ``VALIDATION_FAILED`` when the rule returns ``True`` (guardrail + triggered), ``PASSED`` otherwise. + """ + try: + if self._param_count == 2: + if input_data is None or output_data is None: + return GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, + reason="Two-parameter rule skipped: input or output data unavailable", + ) + violation = self.rule(input_data, output_data) # type: ignore[call-arg] + else: + target = ( + input_data if stage == GuardrailExecutionStage.PRE else output_data + ) + if target is None: + return GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, + reason="Rule skipped: data unavailable at this stage", + ) + violation = self.rule(target) # type: ignore[call-arg] + except Exception as exc: + return GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, + reason=f"Rule raised exception: {exc}", + ) + + if violation: + return GuardrailValidationResult( + result=GuardrailValidationResultType.VALIDATION_FAILED, + reason="Rule detected violation", + ) + return GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, + reason="Rule passed", + ) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/harmful_content.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/harmful_content.py new file mode 100644 index 000000000..d186341d7 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/harmful_content.py @@ -0,0 +1,77 @@ +"""Harmful content detection guardrail validator.""" + +from typing import Any, Sequence +from uuid import uuid4 + +from uipath.platform.guardrails.guardrails import ( + BuiltInValidatorGuardrail, + EnumListParameterValue, + MapEnumParameterValue, +) + +from .._models import HarmfulContentEntity +from ._base import BuiltInGuardrailValidator + + +class HarmfulContentValidator(BuiltInGuardrailValidator): + """Validate data for harmful content using the UiPath API. + + Supported at all stages (PRE, POST, PRE_AND_POST). + + Args: + entities: One or more :class:`~uipath.platform.guardrails.decorators.HarmfulContentEntity` + instances specifying which harmful content categories to detect + and their severity thresholds. + + Raises: + ValueError: If *entities* is empty. + """ + + def __init__(self, entities: Sequence[HarmfulContentEntity]) -> None: + """Initialize HarmfulContentValidator with entities to detect.""" + if not entities: + raise ValueError("entities must be provided and non-empty") + self.entities = list(entities) + + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build a harmful content :class:`BuiltInValidatorGuardrail`. + + Args: + name: Name for the guardrail. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Configured :class:`BuiltInValidatorGuardrail` for harmful content detection. + """ + entity_names = [entity.name for entity in self.entities] + entity_thresholds: dict[str, Any] = { + entity.name: entity.threshold for entity in self.entities + } + + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + description=description + or f"Detects harmful content: {', '.join(entity_names)}", + enabled_for_evals=enabled_for_evals, + guardrail_type="builtInValidator", + validator_type="harmful_content", + validator_parameters=[ + EnumListParameterValue( + parameter_type="enum-list", + id="harmfulContentEntities", + value=entity_names, + ), + MapEnumParameterValue( + parameter_type="map-enum", + id="harmfulContentEntityThresholds", + value=entity_thresholds, + ), + ], + ) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/intellectual_property.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/intellectual_property.py new file mode 100644 index 000000000..8a18e6a37 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/intellectual_property.py @@ -0,0 +1,67 @@ +"""Intellectual property detection guardrail validator.""" + +from typing import Sequence +from uuid import uuid4 + +from uipath.platform.guardrails.guardrails import ( + BuiltInValidatorGuardrail, + EnumListParameterValue, +) + +from .._enums import GuardrailExecutionStage +from ._base import BuiltInGuardrailValidator + + +class IntellectualPropertyValidator(BuiltInGuardrailValidator): + """Validate output for intellectual property violations using the UiPath API. + + Restricted to POST stage only — IP detection is an output-only concern. + + Args: + entities: One or more entity type strings (e.g. + ``IntellectualPropertyEntityType.TEXT``). + + Raises: + ValueError: If *entities* is empty. + """ + + supported_stages = [GuardrailExecutionStage.POST] + + def __init__(self, entities: Sequence[str]) -> None: + """Initialize IntellectualPropertyValidator with entities to detect.""" + if not entities: + raise ValueError("entities must be provided and non-empty") + self.entities = list(entities) + + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build an intellectual property :class:`BuiltInValidatorGuardrail`. + + Args: + name: Name for the guardrail. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Configured :class:`BuiltInValidatorGuardrail` for IP detection. + """ + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + description=description + or f"Detects intellectual property: {', '.join(self.entities)}", + enabled_for_evals=enabled_for_evals, + guardrail_type="builtInValidator", + validator_type="intellectual_property", + validator_parameters=[ + EnumListParameterValue( + parameter_type="enum-list", + id="ipEntities", + value=self.entities, + ), + ], + ) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/pii.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/pii.py new file mode 100644 index 000000000..64d0a47aa --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/pii.py @@ -0,0 +1,76 @@ +"""PII detection guardrail validator.""" + +from typing import Any, Sequence +from uuid import uuid4 + +from uipath.platform.guardrails.guardrails import ( + BuiltInValidatorGuardrail, + EnumListParameterValue, + MapEnumParameterValue, +) + +from .._models import PIIDetectionEntity +from ._base import BuiltInGuardrailValidator + + +class PIIValidator(BuiltInGuardrailValidator): + """Validate data for PII entities using the UiPath PII detection API. + + Supported at all stages. + + Args: + entities: One or more :class:`~uipath.platform.guardrails.decorators.PIIDetectionEntity` + instances specifying which PII types to detect and their confidence thresholds. + + Raises: + ValueError: If *entities* is empty. + """ + + def __init__(self, entities: Sequence[PIIDetectionEntity]) -> None: + """Initialize PIIValidator with a list of entities to detect.""" + if not entities: + raise ValueError("entities must be provided and non-empty") + self.entities = list(entities) + + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build a PII detection :class:`BuiltInValidatorGuardrail`. + + Args: + name: Name for the guardrail. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Configured :class:`BuiltInValidatorGuardrail` for PII detection. + """ + entity_names = [entity.name for entity in self.entities] + entity_thresholds: dict[str, Any] = { + entity.name: entity.threshold for entity in self.entities + } + + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + description=description + or f"Detects PII entities: {', '.join(entity_names)}", + enabled_for_evals=enabled_for_evals, + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[ + EnumListParameterValue( + parameter_type="enum-list", + id="entities", + value=entity_names, + ), + MapEnumParameterValue( + parameter_type="map-enum", + id="entityThresholds", + value=entity_thresholds, + ), + ], + ) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/prompt_injection.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/prompt_injection.py new file mode 100644 index 000000000..b0943b396 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/prompt_injection.py @@ -0,0 +1,65 @@ +"""Prompt injection detection guardrail validator.""" + +from uuid import uuid4 + +from uipath.platform.guardrails.guardrails import ( + BuiltInValidatorGuardrail, + NumberParameterValue, +) + +from .._enums import GuardrailExecutionStage +from ._base import BuiltInGuardrailValidator + + +class PromptInjectionValidator(BuiltInGuardrailValidator): + """Validate input for prompt injection attacks via the UiPath API. + + Restricted to PRE stage only — prompt injection is an input-only concern. + + Args: + threshold: Detection confidence threshold (0.0–1.0). Defaults to ``0.5``. + + Raises: + ValueError: If *threshold* is outside [0.0, 1.0]. + """ + + supported_stages = [GuardrailExecutionStage.PRE] + + def __init__(self, threshold: float = 0.5) -> None: + """Initialize PromptInjectionValidator with a detection threshold.""" + if not 0.0 <= threshold <= 1.0: + raise ValueError(f"threshold must be between 0.0 and 1.0, got {threshold}") + self.threshold = threshold + + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build a prompt injection :class:`BuiltInValidatorGuardrail`. + + Args: + name: Name for the guardrail. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Configured :class:`BuiltInValidatorGuardrail` for prompt injection. + """ + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + description=description + or f"Detects prompt injection with threshold {self.threshold}", + enabled_for_evals=enabled_for_evals, + guardrail_type="builtInValidator", + validator_type="prompt_injection", + validator_parameters=[ + NumberParameterValue( + parameter_type="number", + id="threshold", + value=self.threshold, + ), + ], + ) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/user_prompt_attacks.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/user_prompt_attacks.py new file mode 100644 index 000000000..7275acc25 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/user_prompt_attacks.py @@ -0,0 +1,44 @@ +"""User prompt attacks detection guardrail validator.""" + +from uuid import uuid4 + +from uipath.platform.guardrails.guardrails import BuiltInValidatorGuardrail + +from .._enums import GuardrailExecutionStage +from ._base import BuiltInGuardrailValidator + + +class UserPromptAttacksValidator(BuiltInGuardrailValidator): + """Validate input for user prompt attacks via the UiPath API. + + Restricted to PRE stage only — prompt attacks are an input-only concern. + Takes no parameters. + """ + + supported_stages = [GuardrailExecutionStage.PRE] + + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build a user prompt attacks :class:`BuiltInValidatorGuardrail`. + + Args: + name: Name for the guardrail. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Configured :class:`BuiltInValidatorGuardrail` for user prompt attacks. + """ + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + description=description or "Detects user prompt attacks", + enabled_for_evals=enabled_for_evals, + guardrail_type="builtInValidator", + validator_type="user_prompt_attacks", + validator_parameters=[], + ) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py index cfc1e295f..16262ca5e 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py @@ -37,8 +37,43 @@ class NumberParameterValue(BaseModel): model_config = ConfigDict(populate_by_name=True, extra="allow") +class EnumParameterValue(BaseModel): + """Single-select enum parameter value.""" + + parameter_type: Literal["enum"] = Field(alias="$parameterType") + id: str + value: str + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class TextParameterValue(BaseModel): + """Free-text parameter value.""" + + parameter_type: Literal["text"] = Field(alias="$parameterType") + id: str + value: str + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class TextListParameterValue(BaseModel): + """List-of-text parameter value.""" + + parameter_type: Literal["text-list"] = Field(alias="$parameterType") + id: str + value: list[str] + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + ValidatorParameter = Annotated[ - EnumListParameterValue | MapEnumParameterValue | NumberParameterValue, + EnumListParameterValue + | MapEnumParameterValue + | NumberParameterValue + | EnumParameterValue + | TextParameterValue + | TextListParameterValue, Field(discriminator="parameter_type"), ] diff --git a/packages/uipath-platform/src/uipath/platform/memory/__init__.py b/packages/uipath-platform/src/uipath/platform/memory/__init__.py new file mode 100644 index 000000000..31e364814 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/__init__.py @@ -0,0 +1,39 @@ +"""Init file for memory module.""" + +from ._memory_service import MemoryService +from .memory import ( + CachedRecall, + EscalationMemoryIngestRequest, + EscalationMemoryMatch, + EscalationMemorySearchResponse, + FieldSettings, + MemoryMatch, + MemoryMatchField, + MemorySearchRequest, + MemorySearchResponse, + MemorySpace, + MemorySpaceCreateRequest, + MemorySpaceListResponse, + SearchField, + SearchMode, + SearchSettings, +) + +__all__ = [ + "CachedRecall", + "EscalationMemoryIngestRequest", + "EscalationMemoryMatch", + "EscalationMemorySearchResponse", + "FieldSettings", + "MemoryMatch", + "MemoryMatchField", + "MemorySearchRequest", + "MemorySearchResponse", + "MemoryService", + "MemorySpace", + "MemorySpaceCreateRequest", + "MemorySpaceListResponse", + "SearchField", + "SearchMode", + "SearchSettings", +] diff --git a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py new file mode 100644 index 000000000..73d788f80 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py @@ -0,0 +1,493 @@ +"""Memory Spaces service. + +Memory space CRUD (create/list) goes through ECS v2. +Search and escalation memory operations go through LLMOps, which +enriches traces/feedback before forwarding to ECS. +""" + +from typing import Any, Optional + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._bindings import resource_override +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext, header_folder +from ..common._models import Endpoint, RequestSpec +from ..orchestrator._folder_service import FolderService +from .memory import ( + EscalationMemoryIngestRequest, + EscalationMemorySearchResponse, + MemorySearchRequest, + MemorySearchResponse, + MemorySpace, + MemorySpaceCreateRequest, + MemorySpaceListResponse, +) + +_MEMORY_SPACES_BASE = "/ecs_/v2/episodicmemories" +_LLMOPS_AGENT_BASE = "/llmopstenant_/api/Agent/memory" + + +class MemoryService(FolderContext, BaseService): + """Service for Agent Memory Spaces. + + Agent Memory allows agents to persist context across jobs using dynamic + few-shot retrieval. Memory spaces are folder-scoped and managed via ECS. + Search is routed through LLMOps, which handles trace/feedback enrichment + and system prompt injection. Escalation memory enables agents to recall + previously resolved escalation outcomes. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: FolderService, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + + # ── Memory space operations (ECS) ────────────────────────────────── + + @resource_override(resource_type="memorySpace") + @traced(name="memory_create", run_type="uipath") + def create( + self, + name: str, + description: Optional[str] = None, + is_encrypted: Optional[bool] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> MemorySpace: + """Create a new memory space. + + Args: + name: The name of the memory space (max 128 chars). + description: Optional description (max 1024 chars). + is_encrypted: Whether the memory space should be encrypted. + folder_key: The folder key for the operation. + folder_path: The folder path for the operation. + + Returns: + MemorySpace: The created memory space. + """ + spec = self._create_spec( + name, description, is_encrypted, folder_key, folder_path + ) + response = self.request( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ).json() + return MemorySpace.model_validate(response) + + @resource_override(resource_type="memorySpace") + @traced(name="memory_create", run_type="uipath") + async def create_async( + self, + name: str, + description: Optional[str] = None, + is_encrypted: Optional[bool] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> MemorySpace: + """Asynchronously create a new memory space. + + Args: + name: The name of the memory space (max 128 chars). + description: Optional description (max 1024 chars). + is_encrypted: Whether the memory space should be encrypted. + folder_key: The folder key for the operation. + folder_path: The folder path for the operation. + + Returns: + MemorySpace: The created memory space. + """ + spec = self._create_spec( + name, description, is_encrypted, folder_key, folder_path + ) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + ).json() + return MemorySpace.model_validate(response) + + @traced(name="memory_list", run_type="uipath") + def list( + self, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: Optional[int] = None, + skip: Optional[int] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> MemorySpaceListResponse: + """List memory spaces with optional OData query parameters. + + Args: + filter: OData $filter expression. + orderby: OData $orderby expression. + top: Maximum number of results. + skip: Number of results to skip. + folder_key: The folder key for the operation. + folder_path: The folder path for the operation. + + Returns: + MemorySpaceListResponse: The list of memory spaces. + """ + spec = self._list_spec(filter, orderby, top, skip, folder_key, folder_path) + response = self.request( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + return MemorySpaceListResponse.model_validate(response) + + @traced(name="memory_list", run_type="uipath") + async def list_async( + self, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: Optional[int] = None, + skip: Optional[int] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> MemorySpaceListResponse: + """Asynchronously list memory spaces. + + Args: + filter: OData $filter expression. + orderby: OData $orderby expression. + top: Maximum number of results. + skip: Number of results to skip. + folder_key: The folder key for the operation. + folder_path: The folder path for the operation. + + Returns: + MemorySpaceListResponse: The list of memory spaces. + """ + spec = self._list_spec(filter, orderby, top, skip, folder_key, folder_path) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + return MemorySpaceListResponse.model_validate(response) + + # ── Search (LLMOps) ─────────────────────────────────────────────── + + @traced(name="memory_search", run_type="uipath") + def search( + self, + memory_space_id: str, + request: MemorySearchRequest, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> MemorySearchResponse: + """Search a memory space via LLMOps. + + Returns search results with scores and a systemPromptInjection + string ready for the agent loop. + + Args: + memory_space_id: The GUID of the memory space. + request: The search request payload. + folder_key: The folder key for the operation. + folder_path: The folder path for the operation. + + Returns: + MemorySearchResponse: Results, metadata, and system prompt injection. + """ + spec = self._search_spec(memory_space_id, folder_key, folder_path) + response = self.request( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ).json() + return MemorySearchResponse.model_validate(response) + + @traced(name="memory_search", run_type="uipath") + async def search_async( + self, + memory_space_id: str, + request: MemorySearchRequest, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> MemorySearchResponse: + """Asynchronously search a memory space via LLMOps. + + Returns search results with scores and a systemPromptInjection + string ready for the agent loop. + + Args: + memory_space_id: The GUID of the memory space. + request: The search request payload. + folder_key: The folder key for the operation. + folder_path: The folder path for the operation. + + Returns: + MemorySearchResponse: Results, metadata, and system prompt injection. + """ + spec = self._search_spec(memory_space_id, folder_key, folder_path) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ) + ).json() + return MemorySearchResponse.model_validate(response) + + # ── Escalation memory (LLMOps) ──────────────────────────────────── + + @traced(name="memory_escalation_search", run_type="uipath") + def escalation_search( + self, + memory_space_id: str, + request: MemorySearchRequest, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> EscalationMemorySearchResponse: + """Search escalation memory for previously resolved outcomes. + + Allows agents to recall past escalation resolutions to avoid + re-escalating for similar situations. + + Args: + memory_space_id: The GUID of the memory space. + request: The search request payload (same as regular search). + folder_key: The folder key for the operation. + folder_path: The folder path for the operation. + + Returns: + EscalationMemorySearchResponse: Matched escalation outcomes. + """ + spec = self._escalation_search_spec(memory_space_id, folder_key, folder_path) + response = self.request( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ).json() + return EscalationMemorySearchResponse.model_validate(response) + + @traced(name="memory_escalation_search", run_type="uipath") + async def escalation_search_async( + self, + memory_space_id: str, + request: MemorySearchRequest, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> EscalationMemorySearchResponse: + """Asynchronously search escalation memory for previously resolved outcomes. + + Allows agents to recall past escalation resolutions to avoid + re-escalating for similar situations. + + Args: + memory_space_id: The GUID of the memory space. + request: The search request payload (same as regular search). + folder_key: The folder key for the operation. + folder_path: The folder path for the operation. + + Returns: + EscalationMemorySearchResponse: Matched escalation outcomes. + """ + spec = self._escalation_search_spec(memory_space_id, folder_key, folder_path) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ) + ).json() + return EscalationMemorySearchResponse.model_validate(response) + + @traced(name="memory_escalation_ingest", run_type="uipath") + def escalation_ingest( + self, + memory_space_id: str, + request: EscalationMemoryIngestRequest, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> None: + """Ingest a resolved escalation outcome into memory. + + Persists the outcome so future agent runs can recall it + without re-escalating. + + Args: + memory_space_id: The GUID of the memory space. + request: The escalation ingest payload. + folder_key: The folder key for the operation. + folder_path: The folder path for the operation. + """ + spec = self._escalation_ingest_spec(memory_space_id, folder_key, folder_path) + self.request( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ) + + @traced(name="memory_escalation_ingest", run_type="uipath") + async def escalation_ingest_async( + self, + memory_space_id: str, + request: EscalationMemoryIngestRequest, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> None: + """Asynchronously ingest a resolved escalation outcome into memory. + + Persists the outcome so future agent runs can recall it + without re-escalating. + + Args: + memory_space_id: The GUID of the memory space. + request: The escalation ingest payload. + folder_key: The folder key for the operation. + folder_path: The folder path for the operation. + """ + spec = self._escalation_ingest_spec(memory_space_id, folder_key, folder_path) + await self.request_async( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ) + + # ── Private spec builders ───────────────────────────────────────── + + def _resolve_folder( + self, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Optional[str]: + """Resolve the folder key, supporting folder_path lookup for serverless. + + Priority: + 1. Explicit folder_key argument + 2. Explicit folder_path argument → resolve via FolderService + 3. UIPATH_FOLDER_KEY env var (via FolderContext._folder_key) + 4. UIPATH_FOLDER_PATH env var → resolve via FolderService + """ + if folder_key is None and folder_path is not None: + folder_key = self._folders_service.retrieve_key(folder_path=folder_path) + + if folder_key is None and folder_path is None: + folder_key = self._folder_key or ( + self._folders_service.retrieve_key(folder_path=self._folder_path) + if self._folder_path + else None + ) + + return folder_key + + # -- ECS specs -- + + def _create_spec( + self, + name: str, + description: Optional[str], + is_encrypted: Optional[bool], + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key, folder_path) + body = MemorySpaceCreateRequest( + name=name, + description=description, + is_encrypted=is_encrypted, + ) + return RequestSpec( + method="POST", + endpoint=Endpoint(f"{_MEMORY_SPACES_BASE}/create"), + json=body.model_dump(by_alias=True, exclude_none=True), + headers={**header_folder(folder_key, None)}, + ) + + def _list_spec( + self, + filter: Optional[str], + orderby: Optional[str], + top: Optional[int], + skip: Optional[int], + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key, folder_path) + params: dict[str, Any] = {} + if filter is not None: + params["$filter"] = filter + if orderby is not None: + params["$orderby"] = orderby + if top is not None: + params["$top"] = top + if skip is not None: + params["$skip"] = skip + return RequestSpec( + method="GET", + endpoint=Endpoint(_MEMORY_SPACES_BASE), + params=params, + headers={**header_folder(folder_key, None)}, + ) + + # -- LLMOps specs -- + + def _search_spec( + self, + memory_space_id: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key, folder_path) + return RequestSpec( + method="POST", + endpoint=Endpoint(f"{_LLMOPS_AGENT_BASE}/{memory_space_id}/search"), + headers={**header_folder(folder_key, None)}, + ) + + def _escalation_search_spec( + self, + memory_space_id: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key, folder_path) + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"{_LLMOPS_AGENT_BASE}/{memory_space_id}/escalation/search" + ), + headers={**header_folder(folder_key, None)}, + ) + + def _escalation_ingest_spec( + self, + memory_space_id: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key, folder_path) + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"{_LLMOPS_AGENT_BASE}/{memory_space_id}/escalation/ingest" + ), + headers={**header_folder(folder_key, None)}, + ) diff --git a/packages/uipath-platform/src/uipath/platform/memory/memory.py b/packages/uipath-platform/src/uipath/platform/memory/memory.py new file mode 100644 index 000000000..aadffbc79 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/memory.py @@ -0,0 +1,191 @@ +"""Pydantic models for the Memory Spaces API. + +Memory space CRUD goes through ECS v2. Search goes through LLMOps, +which enriches traces/feedback before forwarding to ECS. +Escalation memory operations also go through LLMOps. +""" + +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + +# ── Enums ────────────────────────────────────────────────────────────── + + +class SearchMode(str, Enum): + """Search mode for memory space queries.""" + + Hybrid = "Hybrid" + Semantic = "Semantic" + + +# ── Shared field models (used by both ECS and LLMOps) ───────────────── + + +class FieldSettings(BaseModel): + """Per-field search settings (optional overrides).""" + + model_config = ConfigDict(populate_by_name=True) + + weight: float = Field(default=1.0, alias="weight", ge=0.0, le=1.0) + threshold: Optional[float] = Field(None, alias="threshold", ge=0.0, le=1.0) + search_mode: Optional[SearchMode] = Field(None, alias="searchMode") + + +class SearchField(BaseModel): + """A field in a search request, with per-field settings.""" + + model_config = ConfigDict(populate_by_name=True) + + key_path: List[str] = Field(..., alias="keyPath", min_length=1) + value: str = Field(..., alias="value", min_length=1) + settings: FieldSettings = Field(default_factory=FieldSettings, alias="settings") + + +class SearchSettings(BaseModel): + """Top-level search settings.""" + + model_config = ConfigDict(populate_by_name=True) + + threshold: float = Field(default=0.0, alias="threshold", ge=0.0, le=1.0) + result_count: int = Field(default=1, alias="resultCount", ge=1, le=10) + search_mode: SearchMode = Field(..., alias="searchMode") + + +class MemoryMatchField(BaseModel): + """A field within a search result, with scoring details.""" + + model_config = ConfigDict(populate_by_name=True) + + key_path: List[str] = Field(..., alias="keyPath") + value: str = Field(..., alias="value") + weight: float = Field(..., alias="weight") + score: float = Field(..., alias="score") + weighted_score: float = Field(..., alias="weightedScore") + + +# ── ECS request models (memory space CRUD) ──────────────────────────── + + +class MemorySpaceCreateRequest(BaseModel): + """Request payload for creating a memory space (ECS).""" + + model_config = ConfigDict(populate_by_name=True) + + name: str = Field(..., alias="name", max_length=128, min_length=1) + description: Optional[str] = Field(None, alias="description", max_length=1024) + is_encrypted: Optional[bool] = Field(None, alias="isEncrypted") + + +# ── ECS response models ─────────────────────────────────────────────── + + +class MemorySpace(BaseModel): + """A memory space (folder-scoped, from ECS).""" + + model_config = ConfigDict(populate_by_name=True) + + id: str = Field(..., alias="id") + name: str = Field(..., alias="name") + description: Optional[str] = Field(None, alias="description") + last_queried: Optional[str] = Field(None, alias="lastQueried") + memories_count: int = Field(default=0, alias="memoriesCount") + folder_key: str = Field(..., alias="folderKey") + created_by_user_id: Optional[str] = Field(None, alias="createdByUserId") + is_encrypted: bool = Field(default=False, alias="isEncrypted") + + +class MemorySpaceListResponse(BaseModel): + """OData response from listing memory spaces (ECS).""" + + model_config = ConfigDict(populate_by_name=True) + + value: List[MemorySpace] = Field(default_factory=list, alias="value") + + +# ── LLMOps search models ────────────────────────────────────────────── + + +class MemorySearchRequest(BaseModel): + """Request payload for searching memory via LLMOps. + + Includes definitionSystemPrompt so LLMOps can generate the + systemPromptInjection for the agent loop. + """ + + model_config = ConfigDict(populate_by_name=True) + + fields: List[SearchField] = Field(..., alias="fields", min_length=1, max_length=20) + settings: SearchSettings = Field(..., alias="settings") + definition_system_prompt: Optional[str] = Field( + None, alias="definitionSystemPrompt" + ) + + +class MemoryMatch(BaseModel): + """A single matched memory from a search operation (LLMOps).""" + + model_config = ConfigDict(populate_by_name=True) + + memory_item_id: str = Field(..., alias="memoryItemId") + score: float = Field(..., alias="score") + semantic_score: float = Field(..., alias="semanticScore") + weighted_score: float = Field(..., alias="weightedScore") + fields: List[MemoryMatchField] = Field(..., alias="fields") + span: Optional[Any] = Field(None, alias="span") + feedback: Optional[Any] = Field(None, alias="feedback") + + +class MemorySearchResponse(BaseModel): + """Response from LLMOps search, including system prompt injection.""" + + model_config = ConfigDict(populate_by_name=True) + + results: List[MemoryMatch] = Field(default_factory=list, alias="results") + metadata: Dict[str, str] = Field(default_factory=dict, alias="metadata") + system_prompt_injection: str = Field("", alias="systemPromptInjection") + + +# ── LLMOps escalation memory models ────────────────────────────────── + + +class EscalationMemoryIngestRequest(BaseModel): + """Request payload for ingesting an escalation outcome into memory. + + Used by the escalation tool to persist resolved outcomes so + future runs can recall them without re-escalating. + """ + + model_config = ConfigDict(populate_by_name=True) + + span_id: str = Field(..., alias="spanId") + trace_id: str = Field(..., alias="traceId") + answer: str = Field(..., alias="answer") + attributes: str = Field(..., alias="attributes") + user_id: Optional[str] = Field(None, alias="userId") + + +class CachedRecall(BaseModel): + """A cached escalation answer retrieved from memory.""" + + model_config = ConfigDict(populate_by_name=True) + + output: Optional[Any] = Field(None, alias="output") + outcome: Optional[str] = Field(None, alias="outcome") + + +class EscalationMemoryMatch(BaseModel): + """A single match from an escalation memory search.""" + + model_config = ConfigDict(populate_by_name=True) + + answer: Optional[CachedRecall] = Field(None, alias="answer") + + +class EscalationMemorySearchResponse(BaseModel): + """Response from LLMOps escalation memory search.""" + + model_config = ConfigDict(populate_by_name=True) + + results: Optional[List[EscalationMemoryMatch]] = Field(None, alias="results") diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py index 7561dd4ea..2e673fb7c 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py @@ -283,6 +283,56 @@ async def retrieve_async( else: return Asset.model_validate(response.json()["value"][0]) + def _resolve_robot_key( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Optional[str]: + """Return the robot key, or ``None`` if the asset opts into direct API access. + + Raises ``ValueError`` when no robot key is available and ``AllowDirectApiAccess`` + is not enabled on the asset. + """ + try: + robot_key = self._execution_context.robot_key + except ValueError: + robot_key = None + + if robot_key is None: + asset = self.retrieve( + name=name, folder_key=folder_key, folder_path=folder_path + ) + if not asset.allow_direct_api_access: + raise ValueError( + f"No robot key available and 'AllowDirectApiAccess' is disabled for asset '{name}'." + ) + return robot_key + + async def _resolve_robot_key_async( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Optional[str]: + """Async variant of :meth:`_resolve_robot_key`.""" + try: + robot_key = self._execution_context.robot_key + except ValueError: + robot_key = None + + if robot_key is None: + asset = await self.retrieve_async( + name=name, folder_key=folder_key, folder_path=folder_path + ) + if not asset.allow_direct_api_access: + raise ValueError( + f"No robot key available and 'AllowDirectApiAccess' is disabled for asset '{name}'." + ) + return robot_key + @resource_override(resource_type="asset") @traced( name="assets_credential", run_type="uipath", hide_input=True, hide_output=True @@ -294,9 +344,11 @@ def retrieve_credential( folder_key: Optional[str] = None, folder_path: Optional[str] = None, ) -> Optional[str]: - """Gets a specified Orchestrator credential. + """Get the decrypted password of a Credential asset. - The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable) + The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable). + If no robot key is available, the asset's `AllowDirectApiAccess` flag is checked: when + enabled, the credential is fetched without a robot key; otherwise a `ValueError` is raised. Related Activity: [Get Credential](https://docs.uipath.com/activities/other/latest/workflow/get-robot-credential) @@ -309,22 +361,18 @@ def retrieve_credential( Optional[str]: The decrypted credential password. Raises: - ValueError: If the method is called for a user asset. + ValueError: If no robot key is available and the asset does not have `AllowDirectApiAccess` enabled. """ - try: - is_user = self._execution_context.robot_key is not None - except ValueError: - is_user = False - - if not is_user: - raise ValueError("This method can only be used for robot assets.") + robot_key = self._resolve_robot_key( + name, folder_key=folder_key, folder_path=folder_path + ) - spec = self._retrieve_spec( + spec = self._retrieve_credential_spec( name, + robot_key=robot_key, folder_key=folder_key, folder_path=folder_path, ) - response = self.request( spec.method, url=spec.endpoint, @@ -333,10 +381,7 @@ def retrieve_credential( content=spec.content, headers=spec.headers, ) - - user_asset = UserAsset.model_validate(response.json()) - - return user_asset.credential_password + return UserAsset.model_validate(response.json()).credential_password @resource_override(resource_type="asset") @traced( @@ -349,9 +394,11 @@ async def retrieve_credential_async( folder_key: Optional[str] = None, folder_path: Optional[str] = None, ) -> Optional[str]: - """Asynchronously gets a specified Orchestrator credential. + """Asynchronously get the decrypted password of a Credential asset. - The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable) + The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable). + If no robot key is available, the asset's `AllowDirectApiAccess` flag is checked: when + enabled, the credential is fetched without a robot key; otherwise a `ValueError` is raised. Related Activity: [Get Credential](https://docs.uipath.com/activities/other/latest/workflow/get-robot-credential) @@ -364,22 +411,18 @@ async def retrieve_credential_async( Optional[str]: The decrypted credential password. Raises: - ValueError: If the method is called for a user asset. + ValueError: If no robot key is available and the asset does not have `AllowDirectApiAccess` enabled. """ - try: - is_user = self._execution_context.robot_key is not None - except ValueError: - is_user = False - - if not is_user: - raise ValueError("This method can only be used for robot assets.") + robot_key = await self._resolve_robot_key_async( + name, folder_key=folder_key, folder_path=folder_path + ) - spec = self._retrieve_spec( + spec = self._retrieve_credential_spec( name, + robot_key=robot_key, folder_key=folder_key, folder_path=folder_path, ) - response = await self.request_async( spec.method, url=spec.endpoint, @@ -388,10 +431,99 @@ async def retrieve_credential_async( content=spec.content, headers=spec.headers, ) + return UserAsset.model_validate(response.json()).credential_password + + @resource_override(resource_type="asset") + @traced(name="assets_secret", run_type="uipath", hide_input=True, hide_output=True) + def retrieve_secret( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Optional[str]: + """Get the decrypted value of a Secret asset. + + The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable). + If no robot key is available, the asset's `AllowDirectApiAccess` flag is checked: when + enabled, the secret is fetched without a robot key; otherwise a `ValueError` is raised. - user_asset = UserAsset.model_validate(response.json()) + Args: + name (str): The name of the secret asset. + folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. - return user_asset.credential_password + Returns: + Optional[str]: The decrypted secret value. + + Raises: + ValueError: If no robot key is available and the asset does not have `AllowDirectApiAccess` enabled. + """ + robot_key = self._resolve_robot_key( + name, folder_key=folder_key, folder_path=folder_path + ) + + spec = self._retrieve_credential_spec( + name, + robot_key=robot_key, + folder_key=folder_key, + folder_path=folder_path, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + return UserAsset.model_validate(response.json()).secret_value + + @resource_override(resource_type="asset") + @traced(name="assets_secret", run_type="uipath", hide_input=True, hide_output=True) + async def retrieve_secret_async( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Optional[str]: + """Asynchronously get the decrypted value of a Secret asset. + + The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable). + If no robot key is available, the asset's `AllowDirectApiAccess` flag is checked: when + enabled, the secret is fetched without a robot key; otherwise a `ValueError` is raised. + + Args: + name (str): The name of the secret asset. + folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. + + Returns: + Optional[str]: The decrypted secret value. + + Raises: + ValueError: If no robot key is available and the asset does not have `AllowDirectApiAccess` enabled. + """ + robot_key = await self._resolve_robot_key_async( + name, folder_key=folder_key, folder_path=folder_path + ) + + spec = self._retrieve_credential_spec( + name, + robot_key=robot_key, + folder_key=folder_key, + folder_path=folder_path, + ) + response = await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + return UserAsset.model_validate(response.json()).secret_value @traced(name="assets_update", run_type="uipath", hide_input=True, hide_output=True) def update( @@ -513,6 +645,32 @@ def _retrieve_spec( }, ) + def _retrieve_credential_spec( + self, + name: str, + *, + robot_key: Optional[str], + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + body: Dict[str, Any] = { + "assetName": name, + "supportsCredentialsProxyDisconnected": True, + } + if robot_key is not None: + body["robotKey"] = robot_key + + return RequestSpec( + method="POST", + endpoint=Endpoint( + "/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey" + ), + json=body, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + def _update_spec( self, robot_asset: UserAsset, diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py index 0d536cd46..fc1d84db3 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py @@ -365,7 +365,7 @@ def delete( self.request( "DELETE", url=f"/orchestrator_/odata/Buckets({bucket.id})", - headers={**self.folder_headers}, + headers={**header_folder(folder_key, folder_path)}, ) @resource_override(resource_type="bucket") @@ -386,7 +386,7 @@ async def delete_async( await self.request_async( "DELETE", url=f"/orchestrator_/odata/Buckets({bucket.id})", - headers={**self.folder_headers}, + headers={**header_folder(folder_key, folder_path)}, ) @resource_override(resource_type="bucket") diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py index f9433d221..a9a132e02 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py @@ -674,6 +674,52 @@ def _retrieve_api_payload_spec( }, ) + def retrieve_inbox_payload(self, inbox_id: str) -> Any: + """Fetch payload data for Integration Services (Inbox) triggers. + + Unlike `retrieve_api_payload`, this returns the response body as-is. + Orchestrator's `GET /JobTriggers/GetPayload/{inboxId}` returns the + stored payload directly without an envelope. + + Args: + inbox_id: The Id of the inbox to fetch the payload for. + + Returns: + The stored payload. + """ + spec = self._retrieve_api_payload_spec(inbox_id=inbox_id) + + response = self.request( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + + return response.json() + + async def retrieve_inbox_payload_async(self, inbox_id: str) -> Any: + """Asynchronously fetch payload data for Integration Services (Inbox) triggers. + + Unlike `retrieve_api_payload_async`, this returns the response body + as-is. Orchestrator's `GET /JobTriggers/GetPayload/{inboxId}` returns + the stored payload directly without an envelope. + + Args: + inbox_id: The Id of the inbox to fetch the payload for. + + Returns: + The stored payload. + """ + spec = self._retrieve_api_payload_spec(inbox_id=inbox_id) + + response = await self.request_async( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + + return response.json() + def _extract_first_inbox_id(self, response: Any) -> str: if len(response["value"]) > 0: return response["value"][0]["ItemKey"] diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py index 10b6010e2..e6ecc988a 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py @@ -50,6 +50,7 @@ def invoke( folder_path: Optional[str] = None, attachments: Optional[list[Attachment]] = None, parent_operation_id: Optional[str] = None, + run_as_me: Optional[bool] = None, **kwargs: Any, ) -> Job: """Start execution of a process by its name. @@ -63,6 +64,7 @@ def invoke( folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. parent_operation_id (Optional[str]): The parent operation ID for BTS tracking correlation. + run_as_me (Optional[bool]): If True, the job will run under the calling user's identity. Returns: Job: The job execution details. @@ -100,6 +102,7 @@ def invoke( folder_path=folder_path, parent_span_id=kwargs.get("parent_span_id"), parent_operation_id=parent_operation_id, + run_as_me=run_as_me, ) response = self.request( spec.method, @@ -123,6 +126,7 @@ async def invoke_async( folder_path: Optional[str] = None, attachments: Optional[list[Attachment]] = None, parent_operation_id: Optional[str] = None, + run_as_me: Optional[bool] = None, **kwargs: Any, ) -> Job: """Asynchronously start execution of a process by its name. @@ -136,6 +140,7 @@ async def invoke_async( folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. parent_operation_id (Optional[str]): The parent operation ID for BTS tracking correlation. + run_as_me (Optional[bool]): If True, the job will run under the calling user's identity. Returns: Job: The job execution details. @@ -168,6 +173,7 @@ async def main(): folder_path=folder_path, parent_span_id=kwargs.get("parent_span_id"), parent_operation_id=parent_operation_id, + run_as_me=run_as_me, ) response = await self.request_async( @@ -313,13 +319,21 @@ def _invoke_spec( folder_path: Optional[str] = None, parent_span_id: Optional[str] = None, parent_operation_id: Optional[str] = None, + run_as_me: Optional[bool] = None, ) -> RequestSpec: - payload: Dict[str, Any] = {"ReleaseName": name, **(input_data or {})} + payload: Dict[str, Any] = { + "ReleaseName": name, + **(input_data or {}), + "Source": "AgentService", + } self._add_tracing(payload, UiPathConfig.trace_id, parent_span_id) if parent_operation_id: payload["ParentOperationId"] = parent_operation_id + if run_as_me is not None: + payload["RunAsMe"] = run_as_me + request_spec = RequestSpec( method="POST", endpoint=Endpoint( diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py index eede00ecf..1a2985072 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py @@ -454,7 +454,9 @@ def _create_item_spec( elif isinstance(item, QueueItem): queue_item = item - item_data = queue_item.model_dump(exclude_unset=True, by_alias=True) + item_data = queue_item.model_dump( + mode="json", exclude_unset=True, by_alias=True + ) resolved_name = queue_name or item_data.get("Name") if resolved_name is None: raise ValueError( @@ -493,9 +495,11 @@ def _create_items_spec( "queueName": queue_name, "commitType": commit_type.value, "queueItems": [ - item.model_dump(exclude_unset=True, by_alias=True) + item.model_dump(mode="json", exclude_unset=True, by_alias=True) if isinstance(item, QueueItem) - else QueueItem(**item).model_dump(exclude_unset=True, by_alias=True) + else QueueItem(**item).model_dump( + mode="json", exclude_unset=True, by_alias=True + ) for item in items ], }, @@ -519,7 +523,7 @@ def _create_transaction_item_spec( transaction_item = item transaction_data = transaction_item.model_dump( - exclude_unset=True, by_alias=True + mode="json", exclude_unset=True, by_alias=True ) resolved_name = queue_name or transaction_data.get("Name") if resolved_name is None: @@ -580,7 +584,7 @@ def _complete_transaction_item_spec( ), json={ "transactionResult": transaction_result.model_dump( - exclude_unset=True, by_alias=True + mode="json", exclude_unset=True, by_alias=True ) }, headers={ diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py b/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py index 6ee89e806..122821029 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py @@ -38,6 +38,7 @@ class UserAsset(BaseModel): int_value: Optional[int] = Field(default=None, alias="IntValue") credential_username: Optional[str] = Field(default=None, alias="CredentialUsername") credential_password: Optional[str] = Field(default=None, alias="CredentialPassword") + secret_value: Optional[str] = Field(default=None, alias="SecretValue") external_name: Optional[str] = Field(default=None, alias="ExternalName") credential_store_id: Optional[int] = Field(default=None, alias="CredentialStoreId") key_value_list: Optional[List[Dict[str, str]]] = Field( @@ -46,6 +47,9 @@ class UserAsset(BaseModel): connection_data: Optional[CredentialsConnectionData] = Field( default=None, alias="ConnectionData" ) + allow_direct_api_access: Optional[bool] = Field( + default=None, alias="AllowDirectApiAccess" + ) id: Optional[int] = Field(default=None, alias="Id") @@ -69,5 +73,9 @@ class Asset(BaseModel): int_value: Optional[int] = Field(default=None, alias="IntValue") credential_username: Optional[str] = Field(default=None, alias="CredentialUsername") credential_password: Optional[str] = Field(default=None, alias="CredentialPassword") + secret_value: Optional[str] = Field(default=None, alias="SecretValue") external_name: Optional[str] = Field(default=None, alias="ExternalName") credential_store_id: Optional[int] = Field(default=None, alias="CredentialStoreId") + allow_direct_api_access: Optional[bool] = Field( + default=None, alias="AllowDirectApiAccess" + ) diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/job.py b/packages/uipath-platform/src/uipath/platform/orchestrator/job.py index 7ade631e5..6464405b4 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/job.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/job.py @@ -79,5 +79,5 @@ class Job(BaseModel): has_errors: Optional[bool] = Field(default=None, alias="HasErrors") has_warnings: Optional[bool] = Field(default=None, alias="HasWarnings") job_error: Optional[JobErrorInfo] = Field(default=None, alias="JobError") - folder_key: str = Field(alias="FolderKey") + folder_key: Optional[str] = Field(default=None, alias="FolderKey") id: int = Field(alias="Id") diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py b/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py index 9a811d876..dd96353a0 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py @@ -17,6 +17,7 @@ class McpServerType(IntEnum): SelfHosted = 3 # tunnel to (externally) self-hosted server Remote = 4 # HTTP connection to remote MCP server ProcessAssistant = 5 # Dynamic user process assistant + Platform = 6 # Platform MCP server (e.g: Orchestrator, TestManager) class McpServerStatus(IntEnum): diff --git a/packages/uipath-platform/src/uipath/platform/pii_detection/__init__.py b/packages/uipath-platform/src/uipath/platform/pii_detection/__init__.py new file mode 100644 index 000000000..7ab3b9e26 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/pii_detection/__init__.py @@ -0,0 +1,36 @@ +"""PiiDetection service package. + +Provides the ``PiiDetectionService`` client, Pydantic request/response models for +the PII detection endpoint, and utilities for rehydrating masked text with +original PII values after LLM processing. +""" + +from ._pii_detection_service import PiiDetectionService +from .pii_detection import ( + PiiDetectionRequest, + PiiDetectionResponse, + PiiDocument, + PiiDocumentResult, + PiiEntity, + PiiEntityThreshold, + PiiFile, + PiiFileResult, +) +from .pii_utilities import ( + rehydrate_from_pii_entities, + rehydrate_from_pii_response, +) + +__all__ = [ + "PiiDetectionRequest", + "PiiDetectionResponse", + "PiiDetectionService", + "PiiDocument", + "PiiDocumentResult", + "PiiEntity", + "PiiEntityThreshold", + "PiiFile", + "PiiFileResult", + "rehydrate_from_pii_entities", + "rehydrate_from_pii_response", +] diff --git a/packages/uipath-platform/src/uipath/platform/pii_detection/_pii_detection_service.py b/packages/uipath-platform/src/uipath/platform/pii_detection/_pii_detection_service.py new file mode 100644 index 000000000..a39ed4196 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/pii_detection/_pii_detection_service.py @@ -0,0 +1,80 @@ +"""PiiDetection service for UiPath Platform. + +Provides methods for detecting PII in documents and files. +""" + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec +from .pii_detection import PiiDetectionRequest, PiiDetectionResponse + +_PII_DETECTION_ENDPOINT = Endpoint("llmopstenant_/api/pii-detection") + +# PII detection over documents/files can be slow, so override the default +# httpx client timeout (30s) with a longer per-request timeout. +_PII_DETECTION_TIMEOUT = 290.0 + + +class PiiDetectionService(BaseService): + """Service for detecting PII via UiPath.""" + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + + @traced(name="pii_detection_detect_pii", run_type="uipath") + def detect_pii(self, request: PiiDetectionRequest) -> PiiDetectionResponse: + """Detect PII in the provided documents and/or files. + + Args: + request: The PII detection request payload. + + Returns: + The PII detection response. + """ + spec = self._pii_detection_spec(request) + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + scoped="tenant", + timeout=_PII_DETECTION_TIMEOUT, + ) + return PiiDetectionResponse.model_validate(response.json()) + + @traced(name="pii_detection_detect_pii", run_type="uipath") + async def detect_pii_async( + self, request: PiiDetectionRequest + ) -> PiiDetectionResponse: + """Detect PII in the provided documents and/or files (async). + + Args: + request: The PII detection request payload. + + Returns: + The PII detection response. + """ + spec = self._pii_detection_spec(request) + response = await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + scoped="tenant", + timeout=_PII_DETECTION_TIMEOUT, + ) + return PiiDetectionResponse.model_validate(response.json()) + + def _pii_detection_spec(self, request: PiiDetectionRequest) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=_PII_DETECTION_ENDPOINT, + json=request.model_dump(by_alias=True, exclude_none=True), + ) diff --git a/packages/uipath-platform/src/uipath/platform/pii_detection/pii_detection.py b/packages/uipath-platform/src/uipath/platform/pii_detection/pii_detection.py new file mode 100644 index 000000000..94ac10fca --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/pii_detection/pii_detection.py @@ -0,0 +1,91 @@ +"""Public Pydantic models for the PiiDetection service.""" + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PiiDocument(BaseModel): + """A text document to scan for PII.""" + + id: str + role: str + document: str + + +class PiiFile(BaseModel): + """A file reference to scan for PII.""" + + model_config = ConfigDict(populate_by_name=True) + + file_name: str = Field(alias="fileName") + file_url: str = Field(alias="fileUrl") + file_type: str = Field(alias="fileType") + + +class PiiEntityThreshold(BaseModel): + """Per-entity confidence threshold override.""" + + model_config = ConfigDict(populate_by_name=True) + + category: str = Field(alias="pii-entity-category") + confidence_threshold: float = Field(alias="pii-entity-confidence-threshold") + + +class PiiDetectionRequest(BaseModel): + """Request payload for the PII detection endpoint.""" + + model_config = ConfigDict(populate_by_name=True) + + documents: Optional[list[PiiDocument]] = None + files: Optional[list[PiiFile]] = None + language_code: Optional[str] = Field(default=None, alias="languageCode") + confidence_threshold: Optional[float] = Field( + default=None, alias="confidenceThreshold" + ) + entity_thresholds: Optional[list[PiiEntityThreshold]] = Field( + default=None, alias="entityThresholds" + ) + + +class PiiEntity(BaseModel): + """A single detected PII entity.""" + + model_config = ConfigDict(populate_by_name=True) + + pii_text: str = Field(alias="piiText") + replacement_text: str = Field(alias="replacementText") + pii_type: str = Field(alias="piiType") + offset: int + confidence_score: float = Field(alias="confidenceScore") + + +class PiiDocumentResult(BaseModel): + """PII detection result for a single document.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str + role: str + masked_document: str = Field(alias="maskedDocument") + initial_document: str = Field(alias="initialDocument") + pii_entities: list[PiiEntity] = Field(default_factory=list, alias="piiEntities") + + +class PiiFileResult(BaseModel): + """PII detection result for a single file (fileUrl is the redacted URL).""" + + model_config = ConfigDict(populate_by_name=True) + + file_name: str = Field(alias="fileName") + file_url: str = Field(alias="fileUrl") + pii_entities: list[PiiEntity] = Field(default_factory=list, alias="piiEntities") + + +class PiiDetectionResponse(BaseModel): + """Response payload from the PII detection endpoint.""" + + model_config = ConfigDict(populate_by_name=True) + + response: list[PiiDocumentResult] = Field(default_factory=list) + files: list[PiiFileResult] = Field(default_factory=list) diff --git a/packages/uipath-platform/src/uipath/platform/pii_detection/pii_utilities.py b/packages/uipath-platform/src/uipath/platform/pii_detection/pii_utilities.py new file mode 100644 index 000000000..b2fa482d0 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/pii_detection/pii_utilities.py @@ -0,0 +1,98 @@ +"""Utility methods for working with PII data. + +Python port of UiPath.SemanticProxy.Client.PiiUtilities (C#). +""" + +import re +from typing import Callable, Iterable + +from .pii_detection import PiiDetectionResponse, PiiEntity + + +def rehydrate_from_pii_entities( + masked_text: str, pii_entities: Iterable[PiiEntity] +) -> str: + """Rehydrate masked text by replacing PII placeholders with original values. + + Placeholders (e.g. ``[Person-1]``) are matched case-insensitively and replaced + with the corresponding original PII text. The function also replaces variants + without the surrounding brackets (e.g. ``Person-1``) in case the LLM stripped + them in its output. + + Args: + masked_text: The masked text with PII placeholders. + pii_entities: The PII entities containing the original values. + + Returns: + The rehydrated text with original PII values. + """ + if not masked_text: + return masked_text + + entities = [e for e in pii_entities if e.replacement_text] + if not entities: + return masked_text + + # Sort by replacement text length descending to avoid substring collisions + # (e.g. "[Person-10]" must be replaced before "[Person-1]"). + entities.sort(key=lambda e: len(e.replacement_text), reverse=True) + + rehydrated = masked_text + for entity in entities: + if not entity.replacement_text or not entity.pii_text: + continue + # Replace the full placeholder (with brackets) case-insensitively. + # ``_literal_replacer`` bypasses regex backreference interpretation in the + # replacement string. + rehydrated = re.sub( + re.escape(entity.replacement_text), + _literal_replacer(entity.pii_text), + rehydrated, + flags=re.IGNORECASE, + ) + # Also replace the content without brackets (in case the LLM dropped them). + if entity.replacement_text.startswith("[") and entity.replacement_text.endswith( + "]" + ): + no_brackets = entity.replacement_text[1:-1] + rehydrated = re.sub( + re.escape(no_brackets), + _literal_replacer(entity.pii_text), + rehydrated, + flags=re.IGNORECASE, + ) + + return rehydrated + + +def _literal_replacer(replacement: str) -> Callable[[re.Match[str]], str]: + """Return a replacement function that ignores regex backreference syntax.""" + + def replace(_match: re.Match[str]) -> str: + return replacement + + return replace + + +def rehydrate_from_pii_response( + masked_text: str, response: PiiDetectionResponse +) -> str: + """Rehydrate masked text using all PII entities from a detection response. + + Merges entities from both ``response.response`` (detected in documents/prompts) + and ``response.files`` (detected in files), so placeholders originating from + either source are rehydrated. + + Args: + masked_text: The masked text with PII placeholders. + response: The PII detection response containing entities to rehydrate. + + Returns: + The rehydrated text with original PII values. + """ + entities: list[PiiEntity] = [] + for doc in response.response: + entities.extend(doc.pii_entities) + for file in response.files: + entities.extend(file.pii_entities) + return rehydrate_from_pii_entities(masked_text, entities) diff --git a/packages/uipath-platform/src/uipath/platform/resource_catalog/_resource_catalog_service.py b/packages/uipath-platform/src/uipath/platform/resource_catalog/_resource_catalog_service.py index 030d85d01..e42880e71 100644 --- a/packages/uipath-platform/src/uipath/platform/resource_catalog/_resource_catalog_service.py +++ b/packages/uipath-platform/src/uipath/platform/resource_catalog/_resource_catalog_service.py @@ -1,4 +1,4 @@ -from typing import Any, AsyncIterator, Dict, Iterator, List, Optional +from typing import Any, AsyncGenerator, Dict, Iterator, List, Optional from uipath.core.tracing import traced @@ -110,7 +110,7 @@ async def search_async( resource_types: Optional[List[ResourceType]] = None, resource_sub_types: Optional[List[str]] = None, page_size: int = _DEFAULT_PAGE_SIZE, - ) -> AsyncIterator[Resource]: + ) -> AsyncGenerator[Resource, None]: """Asynchronously search for tenant scoped resources and folder scoped resources (accessible to the user). This method automatically handles pagination and yields resources one by one. @@ -258,7 +258,7 @@ async def list_async( folder_path: Optional[str] = None, folder_key: Optional[str] = None, page_size: int = _DEFAULT_PAGE_SIZE, - ) -> AsyncIterator[Resource]: + ) -> AsyncGenerator[Resource, None]: """Asynchronously get tenant scoped resources and folder scoped resources (accessible to the user). If no folder identifier is provided (path or key) only tenant resources will be retrieved. @@ -428,7 +428,7 @@ async def list_by_type_async( folder_path: Optional[str] = None, folder_key: Optional[str] = None, page_size: int = _DEFAULT_PAGE_SIZE, - ) -> AsyncIterator[Resource]: + ) -> AsyncGenerator[Resource, None]: """Asynchronously get resources of a specific type (tenant scoped or folder scoped). If no folder identifier is provided (path or key) only tenant resources will be retrieved. diff --git a/packages/uipath-platform/src/uipath/platform/resource_catalog/resource_catalog.py b/packages/uipath-platform/src/uipath/platform/resource_catalog/resource_catalog.py index bedf6525d..67fdf52f6 100644 --- a/packages/uipath-platform/src/uipath/platform/resource_catalog/resource_catalog.py +++ b/packages/uipath-platform/src/uipath/platform/resource_catalog/resource_catalog.py @@ -22,6 +22,7 @@ class ResourceType(str, Enum): CONNECTOR = "connector" MCP_SERVER = "mcpserver" QUEUE = "queue" + ENTITY = "entity" @classmethod def from_string(cls, value: str) -> "ResourceType": diff --git a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py index 60a169da9..b2dbae787 100644 --- a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py +++ b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py @@ -13,6 +13,7 @@ from uipath.core.serialization import serialize_object from uipath.core.triggers import ( UiPathApiTrigger, + UiPathIntegrationTrigger, UiPathResumeTrigger, UiPathResumeTriggerName, UiPathResumeTriggerType, @@ -43,16 +44,19 @@ WaitEphemeralIndex, WaitEphemeralIndexRaw, WaitEscalation, + WaitIntegrationEvent, WaitJob, WaitJobRaw, WaitSystemAgent, WaitTask, ) +from uipath.platform.connections import EventArguments from uipath.platform.context_grounding import DeepRagStatus, IndexStatus from uipath.platform.context_grounding.context_grounding_index import ( ContextGroundingIndex, ) from uipath.platform.errors import ( + BatchTransformFailedException, BatchTransformNotCompleteException, OperationNotCompleteException, ) @@ -323,6 +327,11 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: "index_name", trigger.payload ), ) + except BatchTransformFailedException as e: + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, + f"{e.message}", + ) from e except BatchTransformNotCompleteException as e: raise UiPathPendingTriggerError( ErrorCategory.SYSTEM, @@ -395,6 +404,23 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: f"Error fetching API trigger payload for inbox {trigger.api_resume.inbox_id}: {str(e)}", ) from e + case UiPathResumeTriggerType.INBOX: + if trigger.integration_resume and trigger.integration_resume.inbox_id: + try: + inbox_payload = await uipath.jobs.retrieve_inbox_payload_async( + trigger.integration_resume.inbox_id + ) + event_args = EventArguments.model_validate(inbox_payload) + return await uipath.connections.retrieve_event_payload_async( + event_args + ) + except Exception as e: + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, + f"Failed to get trigger payload" + f"Error fetching Inbox trigger payload for inbox {trigger.integration_resume.inbox_id}: {str(e)}", + ) from e + case _: raise UiPathFaultedTriggerError( ErrorCategory.SYSTEM, @@ -455,6 +481,9 @@ async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger: case UiPathResumeTriggerType.API: self._handle_api_trigger(suspend_value, resume_trigger) + case UiPathResumeTriggerType.INBOX: + await self._handle_inbox_trigger(suspend_value, resume_trigger) + case UiPathResumeTriggerType.DEEP_RAG: await self._handle_deep_rag_job_trigger( suspend_value, resume_trigger @@ -539,6 +568,8 @@ def _determine_trigger_type(self, value: Any) -> UiPathResumeTriggerType: value, (DocumentExtractionValidation, WaitDocumentExtractionValidation) ): return UiPathResumeTriggerType.IXP_VS_ESCALATION + if isinstance(value, WaitIntegrationEvent): + return UiPathResumeTriggerType.INBOX # default to API trigger return UiPathResumeTriggerType.API @@ -573,6 +604,8 @@ def _determine_trigger_name(self, value: Any) -> UiPathResumeTriggerName: return UiPathResumeTriggerName.BATCH_RAG if isinstance(value, (DocumentExtraction, WaitDocumentExtraction)): return UiPathResumeTriggerName.EXTRACTION + if isinstance(value, WaitIntegrationEvent): + return UiPathResumeTriggerName.INBOX # default to API trigger return UiPathResumeTriggerName.API @@ -895,6 +928,57 @@ def _handle_api_trigger( inbox_id=str(uuid.uuid4()), request=serialize_object(value) ) + async def _handle_inbox_trigger( + self, value: WaitIntegrationEvent, resume_trigger: UiPathResumeTrigger + ) -> None: + """Handle Inbox-type resume triggers. + + Resolves `connection_name` (scoped to `connection_folder_path` when + provided) to a connection id via the Connections service, populates + `integration_resume` with the Integration Services configuration plus a + freshly generated `inbox_id`. The Connections-service registration is + performed server-side by Orchestrator's `CreateResumeTriggerTaskHandler` + once the job suspends. + + Args: + value: The suspend value (WaitIntegrationEvent) + resume_trigger: The resume trigger to populate + + Raises: + Exception: If no connection matches `connection_name`, or if more + than one exact match is found. + """ + uipath = UiPath() + connections = await uipath.connections.list_async( + name=value.connection_name, + folder_path=value.connection_folder_path, + connector_key=value.connector, + ) + connection = next( + (c for c in connections if c.name == value.connection_name), None + ) + if connection is None: + raise Exception( + f"No connection named '{value.connection_name}' " + f"for connector '{value.connector}' found" + + ( + f" in folder '{value.connection_folder_path}'" + if value.connection_folder_path + else "" + ) + ) + assert connection.id is not None + + resume_trigger.integration_resume = UiPathIntegrationTrigger( + connector=value.connector, + connection_id=connection.id, + operation=value.operation, + object_name=value.object_name, + filter_expression=value.filter_expression, + parameters=value.parameters, + inbox_id=str(uuid.uuid4()), + ) + class UiPathResumeTriggerHandler: """Combined handler for creating and reading resume triggers. diff --git a/packages/uipath-platform/src/uipath/platform/semantic_proxy/__init__.py b/packages/uipath-platform/src/uipath/platform/semantic_proxy/__init__.py new file mode 100644 index 000000000..e17867ac7 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/semantic_proxy/__init__.py @@ -0,0 +1,36 @@ +"""SemanticProxy service package. + +Provides the ``SemanticProxyService`` client, Pydantic request/response models for +the PII detection endpoint, and utilities for rehydrating masked text with +original PII values after LLM processing. +""" + +from ._semantic_proxy_service import SemanticProxyService +from .pii_utilities import ( + rehydrate_from_pii_entities, + rehydrate_from_pii_response, +) +from .semantic_proxy import ( + PiiDetectionRequest, + PiiDetectionResponse, + PiiDocument, + PiiDocumentResult, + PiiEntity, + PiiEntityThreshold, + PiiFile, + PiiFileResult, +) + +__all__ = [ + "PiiDetectionRequest", + "PiiDetectionResponse", + "PiiDocument", + "PiiDocumentResult", + "PiiEntity", + "PiiEntityThreshold", + "PiiFile", + "PiiFileResult", + "SemanticProxyService", + "rehydrate_from_pii_entities", + "rehydrate_from_pii_response", +] diff --git a/packages/uipath-platform/src/uipath/platform/semantic_proxy/_semantic_proxy_service.py b/packages/uipath-platform/src/uipath/platform/semantic_proxy/_semantic_proxy_service.py new file mode 100644 index 000000000..a68d7c25c --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/semantic_proxy/_semantic_proxy_service.py @@ -0,0 +1,74 @@ +"""SemanticProxy service for UiPath Platform. + +Provides methods for interacting with the SemanticProxy service (e.g. PII detection). +""" + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec +from .semantic_proxy import PiiDetectionRequest, PiiDetectionResponse + +_PII_DETECTION_ENDPOINT = Endpoint("semanticproxy_/api/pii-detection") + + +class SemanticProxyService(BaseService): + """Service for interacting with UiPath SemanticProxy.""" + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + + @traced(name="semantic_proxy_detect_pii", run_type="uipath") + def detect_pii(self, request: PiiDetectionRequest) -> PiiDetectionResponse: + """Detect PII in the provided documents and/or files. + + Args: + request: The PII detection request payload. + + Returns: + The PII detection response. + """ + spec = self._pii_detection_spec(request) + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + scoped="tenant", + ) + return PiiDetectionResponse.model_validate(response.json()) + + @traced(name="semantic_proxy_detect_pii", run_type="uipath") + async def detect_pii_async( + self, request: PiiDetectionRequest + ) -> PiiDetectionResponse: + """Detect PII in the provided documents and/or files (async). + + Args: + request: The PII detection request payload. + + Returns: + The PII detection response. + """ + spec = self._pii_detection_spec(request) + response = await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + scoped="tenant", + ) + return PiiDetectionResponse.model_validate(response.json()) + + def _pii_detection_spec(self, request: PiiDetectionRequest) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=_PII_DETECTION_ENDPOINT, + json=request.model_dump(by_alias=True, exclude_none=True), + ) diff --git a/packages/uipath-platform/src/uipath/platform/semantic_proxy/pii_utilities.py b/packages/uipath-platform/src/uipath/platform/semantic_proxy/pii_utilities.py new file mode 100644 index 000000000..0f031a19a --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/semantic_proxy/pii_utilities.py @@ -0,0 +1,98 @@ +"""Utility methods for working with PII data. + +Python port of UiPath.SemanticProxy.Client.PiiUtilities (C#). +""" + +import re +from typing import Callable, Iterable + +from .semantic_proxy import PiiDetectionResponse, PiiEntity + + +def rehydrate_from_pii_entities( + masked_text: str, pii_entities: Iterable[PiiEntity] +) -> str: + """Rehydrate masked text by replacing PII placeholders with original values. + + Placeholders (e.g. ``[Person-1]``) are matched case-insensitively and replaced + with the corresponding original PII text. The function also replaces variants + without the surrounding brackets (e.g. ``Person-1``) in case the LLM stripped + them in its output. + + Args: + masked_text: The masked text with PII placeholders. + pii_entities: The PII entities containing the original values. + + Returns: + The rehydrated text with original PII values. + """ + if not masked_text: + return masked_text + + entities = [e for e in pii_entities if e.replacement_text] + if not entities: + return masked_text + + # Sort by replacement text length descending to avoid substring collisions + # (e.g. "[Person-10]" must be replaced before "[Person-1]"). + entities.sort(key=lambda e: len(e.replacement_text), reverse=True) + + rehydrated = masked_text + for entity in entities: + if not entity.replacement_text or not entity.pii_text: + continue + # Replace the full placeholder (with brackets) case-insensitively. + # ``_literal_replacer`` bypasses regex backreference interpretation in the + # replacement string. + rehydrated = re.sub( + re.escape(entity.replacement_text), + _literal_replacer(entity.pii_text), + rehydrated, + flags=re.IGNORECASE, + ) + # Also replace the content without brackets (in case the LLM dropped them). + if entity.replacement_text.startswith("[") and entity.replacement_text.endswith( + "]" + ): + no_brackets = entity.replacement_text[1:-1] + rehydrated = re.sub( + re.escape(no_brackets), + _literal_replacer(entity.pii_text), + rehydrated, + flags=re.IGNORECASE, + ) + + return rehydrated + + +def _literal_replacer(replacement: str) -> Callable[[re.Match[str]], str]: + """Return a replacement function that ignores regex backreference syntax.""" + + def replace(_match: re.Match[str]) -> str: + return replacement + + return replace + + +def rehydrate_from_pii_response( + masked_text: str, response: PiiDetectionResponse +) -> str: + """Rehydrate masked text using all PII entities from a detection response. + + Merges entities from both ``response.response`` (detected in documents/prompts) + and ``response.files`` (detected in files), so placeholders originating from + either source are rehydrated. + + Args: + masked_text: The masked text with PII placeholders. + response: The PII detection response containing entities to rehydrate. + + Returns: + The rehydrated text with original PII values. + """ + entities: list[PiiEntity] = [] + for doc in response.response: + entities.extend(doc.pii_entities) + for file in response.files: + entities.extend(file.pii_entities) + return rehydrate_from_pii_entities(masked_text, entities) diff --git a/packages/uipath-platform/src/uipath/platform/semantic_proxy/semantic_proxy.py b/packages/uipath-platform/src/uipath/platform/semantic_proxy/semantic_proxy.py new file mode 100644 index 000000000..2be35e975 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/semantic_proxy/semantic_proxy.py @@ -0,0 +1,91 @@ +"""Public Pydantic models for the SemanticProxy service.""" + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PiiDocument(BaseModel): + """A text document to scan for PII.""" + + id: str + role: str + document: str + + +class PiiFile(BaseModel): + """A file reference to scan for PII.""" + + model_config = ConfigDict(populate_by_name=True) + + file_name: str = Field(alias="fileName") + file_url: str = Field(alias="fileUrl") + file_type: str = Field(alias="fileType") + + +class PiiEntityThreshold(BaseModel): + """Per-entity confidence threshold override.""" + + model_config = ConfigDict(populate_by_name=True) + + category: str = Field(alias="pii-entity-category") + confidence_threshold: float = Field(alias="pii-entity-confidence-threshold") + + +class PiiDetectionRequest(BaseModel): + """Request payload for the PII detection endpoint.""" + + model_config = ConfigDict(populate_by_name=True) + + documents: Optional[list[PiiDocument]] = None + files: Optional[list[PiiFile]] = None + language_code: Optional[str] = Field(default=None, alias="languageCode") + confidence_threshold: Optional[float] = Field( + default=None, alias="confidenceThreshold" + ) + entity_thresholds: Optional[list[PiiEntityThreshold]] = Field( + default=None, alias="entityThresholds" + ) + + +class PiiEntity(BaseModel): + """A single detected PII entity.""" + + model_config = ConfigDict(populate_by_name=True) + + pii_text: str = Field(alias="piiText") + replacement_text: str = Field(alias="replacementText") + pii_type: str = Field(alias="piiType") + offset: int + confidence_score: float = Field(alias="confidenceScore") + + +class PiiDocumentResult(BaseModel): + """PII detection result for a single document.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str + role: str + masked_document: str = Field(alias="maskedDocument") + initial_document: str = Field(alias="initialDocument") + pii_entities: list[PiiEntity] = Field(default_factory=list, alias="piiEntities") + + +class PiiFileResult(BaseModel): + """PII detection result for a single file (fileUrl is the redacted URL).""" + + model_config = ConfigDict(populate_by_name=True) + + file_name: str = Field(alias="fileName") + file_url: str = Field(alias="fileUrl") + pii_entities: list[PiiEntity] = Field(default_factory=list, alias="piiEntities") + + +class PiiDetectionResponse(BaseModel): + """Response payload from the PII detection endpoint.""" + + model_config = ConfigDict(populate_by_name=True) + + response: list[PiiDocumentResult] = Field(default_factory=list) + files: list[PiiFileResult] = Field(default_factory=list) diff --git a/packages/uipath-platform/tests/common/test_config_env_vars.py b/packages/uipath-platform/tests/common/test_config_env_vars.py new file mode 100644 index 000000000..8ff7b636d --- /dev/null +++ b/packages/uipath-platform/tests/common/test_config_env_vars.py @@ -0,0 +1,55 @@ +import pytest + +from uipath.platform.common._config import UiPathConfig + + +@pytest.fixture(autouse=True) +def _clear_env(monkeypatch): + for var in ( + "UIPATH_PROJECT_ID", + "UIPATH_AGENT_ID", + "UIPATH_CLOUD_USER_ID", + "UIPATH_PROJECT_FILES_SOURCE", + ): + monkeypatch.delenv(var, raising=False) + + +class TestProjectId: + def test_reads_env_var(self, monkeypatch): + monkeypatch.setenv("UIPATH_PROJECT_ID", "file-source-id") + assert UiPathConfig.project_id == "file-source-id" + + def test_returns_none_when_unset(self): + assert UiPathConfig.project_id is None + + +class TestAgentId: + def test_returns_explicit_agent_id_when_set(self, monkeypatch): + monkeypatch.setenv("UIPATH_PROJECT_ID", "debug-project-guid") + monkeypatch.setenv("UIPATH_AGENT_ID", "real-agent-id") + assert UiPathConfig.agent_id == "real-agent-id" + + def test_falls_back_to_project_id_when_agent_id_unset(self, monkeypatch): + monkeypatch.setenv("UIPATH_PROJECT_ID", "cloud-project-id") + assert UiPathConfig.agent_id == "cloud-project-id" + + def test_returns_none_when_neither_set(self): + assert UiPathConfig.agent_id is None + + +class TestCloudUserId: + def test_returns_value_when_set(self, monkeypatch): + monkeypatch.setenv("UIPATH_CLOUD_USER_ID", "user-guid") + assert UiPathConfig.cloud_user_id == "user-guid" + + def test_returns_none_when_unset(self): + assert UiPathConfig.cloud_user_id is None + + +class TestProjectFilesSource: + def test_returns_value_when_set(self, monkeypatch): + monkeypatch.setenv("UIPATH_PROJECT_FILES_SOURCE", "Local") + assert UiPathConfig.project_files_source == "Local" + + def test_returns_none_when_unset(self): + assert UiPathConfig.project_files_source is None diff --git a/packages/uipath-platform/tests/common/test_job_context.py b/packages/uipath-platform/tests/common/test_job_context.py new file mode 100644 index 000000000..6a4e1a97d --- /dev/null +++ b/packages/uipath-platform/tests/common/test_job_context.py @@ -0,0 +1,22 @@ +import pytest + +from uipath.platform.common._job_context import header_job_key +from uipath.platform.common.constants import ENV_JOB_KEY, HEADER_JOB_KEY + + +def test_returns_header_when_env_var_set(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ENV_JOB_KEY, "test-job-key") + + assert header_job_key() == {HEADER_JOB_KEY: "test-job-key"} + + +def test_returns_empty_when_env_var_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(ENV_JOB_KEY, raising=False) + + assert header_job_key() == {} + + +def test_returns_empty_when_env_var_blank(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ENV_JOB_KEY, "") + + assert header_job_key() == {} diff --git a/packages/uipath-platform/tests/services/test_actions_service.py b/packages/uipath-platform/tests/services/test_actions_service.py index b97d326e8..ea5fe2c84 100644 --- a/packages/uipath-platform/tests/services/test_actions_service.py +++ b/packages/uipath-platform/tests/services/test_actions_service.py @@ -1,3 +1,4 @@ +import json from typing import Any import pytest @@ -6,6 +7,7 @@ from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.action_center import Task from uipath.platform.action_center._tasks_service import TasksService +from uipath.platform.action_center.tasks import TaskRecipient, TaskRecipientType from uipath.platform.common.constants import HEADER_USER_AGENT @@ -185,6 +187,167 @@ def test_create_with_assignee( assert action.title == "Test Action" +def _mock_app_lookup_and_create( + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Common httpx mock setup for app lookup + task creation + assign.""" + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + httpx_mock.add_response( + url=f"{base_url}{org}/apps_/default/api/v1/default/deployed-action-apps-schemas?search=test-app&filterByDeploymentTitle=true", + status_code=200, + json={ + "deployed": [ + { + "systemName": "test-app", + "deploymentTitle": "test-app", + "actionSchema": { + "key": "test-key", + "inputs": [], + "outputs": [], + "inOuts": [], + "outcomes": [], + }, + "deploymentFolder": { + "fullyQualifiedName": "test-folder-path", + "key": "test-folder-key", + }, + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/tasks/AppTasks/CreateAppTask", + status_code=200, + json={"id": 1, "title": "Test Action"}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Tasks/UiPath.Server.Configuration.OData.AssignTasks", + status_code=200, + json={}, + ) + + +def _assign_request_payload(httpx_mock: HTTPXMock) -> dict[str, Any]: + """Return the parsed JSON body of the last AssignTasks request captured by the mock.""" + assign_request = next( + req + for req in reversed(httpx_mock.get_requests()) + if "AssignTasks" in str(req.url) + ) + return json.loads(assign_request.content) + + +class TestAssignTaskSpec: + """Tests for the task-assignment payload built by `_assign_task_spec`.""" + + def test_assign_workload_recipient_uses_workload_criteria_with_group( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + _mock_app_lookup_and_create(httpx_mock, base_url, org, tenant, monkeypatch) + + service.create( + title="Test Action", + app_name="test-app", + data={"x": 1}, + recipient=TaskRecipient( + type=TaskRecipientType.WORKLOAD, + value="Support Team", + displayName="Support Team", + ), + ) + + payload = _assign_request_payload(httpx_mock) + assert payload == { + "taskAssignments": [ + { + "taskId": 1, + "assignmentCriteria": "Workload", + "assigneeNamesOrEmails": ["Support Team"], + } + ] + } + + def test_assign_round_robin_recipient_uses_round_robin_criteria( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + _mock_app_lookup_and_create(httpx_mock, base_url, org, tenant, monkeypatch) + + service.create( + title="Test Action", + app_name="test-app", + data={"x": 1}, + recipient=TaskRecipient( + type=TaskRecipientType.ROUND_ROBIN, + value="Support Team", + displayName="Support Team", + ), + ) + + payload = _assign_request_payload(httpx_mock) + assert payload == { + "taskAssignments": [ + { + "taskId": 1, + "assignmentCriteria": "RoundRobin", + "assigneeNamesOrEmails": ["Support Team"], + } + ] + } + + def test_assign_workload_with_multiple_emails_uses_values_list( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Custom-assignees path: Workload criteria with a list of emails.""" + _mock_app_lookup_and_create(httpx_mock, base_url, org, tenant, monkeypatch) + + service.create( + title="Test Action", + app_name="test-app", + data={"x": 1}, + recipient=TaskRecipient( + type=TaskRecipientType.WORKLOAD, + value="alice@example.com", + values=["alice@example.com", "bob@example.com"], + ), + ) + + payload = _assign_request_payload(httpx_mock) + assert payload == { + "taskAssignments": [ + { + "taskId": 1, + "assignmentCriteria": "Workload", + "assigneeNamesOrEmails": [ + "alice@example.com", + "bob@example.com", + ], + } + ] + } + + def _make_deployed_app( name: str, folder_path: str, @@ -555,3 +718,147 @@ def test_create_raises_when_no_folder_key_or_path_provided( app_name="my-app", app_folder_path=None, ) + + +# --------------------------------------------------------------------------- +# QuickForm task tests +# --------------------------------------------------------------------------- + +_QF_SCHEMA: dict[str, Any] = { + "id": "7ebef452-fee9-45df-8fc2-01f1d0248540", + "fields": [ + {"id": "f1", "type": "text", "label": "F1", "direction": "input"}, + {"id": "f2", "type": "text", "label": "F2", "direction": "output"}, + ], + "outcomes": [ + {"id": "approve", "name": "Approve", "type": "string", "isPrimary": True}, + ], +} +_QF_DEFAULTS = { + "title": "QF task", + "task_schema_key": _QF_SCHEMA["id"], + "schema": _QF_SCHEMA, +} +_QF_CREATE_RESPONSE = {"id": 42, "title": _QF_DEFAULTS["title"]} + + +@pytest.fixture +def qf_create_url(base_url: str, org: str, tenant: str) -> str: + return f"{base_url}{org}{tenant}/orchestrator_/tasks/GenericTasks/CreateTask" + + +@pytest.fixture +def qf_assign_url(base_url: str, org: str, tenant: str) -> str: + return ( + f"{base_url}{org}{tenant}" + "/orchestrator_/odata/Tasks/UiPath.Server.Configuration.OData.AssignTasks" + ) + + +def _posted_body(httpx_mock: HTTPXMock, url: str) -> dict[str, Any]: + for req in httpx_mock.get_requests(): + if str(req.url) == url: + return json.loads(req.content) + raise AssertionError(f"no request was POSTed to {url}") + + +@pytest.fixture +def qf_runner(httpx_mock: HTTPXMock, service: TasksService, qf_create_url: str) -> Any: + """Factory: stub the QF endpoint, call create_quickform with overrides, + return (task, posted_body). One call per test eliminates setup duplication. + """ + httpx_mock.add_response( + url=qf_create_url, status_code=200, json=_QF_CREATE_RESPONSE + ) + + def _run(**overrides: Any) -> tuple[Task, dict[str, Any]]: + task = service.create_quickform(**{**_QF_DEFAULTS, **overrides}) + return task, _posted_body(httpx_mock, qf_create_url) + + return _run + + +@pytest.fixture +def qf_runner_async( + httpx_mock: HTTPXMock, service: TasksService, qf_create_url: str +) -> Any: + """Async variant of qf_runner.""" + httpx_mock.add_response( + url=qf_create_url, status_code=200, json=_QF_CREATE_RESPONSE + ) + + async def _run(**overrides: Any) -> tuple[Task, dict[str, Any]]: + task = await service.create_quickform_async(**{**_QF_DEFAULTS, **overrides}) + return task, _posted_body(httpx_mock, qf_create_url) + + return _run + + +def test_create_quickform_baseline_payload(qf_runner: Any) -> None: + task, body = qf_runner() + assert body == { + "type": 6, + "taskSchemaKey": _QF_DEFAULTS["task_schema_key"], + "schema": _QF_SCHEMA, + "title": _QF_DEFAULTS["title"], + "data": {}, + } + assert isinstance(task, Task) + assert task.id == 42 + + +def test_create_quickform_data_passthrough(qf_runner: Any) -> None: + _, body = qf_runner(data={"x": 1}) + assert body["data"] == {"x": 1} + + +def test_create_quickform_includes_optional_fields_when_set(qf_runner: Any) -> None: + _, body = qf_runner( + priority="High", + labels=["a", "b"], + is_actionable_message_enabled=True, + actionable_message_metadata={"fieldSet": {}, "actionSet": {}}, + creator_job_key="3fa85f64-5717-4562-b3fc-2c963f66afa6", + ) + assert body["priority"] == "High" + assert {tag["name"] for tag in body["tags"]} == {"a", "b"} + assert body["isActionableMessageEnabled"] is True + assert body["actionableMessageMetaData"] == {"fieldSet": {}, "actionSet": {}} + assert body["creatorJobKey"] == "3fa85f64-5717-4562-b3fc-2c963f66afa6" + + +def test_create_quickform_omits_optional_fields_when_unset(qf_runner: Any) -> None: + _, body = qf_runner() + for omitted in ( + "creatorJobKey", + "priority", + "tags", + "isActionableMessageEnabled", + "actionableMessageMetaData", + ): + assert omitted not in body + + +async def test_create_quickform_async_baseline_payload(qf_runner_async: Any) -> None: + task, body = await qf_runner_async() + assert body["type"] == 6 + assert body["taskSchemaKey"] == _QF_DEFAULTS["task_schema_key"] + assert task.id == 42 + + +def test_create_quickform_with_assignee_triggers_assign_call( + httpx_mock: HTTPXMock, qf_runner: Any, qf_assign_url: str +) -> None: + httpx_mock.add_response(url=qf_assign_url, status_code=200, json={}) + qf_runner(assignee="user@example.com") + body = _posted_body(httpx_mock, qf_assign_url) + assert body["taskAssignments"][0]["UserNameOrEmail"] == "user@example.com" + + +async def test_create_quickform_async_with_assignee_triggers_assign_call( + httpx_mock: HTTPXMock, qf_runner_async: Any, qf_assign_url: str +) -> None: + httpx_mock.add_response(url=qf_assign_url, status_code=200, json={}) + await qf_runner_async(assignee="user@example.com") + body = _posted_body(httpx_mock, qf_assign_url) + assert body["taskAssignments"][0]["UserNameOrEmail"] == "user@example.com" diff --git a/packages/uipath-platform/tests/services/test_assets_service.py b/packages/uipath-platform/tests/services/test_assets_service.py index 6e83c3b9d..4b01ed210 100644 --- a/packages/uipath-platform/tests/services/test_assets_service.py +++ b/packages/uipath-platform/tests/services/test_assets_service.py @@ -362,20 +362,94 @@ def test_retrieve_credential( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.retrieve_credential/{version}" ) - def test_retrieve_credential_user_asset( + def test_retrieve_credential_no_robot_key_direct_access_disabled( self, - service: AssetsService, - monkeypatch: pytest.MonkeyPatch, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, config: UiPathApiConfig, + monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service = AssetsService( + config=config, + execution_context=UiPathExecutionContext(), + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$filter=Name eq 'Test Credential'&$top=1", + status_code=200, + json={ + "value": [ + { + "Key": "asset-key", + "Name": "Test Credential", + "ValueType": "Credential", + "AllowDirectApiAccess": False, + } + ] + }, + ) + with pytest.raises(ValueError): - monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) - service = AssetsService( - config=config, - execution_context=UiPathExecutionContext(), - ) service.retrieve_credential(name="Test Credential") + def test_retrieve_credential_no_robot_key_direct_access_enabled( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + config: UiPathApiConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + import json + + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service = AssetsService( + config=config, + execution_context=UiPathExecutionContext(), + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$filter=Name eq 'Test Credential'&$top=1", + status_code=200, + json={ + "value": [ + { + "Key": "asset-key", + "Name": "Test Credential", + "ValueType": "Credential", + "AllowDirectApiAccess": True, + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "id": 1, + "name": "Test Credential", + "credential_username": "test-user", + "credential_password": "test-password", + }, + ) + + credential = service.retrieve_credential(name="Test Credential") + + assert credential == "test-password" + + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 2 + credential_request = sent_requests[1] + assert credential_request.method == "POST" + request_body = json.loads(credential_request.content) + assert request_body["assetName"] == "Test Credential" + assert request_body["supportsCredentialsProxyDisconnected"] is True + assert "robotKey" not in request_body + async def test_retrieve_credential_async( self, httpx_mock: HTTPXMock, @@ -417,6 +491,196 @@ async def test_retrieve_credential_async( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.retrieve_credential_async/{version}" ) + @pytest.mark.anyio + async def test_retrieve_credential_async_no_robot_key_direct_access_disabled( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + config: UiPathApiConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service = AssetsService( + config=config, + execution_context=UiPathExecutionContext(), + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$filter=Name eq 'Test Credential'&$top=1", + status_code=200, + json={ + "value": [ + { + "Key": "asset-key", + "Name": "Test Credential", + "ValueType": "Credential", + "AllowDirectApiAccess": False, + } + ] + }, + ) + + with pytest.raises(ValueError): + await service.retrieve_credential_async(name="Test Credential") + + @pytest.mark.anyio + async def test_retrieve_credential_async_no_robot_key_direct_access_enabled( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + config: UiPathApiConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + import json + + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service = AssetsService( + config=config, + execution_context=UiPathExecutionContext(), + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$filter=Name eq 'Test Credential'&$top=1", + status_code=200, + json={ + "value": [ + { + "Key": "asset-key", + "Name": "Test Credential", + "ValueType": "Credential", + "AllowDirectApiAccess": True, + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "id": 1, + "name": "Test Credential", + "credential_username": "test-user", + "credential_password": "test-password", + }, + ) + + credential = await service.retrieve_credential_async(name="Test Credential") + + assert credential == "test-password" + + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 2 + credential_request = sent_requests[1] + assert credential_request.method == "POST" + request_body = json.loads(credential_request.content) + assert request_body["assetName"] == "Test Credential" + assert request_body["supportsCredentialsProxyDisconnected"] is True + assert "robotKey" not in request_body + + def test_retrieve_secret( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """retrieve_secret returns SecretValue for Secret-type assets.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "Id": 1, + "Name": "Test Secret", + "ValueType": "Secret", + "SecretValue": "super-secret-value", + }, + ) + + secret = service.retrieve_secret(name="Test Secret") + + assert secret == "super-secret-value" + + async def test_retrieve_secret_async( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """retrieve_secret_async returns SecretValue for Secret-type assets.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "Id": 1, + "Name": "Test Secret", + "ValueType": "Secret", + "SecretValue": "super-secret-value", + }, + ) + + secret = await service.retrieve_secret_async(name="Test Secret") + + assert secret == "super-secret-value" + + def test_retrieve_robot_asset_exposes_secret_value( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """`retrieve` must expose SecretValue on UserAsset for Secret-type assets.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "Id": 1, + "Name": "Test Secret", + "ValueType": "Secret", + "SecretValue": "super-secret-value", + }, + ) + + asset = service.retrieve(name="Test Secret") + + assert isinstance(asset, UserAsset) + assert asset.value_type == "Secret" + assert asset.secret_value == "super-secret-value" + + async def test_retrieve_async_robot_asset_exposes_secret_value( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """`retrieve_async` must expose SecretValue on UserAsset for Secret-type assets.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "Id": 1, + "Name": "Test Secret", + "ValueType": "Secret", + "SecretValue": "super-secret-value", + }, + ) + + asset = await service.retrieve_async(name="Test Secret") + + assert isinstance(asset, UserAsset) + assert asset.value_type == "Secret" + assert asset.secret_value == "super-secret-value" + def test_update( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/tests/services/test_automation_ops_service.py b/packages/uipath-platform/tests/services/test_automation_ops_service.py new file mode 100644 index 000000000..e19a02a5a --- /dev/null +++ b/packages/uipath-platform/tests/services/test_automation_ops_service.py @@ -0,0 +1,195 @@ +"""Tests for AutomationOpsService.""" + +import json + +import httpx +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.automation_ops import AutomationOpsService + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, +) -> AutomationOpsService: + return AutomationOpsService(config=config, execution_context=execution_context) + + +class TestAutomationOpsService: + """Test AutomationOpsService functionality.""" + + class TestGetDeployedPolicy: + """Test get_deployed_policy (sync).""" + + def test_returns_policy_dict( + self, + httpx_mock: HTTPXMock, + service: AutomationOpsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + expected_policy = { + "policy-name": "AITL Policy", + "data": { + "container": {"pii-in-flight-agents": True}, + "pii-entity-table": [], + }, + } + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agenthub_/api/policies/deployed-policy", + status_code=200, + json=expected_policy, + ) + + result = service.get_deployed_policy() + + assert result == expected_policy + + def test_returns_empty_dict_when_no_policy_deployed( + self, + httpx_mock: HTTPXMock, + service: AutomationOpsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agenthub_/api/policies/deployed-policy", + status_code=200, + content=b"", + ) + + result = service.get_deployed_policy() + + assert result == {} + + def test_uses_post_method( + self, + httpx_mock: HTTPXMock, + service: AutomationOpsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json={}) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agenthub_/api/policies/deployed-policy", + callback=capture, + ) + + service.get_deployed_policy() + + assert captured_request is not None + assert captured_request.method == "POST" + + class TestGetDeployedPolicyAsync: + """Test get_deployed_policy_async.""" + + async def test_returns_policy_dict( + self, + httpx_mock: HTTPXMock, + service: AutomationOpsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + expected_policy = { + "policy-name": "AITL Policy", + "data": { + "container": {"pii-in-flight-agents": False}, + }, + } + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agenthub_/api/policies/deployed-policy", + status_code=200, + json=expected_policy, + ) + + result = await service.get_deployed_policy_async() + + assert result == expected_policy + + async def test_returns_empty_dict_when_no_policy_deployed( + self, + httpx_mock: HTTPXMock, + service: AutomationOpsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agenthub_/api/policies/deployed-policy", + status_code=200, + content=b"", + ) + + result = await service.get_deployed_policy_async() + + assert result == {} + + async def test_url_is_tenant_scoped( + self, + httpx_mock: HTTPXMock, + service: AutomationOpsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json={}) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agenthub_/api/policies/deployed-policy", + callback=capture, + ) + + await service.get_deployed_policy_async() + + assert captured_request is not None + # Tenant-scoped: both org and tenant segments appear in the path + assert org.strip("/") in captured_request.url.path + assert tenant.strip("/") in captured_request.url.path + + async def test_request_has_no_body( + self, + httpx_mock: HTTPXMock, + service: AutomationOpsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json={}) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agenthub_/api/policies/deployed-policy", + callback=capture, + ) + + await service.get_deployed_policy_async() + + assert captured_request is not None + # POST with no body — body should be empty (or an empty JSON object) + body = captured_request.content + assert body in (b"", b"null") or json.loads(body) in ({}, None) diff --git a/packages/uipath-platform/tests/services/test_azure_guardrail_validators.py b/packages/uipath-platform/tests/services/test_azure_guardrail_validators.py new file mode 100644 index 000000000..fd6a3f39e --- /dev/null +++ b/packages/uipath-platform/tests/services/test_azure_guardrail_validators.py @@ -0,0 +1,146 @@ +"""Tests for the Azure-provided guardrail validators. + +Covers HarmfulContentValidator, IntellectualPropertyValidator, and +UserPromptAttacksValidator — verifying guardrail construction, parameter +serialization, stage enforcement, and input validation. +""" + +from __future__ import annotations + +import pytest + +from uipath.platform.guardrails.decorators import ( + GuardrailExecutionStage, + HarmfulContentEntity, + HarmfulContentEntityType, + HarmfulContentValidator, + IntellectualPropertyEntityType, + IntellectualPropertyValidator, + UserPromptAttacksValidator, +) + +# --------------------------------------------------------------------------- +# HarmfulContentValidator +# --------------------------------------------------------------------------- + + +class TestHarmfulContentValidator: + """Tests for HarmfulContentValidator.""" + + def test_builds_guardrail(self): + """Verify get_built_in_guardrail returns correct structure.""" + validator = HarmfulContentValidator( + entities=[ + HarmfulContentEntity(HarmfulContentEntityType.VIOLENCE, threshold=3), + HarmfulContentEntity(HarmfulContentEntityType.HATE, threshold=4), + ] + ) + guardrail = validator.get_built_in_guardrail( + name="Test HC", + description="test", + enabled_for_evals=True, + ) + assert guardrail.validator_type == "harmful_content" + assert len(guardrail.validator_parameters) == 2 + + enum_param = guardrail.validator_parameters[0] + assert enum_param.id == "harmfulContentEntities" + assert enum_param.value == ["Violence", "Hate"] + + map_param = guardrail.validator_parameters[1] + assert map_param.id == "harmfulContentEntityThresholds" + assert map_param.value == {"Violence": 3, "Hate": 4} + + def test_empty_entities_raises(self): + """Empty entities should raise ValueError.""" + with pytest.raises(ValueError, match="non-empty"): + HarmfulContentValidator(entities=[]) + + def test_threshold_validation(self): + """Threshold outside 0-6 should raise ValueError.""" + with pytest.raises(ValueError, match="between 0 and 6"): + HarmfulContentEntity(HarmfulContentEntityType.VIOLENCE, threshold=7) + with pytest.raises(ValueError, match="between 0 and 6"): + HarmfulContentEntity(HarmfulContentEntityType.VIOLENCE, threshold=-1) + + def test_all_stages_supported(self): + """supported_stages should be empty (all stages allowed).""" + validator = HarmfulContentValidator( + entities=[HarmfulContentEntity(HarmfulContentEntityType.VIOLENCE)] + ) + assert validator.supported_stages == [] + # Should not raise for any stage + validator.validate_stage(GuardrailExecutionStage.PRE) + validator.validate_stage(GuardrailExecutionStage.POST) + + +# --------------------------------------------------------------------------- +# IntellectualPropertyValidator +# --------------------------------------------------------------------------- + + +class TestIntellectualPropertyValidator: + """Tests for IntellectualPropertyValidator.""" + + def test_builds_guardrail(self): + """Verify get_built_in_guardrail returns correct structure.""" + validator = IntellectualPropertyValidator( + entities=[ + IntellectualPropertyEntityType.TEXT, + IntellectualPropertyEntityType.CODE, + ] + ) + guardrail = validator.get_built_in_guardrail( + name="Test IP", + description=None, + enabled_for_evals=False, + ) + assert guardrail.validator_type == "intellectual_property" + assert len(guardrail.validator_parameters) == 1 + + param = guardrail.validator_parameters[0] + assert param.id == "ipEntities" + assert param.value == ["Text", "Code"] + + def test_empty_entities_raises(self): + """Empty entities should raise ValueError.""" + with pytest.raises(ValueError, match="non-empty"): + IntellectualPropertyValidator(entities=[]) + + def test_post_only(self): + """Should only support POST stage.""" + validator = IntellectualPropertyValidator( + entities=[IntellectualPropertyEntityType.TEXT] + ) + assert validator.supported_stages == [GuardrailExecutionStage.POST] + validator.validate_stage(GuardrailExecutionStage.POST) + with pytest.raises(ValueError, match="does not support stage"): + validator.validate_stage(GuardrailExecutionStage.PRE) + + +# --------------------------------------------------------------------------- +# UserPromptAttacksValidator +# --------------------------------------------------------------------------- + + +class TestUserPromptAttacksValidator: + """Tests for UserPromptAttacksValidator.""" + + def test_builds_guardrail(self): + """Verify get_built_in_guardrail returns correct structure.""" + validator = UserPromptAttacksValidator() + guardrail = validator.get_built_in_guardrail( + name="Test UPA", + description=None, + enabled_for_evals=True, + ) + assert guardrail.validator_type == "user_prompt_attacks" + assert guardrail.validator_parameters == [] + + def test_pre_only(self): + """Should only support PRE stage.""" + validator = UserPromptAttacksValidator() + assert validator.supported_stages == [GuardrailExecutionStage.PRE] + validator.validate_stage(GuardrailExecutionStage.PRE) + with pytest.raises(ValueError, match="does not support stage"): + validator.validate_stage(GuardrailExecutionStage.POST) diff --git a/packages/uipath-platform/tests/services/test_buckets_service.py b/packages/uipath-platform/tests/services/test_buckets_service.py index 0fbb5f974..8cbb9f50c 100644 --- a/packages/uipath-platform/tests/services/test_buckets_service.py +++ b/packages/uipath-platform/tests/services/test_buckets_service.py @@ -646,6 +646,90 @@ async def test_create_async( assert bucket.id == 1 +class TestDelete: + """Tests for delete() / delete_async(). + + Regression coverage for UV-14977: delete() must build the folder header + from the folder_path/folder_key arguments (via header_folder), not solely + from the UIPATH_FOLDER_PATH / UIPATH_FOLDER_KEY env vars. + """ + + def test_delete_by_name_uses_folder_path_arg( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """delete(name=..., folder_path=...) sends the arg folder header on DELETE.""" + # retrieve() locates the bucket in the target folder + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'old-storage'&$top=1", + status_code=200, + json={"value": [{"Id": 203380, "Name": "old-storage", "Identifier": "id"}]}, + match_headers={"x-uipath-folderpath": "Playground"}, + ) + # the DELETE must carry the arg folder header (not the env-var fallback) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(203380)", + method="DELETE", + status_code=204, + match_headers={"x-uipath-folderpath": "Playground"}, + ) + + service.delete(name="old-storage", folder_path="Playground") + + def test_delete_by_key_uses_folder_key_arg( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """delete(key=..., folder_key=...) sends the arg folder-key header on DELETE.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='bucket-key')", + status_code=200, + json={"value": [{"Id": 55, "Name": "kbucket", "Identifier": "bucket-key"}]}, + match_headers={"x-uipath-folderkey": "folder-123"}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(55)", + method="DELETE", + status_code=204, + match_headers={"x-uipath-folderkey": "folder-123"}, + ) + + service.delete(key="bucket-key", folder_key="folder-123") + + @pytest.mark.asyncio + async def test_delete_async_uses_folder_path_arg( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Async version honors the folder_path argument on DELETE.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'old-storage'&$top=1", + status_code=200, + json={"value": [{"Id": 99, "Name": "old-storage", "Identifier": "id"}]}, + match_headers={"x-uipath-folderpath": "Playground"}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(99)", + method="DELETE", + status_code=204, + match_headers={"x-uipath-folderpath": "Playground"}, + ) + + await service.delete_async(name="old-storage", folder_path="Playground") + + class TestEdgeCases: """Tests for edge cases and error handling.""" diff --git a/packages/uipath-platform/tests/services/test_connections_service.py b/packages/uipath-platform/tests/services/test_connections_service.py index 75c89b517..27dec7310 100644 --- a/packages/uipath-platform/tests/services/test_connections_service.py +++ b/packages/uipath-platform/tests/services/test_connections_service.py @@ -17,7 +17,10 @@ ConnectionToken, EventArguments, ) -from uipath.platform.connections._connections_service import ConnectionsService +from uipath.platform.connections._connections_service import ( + HEADER_ACTIVITY_JOB_ID, + ConnectionsService, +) from uipath.platform.orchestrator._folder_service import FolderService @@ -1421,6 +1424,60 @@ def test_invoke_activity_sets_standard_headers( assert sent_request.headers["x-uipath-originator"] == "uipath-python" assert sent_request.headers["x-uipath-source"] == "uipath-python" + def test_invoke_activity_propagates_job_id_header( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + simple_activity_metadata: ActivityMetadata, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Activity invocations carry x-uipath-job-id so GenAI calls can be stitched for licensing.""" + monkeypatch.setenv("UIPATH_JOB_KEY", "job-key-abc") + connection_id = "test-connection-123" + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={}) + + service.invoke_activity( + activity_metadata=simple_activity_metadata, + connection_id=connection_id, + activity_input={"body_field1": "x"}, + ) + + sent_request = httpx_mock.get_requests()[1] + assert sent_request.headers[HEADER_ACTIVITY_JOB_ID] == "job-key-abc" + + def test_invoke_activity_omits_job_id_header_when_unset( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + simple_activity_metadata: ActivityMetadata, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """No job-id header is sent when UIPATH_JOB_KEY is not set.""" + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) + connection_id = "test-connection-123" + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={}) + + service.invoke_activity( + activity_metadata=simple_activity_metadata, + connection_id=connection_id, + activity_input={"body_field1": "x"}, + ) + + sent_request = httpx_mock.get_requests()[1] + assert HEADER_ACTIVITY_JOB_ID not in sent_request.headers + def test_invoke_activity_with_body_fields( self, httpx_mock: HTTPXMock, @@ -2116,3 +2173,173 @@ async def test_invoke_activity_async_uses_connection_id_from_retrieve_response( assert f"/element/instances/{original_connection_id}/" not in str( activity_request.url ) + + +def _multipart_part(body: bytes, boundary: str, name: str) -> str: + """Return the raw text of the multipart part with the given form-field name.""" + text = body.decode("utf-8", errors="replace") + for part in text.split(f"--{boundary}"): + if f'name="{name}"' in part: + return part + raise AssertionError(f"part {name!r} not found in multipart body") + + +class TestMultipartFileUpload: + """Regression tests for the multipart serializer that handles file uploads. + + Before this fix, ``_build_activity_request_spec`` always built + ``files[key] = (key, val, None)``, using the form-field name as the + multipart filename and dropping the content type. Downstream services + (e.g. Coupa's ``add_attachment`` endpoint) ended up storing every + attachment with the literal name ``attachment[file]`` and no extension. + + The serializer now branches on the value type: + + * tuple → passed through (caller controls filename + content type) + * bytes → legacy fallback, key as filename, octet-stream content type + * scalar → plain multipart form field (no filename in Content-Disposition) + """ + + def test_invoke_activity_multipart_tuple_3_preserves_filename( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + multipart_activity_metadata: ActivityMetadata, + ) -> None: + """3-tuple input is forwarded verbatim, so the real filename + content type land on the wire.""" + connection_id = "test-connection-123" + activity_input = { + "file_param": ("invoice.pdf", b"%PDF-1.4 fake", "application/pdf"), + "description": "Test file upload", + } + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={"ok": True}) + + _ = service.invoke_activity( + activity_metadata=multipart_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + sent_request = httpx_mock.get_requests()[1] + boundary = sent_request.headers["content-type"].split("boundary=")[1] + part = _multipart_part(sent_request.content, boundary, "file_param") + + assert 'filename="invoice.pdf"' in part + assert "Content-Type: application/pdf" in part + assert b"%PDF-1.4 fake" in sent_request.content + + def test_invoke_activity_multipart_tuple_2_preserves_filename( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + multipart_activity_metadata: ActivityMetadata, + ) -> None: + """2-tuple (filename, content) shorthand: filename preserved, httpx infers the content type.""" + connection_id = "test-connection-123" + activity_input = { + "file_param": ("invoice.pdf", b"%PDF-1.4 fake"), + "description": "Test file upload", + } + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={"ok": True}) + + _ = service.invoke_activity( + activity_metadata=multipart_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + sent_request = httpx_mock.get_requests()[1] + boundary = sent_request.headers["content-type"].split("boundary=")[1] + part = _multipart_part(sent_request.content, boundary, "file_param") + + assert 'filename="invoice.pdf"' in part + assert b"%PDF-1.4 fake" in sent_request.content + + def test_invoke_activity_multipart_bytes_backwards_compatible( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + multipart_activity_metadata: ActivityMetadata, + ) -> None: + """Existing callers passing raw bytes keep working — filename = form-field name (legacy).""" + connection_id = "test-connection-123" + activity_input = { + "file_param": b"raw bytes", + "description": "Test", + } + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={"ok": True}) + + _ = service.invoke_activity( + activity_metadata=multipart_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + sent_request = httpx_mock.get_requests()[1] + boundary = sent_request.headers["content-type"].split("boundary=")[1] + part = _multipart_part(sent_request.content, boundary, "file_param") + + # Legacy fallback: form-field name used as filename, octet-stream content type. + assert 'filename="file_param"' in part + assert "Content-Type: application/octet-stream" in part + assert b"raw bytes" in sent_request.content + + def test_invoke_activity_multipart_scalar_is_plain_form_field( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + ) -> None: + """Scalar multipart_params get sent as plain form fields (no bogus filename).""" + metadata = ActivityMetadata( + object_path="/elements/test-connector/upload", + method_name="POST", + content_type="multipart/form-data", + parameter_location_info=ActivityParameterLocationInfo( + multipart_params=["file_param", "payload"], + body_fields=[], + ), + ) + connection_id = "test-connection-123" + activity_input = { + "file_param": ("doc.pdf", b"data", "application/pdf"), + "payload": "{}", + } + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={"ok": True}) + + _ = service.invoke_activity( + activity_metadata=metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + sent_request = httpx_mock.get_requests()[1] + boundary = sent_request.headers["content-type"].split("boundary=")[1] + payload_part = _multipart_part(sent_request.content, boundary, "payload") + + # Scalar payload must NOT carry a filename in Content-Disposition. + assert "filename=" not in payload_part + assert "{}" in payload_part diff --git a/packages/uipath-platform/tests/services/test_context_grounding_service.py b/packages/uipath-platform/tests/services/test_context_grounding_service.py index 135ac281b..70c529ec6 100644 --- a/packages/uipath-platform/tests/services/test_context_grounding_service.py +++ b/packages/uipath-platform/tests/services/test_context_grounding_service.py @@ -6,7 +6,11 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.common.constants import ( + ENV_JOB_KEY, + HEADER_JOB_KEY, + HEADER_USER_AGENT, +) from uipath.platform.context_grounding import ( BatchTransformCreationResponse, BatchTransformOutputColumn, @@ -31,6 +35,7 @@ from uipath.platform.context_grounding._context_grounding_service import ( ContextGroundingService, ) +from uipath.platform.errors import ContextGroundingIndexNotFoundError from uipath.platform.orchestrator._buckets_service import BucketsService from uipath.platform.orchestrator._folder_service import FolderService @@ -761,6 +766,529 @@ async def test_retrieve_async_falls_back_to_across_folders_when_no_folder_contex assert len(sent_requests) == 1 assert "/ecs_/v2/indexes/allacrossfolders" in str(sent_requests[0].url) + def test_retrieve_system_indexes( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "sys-index-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + }, + { + "id": "sys-index-2", + "name": "system-other-index", + "lastIngestionStatus": "Completed", + }, + ] + }, + ) + + indexes = service._retrieve_system_indexes() + + assert isinstance(indexes, list) + assert len(indexes) == 2 + assert isinstance(indexes[0], ContextGroundingIndex) + assert indexes[0].id == "sys-index-1" + assert indexes[0].name == "system-template-index" + + sent_requests = httpx_mock.get_requests() + assert sent_requests[0].method == "GET" + assert "/ecs_/v2/indexes/allsystemindexes" in str(sent_requests[0].url) + assert "x-uipath-folderkey" not in sent_requests[0].headers + + assert HEADER_USER_AGENT in sent_requests[0].headers + assert ( + sent_requests[0].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService._retrieve_system_indexes/{version}" + ) + + def test_retrieve_system_indexes_with_name_filter( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={ + "value": [ + { + "id": "sys-index-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + }, + ] + }, + ) + + indexes = service._retrieve_system_indexes(name="system-template-index") + + assert len(indexes) == 1 + assert indexes[0].name == "system-template-index" + + sent_requests = httpx_mock.get_requests() + assert "allsystemindexes" in str(sent_requests[0].url) + assert "x-uipath-folderkey" not in sent_requests[0].headers + + @pytest.mark.anyio + async def test_retrieve_system_indexes_async( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "sys-index-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + indexes = await service._retrieve_system_indexes_async() + + assert len(indexes) == 1 + assert indexes[0].id == "sys-index-1" + + sent_requests = httpx_mock.get_requests() + assert sent_requests[0].method == "GET" + assert "/ecs_/v2/indexes/allsystemindexes" in str(sent_requests[0].url) + assert "x-uipath-folderkey" not in sent_requests[0].headers + + assert ( + sent_requests[0].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService._retrieve_system_indexes_async/{version}" + ) + + def test_retrieve_system_indexes_escapes_single_quote_in_name( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'O''Brien'", + status_code=200, + json={ + "value": [ + { + "id": "sys-1", + "name": "O'Brien", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + indexes = service._retrieve_system_indexes(name="O'Brien") + + assert len(indexes) == 1 + assert indexes[0].name == "O'Brien" + + def test_retrieve_across_folders_escapes_single_quote_in_name( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'O''Brien'", + status_code=200, + json={ + "value": [ + { + "id": "idx-1", + "name": "O'Brien", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + indexes = service.retrieve_across_folders(name="O'Brien") + + assert len(indexes) == 1 + assert indexes[0].name == "O'Brien" + + def test_retrieve_system_indexes_empty( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource", + status_code=200, + json={"value": []}, + ) + + indexes = service._retrieve_system_indexes() + + assert indexes == [] + + def test_retrieve_raises_typed_not_found_when_across_folders_empty( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'missing-index'", + status_code=200, + json={"value": []}, + ) + + with pytest.raises(ContextGroundingIndexNotFoundError) as exc_info: + service_no_folder.retrieve(name="missing-index") + + assert exc_info.value.index_name == "missing-index" + + @pytest.mark.anyio + async def test_retrieve_async_raises_typed_not_found_when_across_folders_empty( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'missing-index'", + status_code=200, + json={"value": []}, + ) + + with pytest.raises(ContextGroundingIndexNotFoundError): + await service_no_folder.retrieve_async(name="missing-index") + + def test_retrieve_falls_back_to_system_indexes_when_flag_true( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={"value": []}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={ + "value": [ + { + "id": "sys-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + index = service_no_folder.retrieve( + name="system-template-index", include_system_indexes=True + ) + + assert index.id == "sys-1" + assert index.name == "system-template-index" + + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 2 + assert "/ecs_/v2/indexes/allacrossfolders" in str(sent_requests[0].url) + assert "/ecs_/v2/indexes/allsystemindexes" in str(sent_requests[1].url) + + def test_retrieve_does_not_fall_back_to_system_indexes_when_flag_false( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'missing-index'", + status_code=200, + json={"value": []}, + ) + + with pytest.raises(ContextGroundingIndexNotFoundError): + service_no_folder.retrieve(name="missing-index") + + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 1 + assert "/ecs_/v2/indexes/allacrossfolders" in str(sent_requests[0].url) + + def test_retrieve_skips_system_indexes_when_across_folders_resolves( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'tenant-index'", + status_code=200, + json={ + "value": [ + { + "id": "tenant-1", + "name": "tenant-index", + "lastIngestionStatus": "Completed", + "folderKey": "folder-x", + } + ] + }, + ) + + index = service_no_folder.retrieve( + name="tenant-index", include_system_indexes=True + ) + + assert index.id == "tenant-1" + + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 1 + assert "/ecs_/v2/indexes/allacrossfolders" in str(sent_requests[0].url) + + def test_retrieve_falls_back_to_system_indexes_after_folder_lookup_misses( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'system-template-index'&$expand=dataSource", + status_code=200, + json={"value": []}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={ + "value": [ + { + "id": "sys-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + index = service.retrieve( + name="system-template-index", + folder_path="test-folder-path", + include_system_indexes=True, + ) + + assert index.id == "sys-1" + + @pytest.mark.anyio + async def test_retrieve_async_falls_back_to_system_indexes_when_flag_true( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={"value": []}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={ + "value": [ + { + "id": "sys-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + index = await service_no_folder.retrieve_async( + name="system-template-index", include_system_indexes=True + ) + + assert index.id == "sys-1" + + def test_retrieve_with_flag_raises_when_system_indexes_also_empty( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'missing-index'", + status_code=200, + json={"value": []}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'missing-index'", + status_code=200, + json={"value": []}, + ) + + with pytest.raises(ContextGroundingIndexNotFoundError): + service_no_folder.retrieve( + name="missing-index", include_system_indexes=True + ) + + def test_unified_search_forwards_include_system_indexes( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={"value": []}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={ + "value": [ + { + "id": "sys-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v1.2/search/sys-1", + status_code=200, + json={ + "semanticResults": { + "values": [], + "metadata": {"operation_id": "op-1", "strategy": "semantic"}, + } + }, + ) + + result = service_no_folder.unified_search( + name="system-template-index", + query="hello", + include_system_indexes=True, + ) + + assert isinstance(result, UnifiedQueryResult) + + sent_requests = httpx_mock.get_requests() + assert any("allsystemindexes" in str(r.url) for r in sent_requests) + assert any("search/sys-1" in str(r.url) for r in sent_requests) + + @pytest.mark.anyio + async def test_unified_search_async_forwards_include_system_indexes( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={"value": []}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={ + "value": [ + { + "id": "sys-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v1.2/search/sys-1", + status_code=200, + json={ + "semanticResults": { + "values": [], + "metadata": {"operation_id": "op-1", "strategy": "semantic"}, + } + }, + ) + + result = await service_no_folder.unified_search_async( + name="system-template-index", + query="hello", + include_system_indexes=True, + ) + + assert isinstance(result, UnifiedQueryResult) + + sent_requests = httpx_mock.get_requests() + assert any("allsystemindexes" in str(r.url) for r in sent_requests) + assert any("search/sys-1" in str(r.url) for r in sent_requests) + def test_search_uses_index_folder_key_when_no_folder_context( self, httpx_mock: HTTPXMock, @@ -2749,6 +3277,71 @@ async def test_create_ephemeral_index_async( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.create_ephemeral_index_async/{version}" ) + def test_create_ephemeral_index_with_folder_key( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + import uuid + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/createephemeral", + status_code=200, + json={ + "id": "ephemeral-index-id", + "name": "ephemeral-index", + "lastIngestionStatus": "Queued", + }, + ) + + attachment_ids = [str(uuid.uuid4())] + service.create_ephemeral_index( + usage="DeepRAG", + attachments=attachment_ids, + folder_key="test-folder-key", + ) + + sent_requests = httpx_mock.get_requests() + assert sent_requests is not None + assert "x-uipath-folderkey" in sent_requests[0].headers + assert sent_requests[0].headers["x-uipath-folderkey"] == "test-folder-key" + + @pytest.mark.anyio + async def test_create_ephemeral_index_async_with_folder_key( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + import uuid + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/createephemeral", + status_code=200, + json={ + "id": "ephemeral-index-id", + "name": "ephemeral-index", + "lastIngestionStatus": "Queued", + }, + ) + + attachment_ids = [str(uuid.uuid4())] + await service.create_ephemeral_index_async( + usage="DeepRAG", + attachments=attachment_ids, + folder_key="test-folder-key", + ) + + sent_requests = httpx_mock.get_requests() + assert sent_requests is not None + assert "x-uipath-folderkey" in sent_requests[0].headers + assert sent_requests[0].headers["x-uipath-folderkey"] == "test-folder-key" + @pytest.mark.anyio async def test_download_batch_transform_result_async_creates_nested_directories( self, @@ -3111,7 +3704,7 @@ async def test_unified_search_async( response = await service.unified_search_async( name="test-index", query="test query", - search_mode=SearchMode.AUTO, + search_mode=SearchMode.SEMANTIC, ) assert isinstance(response, UnifiedQueryResult) @@ -3193,3 +3786,135 @@ def test_unified_search_with_scope( assert "filter" not in request_body assert request_body["scope"]["folder"] == "docs" assert request_body["scope"]["extension"] == ".pdf" + + +class TestJobKeyHeader: + """X-UiPath-JobKey is attached to outbound ECS calls when UIPATH_JOB_KEY is set.""" + + _INDEX = ContextGroundingIndex( + id="test-index-id", + name="test-index", + last_ingestion_status="Completed", + ) + + def test_ingest_data_carries_job_key_header( + self, + service: ContextGroundingService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv(ENV_JOB_KEY, "job-key-ingest") + + with patch.object(service, "request") as mock_request: + mock_request.return_value = MagicMock() + service.ingest_data(self._INDEX, folder_key="test-folder-key") + + headers = mock_request.call_args[1]["headers"] + assert headers[HEADER_JOB_KEY] == "job-key-ingest" + + @pytest.mark.anyio + async def test_ingest_data_async_carries_job_key_header( + self, + service: ContextGroundingService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv(ENV_JOB_KEY, "job-key-ingest-async") + + with patch.object(service, "request_async") as mock_request: + mock_request.return_value = MagicMock() + await service.ingest_data_async(self._INDEX, folder_key="test-folder-key") + + headers = mock_request.call_args[1]["headers"] + assert headers[HEADER_JOB_KEY] == "job-key-ingest-async" + + def test_unified_search_carries_job_key_header( + self, + service: ContextGroundingService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv(ENV_JOB_KEY, "job-key-search") + + with patch.object(service, "request") as mock_request: + mock_response = MagicMock() + mock_response.json.return_value = { + "semanticResults": {"values": []}, + "explanation": None, + } + mock_request.return_value = mock_response + with patch.object(service, "retrieve", return_value=self._INDEX): + service.unified_search( + name="test-index", + query="test query", + folder_key="test-folder-key", + ) + + headers = mock_request.call_args[1]["headers"] + assert headers[HEADER_JOB_KEY] == "job-key-search" + + def test_start_deep_rag_carries_job_key_header( + self, + service: ContextGroundingService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv(ENV_JOB_KEY, "job-key-deeprag") + + with patch.object(service, "request") as mock_request: + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "new-deep-rag-task-id", + "lastDeepRagStatus": "Queued", + "createdDate": "2024-01-15T10:30:00Z", + } + mock_request.return_value = mock_response + service.start_deep_rag( + index_id="test-index-id", + name="my-deep-rag-task", + prompt="Summarize", + glob_pattern="*.pdf", + citation_mode=CitationMode.INLINE, + folder_key="test-folder-key", + ) + + headers = mock_request.call_args[1]["headers"] + assert headers[HEADER_JOB_KEY] == "job-key-deeprag" + + def test_start_batch_transform_carries_job_key_header( + self, + service: ContextGroundingService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv(ENV_JOB_KEY, "job-key-batch") + + with patch.object(service, "request") as mock_request: + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "new-batch-id", + "lastBatchRagStatus": "Queued", + } + mock_request.return_value = mock_response + service.start_batch_transform( + index_id="test-index-id", + name="my-batch-task", + prompt="Extract", + output_columns=[ + BatchTransformOutputColumn(name="col1", description="d") + ], + enable_web_search_grounding=False, + folder_key="test-folder-key", + ) + + headers = mock_request.call_args[1]["headers"] + assert headers[HEADER_JOB_KEY] == "job-key-batch" + + def test_ingest_data_omits_job_key_header_when_env_unset( + self, + service: ContextGroundingService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv(ENV_JOB_KEY, raising=False) + + with patch.object(service, "request") as mock_request: + mock_request.return_value = MagicMock() + service.ingest_data(self._INDEX, folder_key="test-folder-key") + + headers = mock_request.call_args[1]["headers"] + assert HEADER_JOB_KEY not in headers diff --git a/packages/uipath-platform/tests/services/test_conversations_service.py b/packages/uipath-platform/tests/services/test_conversations_service.py index 31aa4a653..37e08bdfa 100644 --- a/packages/uipath-platform/tests/services/test_conversations_service.py +++ b/packages/uipath-platform/tests/services/test_conversations_service.py @@ -38,7 +38,6 @@ async def test_retrieve_message( "role": "assistant", "contentParts": [], "toolCalls": [], - "interrupts": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", }, @@ -95,7 +94,6 @@ async def test_retrieve_message_with_content_parts( } ], "toolCalls": [], - "interrupts": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", }, @@ -145,7 +143,6 @@ async def test_retrieve_message_with_tool_calls( "updatedAt": "2024-01-01T00:00:00Z", } ], - "interrupts": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", }, diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index 8a9abafef..258c3a9d8 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -1,3 +1,4 @@ +import json import re import uuid from dataclasses import make_dataclass @@ -8,8 +9,13 @@ from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.entities import Entity, EntityRouting, QueryRoutingOverrideContext +from uipath.platform.common._bindings import ( + EntityResourceOverwrite, + _resource_overwrites, +) +from uipath.platform.entities import ChoiceSetValue, DataFabricEntityItem, Entity from uipath.platform.entities._entities_service import EntitiesService +from uipath.platform.entities._entity_data_service import EntityDataService @pytest.fixture @@ -263,12 +269,52 @@ def test_retrieve_records_with_optional_fields( limit=1, ) + def test_retrieve_records_without_start_and_limit( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{str(entity_key)}/read", + status_code=200, + json={ + "totalCount": 1, + "value": [ + {"Id": "12345", "name": "record_name", "integer_field": 10}, + ], + }, + ) + + records = service.list_records(entity_key=str(entity_key)) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + # Verify no start or limit query params are sent + assert "start" not in str(sent_request.url.params) + assert "limit" not in str(sent_request.url.params) + + assert isinstance(records, list) + assert len(records) == 1 + assert records[0].id == "12345" + @pytest.mark.parametrize( "sql_query", [ "SELECT id FROM Customers WHERE id = 1", "SELECT id, name FROM Customers LIMIT 10", - "SELECT * FROM Customers WHERE status = 'Active'", + "SELECT COUNT(id) FROM Customers", + "SELECT SUM(amount) FROM Orders", + "SELECT AVG(price) FROM Products", + "SELECT MIN(created), MAX(created) FROM Events", + "SELECT COUNT(id) AS total, SUM(amount) AS amt FROM Orders", + "SELECT COUNT(id), name FROM Customers LIMIT 10", "SELECT id, name, email, phone FROM Customers LIMIT 5", "SELECT DISTINCT id FROM Customers WHERE id > 100", "SELECT id FROM Customers WHERE name = 'foo;bar'", @@ -280,7 +326,7 @@ def test_retrieve_records_with_optional_fields( def test_validate_sql_query_allows_supported_select_queries( self, sql_query: str, service: EntitiesService ) -> None: - service._validate_sql_query(sql_query) + service._data._validate_sql_query(sql_query) @pytest.mark.parametrize( "sql_query,error_message", @@ -316,9 +362,49 @@ def test_validate_sql_query_allows_supported_select_queries( "SELECT id FROM Customers", "Queries without WHERE must include a LIMIT clause.", ), + ( + "SELECT UPPER(name) FROM Customers", + "Queries without WHERE must include a LIMIT clause.", + ), + ( + "SELECT COALESCE(name, 'N/A') FROM Customers", + "Queries without WHERE must include a LIMIT clause.", + ), + ( + "SELECT 1 LIMIT 1", + "Queries must include a FROM clause.", + ), + ( + "SELECT COUNT(*) FROM Customers", + "COUNT(*) is not supported. Use COUNT(column_name) instead.", + ), + ( + "SELECT COUNT(*), name FROM Customers LIMIT 10", + "COUNT(*) is not supported. Use COUNT(column_name) instead.", + ), + ( + "SELECT COUNT(*) AS total FROM Customers", + "COUNT(*) is not supported. Use COUNT(column_name) instead.", + ), ( "SELECT * FROM Customers LIMIT 10", - "SELECT * without filtering is not allowed.", + "SELECT * is not allowed. Specify column names instead.", + ), + ( + "SELECT Customers.* FROM Customers LIMIT 10", + "SELECT * is not allowed. Specify column names instead.", + ), + ( + "SELECT t.* FROM Customers t LIMIT 10", + "SELECT * is not allowed. Specify column names instead.", + ), + ( + "SELECT * FROM Customers WHERE status = 'Active'", + "SELECT * is not allowed. Specify column names instead.", + ), + ( + "SELECT Customers.* FROM Customers WHERE status = 'Active'", + "SELECT * is not allowed. Specify column names instead.", ), ( "SELECT id, name, email, phone, address FROM Customers LIMIT 10", @@ -330,20 +416,20 @@ def test_validate_sql_query_rejects_disallowed_queries( self, sql_query: str, error_message: str, service: EntitiesService ) -> None: with pytest.raises(ValueError, match=re.escape(error_message)): - service._validate_sql_query(sql_query) + service._data._validate_sql_query(sql_query) def test_query_entity_records_rejects_invalid_sql_before_network_call( self, service: EntitiesService, ) -> None: - service.request = MagicMock() # type: ignore[method-assign] + service._data.request = MagicMock() # type: ignore[method-assign] with pytest.raises( ValueError, match=re.escape("Only SELECT statements are allowed.") ): service.query_entity_records("UPDATE Customers SET name = 'X'") - service.request.assert_not_called() + service._data.request.assert_not_called() def test_query_entity_records_calls_request_for_valid_sql( self, @@ -352,26 +438,26 @@ def test_query_entity_records_calls_request_for_valid_sql( response = MagicMock() response.json.return_value = {"results": [{"id": 1}, {"id": 2}]} - service.request = MagicMock(return_value=response) # type: ignore[method-assign] + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] result = service.query_entity_records("SELECT id FROM Customers WHERE id > 0") assert result == [{"id": 1}, {"id": 2}] - service.request.assert_called_once() + service._data.request.assert_called_once() @pytest.mark.anyio async def test_query_entity_records_async_rejects_invalid_sql_before_network_call( self, service: EntitiesService, ) -> None: - service.request_async = AsyncMock() # type: ignore[method-assign] + service._data.request_async = AsyncMock() # type: ignore[method-assign] with pytest.raises(ValueError, match=re.escape("Subqueries are not allowed.")): await service.query_entity_records_async( "SELECT id FROM Customers WHERE id IN (SELECT id FROM Orders)" ) - service.request_async.assert_not_called() + service._data.request_async.assert_not_called() @pytest.mark.anyio async def test_query_entity_records_async_calls_request_for_valid_sql( @@ -381,77 +467,69 @@ async def test_query_entity_records_async_calls_request_for_valid_sql( response = MagicMock() response.json.return_value = {"results": [{"id": "c1"}]} - service.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] + service._data.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] result = await service.query_entity_records_async( "SELECT id FROM Customers WHERE id = 'c1'" ) assert result == [{"id": "c1"}] - service.request_async.assert_called_once() + service._data.request_async.assert_called_once() - def test_query_entity_records_with_routing_context( + def test_query_entity_records_builds_routing_context_from_folders_map( self, - service: EntitiesService, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, ) -> None: + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_map={"Customers": "solution_folder", "Orders": "folder-2"}, + ) response = MagicMock() response.json.return_value = {"results": [{"id": 1}]} - service.request = MagicMock(return_value=response) # type: ignore[method-assign] - - routing = QueryRoutingOverrideContext( - entity_routings=[ - EntityRouting(entity_name="Customers", folder_id="folder-1"), - EntityRouting( - entity_name="Orders", - folder_id="folder-2", - override_entity_name="OrdersV2", - ), - ] - ) + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] - result = service.query_entity_records( - "SELECT id FROM Customers LIMIT 10", routing_context=routing - ) + result = service.query_entity_records("SELECT id FROM Customers LIMIT 10") assert result == [{"id": 1}] - call_kwargs = service.request.call_args + call_kwargs = service._data.request.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") assert body["query"] == "SELECT id FROM Customers LIMIT 10" assert body["routingContext"] == { "entityRoutings": [ - {"entityName": "Customers", "folderId": "folder-1"}, - { - "entityName": "Orders", - "folderId": "folder-2", - "overrideEntityName": "OrdersV2", - }, + {"entityName": "Customers", "folderId": "solution_folder"}, + {"entityName": "Orders", "folderId": "folder-2"}, ] } @pytest.mark.anyio - async def test_query_entity_records_async_with_routing_context( + async def test_query_entity_records_async_builds_routing_context_from_folders_map( self, - service: EntitiesService, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, ) -> None: + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_map={"Customers": "solution_folder"}, + ) response = MagicMock() response.json.return_value = {"results": [{"id": "c1"}]} - service.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] - - routing = QueryRoutingOverrideContext( - entity_routings=[ - EntityRouting(entity_name="Customers", folder_id="folder-1"), - ] - ) + service._data.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] result = await service.query_entity_records_async( - "SELECT id FROM Customers WHERE id = 'c1'", - routing_context=routing, + "SELECT id FROM Customers WHERE id = 'c1'" ) assert result == [{"id": "c1"}] - call_kwargs = service.request_async.call_args + call_kwargs = service._data.request_async.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") - assert "routingContext" in body + assert body["routingContext"] == { + "entityRoutings": [ + {"entityName": "Customers", "folderId": "solution_folder"}, + ] + } def test_query_entity_records_without_routing_context_omits_key( self, @@ -459,10 +537,2113 @@ def test_query_entity_records_without_routing_context_omits_key( ) -> None: response = MagicMock() response.json.return_value = {"results": []} - service.request = MagicMock(return_value=response) # type: ignore[method-assign] + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] service.query_entity_records("SELECT id FROM Customers WHERE id > 0") - call_kwargs = service.request.call_args + call_kwargs = service._data.request.call_args body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") assert "routingContext" not in body + + def test_query_entity_records_picks_up_entity_overwrites_from_context( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + from uipath.platform.common._bindings import ( + EntityResourceOverwrite, + _resource_overwrites, + ) + + service = EntitiesService( + config=config, + execution_context=execution_context, + ) + response = MagicMock() + response.json.return_value = {"results": [{"id": 1}]} + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] + + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Overwritten Customers", + folder_id="overwritten-folder-id", + ) + token = _resource_overwrites.set({"entity.Customers": overwrite}) + try: + service.query_entity_records("SELECT id FROM Customers LIMIT 10") + finally: + _resource_overwrites.reset(token) + + call_kwargs = service._data.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["routingContext"] == { + "entityRoutings": [ + { + "entityName": "Customers", + "folderId": "overwritten-folder-id", + "overrideEntityName": "Overwritten Customers", + }, + ] + } + + def test_query_entity_records_merges_folders_map_with_entity_name_overrides( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_map={ + "Customers": "overwritten-folder-id", + "Orders": "orders-folder", + }, + entity_name_overrides={"Customers": "Overwritten Customers"}, + ) + response = MagicMock() + response.json.return_value = {"results": []} + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] + + service.query_entity_records("SELECT id FROM Customers LIMIT 10") + + call_kwargs = service._data.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + routings = body["routingContext"]["entityRoutings"] + assert { + "entityName": "Customers", + "folderId": "overwritten-folder-id", + "overrideEntityName": "Overwritten Customers", + } in routings + assert {"entityName": "Orders", "folderId": "orders-folder"} in routings + # Exactly two routings — no duplicates + assert len(routings) == 2 + + def test_resolve_entity_set_uses_effective_sql_name_in_routing_context( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + service = EntitiesService( + config=config, + execution_context=execution_context, + ) + service.retrieve_by_name = MagicMock( # type: ignore[method-assign] + return_value=MagicMock(spec=Entity) + ) + + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Overwritten Customers", + folder_id="known-folder-key", + ) + token = _resource_overwrites.set({"entity.entity-1": overwrite}) + try: + resolution = service.resolve_entity_set( + [ + DataFabricEntityItem( + id="entity-1", + name="Customers", + folder_key="original-folder-key", + ) + ] + ) + finally: + _resource_overwrites.reset(token) + + assert resolution.entities_service._routing_strategy.routing_context is not None + assert resolution.entities_service._routing_strategy.routing_context.model_dump( + by_alias=True, exclude_none=True + ) == { + "entityRoutings": [ + { + "entityName": "Customers", + "folderId": "known-folder-key", + "overrideEntityName": "Overwritten Customers", + } + ] + } + service.retrieve_by_name.assert_called_once_with( + "Overwritten Customers", + "known-folder-key", + ) + + @pytest.mark.asyncio + async def test_resolve_entity_set_async_resolves_folder_paths_before_fetch( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + folders_service = MagicMock() + folders_service.retrieve_key_async = AsyncMock( + return_value="resolved-folder-id" + ) + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + service.retrieve_by_name_async = AsyncMock( # type: ignore[method-assign] + return_value=MagicMock(spec=Entity) + ) + + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Overwritten Customers", + folder_path="Shared/Finance", + ) + token = _resource_overwrites.set({"entity.entity-1": overwrite}) + try: + resolution = await service.resolve_entity_set_async( + [ + DataFabricEntityItem( + id="entity-1", + name="Customers", + folder_key="original-folder-key", + ) + ] + ) + finally: + _resource_overwrites.reset(token) + + folders_service.retrieve_key_async.assert_awaited_once_with( + folder_path="Shared/Finance" + ) + assert resolution.entities_service._routing_strategy.routing_context is not None + assert resolution.entities_service._routing_strategy.routing_context.model_dump( + by_alias=True, exclude_none=True + ) == { + "entityRoutings": [ + { + "entityName": "Customers", + "folderId": "resolved-folder-id", + "overrideEntityName": "Overwritten Customers", + } + ] + } + service.retrieve_by_name_async.assert_awaited_once_with( + "Overwritten Customers", + "resolved-folder-id", + ) + + def test_query_entity_records_context_overwrite_same_name_no_override_field( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + from uipath.platform.common._bindings import ( + EntityResourceOverwrite, + _resource_overwrites, + ) + + service = EntitiesService( + config=config, + execution_context=execution_context, + ) + response = MagicMock() + response.json.return_value = {"results": []} + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] + + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Customers", + folder_id="different-folder-id", + ) + token = _resource_overwrites.set({"entity.Customers": overwrite}) + try: + service.query_entity_records("SELECT id FROM Customers LIMIT 10") + finally: + _resource_overwrites.reset(token) + + call_kwargs = service._data.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["routingContext"] == { + "entityRoutings": [ + { + "entityName": "Customers", + "folderId": "different-folder-id", + }, + ] + } + + def test_query_entity_records_resolves_overwrite_folder_path_to_folder_key( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + from uipath.platform.common._bindings import ( + EntityResourceOverwrite, + _resource_overwrites, + ) + + folders_service = MagicMock() + folders_service.retrieve_key.return_value = "resolved-folder-id" + + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + response = MagicMock() + response.json.return_value = {"results": []} + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] + + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Overwritten Customers", + folder_path="Shared/Finance", + ) + token = _resource_overwrites.set({"entity.Customers": overwrite}) + try: + service.query_entity_records("SELECT id FROM Customers LIMIT 10") + finally: + _resource_overwrites.reset(token) + + call_kwargs = service._data.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["routingContext"] == { + "entityRoutings": [ + { + "entityName": "Customers", + "folderId": "resolved-folder-id", + "overrideEntityName": "Overwritten Customers", + }, + ] + } + + def test_query_entity_records_uses_folder_id_directly_without_resolution( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + from uipath.platform.common._bindings import ( + EntityResourceOverwrite, + _resource_overwrites, + ) + + folders_service = MagicMock() + folders_service.retrieve_key.return_value = None + + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + response = MagicMock() + response.json.return_value = {"results": []} + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] + + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Overwritten Customers", + folder_id="known-folder-key", + ) + token = _resource_overwrites.set({"entity.Customers": overwrite}) + try: + service.query_entity_records("SELECT id FROM Customers LIMIT 10") + finally: + _resource_overwrites.reset(token) + + # folder_id is a key — should NOT be sent through FolderService + folders_service.retrieve_key.assert_not_called() + + call_kwargs = service._data.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["routingContext"] == { + "entityRoutings": [ + { + "entityName": "Customers", + "folderId": "known-folder-key", + "overrideEntityName": "Overwritten Customers", + }, + ] + } + + def test_list_choicesets( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/choiceset", + status_code=200, + json=[ + { + "name": "Status", + "displayName": "Status", + "entityType": "ChoiceSet", + "description": "Status choices", + "isRbacEnabled": False, + "id": "cs-001", + }, + { + "name": "Priority", + "displayName": "Priority", + "entityType": "ChoiceSet", + "description": "Priority levels", + "isRbacEnabled": False, + "id": "cs-002", + }, + ], + ) + + choicesets = service.list_choicesets() + + assert isinstance(choicesets, list) + assert len(choicesets) == 2 + assert choicesets[0].name == "Status" + assert choicesets[0].entity_type == "ChoiceSet" + assert choicesets[0].id == "cs-001" + assert choicesets[1].name == "Priority" + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert sent_request.method == "GET" + assert str(sent_request.url).endswith("/datafabric_/api/Entity/choiceset") + + @pytest.mark.anyio + async def test_list_choicesets_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/choiceset", + status_code=200, + json=[ + { + "name": "Role", + "displayName": "Role", + "entityType": "ChoiceSet", + "isRbacEnabled": False, + "id": "cs-003", + }, + ], + ) + + choicesets = await service.list_choicesets_async() + + assert len(choicesets) == 1 + assert choicesets[0].name == "Role" + assert choicesets[0].id == "cs-003" + + def test_get_choiceset_values( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + choiceset_id = "cs-001" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{choiceset_id}/query_expansion", + status_code=200, + json={ + "totalRecordCount": 3, + "jsonValue": json.dumps( + [ + { + "Id": "v1", + "Name": "Active", + "DisplayName": "Active", + "NumberId": 0, + "CreateTime": "2026-01-01T00:00:00Z", + "UpdateTime": "2026-01-01T00:00:00Z", + }, + { + "Id": "v2", + "Name": "Inactive", + "DisplayName": "Inactive", + "NumberId": 1, + "CreateTime": "2026-01-01T00:00:00Z", + "UpdateTime": "2026-01-01T00:00:00Z", + }, + { + "Id": "v3", + "Name": "Pending", + "DisplayName": "Pending", + "NumberId": 2, + }, + ] + ), + }, + ) + + values = service.get_choiceset_values(choiceset_id) + + assert isinstance(values, list) + assert len(values) == 3 + assert isinstance(values[0], ChoiceSetValue) + assert values[0].id == "v1" + assert values[0].name == "Active" + assert values[0].display_name == "Active" + assert values[0].number_id == 0 + assert values[1].number_id == 1 + assert values[2].name == "Pending" + assert values[2].created_by is None + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert sent_request.method == "POST" + + def test_get_choiceset_values_with_pagination( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + choiceset_id = "cs-001" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{choiceset_id}/query_expansion?start=0&limit=2", + status_code=200, + json={ + "totalRecordCount": 5, + "jsonValue": json.dumps( + [ + { + "Id": "v1", + "Name": "Active", + "DisplayName": "Active", + "NumberId": 0, + }, + { + "Id": "v2", + "Name": "Inactive", + "DisplayName": "Inactive", + "NumberId": 1, + }, + ] + ), + }, + ) + + values = service.get_choiceset_values(choiceset_id, start=0, limit=2) + + assert len(values) == 2 + assert values[0].name == "Active" + assert values[1].name == "Inactive" + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert "start=0" in str(sent_request.url) + assert "limit=2" in str(sent_request.url) + + @pytest.mark.anyio + async def test_get_choiceset_values_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + choiceset_id = "cs-002" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{choiceset_id}/query_expansion", + status_code=200, + json={ + "totalRecordCount": 1, + "jsonValue": json.dumps( + [ + { + "Id": "v1", + "Name": "ReadOnly", + "DisplayName": "Read Only", + "NumberId": 0, + }, + ] + ), + }, + ) + + values = await service.get_choiceset_values_async(choiceset_id) + + assert len(values) == 1 + assert values[0].display_name == "Read Only" + assert values[0].number_id == 0 + + def test_get_choiceset_values_empty( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + choiceset_id = "cs-empty" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{choiceset_id}/query_expansion", + status_code=200, + json={ + "totalRecordCount": 0, + "jsonValue": "[]", + }, + ) + + values = service.get_choiceset_values(choiceset_id) + + assert values == [] + + +class TestEntitiesServiceNewMethods: + """Single-record, structured-query, attachment, schema and bulk-import tests.""" + + def test_insert_record_fires_post_with_expansion_level( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + from uipath.platform.entities import EntityRecord + + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/insert?expansionLevel=2", + status_code=200, + json={"Id": "rec-1", "name": "alice"}, + ) + + record = service.insert_record( + entity_key=str(entity_key), + data={"name": "alice"}, + expansion_level=2, + ) + + assert isinstance(record, EntityRecord) + assert record.id == "rec-1" + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + assert json.loads(sent.content) == {"name": "alice"} + + async def test_insert_record_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/insert", + status_code=200, + json={"Id": "rec-1"}, + ) + + record = await service.insert_record_async( + entity_key=str(entity_key), data={"name": "bob"} + ) + assert record.id == "rec-1" + + def test_get_record( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + record_id = "12345" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/read/{record_id}?expansionLevel=1", + status_code=200, + json={"Id": record_id, "name": "found"}, + ) + + record = service.get_record( + entity_key=str(entity_key), record_id=record_id, expansion_level=1 + ) + + assert record.id == record_id + + def test_update_record_accepts_dict( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + record_id = "rec-9" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/update/{record_id}", + status_code=200, + json={"Id": record_id, "name": "updated"}, + ) + + record = service.update_record( + entity_key=str(entity_key), + record_id=record_id, + data={"name": "updated"}, + ) + + assert record.id == record_id + sent = httpx_mock.get_request() + assert sent is not None + assert json.loads(sent.content) == {"name": "updated"} + + def test_delete_record_uses_http_delete( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + record_id = "rec-9" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/delete/{record_id}", + method="DELETE", + status_code=200, + ) + + service.delete_record(entity_key=str(entity_key), record_id=record_id) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "DELETE" + + def test_query_v1_with_filter_and_pagination( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + from uipath.platform.entities import ( + EntityQueryFilter, + EntityQueryFilterGroup, + EntityQuerySortOption, + LogicalOperator, + QueryFilterOperator, + ) + + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/query.*" + ), + status_code=200, + json={ + "value": [{"Id": "1", "name": "alice"}, {"Id": "2", "name": "bob"}], + "totalRecordCount": 5, + }, + ) + + result = service.retrieve_records( + entity_key=str(entity_key), + filter_group=EntityQueryFilterGroup( + logical_operator=LogicalOperator.And, + query_filters=[ + EntityQueryFilter( + field_name="status", + operator=QueryFilterOperator.Equals, + value="active", + ) + ], + ), + sort_options=[EntityQuerySortOption(field_name="name", is_descending=True)], + selected_fields=["Id", "name"], + start=0, + limit=2, + expansion_level=1, + ) + + assert result.total_count == 5 + assert len(result.items) == 2 + assert result.has_next_page is True + # Backend doesn't return next_cursor on this endpoint — caller paginates + # by passing the next ``start`` themselves. + assert result.next_cursor is None + + sent = httpx_mock.get_request() + assert sent is not None + assert "/query" in str(sent.url) and "/v2/" not in str(sent.url) + # expansionLevel is a URL query param, not body + assert sent.url.params.get("expansionLevel") == "1" + body = json.loads(sent.content) + assert body["filterGroup"]["logicalOperator"] == 0 # And + assert body["filterGroup"]["queryFilters"][0]["fieldName"] == "status" + assert body["sortOptions"][0]["fieldName"] == "name" + assert body["selectedFields"] == ["Id", "name"] + # start/limit go in BODY, not as $top/$skip query params + assert body["start"] == 0 + assert body["limit"] == 2 + + def test_query_aggregate_response_handles_id_less_rows( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Aggregate / GROUP BY rows lack ``Id`` — parsed as :class:`AggregateRow`.""" + from uipath.platform.entities import ( + AggregateRow, + EntityAggregate, + EntityAggregateFunction, + ) + + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/query.*" + ), + status_code=200, + json={ + "value": [ + {"status": "active", "total": 12}, + {"status": "inactive", "total": 7}, + ], + "totalRecordCount": 2, + }, + ) + + result = service.retrieve_records( + entity_key=str(entity_key), + selected_fields=["status"], + group_by=["status"], + aggregates=[ + EntityAggregate( + function=EntityAggregateFunction.Count, + field="Id", + alias="total", + ) + ], + ) + + assert result.total_count == 2 + assert len(result.items) == 2 + # Aggregate rows lack ``Id`` and are exposed as :class:`AggregateRow`. + for row in result.items: + assert isinstance(row, AggregateRow) + assert result.items[0].status == "active" + assert result.items[0].total == 12 + sent = httpx_mock.get_request() + assert sent is not None + body = json.loads(sent.content) + assert body["aggregates"][0]["function"] == "COUNT" + assert body["aggregates"][0]["alias"] == "total" + assert body["groupBy"] == ["status"] + + def test_query_v2_when_binnings_provided( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + from uipath.platform.entities import EntityAggregateFunction, EntityBinning + + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/v2/EntityService/entity/{entity_key}/query.*" + ), + status_code=200, + json={"value": [], "totalCount": 0}, + ) + + service.retrieve_records( + entity_key=str(entity_key), + binnings=[ + EntityBinning( + field_name="status", + aggregate_function=EntityAggregateFunction.Count, + alias="total", + ) + ], + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert "/v2/EntityService/" in str(sent.url) + + def test_upload_attachment_sends_multipart( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_id = "ent-1" + record_id = "rec-1" + field_name = "doc" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Attachment/entity/{entity_id}/{record_id}/{field_name}?expansionLevel=1", + method="POST", + status_code=200, + json={"Id": record_id, "doc": "uploaded"}, + ) + + result = service.upload_attachment( + entity_id=entity_id, + record_id=record_id, + field_name=field_name, + file=b"hello world", + expansion_level=1, + ) + + assert result.get("doc") == "uploaded" + + sent = httpx_mock.get_request() + assert sent is not None + assert b"hello world" in sent.content + + def test_download_attachment_returns_bytes( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_id = "ent-1" + record_id = "rec-1" + field_name = "doc" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Attachment/entity/{entity_id}/{record_id}/{field_name}", + method="GET", + status_code=200, + content=b"file-content", + ) + + content = service.download_attachment( + entity_id=entity_id, record_id=record_id, field_name=field_name + ) + assert content == b"file-content" + + def test_delete_attachment( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_id = "ent-1" + record_id = "rec-1" + field_name = "doc" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Attachment/entity/{entity_id}/{record_id}/{field_name}", + method="DELETE", + status_code=200, + json={}, + ) + + result = service.delete_attachment( + entity_id=entity_id, record_id=record_id, field_name=field_name + ) + assert result == {} + + def test_create_entity_returns_id( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityCreateOptions, + EntityFieldDataType, + ) + + new_entity_id = str(uuid.uuid4()) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity", + method="POST", + status_code=200, + json=new_entity_id, + ) + + created_id = service.create_entity( + name="productCatalog", + fields=[ + EntityCreateFieldOptions( + field_name="productName", + type=EntityFieldDataType.STRING, + is_required=True, + length_limit=200, + ), + ], + options=EntityCreateOptions( + display_name="Product Catalog", + description="Catalog of products", + is_rbac_enabled=True, + ), + ) + + assert created_id == new_entity_id + sent = httpx_mock.get_request() + assert sent is not None + body = json.loads(sent.content) + assert body["displayName"] == "Product Catalog" + assert body["entityDefinition"]["name"] == "productCatalog" + assert body["entityDefinition"]["fields"][0]["name"] == "productName" + assert body["entityDefinition"]["isRbacEnabled"] is True + + def test_delete_entity( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_id = "ent-doomed" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/{entity_id}", + method="DELETE", + status_code=200, + ) + + service.delete_entity(entity_id=entity_id) + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "DELETE" + + def test_update_entity_metadata( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + from uipath.platform.entities import EntityMetadataUpdateOptions + + entity_id = "ent-meta" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/{entity_id}/metadata", + method="PATCH", + status_code=200, + json={}, + ) + + service.update_entity_metadata( + entity_id=entity_id, + metadata=EntityMetadataUpdateOptions( + display_name="New Name", is_rbac_enabled=False + ), + ) + + sent = httpx_mock.get_request() + assert sent is not None + body = json.loads(sent.content) + assert body == {"displayName": "New Name", "isRbacEnabled": False} + + def test_import_records( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_id = "ent-imp" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_id}/bulk-upload", + method="POST", + status_code=200, + json={ + "totalRecords": 10, + "insertedRecords": 9, + "errorFileLink": "https://example.com/errors.csv", + }, + ) + + result = service.import_records(entity_id=entity_id, file=b"a,b,c\n1,2,3\n") + assert result.total_records == 10 + assert result.inserted_records == 9 + assert result.error_file_link == "https://example.com/errors.csv" + + def test_list_records_returns_paginated_metadata( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/read.*" + ), + status_code=200, + json={ + "totalCount": 7, + "value": [{"Id": "1"}, {"Id": "2"}, {"Id": "3"}], + }, + ) + + records = service.list_records( + entity_key=str(entity_key), + start=0, + limit=3, + expansion_level=2, + filter="status eq 'active'", + orderby="name asc", + select=["Id", "name"], + expand=["Company"], + ) + + # New pagination metadata: backend totalCount surfaced verbatim. + assert records.total_count == 7 + assert records.has_next_page is True + # Backend does not currently emit next_cursor; caller paginates with start. + assert records.next_cursor is None + + # Backward-compat: behaves as a list. + assert isinstance(records, list) + assert len(records) == 3 + assert records[0].id == "1" + + sent = httpx_mock.get_request() + assert sent is not None + params = sent.url.params + assert params.get("expansionLevel") == "2" + assert params.get("$filter") == "status eq 'active'" + assert params.get("$orderby") == "name asc" + assert params.get("$select") == "Id,name" + assert params.get("$expand") == "Company" + + def test_insert_records_passes_expansion_level_and_fail_on_first( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/insert-batch.*" + ), + status_code=200, + json={"successRecords": [{"Id": "1"}], "failureRecords": []}, + ) + + service.insert_records( + entity_key=str(entity_key), + records=[{"name": "alice"}], + expansion_level=1, + fail_on_first=True, + ) + + sent = httpx_mock.get_request() + assert sent is not None + params = sent.url.params + assert params.get("expansionLevel") == "1" + assert params.get("failOnFirst") == "true" + # Records are normalized to dicts before being sent. + assert json.loads(sent.content) == [{"name": "alice"}] + + def test_update_records_recovers_failure_records_from_4xx( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """A 400 response that lists per-record failures should parse into the response. + + The caller receives an ``EntityRecordsBatchResponse`` with the failed + records populated rather than an exception, so unknown record ids on + update can be handled the same way as any other batch failure. + """ + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/update-batch", + method="POST", + status_code=400, + json={ + "successRecords": [], + "failureRecords": [ + {"error": "Record not found", "record": {"Id": "missing"}} + ], + }, + ) + + result = service.update_records( + entity_key=str(entity_key), + records=[{"Id": "missing", "name": "x"}], + ) + + assert len(result.failure_records) == 1 + assert result.failure_records[0].error == "Record not found" + + def test_delete_records_recovers_failure_records_from_4xx( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/delete-batch", + method="POST", + status_code=400, + json={ + "successRecords": [], + "failureRecords": [{"error": "not found"}], + }, + ) + + result = service.delete_records( + entity_key=str(entity_key), record_ids=["missing"] + ) + + assert result.failure_records[0].error == "not found" + + def test_record_to_dict_accepts_dict_pydantic_and_object(self) -> None: + from uipath.platform.entities import EntityCreateFieldOptions + + # dict + assert EntityDataService._record_to_dict({"a": 1}) == {"a": 1} + # Pydantic model — uses model_dump + result = EntityDataService._record_to_dict( + EntityCreateFieldOptions(field_name="x") + ) + assert result["fieldName"] == "x" + # Object with __dict__ + from dataclasses import dataclass + + @dataclass + class Rec: + name: str + + assert EntityDataService._record_to_dict(Rec(name="bob")) == {"name": "bob"} + + +class TestEntitiesServiceCreateEntitySqlTypeMapping: + """Verify ``create_entity`` produces the SQL types and constraint defaults the backend expects.""" + + def _captured_field( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + field_options, + ): + from uipath.platform.entities import EntityCreateOptions + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity", + method="POST", + status_code=200, + json="00000000-0000-0000-0000-000000000001", + ) + service.create_entity( + name="myEntity", + fields=[field_options], + options=EntityCreateOptions(display_name="My Entity"), + ) + sent = httpx_mock.get_request() + assert sent is not None + body = json.loads(sent.content) + return body["entityDefinition"]["fields"][0] + + def test_string_field_maps_to_nvarchar_with_default_length( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + f = self._captured_field( + httpx_mock, + service, + base_url, + org, + tenant, + EntityCreateFieldOptions( + field_name="productName", type=EntityFieldDataType.STRING + ), + ) + assert f["sqlType"]["name"] == "NVARCHAR" + assert f["sqlType"]["lengthLimit"] == 200 # default + assert f["fieldDisplayType"] == "Basic" + + def test_decimal_field_includes_precision_and_value_bounds( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + f = self._captured_field( + httpx_mock, + service, + base_url, + org, + tenant, + EntityCreateFieldOptions( + field_name="price", + type=EntityFieldDataType.DECIMAL, + decimal_precision=4, + ), + ) + assert f["sqlType"]["name"] == "DECIMAL" + assert f["sqlType"]["decimalPrecision"] == 4 + assert f["sqlType"]["lengthLimit"] == 1000 + assert f["sqlType"]["maxValue"] == 1_000_000_000_000 + assert f["sqlType"]["minValue"] == -1_000_000_000_000 + + def test_boolean_field_maps_to_bit( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + f = self._captured_field( + httpx_mock, + service, + base_url, + org, + tenant, + EntityCreateFieldOptions( + field_name="isActive", type=EntityFieldDataType.BOOLEAN + ), + ) + assert f["sqlType"]["name"] == "BIT" + assert f["sqlType"]["lengthLimit"] == 100 + + def test_file_field_maps_to_uniqueidentifier_with_file_display_type( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + f = self._captured_field( + httpx_mock, + service, + base_url, + org, + tenant, + EntityCreateFieldOptions( + field_name="document", type=EntityFieldDataType.FILE + ), + ) + assert f["sqlType"]["name"] == "UNIQUEIDENTIFIER" + assert f["fieldDisplayType"] == "File" + assert f["sqlType"]["lengthLimit"] == 300 + + +class TestEntitiesServiceValidation: + """Client-side validation rejects bad entity / field definitions before any HTTP call.""" + + def test_create_entity_rejects_invalid_entity_name(self, service) -> None: + + with pytest.raises(ValueError, match="Invalid entity name"): + service.create_entity(name="1bad", fields=[]) + + def test_create_entity_rejects_invalid_field_name(self, service) -> None: + from uipath.platform.entities import EntityCreateFieldOptions + + with pytest.raises(ValueError, match="Invalid field name"): + service.create_entity( + name="goodEntity", + fields=[EntityCreateFieldOptions(field_name="9bad")], + ) + + def test_create_entity_rejects_reserved_field_name(self, service) -> None: + from uipath.platform.entities import EntityCreateFieldOptions + + with pytest.raises(ValueError, match="reserved"): + service.create_entity( + name="goodEntity", + fields=[EntityCreateFieldOptions(field_name="Id")], + ) + + def test_create_entity_rejects_unsupported_constraint_for_type( + self, service + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + with pytest.raises(ValueError, match="does not accept"): + service.create_entity( + name="goodEntity", + fields=[ + EntityCreateFieldOptions( + field_name="myField", + type=EntityFieldDataType.STRING, + decimal_precision=2, # not allowed on STRING + ) + ], + ) + + def test_create_entity_rejects_out_of_range_constraint(self, service) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + with pytest.raises(ValueError, match="out of range"): + service.create_entity( + name="goodEntity", + fields=[ + EntityCreateFieldOptions( + field_name="myField", + type=EntityFieldDataType.STRING, + length_limit=99999, # > 4000 + ) + ], + ) + + def test_create_entity_rejects_min_ge_max(self, service) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + with pytest.raises(ValueError, match="strictly less than"): + service.create_entity( + name="goodEntity", + fields=[ + EntityCreateFieldOptions( + field_name="myField", + type=EntityFieldDataType.INTEGER, + min_value=100, + max_value=10, + ) + ], + ) + + def test_create_entity_rejects_choiceset_without_choice_set_id( + self, service + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + with pytest.raises(ValueError, match="choice_set_id"): + service.create_entity( + name="goodEntity", + fields=[ + EntityCreateFieldOptions( + field_name="myField", + type=EntityFieldDataType.CHOICE_SET_SINGLE, + ) + ], + ) + + def test_create_entity_rejects_choice_set_multiple_without_choice_set_id( + self, service + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + with pytest.raises(ValueError, match="choice_set_id"): + service.create_entity( + name="goodEntity", + fields=[ + EntityCreateFieldOptions( + field_name="myField", + type=EntityFieldDataType.CHOICE_SET_MULTIPLE, + ) + ], + ) + + def test_create_entity_rejects_relationship_without_reference_entity( + self, service + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + with pytest.raises(ValueError, match="reference_entity_name"): + service.create_entity( + name="goodEntity", + fields=[ + EntityCreateFieldOptions( + field_name="myField", + type=EntityFieldDataType.RELATIONSHIP, + ) + ], + ) + + def test_entity_query_filter_rejects_in_without_value_list(self) -> None: + from uipath.platform.entities import EntityQueryFilter, QueryFilterOperator + + with pytest.raises(ValueError, match="non-empty value_list"): + EntityQueryFilter( + field_name="status", + operator=QueryFilterOperator.In, + ) + + def test_entity_query_filter_rejects_in_with_scalar_value(self) -> None: + from uipath.platform.entities import EntityQueryFilter, QueryFilterOperator + + with pytest.raises(ValueError, match="value must be omitted"): + EntityQueryFilter( + field_name="status", + operator=QueryFilterOperator.In, + value="active", + value_list=["active", "pending"], + ) + + def test_entity_query_filter_rejects_scalar_op_with_value_list(self) -> None: + from uipath.platform.entities import EntityQueryFilter, QueryFilterOperator + + with pytest.raises(ValueError, match="value_list must be omitted"): + EntityQueryFilter( + field_name="amount", + operator=QueryFilterOperator.GreaterThan, + value_list=["10"], + ) + + def test_entity_query_filter_rejects_strict_op_with_null_value(self) -> None: + from uipath.platform.entities import EntityQueryFilter, QueryFilterOperator + + with pytest.raises(ValueError, match="non-null value"): + EntityQueryFilter( + field_name="amount", + operator=QueryFilterOperator.GreaterThan, + ) + + def test_entity_query_filter_allows_equals_with_null_value(self) -> None: + from uipath.platform.entities import EntityQueryFilter, QueryFilterOperator + + f = EntityQueryFilter( + field_name="middle_name", + operator=QueryFilterOperator.Equals, + ) + assert f.value is None and f.value_list is None + + def test_entity_query_filter_allows_in_with_value_list(self) -> None: + from uipath.platform.entities import EntityQueryFilter, QueryFilterOperator + + f = EntityQueryFilter( + field_name="status", + operator=QueryFilterOperator.In, + value_list=["a", "b"], + ) + assert f.value_list == ["a", "b"] + + +class TestEntitiesServiceAsyncAndEdgeCases: + async def test_get_record_async( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + entity_key = uuid.uuid4() + record_id = "rec-1" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/read/{record_id}", + status_code=200, + json={"Id": record_id, "name": "found"}, + ) + record = await service.get_record_async( + entity_key=str(entity_key), record_id=record_id + ) + assert record.id == record_id + + async def test_query_async_v1( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/query" + ), + status_code=200, + json={"value": [{"Id": "1"}], "totalRecordCount": 1}, + ) + result = await service.retrieve_records_async(entity_key=str(entity_key)) + assert result.total_count == 1 + + async def test_delete_record_async( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + entity_key = uuid.uuid4() + record_id = "rec-1" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/delete/{record_id}", + method="DELETE", + status_code=200, + ) + await service.delete_record_async( + entity_key=str(entity_key), record_id=record_id + ) + + async def test_create_entity_async( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + new_id = "00000000-0000-0000-0000-000000000123" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity", + method="POST", + status_code=200, + json=new_id, + ) + result = await service.create_entity_async( + name="goodEntity", + fields=[ + EntityCreateFieldOptions( + field_name="myField", type=EntityFieldDataType.STRING + ) + ], + ) + assert result == new_id + + async def test_delete_entity_async( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/ent-1", + method="DELETE", + status_code=200, + ) + await service.delete_entity_async(entity_id="ent-1") + + async def test_update_entity_metadata_async_with_dict( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/ent-1/metadata", + method="PATCH", + status_code=200, + json={}, + ) + # Accepts a plain dict too + await service.update_entity_metadata_async( + entity_id="ent-1", metadata={"displayName": "X", "description": "Y"} + ) + sent = httpx_mock.get_request() + assert sent is not None + assert json.loads(sent.content) == {"displayName": "X", "description": "Y"} + + def test_update_entity_metadata_normalizes_snake_case_dict_keys( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + """Snake_case dict keys must be sent to the backend as camelCase.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/ent-1/metadata", + method="PATCH", + status_code=200, + json={}, + ) + service.update_entity_metadata( + entity_id="ent-1", + metadata={ + "display_name": "New Name", + "description": "Updated", + "is_rbac_enabled": True, + }, + ) + sent = httpx_mock.get_request() + assert sent is not None + assert json.loads(sent.content) == { + "displayName": "New Name", + "description": "Updated", + "isRbacEnabled": True, + } + + async def test_upload_attachment_async_via_file_path( + self, httpx_mock, service, base_url, org, tenant, version, tmp_path + ) -> None: + path = tmp_path / "data.bin" + path.write_bytes(b"file-on-disk") + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Attachment/entity/ent/rec/doc", + method="POST", + status_code=200, + json={"Id": "rec", "doc": "ok"}, + ) + result = await service.upload_attachment_async( + entity_id="ent", + record_id="rec", + field_name="doc", + file_path=str(path), + ) + assert result["doc"] == "ok" + + sent = httpx_mock.get_request() + assert sent is not None + assert b"file-on-disk" in sent.content + + async def test_download_and_delete_attachment_async( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + url = f"{base_url}{org}{tenant}/datafabric_/api/Attachment/entity/e/r/f" + httpx_mock.add_response( + url=url, method="GET", status_code=200, content=b"bytes" + ) + httpx_mock.add_response(url=url, method="DELETE", status_code=200, json={}) + + content = await service.download_attachment_async( + entity_id="e", record_id="r", field_name="f" + ) + assert content == b"bytes" + assert ( + await service.delete_attachment_async( + entity_id="e", record_id="r", field_name="f" + ) + == {} + ) + + def test_open_file_rejects_both_file_and_path(self) -> None: + with pytest.raises(ValueError, match="exactly one of"): + EntityDataService._open_file(file=b"x", file_path="some/path") + + def test_open_file_rejects_neither_file_nor_path(self) -> None: + with pytest.raises(ValueError, match="exactly one of"): + EntityDataService._open_file(file=None, file_path=None) + + def test_4xx_recovery_only_400_with_strict_shape( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + """5xx and 4xx other than 400 must propagate; 400 with valid shape recovers.""" + entity_key = uuid.uuid4() + # 500 with the shape — must propagate, not be silently treated as success. + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/update-batch", + method="POST", + status_code=500, + json={"successRecords": [], "failureRecords": []}, + ) + from uipath.platform.errors._enriched_exception import EnrichedException + + with pytest.raises(EnrichedException): + service.update_records( + entity_key=str(entity_key), records=[{"Id": "x", "name": "y"}] + ) + + def test_4xx_recovery_404_propagates( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + entity_key = uuid.uuid4() + # 404 with valid shape — still propagates because not a 400. + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/update-batch", + method="POST", + status_code=404, + json={"successRecords": [], "failureRecords": []}, + ) + from uipath.platform.errors._enriched_exception import EnrichedException + + with pytest.raises(EnrichedException): + service.update_records( + entity_key=str(entity_key), records=[{"Id": "x", "name": "y"}] + ) + + def test_4xx_recovery_400_unrelated_body_propagates( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + """A 400 with an error body that lacks ``successRecords``/``failureRecords`` + must surface as an exception (so generic validation errors aren't masked).""" + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/update-batch", + method="POST", + status_code=400, + json={"error": "Validation failed", "code": "InvalidArg"}, + ) + from uipath.platform.errors._enriched_exception import EnrichedException + + with pytest.raises(EnrichedException): + service.update_records( + entity_key=str(entity_key), records=[{"Id": "x", "name": "y"}] + ) + + +class TestEntitiesServiceAsyncCoverage: + """Async-variant tests for previously uncovered paths on schema / data services.""" + + async def test_retrieve_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/{entity_key}", + status_code=200, + json={ + "name": "Customers", + "displayName": "Customers", + "entityType": "Entity", + "fields": [], + "isRbacEnabled": False, + "id": str(entity_key), + }, + ) + entity = await service.retrieve_async(entity_key=str(entity_key)) + assert entity.id == str(entity_key) + + def test_retrieve_by_name( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/Customers/metadata", + status_code=200, + json={ + "name": "Customers", + "displayName": "Customers", + "entityType": "Entity", + "fields": [], + "isRbacEnabled": False, + "id": "ent-1", + }, + ) + entity = service.retrieve_by_name("Customers", folder_key="folder-1") + assert entity.name == "Customers" + sent = httpx_mock.get_request() + assert sent is not None + assert sent.headers.get("X-UIPATH-FolderKey") == "folder-1" + + async def test_retrieve_by_name_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/Orders/metadata", + status_code=200, + json={ + "name": "Orders", + "displayName": "Orders", + "entityType": "Entity", + "fields": [], + "isRbacEnabled": False, + "id": "ent-2", + }, + ) + entity = await service.retrieve_by_name_async("Orders") + assert entity.name == "Orders" + + def test_list_entities_basic( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity", + status_code=200, + json=[ + { + "name": "Customers", + "displayName": "Customers", + "entityType": "Entity", + "fields": [], + "isRbacEnabled": False, + "id": "ent-1", + }, + { + "name": "Orders", + "displayName": "Orders", + "entityType": "Entity", + "fields": [], + "isRbacEnabled": False, + "id": "ent-2", + }, + ], + ) + entities = service.list_entities() + assert len(entities) == 2 + + async def test_list_entities_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity", + status_code=200, + json=[ + { + "name": "Customers", + "displayName": "Customers", + "entityType": "Entity", + "fields": [], + "isRbacEnabled": False, + "id": "ent-1", + } + ], + ) + entities = await service.list_entities_async() + assert len(entities) == 1 + + async def test_list_records_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/read.*" + ), + status_code=200, + json={ + "totalRecordCount": 2, + "value": [{"Id": "1"}, {"Id": "2"}], + }, + ) + records = await service.list_records_async( + entity_key=str(entity_key), start=0, limit=10 + ) + assert records.total_count == 2 + + async def test_update_record_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/update/rec-1", + method="POST", + status_code=200, + json={"Id": "rec-1", "name": "renamed"}, + ) + rec = await service.update_record_async( + entity_key=str(entity_key), + record_id="rec-1", + data={"name": "renamed"}, + ) + assert rec.id == "rec-1" + + async def test_insert_records_async_batch( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/insert-batch.*" + ), + status_code=200, + json={ + "successRecords": [{"Id": "1", "name": "a"}], + "failureRecords": [], + }, + ) + result = await service.insert_records_async( + entity_key=str(entity_key), + records=[{"name": "a"}], + expansion_level=1, + fail_on_first=True, + ) + assert len(result.success_records) == 1 + + async def test_update_records_async_recovers_400_failures( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/update-batch", + method="POST", + status_code=400, + json={ + "successRecords": [], + "failureRecords": [{"error": "not found"}], + }, + ) + result = await service.update_records_async( + entity_key=str(entity_key), + records=[{"Id": "missing", "name": "x"}], + ) + assert result.failure_records[0].error == "not found" + + async def test_delete_records_async_batch( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/delete-batch.*" + ), + status_code=200, + json={ + "successRecords": [{"Id": "rec-1"}], + "failureRecords": [], + }, + ) + result = await service.delete_records_async( + entity_key=str(entity_key), + record_ids=["rec-1"], + fail_on_first=False, + ) + assert len(result.success_records) == 1 + + async def test_import_records_async( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/ent-1/bulk-upload", + method="POST", + status_code=200, + json={ + "totalRecords": 3, + "insertedRecords": 3, + "errorFileLink": None, + }, + ) + result = await service.import_records_async( + entity_id="ent-1", file=b"a,b\n1,2\n" + ) + assert result.inserted_records == 3 + + def test_validate_entity_batch_handles_success_and_failure_records( + self, + service: EntitiesService, + ) -> None: + response = MagicMock() + response.json.return_value = { + "successRecords": [{"Id": "ok-1", "name": "first"}], + "failureRecords": [{"error": "duplicate", "record": {"name": "dup"}}], + } + result = service.validate_entity_batch(response) + assert len(result.success_records) == 1 + assert result.success_records[0].id == "ok-1" + assert result.failure_records[0].error == "duplicate" + + def test_5xx_with_batch_shape_still_propagates( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """500 with successRecords/failureRecords shape must NOT be recovered.""" + from uipath.platform.errors._enriched_exception import EnrichedException + + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/insert-batch", + method="POST", + status_code=500, + json={"successRecords": [], "failureRecords": []}, + ) + with pytest.raises(EnrichedException): + service.insert_records( + entity_key=str(entity_key), + records=[{"name": "x"}], + ) diff --git a/packages/uipath-platform/tests/services/test_governance_provider.py b/packages/uipath-platform/tests/services/test_governance_provider.py new file mode 100644 index 000000000..24e489f65 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_governance_provider.py @@ -0,0 +1,166 @@ +"""Tests for UiPathPlatformGovernanceProvider.""" + +from __future__ import annotations + +import pytest +from pytest_httpx import HTTPXMock +from uipath.core.governance import ( + EnforcementMode, + FiredRule, + GovernanceCompensationProvider, + GovernancePolicyProvider, + GovernRequest, + PolicyContext, +) + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.governance import ( + GovernanceService, + UiPathPlatformGovernanceProvider, +) + +ORG_ID = "11111111-1111-1111-1111-111111111111" +TENANT_ID = "22222222-2222-2222-2222-222222222222" + + +def _make_request() -> GovernRequest: + return GovernRequest( + validators=["pii_detection"], + rules=[ + FiredRule( + rule_id="ASI-01", + rule_name="Block PII in flight", + pack_name="agent-safety", + validator="pii_detection", + ) + ], + data={"prompt": "hello"}, + hook="before_model", + trace_id="0123456789abcdef0123456789abcdef", + src_timestamp="2026-06-22T10:00:00Z", + agent_name="my-agent", + runtime_id="runtime-1", + ) + + +@pytest.fixture +def provider( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> UiPathPlatformGovernanceProvider: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", ORG_ID) + monkeypatch.setenv("UIPATH_TENANT_ID", TENANT_ID) + service = GovernanceService(config=config, execution_context=execution_context) + return UiPathPlatformGovernanceProvider(service=service) + + +class TestConstruction: + def test_accepts_existing_service( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + service = GovernanceService(config=config, execution_context=execution_context) + provider = UiPathPlatformGovernanceProvider(service=service) + assert provider.service is service + + def test_builds_service_from_config( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + provider = UiPathPlatformGovernanceProvider( + config=config, execution_context=execution_context + ) + assert isinstance(provider.service, GovernanceService) + + def test_requires_service_or_full_kwargs(self) -> None: + with pytest.raises(ValueError, match="GovernanceService"): + UiPathPlatformGovernanceProvider() + + +class TestProtocolConformance: + def test_satisfies_policy_provider_protocol( + self, provider: UiPathPlatformGovernanceProvider + ) -> None: + assert isinstance(provider, GovernancePolicyProvider) + + def test_satisfies_compensation_provider_protocol( + self, provider: UiPathPlatformGovernanceProvider + ) -> None: + assert isinstance(provider, GovernanceCompensationProvider) + + +class TestDelegation: + def test_get_policy_delegates_to_service( + self, + httpx_mock: HTTPXMock, + provider: UiPathPlatformGovernanceProvider, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy" + "?agentType=conversational" + ), + status_code=200, + json={"mode": "enforce", "policies": "rules: []"}, + ) + + response = provider.get_policy(PolicyContext(is_conversational=True)) + + assert response.mode is EnforcementMode.ENFORCE + assert response.policies == "rules: []" + + async def test_get_policy_async_delegates_to_service( + self, + httpx_mock: HTTPXMock, + provider: UiPathPlatformGovernanceProvider, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy", + status_code=200, + json={"mode": "audit", "policies": ""}, + ) + + response = await provider.get_policy_async(PolicyContext()) + + assert response.mode is EnforcementMode.AUDIT + + def test_compensate_delegates_to_service( + self, + httpx_mock: HTTPXMock, + provider: UiPathPlatformGovernanceProvider, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + status_code=200, + json={}, + ) + + provider.compensate(_make_request()) + + requests = httpx_mock.get_requests() + assert len(requests) == 1 + assert requests[0].method == "POST" + + async def test_compensate_async_delegates_to_service( + self, + httpx_mock: HTTPXMock, + provider: UiPathPlatformGovernanceProvider, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + status_code=200, + json={}, + ) + + await provider.compensate_async(_make_request()) + + requests = httpx_mock.get_requests() + assert len(requests) == 1 + assert requests[0].method == "POST" diff --git a/packages/uipath-platform/tests/services/test_governance_service.py b/packages/uipath-platform/tests/services/test_governance_service.py new file mode 100644 index 000000000..e437fdda0 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_governance_service.py @@ -0,0 +1,594 @@ +"""Tests for GovernanceService.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest +from pytest_httpx import HTTPXMock +from uipath.core.governance import GovernancePolicyProvider, PolicyContext + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common import resolve_trace_id +from uipath.platform.governance import ( + FiredRule, + GovernanceService, + PolicyResponse, +) + +ORG_ID = "11111111-1111-1111-1111-111111111111" +TENANT_ID = "22222222-2222-2222-2222-222222222222" +TENANT_ID_HEX = TENANT_ID.replace("-", "").lower() + + +def _compensate_kwargs(**overrides: Any) -> dict[str, Any]: + """Default kwargs for ``service.compensate(...)``.""" + defaults: dict[str, Any] = dict( + validators=["pii_detection"], + rules=[ + FiredRule( + rule_id="ASI-01", + rule_name="Block PII in flight", + pack_name="agent-safety", + validator="pii_detection", + ) + ], + data={"prompt": "hello"}, + hook="before_model", + trace_id="0123456789abcdef0123456789abcdef", + src_timestamp="2026-06-22T10:00:00Z", + agent_name="my-agent", + runtime_id="runtime-1", + ) + defaults.update(overrides) + return defaults + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> GovernanceService: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", ORG_ID) + monkeypatch.setenv("UIPATH_TENANT_ID", TENANT_ID) + return GovernanceService(config=config, execution_context=execution_context) + + +class TestGovernanceService: + """Test GovernanceService functionality.""" + + class TestRetrievePolicy: + """Test retrieve_policy (sync).""" + + def test_returns_parsed_policy( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy", + status_code=200, + json={"mode": "enforce", "policies": "rules: []"}, + ) + + result = service.retrieve_policy() + + assert isinstance(result, PolicyResponse) + assert result.mode == "enforce" + assert result.policies == "rules: []" + + def test_defaults_when_fields_missing( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy", + status_code=200, + json={}, + ) + + result = service.retrieve_policy() + + assert result.mode is None + assert result.policies == "" + + def test_sends_tenant_header_and_bearer_token( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + secret: str, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"mode": "audit", "policies": ""}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy", + ) + + service.retrieve_policy() + + request = captured["request"] + assert request.method == "GET" + assert request.headers["x-uipath-internal-tenantid"] == TENANT_ID + assert request.headers["authorization"] == f"Bearer {secret}" + # No agentType query param when caller omits it. + assert "agentType" not in request.url.params + + @pytest.mark.parametrize( + ("is_conversational", "expected"), + [(True, "conversational"), (False, "autonomous")], + ) + def test_appends_agent_type_query_param( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + is_conversational: bool, + expected: str, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"mode": "audit", "policies": ""}) + + httpx_mock.add_callback( + capture, + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy" + f"?agentType={expected}" + ), + ) + + service.retrieve_policy(is_conversational=is_conversational) + + assert captured["request"].url.params["agentType"] == expected + + def test_raises_when_organization_id_missing( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) + monkeypatch.setenv("UIPATH_TENANT_ID", TENANT_ID) + service = GovernanceService( + config=config, execution_context=execution_context + ) + + with pytest.raises(ValueError, match="UIPATH_ORGANIZATION_ID"): + service.retrieve_policy() + + def test_raises_when_tenant_id_missing( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", ORG_ID) + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) + service = GovernanceService( + config=config, execution_context=execution_context + ) + + with pytest.raises(ValueError, match="UIPATH_TENANT_ID"): + service.retrieve_policy() + + def test_raises_on_http_error( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + from uipath.platform.errors import EnrichedException + + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy", + status_code=500, + text="boom", + ) + + with pytest.raises(EnrichedException): + service.retrieve_policy() + + class TestRetrievePolicyAsync: + """Test retrieve_policy_async.""" + + async def test_returns_parsed_policy( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy" + "?agentType=autonomous" + ), + status_code=200, + json={"mode": "audit", "policies": "rules: []"}, + ) + + result = await service.retrieve_policy_async(is_conversational=False) + + assert result.mode == "audit" + assert result.policies == "rules: []" + + class TestCompensate: + """Test compensate (sync).""" + + def test_posts_aliased_payload( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + secret: str, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + ) + + service.compensate(**_compensate_kwargs()) + + request = captured["request"] + assert request.method == "POST" + assert request.headers["x-uipath-internal-tenantid"] == TENANT_ID + assert request.headers["authorization"] == f"Bearer {secret}" + + body = json.loads(request.content) + assert body["type"] == ["pii_detection"] + assert body["rules"] == [ + { + "ruleId": "ASI-01", + "ruleName": "Block PII in flight", + "packName": "agent-safety", + "validator": "pii_detection", + } + ] + assert body["traceId"] == "0123456789abcdef0123456789abcdef" + assert body["src_timestamp"] == "2026-06-22T10:00:00Z" + assert body["agentName"] == "my-agent" + assert body["runtimeId"] == "runtime-1" + assert body["hook"] == "before_model" + assert body["data"] == {"prompt": "hello"} + + def test_autofills_job_context_from_config( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "folder-from-env") + monkeypatch.setenv("UIPATH_JOB_KEY", "job-from-env") + monkeypatch.setenv("UIPATH_PROCESS_UUID", "process-from-env") + monkeypatch.setenv("UIPATH_AGENT_ID", "agent-from-env") + monkeypatch.setenv("UIPATH_PROCESS_VERSION", "1.2.3") + + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + ) + + service.compensate(**_compensate_kwargs()) + + body = json.loads(captured["request"].content) + assert body["folderKey"] == "folder-from-env" + assert body["jobKey"] == "job-from-env" + assert body["processKey"] == "process-from-env" + assert body["referenceId"] == "agent-from-env" + assert body["agentVersion"] == "1.2.3" + + def test_caller_overrides_take_precedence( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder") + monkeypatch.setenv("UIPATH_JOB_KEY", "env-job") + + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + ) + + service.compensate(**_compensate_kwargs(folder_key="explicit-folder")) + + body = json.loads(captured["request"].content) + # Caller-supplied value wins. + assert body["folderKey"] == "explicit-folder" + # Env-backed fallback fills the unset one. + assert body["jobKey"] == "env-job" + # Unset and unbacked → key omitted. + assert "processKey" not in body + + def test_caller_empty_string_is_not_overridden_by_env( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder") + + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + ) + + # Explicit empty string is still a caller value — must not be + # silently replaced by the env-backed UiPathConfig fallback. + service.compensate(**_compensate_kwargs(folder_key="")) + + body = json.loads(captured["request"].content) + assert body["folderKey"] == "" + + def test_omits_job_context_keys_with_no_value( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + for env_key in ( + "UIPATH_FOLDER_KEY", + "UIPATH_JOB_KEY", + "UIPATH_PROCESS_UUID", + "UIPATH_AGENT_ID", + "UIPATH_PROCESS_VERSION", + "UIPATH_PROJECT_ID", + ): + monkeypatch.delenv(env_key, raising=False) + + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={}) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + ) + + service.compensate(**_compensate_kwargs()) + + body = json.loads(captured["request"].content) + for absent in ( + "folderKey", + "jobKey", + "processKey", + "referenceId", + "agentVersion", + ): + assert absent not in body + + def test_raises_on_http_error( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + from uipath.platform.errors import EnrichedException + + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + status_code=400, + text="bad payload", + ) + + with pytest.raises(EnrichedException): + service.compensate(**_compensate_kwargs()) + + def test_raises_when_org_id_missing( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) + monkeypatch.setenv("UIPATH_TENANT_ID", TENANT_ID) + service = GovernanceService( + config=config, execution_context=execution_context + ) + + with pytest.raises(ValueError, match="UIPATH_ORGANIZATION_ID"): + service.compensate(**_compensate_kwargs()) + + class TestCompensateAsync: + """Test compensate_async.""" + + async def test_posts_aliased_payload( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/govern", + status_code=200, + json={}, + ) + + await service.compensate_async(**_compensate_kwargs()) + + requests = httpx_mock.get_requests() + assert len(requests) == 1 + assert requests[0].method == "POST" + + class TestProtocolConformance: + """``get_policy`` adapter is the only protocol-shaped surface left + on :class:`GovernanceService`; compensation conformance is tested + against :class:`UiPathPlatformGovernanceProvider`. + """ + + def test_satisfies_policy_provider_protocol( + self, service: GovernanceService + ) -> None: + assert isinstance(service, GovernancePolicyProvider) + + def test_get_policy_delegates_to_retrieve_policy( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy" + "?agentType=conversational" + ), + status_code=200, + json={"mode": "enforce", "policies": "rules: []"}, + ) + + response = service.get_policy(PolicyContext(is_conversational=True)) + + assert response.mode == "enforce" + assert response.policies == "rules: []" + + async def test_get_policy_async_delegates( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy", + status_code=200, + json={"mode": "audit", "policies": ""}, + ) + + response = await service.get_policy_async(PolicyContext()) + + assert response.mode == "audit" + + class TestServiceUrlOverride: + """Honor UIPATH_SERVICE_URL_AGENTICGOVERNANCE for local dev.""" + + def test_redirects_policy_fetch_to_override( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv( + "UIPATH_SERVICE_URL_AGENTICGOVERNANCE", "http://localhost:8123" + ) + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"mode": "audit", "policies": ""}) + + httpx_mock.add_callback( + capture, url="http://localhost:8123/api/v1/runtime/policy" + ) + + service.retrieve_policy() + + request = captured["request"] + # Routing headers replace the platform router, org-UUID path is dropped. + assert request.headers["X-UiPath-Internal-TenantId"] == TENANT_ID + assert request.headers["X-UiPath-Internal-AccountId"] == ORG_ID + assert ORG_ID not in str(request.url) + + def test_redirects_compensate_to_override( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv( + "UIPATH_SERVICE_URL_AGENTICGOVERNANCE", "http://localhost:8123" + ) + httpx_mock.add_response( + url="http://localhost:8123/api/v1/runtime/govern", + method="POST", + status_code=200, + json={}, + ) + + service.compensate(**_compensate_kwargs()) + + sent = httpx_mock.get_requests()[-1] + assert sent.method == "POST" + assert sent.headers["X-UiPath-Internal-AccountId"] == ORG_ID + + +class TestResolveTraceId: + """Test the resolve_trace_id helper.""" + + def test_returns_fallback_when_no_source_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + + assert resolve_trace_id(fallback="fallback-id") == "fallback-id" + + def test_returns_none_when_no_fallback( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + + assert resolve_trace_id() is None + + def test_reads_uipath_trace_id_in_hex_form( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_TRACE_ID", "0123456789abcdef0123456789abcdef") + + assert resolve_trace_id() == "0123456789abcdef0123456789abcdef" + + def test_normalizes_uipath_trace_id_in_uuid_form( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_TRACE_ID", TENANT_ID) + + assert resolve_trace_id() == TENANT_ID_HEX + + def test_falls_through_when_uipath_trace_id_is_malformed( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_TRACE_ID", "not-a-valid-trace-id") + + # No OTel context active → falls through to caller-supplied fallback. + assert resolve_trace_id(fallback="recovered") == "recovered" diff --git a/packages/uipath-platform/tests/services/test_guardrails_decorators.py b/packages/uipath-platform/tests/services/test_guardrails_decorators.py new file mode 100644 index 000000000..e578cba84 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_guardrails_decorators.py @@ -0,0 +1,1171 @@ +"""Tests for the guardrails decorator framework in uipath-platform. + +Focus: meaningful business behaviour — serialization, PRE/POST evaluation, +modification flow, stage enforcement, factory-function path, and integration +scenarios modelled on the joke-agent-decorator sample. +""" + +from __future__ import annotations + +import dataclasses +import logging +from typing import Annotated, Any +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import BaseModel +from uipath.core.guardrails import ( + GuardrailValidationResult, + GuardrailValidationResultType, +) + +from uipath.platform.guardrails.decorators import ( + BlockAction, + CustomValidator, + GuardrailAction, + GuardrailBlockException, + GuardrailExclude, + GuardrailExecutionStage, + LogAction, + LoggingSeverityLevel, + PIIDetectionEntity, + PIIDetectionEntityType, + PIIValidator, + PromptInjectionValidator, + guardrail, + register_guardrail_adapter, +) +from uipath.platform.guardrails.decorators._core import ( + _collect_output, + _get_excluded_params, + _make_evaluator, + _reconstruct_output, + _serialize_value, +) +from uipath.platform.guardrails.decorators._registry import ( + _adapters, +) + +# --------------------------------------------------------------------------- +# Shared result constants +# --------------------------------------------------------------------------- + +_PASSED = GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, + reason="ok", +) +_FAILED = GuardrailValidationResult( + result=GuardrailValidationResultType.VALIDATION_FAILED, + reason="violation detected", +) + + +# --------------------------------------------------------------------------- +# Registry isolation fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _isolate_adapter_registry(): + """Snapshot and restore the global adapter registry around every test.""" + snapshot = list(_adapters) + yield + _adapters.clear() + _adapters.extend(snapshot) + + +# --------------------------------------------------------------------------- +# Minimal fake types for adapter tests (no LangChain dependency) +# --------------------------------------------------------------------------- + + +class _DummyTarget: + """Minimal callable target recognised by _DummyAdapter.""" + + def __init__(self, return_value: Any = None) -> None: + self.return_value = ( + return_value if return_value is not None else {"output": "result"} + ) + self.invoke_calls: list[Any] = [] + + def invoke(self, args: Any) -> Any: + self.invoke_calls.append(args) + return self.return_value + + +class _WrappedDummyTarget: + """A _DummyTarget wrapped with guardrail evaluation.""" + + def __init__( + self, + target: Any, + evaluator: Any, + action: GuardrailAction, + name: str, + stage: GuardrailExecutionStage, + ) -> None: + self._target = target + self._evaluator = evaluator + self._action = action + self._name = name + self._stage = stage + + def invoke(self, args: Any) -> Any: + input_data = args if isinstance(args, dict) else {"input": args} + if self._stage in ( + GuardrailExecutionStage.PRE, + GuardrailExecutionStage.PRE_AND_POST, + ): + result = self._evaluator( + input_data, GuardrailExecutionStage.PRE, input_data, None + ) + if result.result == GuardrailValidationResultType.VALIDATION_FAILED: + self._action.handle_validation_result(result, input_data, self._name) + raw = self._target.invoke(args) + output_data = raw if isinstance(raw, dict) else {"output": raw} + if self._stage in ( + GuardrailExecutionStage.POST, + GuardrailExecutionStage.PRE_AND_POST, + ): + result = self._evaluator( + output_data, GuardrailExecutionStage.POST, input_data, output_data + ) + if result.result == GuardrailValidationResultType.VALIDATION_FAILED: + self._action.handle_validation_result(result, output_data, self._name) + return raw + + +class _DummyAdapter: + """Adapter that handles _DummyTarget and _WrappedDummyTarget instances.""" + + def recognize(self, target: Any) -> bool: + return isinstance(target, (_DummyTarget, _WrappedDummyTarget)) + + def wrap( + self, + target: Any, + evaluator: Any, + action: GuardrailAction, + name: str, + stage: GuardrailExecutionStage, + ) -> Any: + return _WrappedDummyTarget(target, evaluator, action, name, stage) + + +# --------------------------------------------------------------------------- +# 1. PIIDetectionEntity — threshold boundary enforcement +# --------------------------------------------------------------------------- + + +class TestPIIDetectionEntity: + def test_threshold_below_zero_raises(self): + with pytest.raises(ValueError, match="0.0 and 1.0"): + PIIDetectionEntity(name="Email", threshold=-0.1) + + def test_threshold_above_one_raises(self): + with pytest.raises(ValueError, match="0.0 and 1.0"): + PIIDetectionEntity(name="Email", threshold=1.1) + + +# --------------------------------------------------------------------------- +# 2. LogAction — does NOT stop execution; uses configured severity +# --------------------------------------------------------------------------- + + +class TestLogAction: + def test_violation_logs_guardrail_name_and_execution_continues(self, caplog): + action = LogAction() + with caplog.at_level(logging.WARNING): + result = action.handle_validation_result(_FAILED, "data", "MyGuardrail") + assert result is None # execution continues + assert any("MyGuardrail" in r.message for r in caplog.records) + + def test_pass_emits_no_log(self, caplog): + action = LogAction() + with caplog.at_level(logging.WARNING): + action.handle_validation_result(_PASSED, "data", "G") + assert not caplog.records + + def test_custom_message_overrides_reason(self, caplog): + action = LogAction(message="custom alert") + with caplog.at_level(logging.WARNING): + action.handle_validation_result(_FAILED, "data", "G") + assert any("custom alert" in r.message for r in caplog.records) + + def test_default_message_includes_validation_reason(self, caplog): + action = LogAction() + with caplog.at_level(logging.WARNING): + action.handle_validation_result(_FAILED, "data", "G") + assert any("violation detected" in r.message for r in caplog.records) + + def test_debug_severity(self, caplog): + action = LogAction(severity_level=LoggingSeverityLevel.DEBUG) + with caplog.at_level(logging.DEBUG): + action.handle_validation_result(_FAILED, "data", "G") + debug_records = [r for r in caplog.records if r.levelno == logging.DEBUG] + assert debug_records + + +# --------------------------------------------------------------------------- +# 3. BlockAction — raises GuardrailBlockException on violation +# --------------------------------------------------------------------------- + + +class TestBlockAction: + def test_raises_on_violation(self): + action = BlockAction() + with pytest.raises(GuardrailBlockException): + action.handle_validation_result(_FAILED, "data", "G") + + def test_no_raise_on_pass(self): + action = BlockAction() + result = action.handle_validation_result(_PASSED, "data", "G") + assert result is None + + def test_title_and_detail_from_result(self): + action = BlockAction() + with pytest.raises(GuardrailBlockException) as exc_info: + action.handle_validation_result(_FAILED, "data", "MyGuardrail") + assert exc_info.value.title + assert exc_info.value.detail + + def test_custom_title_and_detail(self): + action = BlockAction(title="Blocked", detail="Not allowed") + with pytest.raises(GuardrailBlockException) as exc_info: + action.handle_validation_result(_FAILED, "data", "G") + assert exc_info.value.title == "Blocked" + assert exc_info.value.detail == "Not allowed" + + +# --------------------------------------------------------------------------- +# 4. PIIValidator — builds correct BuiltInValidatorGuardrail +# --------------------------------------------------------------------------- + + +class TestPIIValidator: + def test_empty_entities_raises(self): + with pytest.raises(ValueError, match="non-empty"): + PIIValidator(entities=[]) + + def test_entity_names_and_thresholds_in_api_parameters(self): + v = PIIValidator( + entities=[ + PIIDetectionEntity(PIIDetectionEntityType.EMAIL, 0.6), + PIIDetectionEntity(PIIDetectionEntityType.PERSON, 0.8), + ] + ) + g = v.get_built_in_guardrail("G", None, True) + param_by_id = {p.id: p for p in g.validator_parameters} + entities_value = param_by_id["entities"].value + assert isinstance(entities_value, list) + assert "Email" in entities_value + assert "Person" in entities_value + thresholds_value = param_by_id["entityThresholds"].value + assert isinstance(thresholds_value, dict) + assert thresholds_value["Email"] == 0.6 + assert thresholds_value["Person"] == 0.8 + + def test_no_scope_restriction(self): + v = PIIValidator(entities=[PIIDetectionEntity(PIIDetectionEntityType.EMAIL)]) + # All stages allowed — no ValueError raised + v.validate_stage(GuardrailExecutionStage.PRE) + v.validate_stage(GuardrailExecutionStage.POST) + + def test_selector_is_none(self): + v = PIIValidator(entities=[PIIDetectionEntity(PIIDetectionEntityType.EMAIL)]) + g = v.get_built_in_guardrail("G", None, True) + assert g.selector is None + + +# --------------------------------------------------------------------------- +# 5. PromptInjectionValidator — LLM-only, PRE-only, threshold validation +# --------------------------------------------------------------------------- + + +class TestPromptInjectionValidator: + def test_threshold_below_zero_raises(self): + with pytest.raises(ValueError, match="threshold"): + PromptInjectionValidator(threshold=-0.1) + + def test_threshold_above_one_raises(self): + with pytest.raises(ValueError, match="threshold"): + PromptInjectionValidator(threshold=1.1) + + def test_restricted_to_pre_stage_only(self): + v = PromptInjectionValidator() + v.validate_stage(GuardrailExecutionStage.PRE) # ok + with pytest.raises(ValueError): + v.validate_stage(GuardrailExecutionStage.POST) + + def test_builds_prompt_injection_guardrail_with_threshold(self): + v = PromptInjectionValidator(threshold=0.7) + g = v.get_built_in_guardrail("PI", None, True) + assert g.validator_type == "prompt_injection" + threshold_param = next(p for p in g.validator_parameters if p.id == "threshold") + assert threshold_param.value == 0.7 + + def test_selector_is_none(self): + v = PromptInjectionValidator() + g = v.get_built_in_guardrail("PI", None, True) + assert g.selector is None + + +# --------------------------------------------------------------------------- +# 6. CustomValidator — rule routing and error handling +# --------------------------------------------------------------------------- + + +class TestCustomValidator: + def test_non_callable_raises(self): + with pytest.raises(ValueError, match="callable"): + CustomValidator(rule="not_a_function") # type: ignore[arg-type] + + def test_wrong_arity_raises(self): + with pytest.raises(ValueError, match="1 or 2"): + CustomValidator(rule=lambda: True) # type: ignore[arg-type] + with pytest.raises(ValueError, match="1 or 2"): + CustomValidator(rule=lambda a, b, c: True) # type: ignore[arg-type] + + def test_one_param_pre_receives_input_data(self): + received: list[Any] = [] + + def capture_pre(args: dict[str, Any]) -> bool: + received.append(args) + return False + + CustomValidator(rule=capture_pre).evaluate( + {}, GuardrailExecutionStage.PRE, {"a": 1}, None + ) + assert received == [{"a": 1}] + + def test_one_param_post_receives_output_data(self): + received: list[Any] = [] + + def capture_post(args: dict[str, Any]) -> bool: + received.append(args) + return False + + CustomValidator(rule=capture_post).evaluate( + {}, GuardrailExecutionStage.POST, {"in": 1}, {"out": 2} + ) + assert received == [{"out": 2}] + + def test_two_param_post_receives_input_and_output(self): + received: list[Any] = [] + + def rule(inp: dict[str, Any], out: dict[str, Any]) -> bool: + received.append((inp, out)) + return False + + CustomValidator(rule=rule).evaluate( + {}, GuardrailExecutionStage.POST, {"in": 1}, {"out": 2} + ) + assert received == [({"in": 1}, {"out": 2})] + + def test_two_param_rule_skipped_when_input_missing(self): + result = CustomValidator(rule=lambda a, b: True).evaluate( + {}, GuardrailExecutionStage.POST, None, {"out": 2} + ) + assert result.result == GuardrailValidationResultType.PASSED + + def test_rule_returning_true_means_violation(self): + result = CustomValidator(rule=lambda args: True).evaluate( + {}, GuardrailExecutionStage.PRE, {"x": 1}, None + ) + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + + def test_rule_exception_returns_passed(self): + def bad(args: dict[str, Any]) -> bool: + raise ValueError("boom") + + result = CustomValidator(rule=bad).evaluate( + {}, GuardrailExecutionStage.PRE, {"x": 1}, None + ) + assert result.result == GuardrailValidationResultType.PASSED + + +# --------------------------------------------------------------------------- +# 7. GuardrailExclude — parameter introspection +# --------------------------------------------------------------------------- + + +class TestGuardrailExclude: + def test_excluded_param_not_in_collected_input(self): + def func( + text: str, + config: Annotated[dict[str, Any], GuardrailExclude()], + ) -> str: + return text + + excluded = _get_excluded_params(func) + assert "config" in excluded + assert "text" not in excluded + + def test_multiple_excluded_params(self): + def func( + a: str, + b: Annotated[int, GuardrailExclude()], + c: Annotated[str, GuardrailExclude()], + ) -> str: + return a + + excluded = _get_excluded_params(func) + assert excluded == {"b", "c"} + + def test_no_annotations_returns_empty_set(self): + def func(a: str, b: int) -> str: + return a + + assert _get_excluded_params(func) == set() + + +# --------------------------------------------------------------------------- +# 8. Serialization helpers +# --------------------------------------------------------------------------- + + +class _PydanticModel(BaseModel): + topic: str + count: int = 0 + + +@dataclasses.dataclass +class _Dataclass: + name: str + value: float + + +class TestSerializationHelpers: + def test_primitive_str_passthrough(self): + assert _serialize_value("hello") == "hello" + + def test_primitive_int_passthrough(self): + assert _serialize_value(42) == 42 + + def test_dict_passthrough(self): + assert _serialize_value({"a": 1}) == {"a": 1} + + def test_pydantic_model_dumps(self): + m = _PydanticModel(topic="test", count=3) + result = _serialize_value(m) + assert result == {"topic": "test", "count": 3} + + def test_dataclass_asdict(self): + d = _Dataclass(name="x", value=1.5) + result = _serialize_value(d) + assert result == {"name": "x", "value": 1.5} + + def test_collect_output_from_pydantic(self): + m = _PydanticModel(topic="joke") + result = _collect_output(m) + assert result == {"topic": "joke", "count": 0} + + def test_collect_output_from_str(self): + result = _collect_output("hello") + assert result == {"return": "hello"} + + def test_collect_output_from_dict(self): + result = _collect_output({"key": "val"}) + assert result == {"key": "val"} + + def test_reconstruct_output_pydantic(self): + original = _PydanticModel(topic="original") + modified = {"topic": "modified", "count": 5} + result = _reconstruct_output(original, modified) + assert isinstance(result, _PydanticModel) + assert result.topic == "modified" + assert result.count == 5 + + def test_reconstruct_output_str(self): + result = _reconstruct_output("original", "modified") + assert result == "modified" + + def test_reconstruct_output_none_returns_original(self): + result = _reconstruct_output("original", None) + assert result == "original" + + +# --------------------------------------------------------------------------- +# 9. @guardrail on sync functions +# --------------------------------------------------------------------------- + + +class TestGuardrailOnSyncFunction: + def test_pre_fires_before_function(self): + calls: list[str] = [] + + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.side_effect = lambda *a, **kw: ( + calls.append("eval") or _PASSED # type: ignore[func-returns-value] + ) + + @guardrail( + validator=mock_validator, + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def fn(text: str) -> str: + calls.append("fn") + return text + + fn("hello") + assert calls == ["eval", "fn"] + + def test_post_fires_after_function(self): + calls: list[str] = [] + + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.side_effect = lambda *a, **kw: ( + calls.append("eval") or _PASSED # type: ignore[func-returns-value] + ) + + @guardrail( + validator=mock_validator, + action=LogAction(), + stage=GuardrailExecutionStage.POST, + ) + def fn(text: str) -> str: + calls.append("fn") + return text + + fn("hello") + assert calls == ["fn", "eval"] + + def test_block_action_raises_guardrail_block_exception(self): + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.return_value = _FAILED + + @guardrail( + validator=mock_validator, + action=BlockAction(title="Blocked", detail="not allowed"), + stage=GuardrailExecutionStage.PRE, + ) + def fn(text: str) -> str: + return text + + with pytest.raises(GuardrailBlockException): + fn("bad input") + + def test_log_action_does_not_stop_execution(self): + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.return_value = _FAILED + + result = [] + + @guardrail( + validator=mock_validator, + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def fn(text: str) -> str: + result.append("called") + return text + + fn("input") + assert result == ["called"] + + def test_pre_input_contains_function_params(self): + captured: list[Any] = [] + + @guardrail( + validator=CustomValidator( + lambda args: captured.append(args) or False # type: ignore[func-returns-value] + ), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def fn(joke: str, count: int) -> str: + return joke + + fn("why did the chicken", 3) + assert captured == [{"joke": "why did the chicken", "count": 3}] + + def test_excluded_param_absent_from_pre_input(self): + captured: list[Any] = [] + + @guardrail( + validator=CustomValidator( + lambda args: captured.append(args) or False # type: ignore[func-returns-value] + ), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def fn( + joke: str, + config: Annotated[dict[str, Any], GuardrailExclude()], + ) -> str: + return joke + + fn("why did the chicken", {"debug": True}) + assert "config" not in captured[0] + assert "joke" in captured[0] + + def test_pre_modification_updates_function_args(self): + class _ReplaceAction(GuardrailAction): + def handle_validation_result(self, result, data, name): + if isinstance(data, dict) and "joke" in data: + return {"joke": data["joke"].replace("donkey", "[censored]")} + return data + + @guardrail( + validator=CustomValidator(lambda args: "donkey" in args.get("joke", "")), + action=_ReplaceAction(), + stage=GuardrailExecutionStage.PRE, + ) + def fn(joke: str) -> str: + return joke + + result = fn("why did the donkey cross the road") + assert result == "why did the [censored] cross the road" + + def test_post_output_contains_return_value(self): + captured: list[Any] = [] + + @guardrail( + validator=CustomValidator( + lambda args: captured.append(args) or False # type: ignore[func-returns-value] + ), + action=LogAction(), + stage=GuardrailExecutionStage.POST, + ) + def fn(x: int) -> dict[str, int]: + return {"result": x * 2} + + fn(5) + assert captured == [{"result": 10}] + + def test_post_modification_updates_return_value(self): + class _FixedAction(GuardrailAction): + def handle_validation_result(self, result, data, name): + return {"result": 99} + + @guardrail( + validator=CustomValidator(lambda args: True), + action=_FixedAction(), + stage=GuardrailExecutionStage.POST, + ) + def fn(x: int) -> dict[str, int]: + return {"result": x * 2} + + assert fn(5) == {"result": 99} + + def test_pre_and_post_both_fire(self): + calls: list[str] = [] + + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.side_effect = lambda name, desc, enabled, data, stage, *a: ( + calls.append(stage.value) or _PASSED # type: ignore[func-returns-value] + ) + + @guardrail( + validator=mock_validator, + action=LogAction(), + stage=GuardrailExecutionStage.PRE_AND_POST, + ) + def fn(x: int) -> int: + return x + 1 + + fn(1) + assert "pre" in calls + assert "post" in calls + + +# --------------------------------------------------------------------------- +# 10. @guardrail on async functions +# --------------------------------------------------------------------------- + + +class TestGuardrailOnAsyncFunction: + @pytest.mark.asyncio + async def test_pre_fires_before_async_function(self): + calls: list[str] = [] + + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.side_effect = lambda *a, **kw: ( + calls.append("eval") or _PASSED # type: ignore[func-returns-value] + ) + + @guardrail( + validator=mock_validator, + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + async def fn(text: str) -> str: + calls.append("fn") + return text + + await fn("hello") + assert calls == ["eval", "fn"] + + @pytest.mark.asyncio + async def test_block_action_raises_in_async(self): + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.return_value = _FAILED + + @guardrail( + validator=mock_validator, + action=BlockAction(), + stage=GuardrailExecutionStage.PRE, + ) + async def fn(text: str) -> str: + return text + + with pytest.raises(GuardrailBlockException): + await fn("bad") + + @pytest.mark.asyncio + async def test_post_modification_in_async(self): + class _FortyTwoAction(GuardrailAction): + def handle_validation_result(self, result, data, name): + return {"result": 42} + + @guardrail( + validator=CustomValidator(lambda args: True), + action=_FortyTwoAction(), + stage=GuardrailExecutionStage.POST, + ) + async def fn(x: int) -> dict[str, int]: + return {"result": x} + + assert await fn(1) == {"result": 42} + + +# --------------------------------------------------------------------------- +# 11. Stage enforcement at decoration time +# --------------------------------------------------------------------------- + + +class TestStageEnforcement: + def test_prompt_injection_on_post_raises_at_decoration(self): + with pytest.raises(ValueError, match="stage"): + guardrail( + lambda text: text, + validator=PromptInjectionValidator(), + action=LogAction(), + stage=GuardrailExecutionStage.POST, + ) + + def test_prompt_injection_on_pre_and_post_raises_at_decoration(self): + with pytest.raises(ValueError, match="stage"): + guardrail( + lambda text: text, + validator=PromptInjectionValidator(), + action=LogAction(), + stage=GuardrailExecutionStage.PRE_AND_POST, + ) + + def test_prompt_injection_on_pre_ok(self): + # Should not raise + guardrail( + lambda text: text, + validator=PromptInjectionValidator(), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + + +# --------------------------------------------------------------------------- +# 12. @guardrail validation (bad arguments) +# --------------------------------------------------------------------------- + + +class TestGuardrailDecorator: + def test_missing_action_raises(self): + with pytest.raises(ValueError, match="action must be provided"): + guardrail( + lambda text: text, + validator=CustomValidator(lambda args: False), + action=None, # type: ignore[arg-type] + ) + + def test_non_action_instance_raises(self): + with pytest.raises(ValueError, match="GuardrailAction"): + guardrail( + lambda text: text, + validator=CustomValidator(lambda args: False), + action="bad", # type: ignore[arg-type] + ) + + def test_invalid_enabled_for_evals_type_raises(self): + with pytest.raises(ValueError, match="boolean"): + guardrail( + lambda text: text, + validator=CustomValidator(lambda args: False), + action=LogAction(), + enabled_for_evals="yes", # type: ignore[arg-type] + ) + + +# --------------------------------------------------------------------------- +# 13. Stacked decorators +# --------------------------------------------------------------------------- + + +class TestStackedDecorators: + def test_both_decorators_fire_on_same_function(self): + calls: list[str] = [] + + def _make_mock(tag: str) -> Any: + m = MagicMock() + m.supported_stages = [] + m.validate_stage = MagicMock() + m.get_built_in_guardrail.return_value = None + m.run.side_effect = lambda *a, **kw: calls.append(tag) or _PASSED # type: ignore[func-returns-value] + return m + + @guardrail( + validator=_make_mock("outer"), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + name="outer", + ) + @guardrail( + validator=_make_mock("inner"), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + name="inner", + ) + def fn(text: str) -> str: + return text + + fn("hello") + assert "outer" in calls + assert "inner" in calls + + def test_outer_block_prevents_inner_from_firing(self): + inner_called = [] + + outer_validator = MagicMock() + outer_validator.supported_stages = [] + outer_validator.validate_stage = MagicMock() + outer_validator.get_built_in_guardrail.return_value = None + outer_validator.run.return_value = _FAILED + + inner_validator = MagicMock() + inner_validator.supported_stages = [] + inner_validator.validate_stage = MagicMock() + inner_validator.get_built_in_guardrail.return_value = None + inner_validator.run.side_effect = lambda *a, **kw: ( + inner_called.append(True) or _PASSED # type: ignore[func-returns-value] + ) + + @guardrail( + validator=outer_validator, + action=BlockAction(), + stage=GuardrailExecutionStage.PRE, + ) + @guardrail( + validator=inner_validator, + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def fn(text: str) -> str: + return text + + with pytest.raises(GuardrailBlockException): + fn("bad") + + assert not inner_called + + +# --------------------------------------------------------------------------- +# 14. Factory function path (adapter wraps return value) +# --------------------------------------------------------------------------- + + +class TestFactoryFunctionPath: + def test_adapter_wraps_return_value_of_factory(self): + register_guardrail_adapter(_DummyAdapter()) + target = _DummyTarget(return_value={"output": "ok"}) + + @guardrail( + validator=CustomValidator(lambda args: False), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def factory() -> _DummyTarget: + return target + + wrapped = factory() + assert isinstance(wrapped, _WrappedDummyTarget) + + def test_factory_pre_guardrail_fires_on_factory_params(self): + register_guardrail_adapter(_DummyAdapter()) + captured: list[Any] = [] + target = _DummyTarget() + + @guardrail( + validator=CustomValidator( + lambda args: captured.append(args) or False # type: ignore[func-returns-value] + ), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def factory(config: str) -> _DummyTarget: + return target + + factory("test-config") + assert captured == [{"config": "test-config"}] + + def test_adapter_recognizes_direct_object(self): + register_guardrail_adapter(_DummyAdapter()) + target = _DummyTarget() + + wrapped = guardrail( + target, + validator=CustomValidator(lambda args: False), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + assert isinstance(wrapped, _WrappedDummyTarget) + + def test_stacked_guardrails_on_factory_both_wrap_return_value(self): + register_guardrail_adapter(_DummyAdapter()) + target = _DummyTarget() + evals: list[str] = [] + + def _make_mock(tag: str) -> Any: + m = MagicMock() + m.supported_stages = [] + m.validate_stage = MagicMock() + m.get_built_in_guardrail.return_value = None + m.run.side_effect = lambda *a, **kw: evals.append(tag) or _PASSED # type: ignore[func-returns-value] + return m + + @guardrail( + validator=_make_mock("outer"), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + @guardrail( + validator=_make_mock("inner"), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def factory() -> _DummyTarget: + return target + + wrapped = factory() + wrapped.invoke({"x": 1}) + assert "outer" in evals + assert "inner" in evals + + +# --------------------------------------------------------------------------- +# 15. _make_evaluator — local vs API path +# --------------------------------------------------------------------------- + + +class TestMakeEvaluator: + def test_custom_validator_path_delegates_to_run(self): + """_make_evaluator with a CustomGuardrailValidator calls validator.run().""" + mock_validator = MagicMock() + mock_validator.run.return_value = _PASSED + evaluator = _make_evaluator(mock_validator, "G", None, True) + result = evaluator({"data": 1}, GuardrailExecutionStage.PRE, {"a": 1}, None) + mock_validator.run.assert_called_once_with( + "G", None, True, {"data": 1}, GuardrailExecutionStage.PRE, {"a": 1}, None + ) + assert result == _PASSED + + def test_built_in_validator_path_lazy_initializes_uipath(self): + """BuiltInGuardrailValidator.run() lazily creates UiPath() and calls API.""" + from uipath.platform.guardrails.decorators.validators import ( + BuiltInGuardrailValidator, + ) + from uipath.platform.guardrails.guardrails import BuiltInValidatorGuardrail + + mock_built_in = MagicMock(spec=BuiltInValidatorGuardrail) + + class _TestBuiltIn(BuiltInGuardrailValidator): + def get_built_in_guardrail(self, name, description, enabled_for_evals): + return mock_built_in + + validator = _TestBuiltIn() + evaluator = _make_evaluator(validator, "G", None, True) + + mock_uipath = MagicMock() + mock_uipath.guardrails.evaluate_guardrail.return_value = _PASSED + with patch("uipath.platform.UiPath", return_value=mock_uipath): + evaluator({"text": "hello"}, GuardrailExecutionStage.PRE, None, None) + evaluator({"text": "hello"}, GuardrailExecutionStage.PRE, None, None) + + # UiPath() should be created only once despite two calls + assert mock_uipath.guardrails.evaluate_guardrail.call_count == 2 + + +# --------------------------------------------------------------------------- +# 16. Joke-agent integration scenarios (plain functions) +# --------------------------------------------------------------------------- + + +class _JokeInput(BaseModel): + topic: str + + +class _JokeOutput(BaseModel): + joke: str + + +class TestJokeAgentScenarios: + """Integration tests modelled on the joke-agent-decorator sample.""" + + def test_pii_validator_blocks_person_name_in_topic(self): + """Agent-level PRE guardrail blocks person names in the input topic.""" + calls: list[str] = [] + + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + + mock_validator.run.return_value = _FAILED + + @guardrail( + validator=mock_validator, + action=BlockAction(title="Person detected", detail="Not allowed"), + stage=GuardrailExecutionStage.PRE, + name="Agent PII", + ) + async def joke_node(state: _JokeInput) -> _JokeOutput: + calls.append("called") + return _JokeOutput(joke="a joke") + + import asyncio + + with pytest.raises(GuardrailBlockException): + asyncio.run(joke_node(_JokeInput(topic="John Smith"))) + assert not calls + + def test_input_pydantic_model_serialized_for_guardrail(self): + """State Pydantic model is serialized to dict and sent to evaluator.""" + captured: list[Any] = [] + + @guardrail( + validator=CustomValidator( + lambda args: captured.append(args) or False # type: ignore[func-returns-value] + ), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def process(state: _JokeInput) -> _JokeOutput: + return _JokeOutput(joke="a joke") + + process(_JokeInput(topic="cats")) + assert captured == [{"state": {"topic": "cats"}}] + + def test_output_pydantic_model_serialized_for_guardrail(self): + """Return Pydantic model is serialized to dict and sent to POST evaluator.""" + captured: list[Any] = [] + + @guardrail( + validator=CustomValidator( + lambda args: captured.append(args) or False # type: ignore[func-returns-value] + ), + action=LogAction(), + stage=GuardrailExecutionStage.POST, + ) + def process(state: _JokeInput) -> _JokeOutput: + return _JokeOutput(joke="funny joke about cats") + + process(_JokeInput(topic="cats")) + assert captured == [{"joke": "funny joke about cats"}] + + def test_excluded_config_param_not_in_guardrail_input(self): + """RunnableConfig-style param excluded from evaluation.""" + captured: list[Any] = [] + + @guardrail( + validator=CustomValidator( + lambda args: captured.append(args) or False # type: ignore[func-returns-value] + ), + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + ) + def process( + state: _JokeInput, + config: Annotated[dict[str, Any], GuardrailExclude()], + ) -> _JokeOutput: + return _JokeOutput(joke="joke") + + process(_JokeInput(topic="dogs"), {"thread_id": "abc"}) + assert "config" not in captured[0] + assert "state" in captured[0] + + def test_word_filter_custom_validator_on_tool_function(self): + """CustomValidator on plain function replaces offensive word via action.""" + censored: list[str] = [] + + class CensorAction(GuardrailAction): + def handle_validation_result(self, result, data, name): + if isinstance(data, dict) and "joke" in data: + censored.append(data["joke"]) + return {"joke": data["joke"].replace("donkey", "[censored]")} + return data + + @guardrail( + validator=CustomValidator( + lambda args: "donkey" in args.get("joke", "").lower() + ), + action=CensorAction(), + stage=GuardrailExecutionStage.PRE, + name="Word Filter", + ) + def analyze_joke(joke: str) -> str: + return f"analyzed: {joke}" + + result = analyze_joke(joke="why did the donkey cross the road") + assert "censored" in result + assert "donkey" not in result + + def test_log_action_does_not_stop_joke_generation(self): + """LogAction on PII violation logs but lets execution continue.""" + + @guardrail( + validator=CustomValidator(lambda args: True), # always violate + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + name="Always-Log", + ) + def generate_joke(topic: str) -> str: + return f"joke about {topic}" + + result = generate_joke("cats") + assert result == "joke about cats" + + def test_length_limiter_blocks_long_joke(self): + """BlockAction on length check raises for over-long content.""" + + @guardrail( + validator=CustomValidator(lambda args: len(args.get("joke", "")) > 10), + action=BlockAction(title="Too long", detail="Joke exceeds limit"), + stage=GuardrailExecutionStage.PRE, + ) + def submit_joke(joke: str) -> str: + return joke + + with pytest.raises(GuardrailBlockException, match="Too long"): + submit_joke(joke="a" * 20) + + # Short joke passes through + assert submit_joke(joke="short") == "short" diff --git a/packages/uipath-platform/tests/services/test_guardrails_service.py b/packages/uipath-platform/tests/services/test_guardrails_service.py index 9d8f5a900..e9d73a06f 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_service.py +++ b/packages/uipath-platform/tests/services/test_guardrails_service.py @@ -262,15 +262,18 @@ def capture_request(request): # Parse the request payload request_payload = json.loads(captured_request.content) - # Verify the payload structure matches the reverted format: + # Verify the payload structure: # { # "validator": guardrail.validator_type, # "input": input_data, # "parameters": parameters, + # "guardrailName": guardrail.name, # } assert "validator" in request_payload assert "input" in request_payload assert "parameters" in request_payload + assert "guardrailName" in request_payload + assert request_payload["guardrailName"] == "PII detection guardrail" # Verify validator is a string (not an object) assert isinstance(request_payload["validator"], str) @@ -298,3 +301,179 @@ def capture_request(request): # Verify result fields assert result.result == GuardrailValidationResultType.PASSED assert result.reason == "Validation passed" + + def test_evaluate_guardrail_sends_trace_context_headers( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Outgoing request includes trace context headers.""" + captured_request = None + + def capture_request(request): + nonlocal captured_request + captured_request = request + return httpx.Response( + status_code=200, + json={ + "result": "PASSED", + "details": "OK", + }, + ) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + callback=capture_request, + ) + + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII guardrail", + description="Test", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["tool1"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[], + ) + + service.evaluate_guardrail("test input", pii_guardrail) + + assert captured_request is not None + # build_trace_context_headers() injects traceparent/tracestate when + # an active span exists; at minimum, the merge with spec.headers + # should not fail and the request should go through successfully. + # When there IS an active trace context, headers are present: + headers = dict(captured_request.headers) + # The request should have been sent (basic smoke check that + # header merging works even when no active span exists) + assert "content-type" in headers + + def test_evaluate_guardrail_extracts_span_id_from_traceparent( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Response with x-uipath-traceparent-id header populates span_id.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + status_code=200, + json={ + "result": "VALIDATION_FAILED", + "details": "PII detected", + }, + headers={ + "x-uipath-traceparent-id": "00-abcdef1234567890abcdef1234567890-1234567890abcdef" + }, + ) + + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII guardrail", + description="Test", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["tool1"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[], + ) + + result = service.evaluate_guardrail("test input", pii_guardrail) + + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + assert result.span_id == "00000000-0000-0000-1234-567890abcdef" + + def test_evaluate_guardrail_no_traceparent_header_no_span_id( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Response without x-uipath-traceparent-id header leaves span_id as None.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + status_code=200, + json={ + "result": "PASSED", + "details": "OK", + }, + ) + + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII guardrail", + description="Test", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["tool1"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[], + ) + + result = service.evaluate_guardrail("test input", pii_guardrail) + + assert result.result == GuardrailValidationResultType.PASSED + assert result.span_id is None + + class TestExtractSpanIdFromTraceparent: + """Tests for _extract_span_id_from_traceparent.""" + + def test_valid_traceparent_16_char_span_id(self) -> None: + result = GuardrailsService._extract_span_id_from_traceparent( + "00-abcdef1234567890abcdef1234567890-1234567890abcdef" + ) + assert result == "00000000-0000-0000-1234-567890abcdef" + + def test_valid_traceparent_32_char_span_id(self) -> None: + result = GuardrailsService._extract_span_id_from_traceparent( + "00-abcdef1234567890abcdef1234567890-0a1b2c3d4e5f67890a1b2c3d4e5f6789" + ) + assert result == "0a1b2c3d-4e5f-6789-0a1b-2c3d4e5f6789" + + def test_none_input(self) -> None: + assert GuardrailsService._extract_span_id_from_traceparent(None) is None + + def test_empty_string(self) -> None: + assert GuardrailsService._extract_span_id_from_traceparent("") is None + + def test_valid_traceparent_4_part_with_trace_flags(self) -> None: + result = GuardrailsService._extract_span_id_from_traceparent( + "00-abcdef1234567890abcdef1234567890-1234567890abcdef-01" + ) + assert result == "00000000-0000-0000-1234-567890abcdef" + + def test_uppercase_hex_normalized_to_lowercase(self) -> None: + result = GuardrailsService._extract_span_id_from_traceparent( + "00-ABCDEF1234567890ABCDEF1234567890-1234567890ABCDEF" + ) + assert result == "00000000-0000-0000-1234-567890abcdef" + + def test_invalid_span_id_length_rejected(self) -> None: + """Span IDs that are neither 16 nor 32 hex chars are rejected.""" + assert ( + GuardrailsService._extract_span_id_from_traceparent( + "00-abcdef1234567890abcdef1234567890-1234abcd" + ) + is None + ) + + def test_invalid_format(self) -> None: + assert ( + GuardrailsService._extract_span_id_from_traceparent("not-valid") is None + ) diff --git a/packages/uipath-platform/tests/services/test_hitl.py b/packages/uipath-platform/tests/services/test_hitl.py index aa288137d..7395908a9 100644 --- a/packages/uipath-platform/tests/services/test_hitl.py +++ b/packages/uipath-platform/tests/services/test_hitl.py @@ -1,3 +1,4 @@ +import json import uuid from typing import Any from unittest.mock import AsyncMock, patch @@ -7,6 +8,7 @@ from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError from uipath.core.triggers import ( UiPathApiTrigger, + UiPathIntegrationTrigger, UiPathResumeTrigger, UiPathResumeTriggerName, UiPathResumeTriggerType, @@ -32,11 +34,13 @@ WaitDocumentExtractionValidation, WaitEphemeralIndex, WaitEphemeralIndexRaw, + WaitIntegrationEvent, WaitJob, WaitJobRaw, WaitSystemAgent, WaitTask, ) +from uipath.platform.connections import Connection from uipath.platform.context_grounding import ( BatchTransformCreationResponse, BatchTransformOutputColumn, @@ -508,6 +512,91 @@ async def test_read_api_trigger_failure( await reader.read_trigger(resume_trigger) assert exc_info.value.category == ErrorCategory.SYSTEM + @pytest.mark.anyio + async def test_read_inbox_trigger( + self, + httpx_mock: HTTPXMock, + base_url: str, + setup_test_env: None, + ) -> None: + """Test reading an Inbox trigger fetches the IS metadata via GetPayload + and then enriches it via /elements_/v1/events/{processedEventId}. + """ + inbox_id = str(uuid.uuid4()) + processed_event_id = "v2::pp::1777041494382::334071::e374ecd5d0f73c21" + inbox_metadata = { + "UiPathEventConnector": "uipath-slack", + "UiPathEvent": "NEW_MESSAGE", + "UiPathEventObjectType": "Message", + "UiPathEventObjectId": "C123:1777041494.382", + "UiPathAdditionalEventData": json.dumps( + {"processedEventId": processed_event_id} + ), + } + enriched_event = { + "channel": "alerts", + "user": "U456", + "text": "hello from slack", + "ts": "1777041494.382", + } + + httpx_mock.add_response( + url=f"{base_url}/orchestrator_/api/JobTriggers/GetPayload/{inbox_id}", + status_code=200, + json=inbox_metadata, + ) + httpx_mock.add_response( + url=f"{base_url}/elements_/v1/events/{processed_event_id}", + status_code=200, + json=enriched_event, + ) + + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.INBOX, + integration_resume=UiPathIntegrationTrigger( + connector="slack", + connection_id=str(uuid.uuid4()), + operation="OnMessage", + object_name="Message", + inbox_id=inbox_id, + ), + ) + + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + assert result == enriched_event + + @pytest.mark.anyio + async def test_read_inbox_trigger_failure( + self, + httpx_mock: HTTPXMock, + base_url: str, + setup_test_env: None, + ) -> None: + """Test reading an Inbox trigger with a failed payload response.""" + inbox_id = str(uuid.uuid4()) + + httpx_mock.add_response( + url=f"{base_url}/orchestrator_/api/JobTriggers/GetPayload/{inbox_id}", + status_code=500, + ) + + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.INBOX, + integration_resume=UiPathIntegrationTrigger( + connector="slack", + connection_id=str(uuid.uuid4()), + operation="OnMessage", + object_name="Message", + inbox_id=inbox_id, + ), + ) + + with pytest.raises(UiPathFaultedTriggerError) as exc_info: + reader = UiPathResumeTriggerReader() + await reader.read_trigger(resume_trigger) + assert exc_info.value.category == ErrorCategory.SYSTEM + @pytest.mark.anyio async def test_read_deep_rag_trigger_successful( self, @@ -809,6 +898,41 @@ async def test_read_batch_rag_trigger_pending( reader = UiPathResumeTriggerReader() await reader.read_trigger(resume_trigger) + @pytest.mark.anyio + async def test_read_batch_rag_trigger_failed( + self, + setup_test_env: None, + ) -> None: + """Test reading a failed batch rag trigger raises faulted error.""" + from uipath.core.errors import UiPathFaultedTriggerError + + from uipath.platform.errors import BatchTransformFailedException + + task_id = "test-batch-rag-id" + destination_path = "test/output.xlsx" + mock_download_async = AsyncMock( + side_effect=BatchTransformFailedException(task_id) + ) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.download_batch_transform_result_async", + new=mock_download_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.BATCH_RAG, + item_key=task_id, + folder_key="test-folder", + folder_path="test-path", + payload={ + "index_name": "test-index", + "destination_path": destination_path, + }, + ) + + with pytest.raises(UiPathFaultedTriggerError): + reader = UiPathResumeTriggerReader() + await reader.read_trigger(resume_trigger) + @pytest.mark.anyio async def test_read_ephemeral_index_trigger_successful( self, @@ -1339,6 +1463,186 @@ async def test_create_resume_trigger_api( assert isinstance(resume_trigger.api_resume.inbox_id, str) assert resume_trigger.api_resume.request == api_input + @pytest.mark.anyio + async def test_create_resume_trigger_wait_integration_event( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for WaitIntegrationEvent.""" + connection_id = str(uuid.uuid4()) + mock_connection = Connection( + id=connection_id, name="Slack-Alerts", element_instance_id=1 + ) + mock_list_async = AsyncMock(return_value=[mock_connection]) + + wait_event = WaitIntegrationEvent( + connector="slack", + connection_name="Slack-Alerts", + connection_folder_path="Shared", + operation="OnMessage", + object_name="Message", + filter_expression="channel == 'alerts'", + parameters={"channel_id": "C123"}, + ) + + with patch( + "uipath.platform.connections._connections_service.ConnectionsService.list_async", + new=mock_list_async, + ): + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_event) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.INBOX + assert resume_trigger.trigger_name == UiPathResumeTriggerName.INBOX + assert resume_trigger.api_resume is None + assert resume_trigger.integration_resume is not None + assert resume_trigger.integration_resume.connector == "slack" + assert resume_trigger.integration_resume.connection_id == connection_id + assert resume_trigger.integration_resume.operation == "OnMessage" + assert resume_trigger.integration_resume.object_name == "Message" + assert ( + resume_trigger.integration_resume.filter_expression == "channel == 'alerts'" + ) + assert resume_trigger.integration_resume.parameters == {"channel_id": "C123"} + assert isinstance(resume_trigger.integration_resume.inbox_id, str) + uuid.UUID(resume_trigger.integration_resume.inbox_id) + mock_list_async.assert_called_once_with( + name="Slack-Alerts", folder_path="Shared", connector_key="slack" + ) + + @pytest.mark.anyio + async def test_create_resume_trigger_wait_integration_event_optional_fields_omitted( + self, + setup_test_env: None, + ) -> None: + """Test that filter_expression, parameters, and folder_path are optional.""" + mock_connection = Connection( + id=str(uuid.uuid4()), name="Teams-Default", element_instance_id=2 + ) + mock_list_async = AsyncMock(return_value=[mock_connection]) + + wait_event = WaitIntegrationEvent( + connector="teams", + connection_name="Teams-Default", + operation="OnReply", + object_name="Reply", + ) + + with patch( + "uipath.platform.connections._connections_service.ConnectionsService.list_async", + new=mock_list_async, + ): + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_event) + + assert resume_trigger.integration_resume is not None + assert resume_trigger.integration_resume.filter_expression is None + assert resume_trigger.integration_resume.parameters is None + mock_list_async.assert_called_once_with( + name="Teams-Default", folder_path=None, connector_key="teams" + ) + + @pytest.mark.anyio + async def test_wait_integration_event_serializes_with_camelcase_aliases( + self, + setup_test_env: None, + ) -> None: + """Wire shape: UiPathResumeTrigger.integration_resume must serialize + with the field names Orchestrator's ResumeTriggerDto/IntegrationResumeDto + expect (PascalCase-ish camelCase). + """ + connection_id = str(uuid.uuid4()) + mock_connection = Connection( + id=connection_id, name="Slack-Alerts", element_instance_id=3 + ) + mock_list_async = AsyncMock(return_value=[mock_connection]) + + wait_event = WaitIntegrationEvent( + connector="slack", + connection_name="Slack-Alerts", + operation="OnMessage", + object_name="Message", + ) + + with patch( + "uipath.platform.connections._connections_service.ConnectionsService.list_async", + new=mock_list_async, + ): + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_event) + + dumped = resume_trigger.model_dump(by_alias=True, exclude_none=True) + + assert dumped["triggerType"] == UiPathResumeTriggerType.INBOX + assert "integrationResume" in dumped + integration = dumped["integrationResume"] + assert integration["connector"] == "slack" + assert integration["connectionId"] == connection_id + assert integration["operation"] == "OnMessage" + assert integration["objectName"] == "Message" + assert "inboxId" in integration + uuid.UUID(integration["inboxId"]) + + @pytest.mark.anyio + async def test_create_resume_trigger_wait_integration_event_no_match( + self, + setup_test_env: None, + ) -> None: + """Listing returns no exact-name match -> creator raises.""" + mock_list_async = AsyncMock(return_value=[]) + + wait_event = WaitIntegrationEvent( + connector="slack", + connection_name="Missing-Connection", + operation="OnMessage", + object_name="Message", + ) + + with patch( + "uipath.platform.connections._connections_service.ConnectionsService.list_async", + new=mock_list_async, + ): + processor = UiPathResumeTriggerCreator() + with pytest.raises(UiPathFaultedTriggerError): + await processor.create_trigger(wait_event) + + @pytest.mark.anyio + async def test_create_resume_trigger_wait_integration_event_filters_to_exact_match( + self, + setup_test_env: None, + ) -> None: + """list_async partial-matches; creator must pick the exact-name entry.""" + target_id = str(uuid.uuid4()) + # list_async partial-matches; simulate prefix-matching returning extras + mock_list_async = AsyncMock( + return_value=[ + Connection( + id=str(uuid.uuid4()), + name="Slack-Alerts-Old", + element_instance_id=4, + ), + Connection(id=target_id, name="Slack-Alerts", element_instance_id=5), + ] + ) + + wait_event = WaitIntegrationEvent( + connector="slack", + connection_name="Slack-Alerts", + operation="OnMessage", + object_name="Message", + ) + + with patch( + "uipath.platform.connections._connections_service.ConnectionsService.list_async", + new=mock_list_async, + ): + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_event) + + assert resume_trigger.integration_resume is not None + assert resume_trigger.integration_resume.connection_id == target_id + @pytest.mark.anyio async def test_create_resume_trigger_create_deep_rag( self, diff --git a/packages/uipath-platform/tests/services/test_llm_trace_context.py b/packages/uipath-platform/tests/services/test_llm_trace_context.py new file mode 100644 index 000000000..2b078cd62 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_llm_trace_context.py @@ -0,0 +1,222 @@ +"""Tests for build_trace_context_headers.""" + +import os +from unittest.mock import patch + +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags +from uipath.core.feature_flags import FeatureFlags + +from uipath.platform.chat.llm_trace_context import build_trace_context_headers +from uipath.platform.common.constants import ENV_PROJECT_KEY + +FEATURE_FLAG = "EnableTraceContextHeaders" + + +def _make_span(): + """Create a real OTEL span for testing.""" + provider = TracerProvider() + tracer = provider.get_tracer("test") + return tracer.start_span("test-span") + + +class TestFeatureFlagDisabled: + """When the feature flag is off, no headers are returned.""" + + def setup_method(self) -> None: + FeatureFlags.reset_flags() + + def test_returns_empty_dict_by_default(self) -> None: + assert build_trace_context_headers() == {} + + def test_returns_empty_dict_when_explicitly_disabled(self) -> None: + FeatureFlags.configure_flags({FEATURE_FLAG: False}) + assert build_trace_context_headers() == {} + + +class TestTraceparentHeader: + """When enabled, x-uipath-traceparent-id is populated from config + span.""" + + def setup_method(self) -> None: + FeatureFlags.reset_flags() + FeatureFlags.configure_flags({FEATURE_FLAG: True}) + + def test_traceparent_from_config_and_span(self) -> None: + span = _make_span() + ctx = span.get_span_context() + expected_span_id = format(ctx.span_id, "032x") + config_trace = "abcdef1234567890abcdef1234567890" + env = {"UIPATH_TRACE_ID": config_trace} + with ( + patch.dict(os.environ, env), + patch( + "uipath.platform.chat.llm_trace_context.trace.get_current_span", + return_value=span, + ), + ): + headers = build_trace_context_headers() + + assert "x-uipath-traceparent-id" in headers + value = headers["x-uipath-traceparent-id"] + assert value == f"00-{config_trace}-{expected_span_id}" + parts = value.split("-") + assert len(parts) == 3 + assert parts[0] == "00" + assert len(parts[1]) == 32 + assert len(parts[2]) == 32 + + def test_no_traceparent_without_config_trace_id(self) -> None: + headers = build_trace_context_headers() + assert "x-uipath-traceparent-id" not in headers + + def test_traceparent_strips_dashes_from_config_trace_id(self) -> None: + span = _make_span() + uuid_trace = "abcdef12-3456-7890-abcd-ef1234567890" + env = {"UIPATH_TRACE_ID": uuid_trace} + with ( + patch.dict(os.environ, env), + patch( + "uipath.platform.chat.llm_trace_context.trace.get_current_span", + return_value=span, + ), + ): + headers = build_trace_context_headers() + + value = headers["x-uipath-traceparent-id"] + parts = value.split("-") + assert parts[1] == "abcdef1234567890abcdef1234567890" + + def test_no_traceparent_with_invalid_span(self) -> None: + ctx = SpanContext( + trace_id=0, + span_id=0, + is_remote=False, + trace_flags=TraceFlags(0), + ) + span = NonRecordingSpan(ctx) + env = {"UIPATH_TRACE_ID": "abcdef1234567890abcdef1234567890"} + with ( + patch.dict(os.environ, env), + patch( + "uipath.platform.chat.llm_trace_context.trace.get_current_span", + return_value=span, + ), + ): + headers = build_trace_context_headers() + + assert "x-uipath-traceparent-id" not in headers + + +class TestBaggageHeader: + """When enabled, x-uipath-tracebaggage is populated from UiPathConfig.""" + + def setup_method(self) -> None: + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + FeatureFlags.reset_flags() + FeatureFlags.configure_flags({FEATURE_FLAG: True}) + + def test_all_env_vars_present(self) -> None: + env = { + "UIPATH_FOLDER_KEY": "folder-abc", + ENV_PROJECT_KEY: "agent-123", + "UIPATH_PROCESS_KEY": "process-789", + } + with patch.dict(os.environ, env, clear=True): + headers = build_trace_context_headers() + + baggage = headers["x-uipath-tracebaggage"] + assert "folderKey=folder-abc" in baggage + assert "agentId=agent-123" in baggage + assert "processKey=process-789" in baggage + + def test_partial_env_vars(self) -> None: + env = {"UIPATH_FOLDER_KEY": "folder-only"} + with patch.dict(os.environ, env, clear=True): + headers = build_trace_context_headers() + + baggage = headers["x-uipath-tracebaggage"] + assert "folderKey=folder-only" in baggage + + def test_agent_id_from_project_key_env(self) -> None: + env = {ENV_PROJECT_KEY: "real-agent-id"} + with patch.dict(os.environ, env, clear=True): + headers = build_trace_context_headers() + + baggage = headers["x-uipath-tracebaggage"] + assert "agentId=real-agent-id" in baggage + + def test_no_agent_id_without_env_vars(self) -> None: + env = {"UIPATH_FOLDER_KEY": "f1"} + with patch.dict(os.environ, env, clear=True): + headers = build_trace_context_headers() + + baggage = headers["x-uipath-tracebaggage"] + assert "agentId" not in baggage + assert "folderKey=f1" in baggage + + def test_no_baggage_without_env_vars(self) -> None: + with patch.dict(os.environ, {}, clear=True): + headers = build_trace_context_headers() + + assert "x-uipath-tracebaggage" not in headers + + def test_baggage_comma_separated(self) -> None: + env = { + "UIPATH_FOLDER_KEY": "f1", + ENV_PROJECT_KEY: "a1", + } + with patch.dict(os.environ, env, clear=True): + headers = build_trace_context_headers() + + baggage = headers["x-uipath-tracebaggage"] + parts = baggage.split(",") + assert len(parts) == 2 # folderKey + agentId + + def test_extra_baggage_included(self) -> None: + env = {"UIPATH_FOLDER_KEY": "f1"} + with patch.dict(os.environ, env, clear=True): + headers = build_trace_context_headers(extra_baggage=["source=agents"]) + + baggage = headers["x-uipath-tracebaggage"] + assert "source=agents" in baggage + assert "folderKey=f1" in baggage + + def test_extra_baggage_only(self) -> None: + with patch.dict(os.environ, {}, clear=True): + headers = build_trace_context_headers( + extra_baggage=["source=agents", "custom=value"] + ) + + baggage = headers["x-uipath-tracebaggage"] + assert baggage == "source=agents,custom=value" + + +class TestBothHeaders: + """When enabled with an active span and env vars, both headers are present.""" + + def setup_method(self) -> None: + FeatureFlags.reset_flags() + FeatureFlags.configure_flags({FEATURE_FLAG: True}) + + def test_both_headers_present(self) -> None: + span = _make_span() + env = { + "UIPATH_FOLDER_KEY": "folder-abc", + "UIPATH_TRACE_ID": "abcdef1234567890abcdef1234567890", + } + with ( + patch.dict(os.environ, env, clear=True), + patch( + "uipath.platform.chat.llm_trace_context.trace.get_current_span", + return_value=span, + ), + ): + headers = build_trace_context_headers() + + assert "x-uipath-traceparent-id" in headers + assert headers["x-uipath-traceparent-id"].startswith( + "00-abcdef1234567890abcdef1234567890-" + ) + assert "x-uipath-tracebaggage" in headers diff --git a/packages/uipath-platform/tests/services/test_memory_service.py b/packages/uipath-platform/tests/services/test_memory_service.py new file mode 100644 index 000000000..716e3438c --- /dev/null +++ b/packages/uipath-platform/tests/services/test_memory_service.py @@ -0,0 +1,504 @@ +"""Unit tests for MemoryService with HTTP mocking.""" + +import json + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.memory import ( + EscalationMemoryIngestRequest, + EscalationMemorySearchResponse, + MemoryMatch, + MemoryMatchField, + MemorySearchRequest, + MemorySearchResponse, + MemorySpace, + MemorySpaceListResponse, + SearchField, + SearchMode, + SearchSettings, +) +from uipath.platform.memory._memory_service import MemoryService +from uipath.platform.orchestrator._folder_service import FolderService + + +@pytest.fixture +def folder_service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, +) -> FolderService: + return FolderService(config=config, execution_context=execution_context) + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folder_service: FolderService, + monkeypatch: pytest.MonkeyPatch, +) -> MemoryService: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "test-folder-key") + return MemoryService( + config=config, + execution_context=execution_context, + folders_service=folder_service, + ) + + +# ── Sample response payloads ────────────────────────────────────────── + +SAMPLE_INDEX = { + "id": "aaaa-bbbb-cccc-dddd", + "name": "test-memory-space", + "description": "A test memory space", + "lastQueried": "2026-03-30T00:00:00Z", + "memoriesCount": 5, + "folderKey": "test-folder-key", + "createdByUserId": "user-123", + "isEncrypted": False, +} + +SAMPLE_LIST_RESPONSE = {"value": [SAMPLE_INDEX]} + +SAMPLE_SEARCH_RESPONSE = { + "results": [ + { + "memoryItemId": "item-001", + "score": 0.95, + "semanticScore": 0.92, + "weightedScore": 0.93, + "fields": [ + { + "keyPath": ["input"], + "value": "What is the capital of France?", + "weight": 1.0, + "score": 0.95, + "weightedScore": 0.95, + } + ], + "span": None, + "feedback": None, + } + ], + "metadata": {"queryTime": "12ms"}, + "systemPromptInjection": "Based on past interactions: Paris is the capital.", +} + +SAMPLE_ESCALATION_SEARCH_RESPONSE = { + "results": [ + { + "answer": { + "output": {"action": "approve", "reason": "meets criteria"}, + "outcome": "approved", + } + } + ], +} + + +class TestMemoryService: + """Unit tests for MemoryService.""" + + class TestCreate: + def test_create_memory_space( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories/create", + status_code=200, + json=SAMPLE_INDEX, + ) + + result = service.create( + name="test-memory-space", + description="A test memory space", + ) + + assert isinstance(result, MemorySpace) + assert result.id == "aaaa-bbbb-cccc-dddd" + assert result.name == "test-memory-space" + assert result.memories_count == 5 + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + body = json.loads(sent.content) + assert body["name"] == "test-memory-space" + assert body["description"] == "A test memory space" + + def test_create_sends_folder_header( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories/create", + status_code=200, + json=SAMPLE_INDEX, + ) + + service.create(name="test", folder_key="custom-folder-key") + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.headers.get("x-uipath-folderkey") == "custom-folder-key" + + def test_create_with_encryption( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories/create", + status_code=200, + json={**SAMPLE_INDEX, "isEncrypted": True}, + ) + + result = service.create( + name="encrypted-space", + is_encrypted=True, + ) + + assert result.is_encrypted is True + sent = httpx_mock.get_request() + assert sent is not None + body = json.loads(sent.content) + assert body["isEncrypted"] is True + + class TestList: + def test_list_memory_spaces( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories", + status_code=200, + json=SAMPLE_LIST_RESPONSE, + ) + + result = service.list() + + assert isinstance(result, MemorySpaceListResponse) + assert len(result.value) == 1 + assert result.value[0].name == "test-memory-space" + + def test_list_with_odata_params( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories?%24filter=Name+eq+%27test%27&%24orderby=Name+asc&%24top=10&%24skip=5", + status_code=200, + json=SAMPLE_LIST_RESPONSE, + ) + + result = service.list( + filter="Name eq 'test'", + orderby="Name asc", + top=10, + skip=5, + ) + + assert isinstance(result, MemorySpaceListResponse) + + def test_list_empty( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories", + status_code=200, + json={"value": []}, + ) + + result = service.list() + + assert isinstance(result, MemorySpaceListResponse) + assert len(result.value) == 0 + + class TestSearch: + def test_search_memory( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/search", + status_code=200, + json=SAMPLE_SEARCH_RESPONSE, + ) + + request = MemorySearchRequest( + fields=[ + SearchField( + key_path=["input"], + value="What is the capital of France?", + ) + ], + settings=SearchSettings( + threshold=0.0, + result_count=5, + search_mode=SearchMode.Hybrid, + ), + definition_system_prompt="You are a helpful assistant.", + ) + + result = service.search( + memory_space_id=memory_space_id, + request=request, + ) + + assert isinstance(result, MemorySearchResponse) + assert len(result.results) == 1 + assert isinstance(result.results[0], MemoryMatch) + assert result.results[0].memory_item_id == "item-001" + assert result.results[0].score == 0.95 + assert isinstance(result.results[0].fields[0], MemoryMatchField) + assert ( + result.system_prompt_injection + == "Based on past interactions: Paris is the capital." + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + body = json.loads(sent.content) + assert body["fields"][0]["keyPath"] == ["input"] + assert body["settings"]["searchMode"] == "Hybrid" + assert body["definitionSystemPrompt"] == "You are a helpful assistant." + + def test_search_sends_folder_header( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/search", + status_code=200, + json=SAMPLE_SEARCH_RESPONSE, + ) + + request = MemorySearchRequest( + fields=[SearchField(key_path=["input"], value="test")], + settings=SearchSettings( + threshold=0.0, + result_count=1, + search_mode=SearchMode.Semantic, + ), + ) + + service.search( + memory_space_id=memory_space_id, + request=request, + folder_key="custom-folder", + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.headers.get("x-uipath-folderkey") == "custom-folder" + + class TestEscalationSearch: + def test_escalation_search( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/search", + status_code=200, + json=SAMPLE_ESCALATION_SEARCH_RESPONSE, + ) + + request = MemorySearchRequest( + fields=[SearchField(key_path=["input"], value="approval request")], + settings=SearchSettings( + threshold=0.0, + result_count=5, + search_mode=SearchMode.Hybrid, + ), + ) + + result = service.escalation_search( + memory_space_id=memory_space_id, + request=request, + ) + + assert isinstance(result, EscalationMemorySearchResponse) + assert result.results is not None + assert len(result.results) == 1 + assert result.results[0].answer is not None + assert result.results[0].answer.outcome == "approved" + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + assert "/escalation/search" in str(sent.url) + + def test_escalation_search_empty_results( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/search", + status_code=200, + json={"results": None}, + ) + + request = MemorySearchRequest( + fields=[SearchField(key_path=["input"], value="no match")], + settings=SearchSettings( + threshold=0.0, + result_count=1, + search_mode=SearchMode.Hybrid, + ), + ) + + result = service.escalation_search( + memory_space_id=memory_space_id, + request=request, + ) + + assert isinstance(result, EscalationMemorySearchResponse) + assert result.results is None + + class TestEscalationIngest: + def test_escalation_ingest( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/ingest", + status_code=200, + ) + + request = EscalationMemoryIngestRequest( + span_id="span-123", + trace_id="trace-456", + answer='{"action": "approve"}', + attributes='{"input": "approve this?"}', + user_id="user-789", + ) + + service.escalation_ingest( + memory_space_id=memory_space_id, + request=request, + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + assert "/escalation/ingest" in str(sent.url) + body = json.loads(sent.content) + assert body["spanId"] == "span-123" + assert body["traceId"] == "trace-456" + assert body["answer"] == '{"action": "approve"}' + assert body["attributes"] == '{"input": "approve this?"}' + assert body["userId"] == "user-789" + + def test_escalation_ingest_sends_folder_header( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/ingest", + status_code=200, + ) + + request = EscalationMemoryIngestRequest( + span_id="s1", + trace_id="t1", + answer="yes", + attributes="{}", + ) + + service.escalation_ingest( + memory_space_id=memory_space_id, + request=request, + folder_key="my-folder", + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.headers.get("x-uipath-folderkey") == "my-folder" + + def test_escalation_ingest_excludes_none_user_id( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/ingest", + status_code=200, + ) + + request = EscalationMemoryIngestRequest( + span_id="s1", + trace_id="t1", + answer="yes", + attributes="{}", + ) + + service.escalation_ingest( + memory_space_id=memory_space_id, + request=request, + ) + + sent = httpx_mock.get_request() + assert sent is not None + body = json.loads(sent.content) + assert "userId" not in body diff --git a/packages/uipath-platform/tests/services/test_memory_service_e2e.py b/packages/uipath-platform/tests/services/test_memory_service_e2e.py new file mode 100644 index 000000000..6c7866611 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_memory_service_e2e.py @@ -0,0 +1,164 @@ +"""E2E tests for MemoryService against real ECS + LLMOps endpoints. + +Prerequisites: + uipath auth --alpha # sets UIPATH_URL + UIPATH_ACCESS_TOKEN + export UIPATH_FOLDER_KEY=... # folder GUID with agent memory enabled + +Run: + cd packages/uipath-platform + uv run pytest tests/services/test_memory_service_e2e.py -m e2e -v +""" + +import os +import uuid + +import pytest + +from uipath.platform import UiPath +from uipath.platform.memory import ( + EscalationMemorySearchResponse, + MemorySearchRequest, + MemorySearchResponse, + MemorySpace, + MemorySpaceListResponse, + SearchField, + SearchMode, + SearchSettings, +) + +pytestmark = pytest.mark.e2e + + +def _require_env(name: str) -> str: + value = os.environ.get(name) + if not value: + pytest.skip(f"Environment variable {name} is not set") + return value + + +@pytest.fixture(scope="module") +def sdk() -> UiPath: + """Create a real UiPath client from env vars. + + Supports two auth modes: + - Token-based: UIPATH_URL + UIPATH_ACCESS_TOKEN (from `uipath auth`) + - Client credentials: UIPATH_URL + UIPATH_CLIENT_ID + UIPATH_CLIENT_SECRET (CI) + """ + _require_env("UIPATH_URL") + client_id = os.environ.get("UIPATH_CLIENT_ID") + client_secret = os.environ.get("UIPATH_CLIENT_SECRET") + if client_id and client_secret: + return UiPath(client_id=client_id, client_secret=client_secret) + _require_env("UIPATH_ACCESS_TOKEN") + return UiPath() + + +@pytest.fixture(scope="module") +def folder_key() -> str: + return _require_env("UIPATH_FOLDER_KEY") + + +@pytest.fixture(scope="module") +def memory_index(sdk: UiPath, folder_key: str): # noqa: ANN201 + """Create a test memory index and clean it up after all tests.""" + unique_name = f"sdk-e2e-test-{uuid.uuid4().hex[:8]}" + index = sdk.memory.create( + name=unique_name, + description="Created by E2E test — safe to delete", + folder_key=folder_key, + ) + yield index + + +class TestMemoryServiceE2E: + """E2E tests for MemoryService lifecycle. + + Requires: UIPATH_URL, UIPATH_ACCESS_TOKEN, UIPATH_FOLDER_KEY + """ + + # ── Index CRUD (ECS) ────────────────────────────────────────── + + def test_create_index(self, memory_index: MemorySpace) -> None: + """Verify index creation returns a well-formed MemorySpace.""" + assert memory_index.id, "Index ID should be set" + assert memory_index.name.startswith("sdk-e2e-test-") + assert memory_index.folder_key, "Folder key should be populated" + assert memory_index.memories_count == 0 + + def test_list_indexes( + self, + sdk: UiPath, + memory_index: MemorySpace, + folder_key: str, + ) -> None: + """Verify list with OData filter returns our index.""" + result = sdk.memory.list( + filter=f"Name eq '{memory_index.name}'", + folder_key=folder_key, + ) + assert isinstance(result, MemorySpaceListResponse) + names = [idx.name for idx in result.value] + assert memory_index.name in names + + # ── Search (LLMOps) ────────────────────────────────────────── + + def test_search_empty_index( + self, + sdk: UiPath, + memory_index: MemorySpace, + folder_key: str, + ) -> None: + """Search an empty index — should return empty results and systemPromptInjection.""" + request = MemorySearchRequest( + fields=[ + SearchField( + key_path=["input"], + value="test query", + ) + ], + settings=SearchSettings( + threshold=0.0, + result_count=5, + search_mode=SearchMode.Hybrid, + ), + definition_system_prompt="You are a helpful assistant.", + ) + result = sdk.memory.search( + memory_space_id=memory_index.id, + request=request, + folder_key=folder_key, + ) + assert isinstance(result, MemorySearchResponse) + assert isinstance(result.results, list) + assert isinstance(result.metadata, dict) + assert isinstance(result.system_prompt_injection, str) + + # ── Escalation search (LLMOps) ──────────────────────────────── + + def test_escalation_search_empty_index( + self, + sdk: UiPath, + memory_index: MemorySpace, + folder_key: str, + ) -> None: + """Search escalation memory on empty index — should return valid response.""" + request = MemorySearchRequest( + fields=[ + SearchField( + key_path=["input"], + value="test escalation query", + ) + ], + settings=SearchSettings( + threshold=0.0, + result_count=5, + search_mode=SearchMode.Hybrid, + ), + definition_system_prompt="You are a helpful assistant.", + ) + result = sdk.memory.escalation_search( + memory_space_id=memory_index.id, + request=request, + folder_key=folder_key, + ) + assert isinstance(result, EscalationMemorySearchResponse) diff --git a/packages/uipath-platform/tests/services/test_pii_detection_service.py b/packages/uipath-platform/tests/services/test_pii_detection_service.py new file mode 100644 index 000000000..2bb424607 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_pii_detection_service.py @@ -0,0 +1,264 @@ +"""Tests for PiiDetectionService.""" + +import json +from typing import Any + +import httpx +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.pii_detection import ( + PiiDetectionRequest, + PiiDetectionResponse, + PiiDetectionService, + PiiDocument, + PiiEntityThreshold, + PiiFile, +) + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, +) -> PiiDetectionService: + return PiiDetectionService(config=config, execution_context=execution_context) + + +@pytest.fixture +def sample_response_json() -> dict[str, Any]: + return { + "response": [ + { + "id": "user-prompt", + "role": "user", + "maskedDocument": "Contact [Person-1]", + "initialDocument": "Contact Alison", + "piiEntities": [ + { + "piiText": "Alison", + "replacementText": "[Person-1]", + "piiType": "Person", + "offset": 8, + "confidenceScore": 0.99, + } + ], + } + ], + "files": [ + { + "fileName": "doc.pdf", + "fileUrl": "https://blob.example.com/redacted/doc.pdf", + "piiEntities": [ + { + "piiText": "alice@example.com", + "replacementText": "[Email-1]", + "piiType": "Email", + "offset": 100, + "confidenceScore": 0.88, + } + ], + } + ], + } + + +class TestPiiDetectionService: + """Test PiiDetectionService functionality.""" + + class TestDetectPii: + """Test detect_pii (sync).""" + + def test_returns_typed_response( + self, + httpx_mock: HTTPXMock, + service: PiiDetectionService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/pii-detection", + status_code=200, + json=sample_response_json, + ) + + request = PiiDetectionRequest( + documents=[ + PiiDocument( + id="user-prompt", role="user", document="Contact Alison" + ) + ] + ) + result = service.detect_pii(request) + + assert isinstance(result, PiiDetectionResponse) + assert len(result.response) == 1 + assert result.response[0].masked_document == "Contact [Person-1]" + assert len(result.files) == 1 + assert result.files[0].file_name == "doc.pdf" + assert result.files[0].pii_entities[0].replacement_text == "[Email-1]" + + class TestDetectPiiAsync: + """Test detect_pii_async.""" + + async def test_returns_typed_response( + self, + httpx_mock: HTTPXMock, + service: PiiDetectionService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/pii-detection", + status_code=200, + json=sample_response_json, + ) + + request = PiiDetectionRequest( + files=[ + PiiFile( + file_name="doc.pdf", + file_url="https://input.example.com/doc.pdf", + file_type="pdf", + ) + ] + ) + result = await service.detect_pii_async(request) + + assert isinstance(result, PiiDetectionResponse) + assert ( + result.files[0].file_url == "https://blob.example.com/redacted/doc.pdf" + ) + + async def test_request_payload_uses_aliases( + self, + httpx_mock: HTTPXMock, + service: PiiDetectionService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json=sample_response_json) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/llmopstenant_/api/pii-detection", + callback=capture, + ) + + request = PiiDetectionRequest( + documents=[ + PiiDocument(id="user-prompt", role="user", document="Hello") + ], + files=[ + PiiFile( + file_name="doc.pdf", + file_url="https://input.example.com/doc.pdf", + file_type="pdf", + ) + ], + language_code="en", + confidence_threshold=0.5, + entity_thresholds=[ + PiiEntityThreshold(category="Person", confidence_threshold=0.7), + ], + ) + await service.detect_pii_async(request) + + assert captured_request is not None + payload = json.loads(captured_request.content) + + # Top-level uses camelCase aliases + assert "documents" in payload + assert "files" in payload + assert "languageCode" in payload + assert "confidenceThreshold" in payload + assert "entityThresholds" in payload + + # File uses camelCase aliases + assert payload["files"][0]["fileName"] == "doc.pdf" + assert payload["files"][0]["fileUrl"] == "https://input.example.com/doc.pdf" + assert payload["files"][0]["fileType"] == "pdf" + + # Entity threshold uses kebab-case aliases + threshold = payload["entityThresholds"][0] + assert threshold["pii-entity-category"] == "Person" + assert threshold["pii-entity-confidence-threshold"] == 0.7 + + async def test_request_excludes_none_fields( + self, + httpx_mock: HTTPXMock, + service: PiiDetectionService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json=sample_response_json) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/llmopstenant_/api/pii-detection", + callback=capture, + ) + + # Only documents set; other optional fields should be omitted + request = PiiDetectionRequest( + documents=[PiiDocument(id="user-prompt", role="user", document="Hello")] + ) + await service.detect_pii_async(request) + + assert captured_request is not None + payload = json.loads(captured_request.content) + assert "files" not in payload + assert "languageCode" not in payload + assert "confidenceThreshold" not in payload + assert "entityThresholds" not in payload + + async def test_url_is_tenant_scoped( + self, + httpx_mock: HTTPXMock, + service: PiiDetectionService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json=sample_response_json) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/llmopstenant_/api/pii-detection", + callback=capture, + ) + + request = PiiDetectionRequest( + documents=[PiiDocument(id="user-prompt", role="user", document="Hello")] + ) + await service.detect_pii_async(request) + + assert captured_request is not None + assert org.strip("/") in captured_request.url.path + assert tenant.strip("/") in captured_request.url.path + assert "/llmopstenant_/api/pii-detection" in captured_request.url.path diff --git a/packages/uipath-platform/tests/services/test_pii_utilities.py b/packages/uipath-platform/tests/services/test_pii_utilities.py new file mode 100644 index 000000000..751897609 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_pii_utilities.py @@ -0,0 +1,179 @@ +"""Tests for PII rehydration utilities.""" + +from uipath.platform.pii_detection import ( + PiiDetectionResponse, + PiiDocumentResult, + PiiEntity, + PiiFileResult, + rehydrate_from_pii_entities, + rehydrate_from_pii_response, +) + + +def _entity( + pii_text: str, + replacement_text: str, + pii_type: str = "Person", + offset: int = 0, + confidence_score: float = 0.9, +) -> PiiEntity: + return PiiEntity( + pii_text=pii_text, + replacement_text=replacement_text, + pii_type=pii_type, + offset=offset, + confidence_score=confidence_score, + ) + + +class TestRehydrateFromPiiEntities: + """Test rehydrate_from_pii_entities.""" + + def test_empty_text_returns_empty(self) -> None: + assert rehydrate_from_pii_entities("", [_entity("Alice", "[Person-1]")]) == "" + + def test_no_entities_returns_text_unchanged(self) -> None: + text = "Hello [Person-1]" + assert rehydrate_from_pii_entities(text, []) == text + + def test_replaces_single_placeholder(self) -> None: + result = rehydrate_from_pii_entities( + "Hello [Person-1]", [_entity("Alice", "[Person-1]")] + ) + assert result == "Hello Alice" + + def test_replaces_multiple_placeholders(self) -> None: + result = rehydrate_from_pii_entities( + "Contact [Person-1] at [Email-1]", + [ + _entity("Alice", "[Person-1]"), + _entity("alice@example.com", "[Email-1]", pii_type="Email"), + ], + ) + assert result == "Contact Alice at alice@example.com" + + def test_longer_placeholders_replaced_first(self) -> None: + """[Person-10] must be rehydrated before [Person-1] to avoid partial match.""" + result = rehydrate_from_pii_entities( + "[Person-1] and [Person-10]", + [ + _entity("Alice", "[Person-1]"), + _entity("Zara", "[Person-10]"), + ], + ) + assert result == "Alice and Zara" + + def test_case_insensitive_placeholder_match(self) -> None: + result = rehydrate_from_pii_entities( + "Hello [person-1]", [_entity("Alice", "[Person-1]")] + ) + assert result == "Hello Alice" + + def test_replaces_bracketless_variant(self) -> None: + """The LLM may drop brackets; bracketless variant should still be replaced.""" + result = rehydrate_from_pii_entities( + "Hello Person-1", [_entity("Alice", "[Person-1]")] + ) + assert result == "Hello Alice" + + def test_skips_entities_with_empty_replacement_text(self) -> None: + result = rehydrate_from_pii_entities( + "Hello [Person-1]", + [ + _entity("Ignored", ""), + _entity("Alice", "[Person-1]"), + ], + ) + assert result == "Hello Alice" + + def test_skips_entities_with_empty_pii_text(self) -> None: + result = rehydrate_from_pii_entities( + "Hello [Person-1]", + [_entity("", "[Person-1]")], + ) + assert result == "Hello [Person-1]" + + def test_preserves_non_placeholder_content(self) -> None: + result = rehydrate_from_pii_entities( + "The meeting with [Person-1] is at 3pm in the boardroom.", + [_entity("Alice", "[Person-1]")], + ) + assert result == "The meeting with Alice is at 3pm in the boardroom." + + def test_pii_text_with_special_characters(self) -> None: + """Special chars in PII text must not break regex substitution.""" + result = rehydrate_from_pii_entities( + "Visit [URL-1]", + [_entity("https://example.com/path?q=1&x=2", "[URL-1]", pii_type="URL")], + ) + assert result == "Visit https://example.com/path?q=1&x=2" + + def test_regex_special_chars_in_replacement_text(self) -> None: + """Regex special chars in the placeholder must be escaped for the pattern.""" + result = rehydrate_from_pii_entities( + "Hello [Person.1]", + [_entity("Alice", "[Person.1]")], + ) + assert result == "Hello Alice" + + def test_pii_text_inserted_verbatim_not_json_escaped(self) -> None: + """PII values are plain text: quotes, newlines and backslashes are + inserted as-is, never JSON-escaped.""" + pii = 'Bob "The Boss"\nC:\\Users\\bob' + result = rehydrate_from_pii_entities( + "Name: [Person-1]", [_entity(pii, "[Person-1]")] + ) + assert result == f"Name: {pii}" + + +class TestRehydrateFromPiiResponse: + """Test rehydrate_from_pii_response.""" + + def test_merges_document_and_file_entities(self) -> None: + response = PiiDetectionResponse( + response=[ + PiiDocumentResult( + id="user-prompt", + role="user", + masked_document="Hi [Person-1]", + initial_document="Hi Alice", + pii_entities=[_entity("Alice", "[Person-1]")], + ) + ], + files=[ + PiiFileResult( + file_name="doc.pdf", + file_url="https://example.com/doc.pdf", + pii_entities=[ + _entity("bob@example.com", "[Email-1]", pii_type="Email") + ], + ) + ], + ) + + result = rehydrate_from_pii_response( + "From [Person-1]: contact [Email-1]", response + ) + assert result == "From Alice: contact bob@example.com" + + def test_file_only_entity_is_rehydrated(self) -> None: + """Entities detected in files (not prompts) must also rehydrate.""" + response = PiiDetectionResponse( + response=[], + files=[ + PiiFileResult( + file_name="doc.pdf", + file_url="https://example.com/doc.pdf", + pii_entities=[ + _entity("alice@example.com", "[Email-1]", pii_type="Email") + ], + ) + ], + ) + + result = rehydrate_from_pii_response("Email is [Email-1]", response) + assert result == "Email is alice@example.com" + + def test_empty_response_returns_text_unchanged(self) -> None: + response = PiiDetectionResponse(response=[], files=[]) + assert rehydrate_from_pii_response("No PII here", response) == "No PII here" diff --git a/packages/uipath-platform/tests/services/test_processes_service.py b/packages/uipath-platform/tests/services/test_processes_service.py index 85b2e3691..015888c54 100644 --- a/packages/uipath-platform/tests/services/test_processes_service.py +++ b/packages/uipath-platform/tests/services/test_processes_service.py @@ -79,6 +79,7 @@ def test_invoke( "startInfo": { "ReleaseName": process_name, "InputArguments": json.dumps(input_arguments), + "Source": "AgentService", } }, separators=(",", ":"), @@ -139,6 +140,7 @@ def test_invoke_without_input_arguments( "startInfo": { "ReleaseName": process_name, "InputArguments": "{}", + "Source": "AgentService", } }, separators=(",", ":"), @@ -300,6 +302,7 @@ async def test_invoke_async( "startInfo": { "ReleaseName": process_name, "InputArguments": json.dumps(input_arguments), + "Source": "AgentService", } }, separators=(",", ":"), @@ -361,6 +364,7 @@ async def test_invoke_async_without_input_arguments( "startInfo": { "ReleaseName": process_name, "InputArguments": "{}", + "Source": "AgentService", } }, separators=(",", ":"), @@ -471,3 +475,96 @@ async def test_invoke_async_over_10k_limit_input( job_request.headers[HEADER_USER_AGENT] == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ProcessesService.invoke_async/{version}" ) + + def test_invoke_with_run_as_me_true( + self, + httpx_mock: HTTPXMock, + service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + process_name = "test-process" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", + status_code=200, + json={ + "value": [ + { + "Key": "test-job-key", + "State": "Running", + "Id": 123, + "FolderKey": "test-folder-key", + } + ] + }, + ) + + service.invoke(process_name, run_as_me=True) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + payload = json.loads(sent_request.content.decode("utf-8")) + assert payload["startInfo"]["RunAsMe"] is True + + def test_invoke_with_run_as_me_false( + self, + httpx_mock: HTTPXMock, + service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + process_name = "test-process" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", + status_code=200, + json={ + "value": [ + { + "Key": "test-job-key", + "State": "Running", + "Id": 123, + "FolderKey": "test-folder-key", + } + ] + }, + ) + + service.invoke(process_name, run_as_me=False) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + payload = json.loads(sent_request.content.decode("utf-8")) + assert payload["startInfo"]["RunAsMe"] is False + + def test_invoke_without_run_as_me_excludes_from_payload( + self, + httpx_mock: HTTPXMock, + service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + process_name = "test-process" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", + status_code=200, + json={ + "value": [ + { + "Key": "test-job-key", + "State": "Running", + "Id": 123, + "FolderKey": "test-folder-key", + } + ] + }, + ) + + service.invoke(process_name) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + payload = json.loads(sent_request.content.decode("utf-8")) + assert "RunAsMe" not in payload["startInfo"] diff --git a/packages/uipath-platform/tests/services/test_queues_service.py b/packages/uipath-platform/tests/services/test_queues_service.py index 51cfeaa95..2dd81ccc0 100644 --- a/packages/uipath-platform/tests/services/test_queues_service.py +++ b/packages/uipath-platform/tests/services/test_queues_service.py @@ -1,4 +1,5 @@ import json +from datetime import datetime, timezone import pytest from pytest_httpx import HTTPXMock @@ -518,6 +519,39 @@ async def test_create_item_with_reference_async( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_item_async/{version}" ) + def test_create_item_with_datetime_fields( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + defer = datetime(2026, 5, 1, 9, 0, 0, tzinfo=timezone.utc) + due = datetime(2026, 5, 2, 17, 30, 0, tzinfo=timezone.utc) + risk = datetime(2026, 5, 2, 12, 0, 0, tzinfo=timezone.utc) + queue_item = QueueItem( + priority=QueueItemPriority.NORMAL, + specific_content={"key": "value"}, + defer_date=defer, + due_date=due, + risk_sla_date=risk, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem", + status_code=200, + json={"Id": 1}, + ) + + service.create_item(queue_item, queue_name="test-queue") + + sent_request = httpx_mock.get_request() + assert sent_request is not None + body = json.loads(sent_request.content.decode()) + assert body["itemData"]["DeferDate"] == defer.isoformat() + assert body["itemData"]["DueDate"] == due.isoformat() + assert body["itemData"]["RiskSlaDate"] == risk.isoformat() + def test_create_transaction_item( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/tests/services/test_remote_a2a_service.py b/packages/uipath-platform/tests/services/test_remote_a2a_service.py new file mode 100644 index 000000000..1f4239d4d --- /dev/null +++ b/packages/uipath-platform/tests/services/test_remote_a2a_service.py @@ -0,0 +1,55 @@ +import pytest + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.agenthub._remote_a2a_service import RemoteA2aService +from uipath.platform.common.constants import HEADER_FOLDER_KEY +from uipath.platform.orchestrator._folder_service import FolderService + + +@pytest.fixture +def folders_service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> FolderService: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "context-folder-key") + return FolderService(config=config, execution_context=execution_context) + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: FolderService, + monkeypatch: pytest.MonkeyPatch, +) -> RemoteA2aService: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "context-folder-key") + return RemoteA2aService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + + +class TestRetrieveSpecFolderResolution: + def test_falls_back_to_folder_context_when_folder_path_missing( + self, service: RemoteA2aService + ) -> None: + """No folder_path (e.g. local debug) must not raise; it falls back to context.""" + spec = service._retrieve_spec(slug="weather", folder_path=None) + + assert "remote-a2a-agents/weather" in str(spec.endpoint) + assert spec.headers[HEADER_FOLDER_KEY] == "context-folder-key" + + def test_resolves_explicit_folder_path( + self, service: RemoteA2aService, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + service._folders_service, + "retrieve_folder_key", + lambda folder_path: "resolved-folder-key", + ) + + spec = service._retrieve_spec(slug="weather", folder_path="MyFolder") + + assert spec.headers[HEADER_FOLDER_KEY] == "resolved-folder-key" diff --git a/packages/uipath-platform/tests/services/test_semantic_proxy_service.py b/packages/uipath-platform/tests/services/test_semantic_proxy_service.py new file mode 100644 index 000000000..51b4f4895 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_semantic_proxy_service.py @@ -0,0 +1,264 @@ +"""Tests for SemanticProxyService.""" + +import json +from typing import Any + +import httpx +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.semantic_proxy import ( + PiiDetectionRequest, + PiiDetectionResponse, + PiiDocument, + PiiEntityThreshold, + PiiFile, + SemanticProxyService, +) + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, +) -> SemanticProxyService: + return SemanticProxyService(config=config, execution_context=execution_context) + + +@pytest.fixture +def sample_response_json() -> dict[str, Any]: + return { + "response": [ + { + "id": "user-prompt", + "role": "user", + "maskedDocument": "Contact [Person-1]", + "initialDocument": "Contact Alison", + "piiEntities": [ + { + "piiText": "Alison", + "replacementText": "[Person-1]", + "piiType": "Person", + "offset": 8, + "confidenceScore": 0.99, + } + ], + } + ], + "files": [ + { + "fileName": "doc.pdf", + "fileUrl": "https://blob.example.com/redacted/doc.pdf", + "piiEntities": [ + { + "piiText": "alice@example.com", + "replacementText": "[Email-1]", + "piiType": "Email", + "offset": 100, + "confidenceScore": 0.88, + } + ], + } + ], + } + + +class TestSemanticProxyService: + """Test SemanticProxyService functionality.""" + + class TestDetectPii: + """Test detect_pii (sync).""" + + def test_returns_typed_response( + self, + httpx_mock: HTTPXMock, + service: SemanticProxyService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/semanticproxy_/api/pii-detection", + status_code=200, + json=sample_response_json, + ) + + request = PiiDetectionRequest( + documents=[ + PiiDocument( + id="user-prompt", role="user", document="Contact Alison" + ) + ] + ) + result = service.detect_pii(request) + + assert isinstance(result, PiiDetectionResponse) + assert len(result.response) == 1 + assert result.response[0].masked_document == "Contact [Person-1]" + assert len(result.files) == 1 + assert result.files[0].file_name == "doc.pdf" + assert result.files[0].pii_entities[0].replacement_text == "[Email-1]" + + class TestDetectPiiAsync: + """Test detect_pii_async.""" + + async def test_returns_typed_response( + self, + httpx_mock: HTTPXMock, + service: SemanticProxyService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/semanticproxy_/api/pii-detection", + status_code=200, + json=sample_response_json, + ) + + request = PiiDetectionRequest( + files=[ + PiiFile( + file_name="doc.pdf", + file_url="https://input.example.com/doc.pdf", + file_type="pdf", + ) + ] + ) + result = await service.detect_pii_async(request) + + assert isinstance(result, PiiDetectionResponse) + assert ( + result.files[0].file_url == "https://blob.example.com/redacted/doc.pdf" + ) + + async def test_request_payload_uses_aliases( + self, + httpx_mock: HTTPXMock, + service: SemanticProxyService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json=sample_response_json) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/semanticproxy_/api/pii-detection", + callback=capture, + ) + + request = PiiDetectionRequest( + documents=[ + PiiDocument(id="user-prompt", role="user", document="Hello") + ], + files=[ + PiiFile( + file_name="doc.pdf", + file_url="https://input.example.com/doc.pdf", + file_type="pdf", + ) + ], + language_code="en", + confidence_threshold=0.5, + entity_thresholds=[ + PiiEntityThreshold(category="Person", confidence_threshold=0.7), + ], + ) + await service.detect_pii_async(request) + + assert captured_request is not None + payload = json.loads(captured_request.content) + + # Top-level uses camelCase aliases + assert "documents" in payload + assert "files" in payload + assert "languageCode" in payload + assert "confidenceThreshold" in payload + assert "entityThresholds" in payload + + # File uses camelCase aliases + assert payload["files"][0]["fileName"] == "doc.pdf" + assert payload["files"][0]["fileUrl"] == "https://input.example.com/doc.pdf" + assert payload["files"][0]["fileType"] == "pdf" + + # Entity threshold uses kebab-case aliases + threshold = payload["entityThresholds"][0] + assert threshold["pii-entity-category"] == "Person" + assert threshold["pii-entity-confidence-threshold"] == 0.7 + + async def test_request_excludes_none_fields( + self, + httpx_mock: HTTPXMock, + service: SemanticProxyService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json=sample_response_json) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/semanticproxy_/api/pii-detection", + callback=capture, + ) + + # Only documents set; other optional fields should be omitted + request = PiiDetectionRequest( + documents=[PiiDocument(id="user-prompt", role="user", document="Hello")] + ) + await service.detect_pii_async(request) + + assert captured_request is not None + payload = json.loads(captured_request.content) + assert "files" not in payload + assert "languageCode" not in payload + assert "confidenceThreshold" not in payload + assert "entityThresholds" not in payload + + async def test_url_is_tenant_scoped( + self, + httpx_mock: HTTPXMock, + service: SemanticProxyService, + base_url: str, + org: str, + tenant: str, + sample_response_json: dict[str, Any], + ) -> None: + captured_request: httpx.Request | None = None + + def capture(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response(status_code=200, json=sample_response_json) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/semanticproxy_/api/pii-detection", + callback=capture, + ) + + request = PiiDetectionRequest( + documents=[PiiDocument(id="user-prompt", role="user", document="Hello")] + ) + await service.detect_pii_async(request) + + assert captured_request is not None + assert org.strip("/") in captured_request.url.path + assert tenant.strip("/") in captured_request.url.path + assert "/semanticproxy_/api/pii-detection" in captured_request.url.path diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index 80cd0d2db..35c2b78e7 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -8,6 +8,292 @@ from opentelemetry.trace import SpanContext, StatusCode from uipath.platform.common import UiPathSpan, _SpanUtils +from uipath.platform.common.constants import ( + ENV_PROJECT_KEY, + ENV_UIPATH_AGENT_ID, + ENV_UIPATH_PROJECT_ID, +) + + +@pytest.fixture(autouse=True) +def _clear_id_cache(): + """Isolate the process-global id cache between tests.""" + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + yield + _read_config_id.cache_clear() + + +class TestOTelToUiPathSpan: + """OTEL attribute -> top-level UiPathSpan field mapping. + + `_SpanUtils.otel_span_to_uipath_span` lifts a small set of OTEL + span attributes onto dedicated `UiPathSpan` fields surfaced under + `to_dict()`. This test documents that mapping — adding a new row + means the attribute is newly mapped, removing one breaks + downstream consumers. + """ + + ATTRIBUTE_FIELD_MAP = [ + ("executionType", "execution_type", "ExecutionType", 1), + ("agentVersion", "agent_version", "AgentVersion", "1.2.3"), + ("agentId", "reference_id", "ReferenceId", "ref-abc"), + ("verbosityLevel", "verbosity_level", "VerbosityLevel", 6), + ] + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_attributes_map_to_top_level_fields(self) -> None: + attrs = { + otel_attr: value for otel_attr, _, _, value in self.ATTRIBUTE_FIELD_MAP + } + + mock_span = Mock(spec=OTelSpan) + mock_context = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = attrs + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + span_dict = uipath_span.to_dict() + + for _, span_field, top_level_key, value in self.ATTRIBUTE_FIELD_MAP: + assert getattr(uipath_span, span_field) == value, span_field + assert span_dict[top_level_key] == value, top_level_key + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_verbosity_level_omitted_when_unset(self) -> None: + """Spans that don't set verbosityLevel must not carry the key on the wire. + + Backwards compat: pre-existing spans never emitted VerbosityLevel; the + LLMOps backend applies its own default. Adding `"VerbosityLevel": null` + unconditionally would change the wire format for every existing span. + """ + mock_span = Mock(spec=OTelSpan) + mock_context = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "legacy-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = {"someOtherAttr": "value"} + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + span_dict = uipath_span.to_dict() + + assert uipath_span.verbosity_level is None + assert "VerbosityLevel" not in span_dict + + +class TestReferenceIdResolution: + """`reference_id` resolution chain. + + `reference_id` is derived from the span's resolved `agentId` attribute + (which itself goes through `resolve_project_id()`), falling back to the + `referenceId` attribute. Falsy values (missing / empty string) at each step + fall through to the next source. The `referenceId` fallback exists for + backwards compatibility with older producers that only emit that attribute. + """ + + @pytest.mark.parametrize( + ("env_value", "attributes", "expected"), + [ + pytest.param( + "env-agent", + {"agentId": "attr-agent", "referenceId": "attr-ref"}, + "env-agent", + id="env-var-overrides-attr", + ), + pytest.param( + None, + {"agentId": "attr-agent", "referenceId": "attr-ref"}, + "attr-agent", + id="agent-id-attr-when-env-unset", + ), + pytest.param( + None, + {"referenceId": "attr-ref"}, + "attr-ref", + id="reference-id-fallback-when-agent-id-missing", + ), + pytest.param( + None, + {"agentId": "", "referenceId": "attr-ref"}, + "attr-ref", + id="reference-id-fallback-when-agent-id-empty", + ), + pytest.param( + None, + {}, + None, + id="none-when-all-sources-missing", + ), + ], + ) + def test_reference_id_chain( + self, + env_value: str | None, + attributes: dict[str, object], + expected: str | None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + monkeypatch.delenv(ENV_UIPATH_AGENT_ID, raising=False) + monkeypatch.delenv(ENV_UIPATH_PROJECT_ID, raising=False) + if env_value is None: + monkeypatch.delenv(ENV_PROJECT_KEY, raising=False) + else: + monkeypatch.setenv(ENV_PROJECT_KEY, env_value) + + mock_span = Mock(spec=OTelSpan) + mock_context = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = attributes + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + assert uipath_span.reference_id == expected + + +class TestAgentIdResolution: + """`agentId` span attribute resolution via `resolve_project_id()`. + + Priority: `uipath.json#id` (cached, read once per process) > `UIPATH_AGENT_ID` + / `UIPATH_PROJECT_ID` > the legacy `PROJECT_KEY` env var injected by the + executor at runtime. When no source is present the `agentId` attribute is + omitted entirely. + """ + + @staticmethod + def _make_span() -> Mock: + mock_span = Mock(spec=OTelSpan) + mock_context = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = {} + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + return mock_span + + @staticmethod + def _resolve(monkeypatch: pytest.MonkeyPatch, tmp_path) -> object: + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + monkeypatch.delenv("UIPATH_CONFIG_PATH", raising=False) + monkeypatch.delenv(ENV_UIPATH_AGENT_ID, raising=False) + monkeypatch.delenv(ENV_UIPATH_PROJECT_ID, raising=False) + monkeypatch.chdir(tmp_path) + uipath_span = _SpanUtils.otel_span_to_uipath_span( + TestAgentIdResolution._make_span(), serialize_attributes=False + ) + attributes = uipath_span.attributes + assert isinstance(attributes, dict) + return attributes.get("agentId") + + def test_agent_id_from_uipath_json_wins_over_env( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + (tmp_path / "uipath.json").write_text( + json.dumps({"id": "00000000-0000-0000-0000-000000000001"}) + ) + monkeypatch.setenv(ENV_PROJECT_KEY, "from-env") + assert ( + self._resolve(monkeypatch, tmp_path) + == "00000000-0000-0000-0000-000000000001" + ) + + def test_agent_id_falls_back_to_project_key( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + # No uipath.json on disk. + monkeypatch.setenv(ENV_PROJECT_KEY, "from-env") + assert self._resolve(monkeypatch, tmp_path) == "from-env" + + def test_agent_id_falls_back_when_config_has_no_id( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + (tmp_path / "uipath.json").write_text(json.dumps({"functions": {}})) + monkeypatch.setenv(ENV_PROJECT_KEY, "from-env") + assert self._resolve(monkeypatch, tmp_path) == "from-env" + + def test_agent_id_absent_when_no_source( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + monkeypatch.delenv(ENV_PROJECT_KEY, raising=False) + assert self._resolve(monkeypatch, tmp_path) is None + + def test_non_guid_config_id_is_ignored_and_falls_back( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + # A malformed (non-GUID) id must not reach ReferenceId; fall back to env. + (tmp_path / "uipath.json").write_text(json.dumps({"id": "not-a-guid"})) + monkeypatch.setenv(ENV_PROJECT_KEY, "from-env") + assert self._resolve(monkeypatch, tmp_path) == "from-env" + + def test_config_id_is_cached( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + from uipath.platform.common._span_utils import _read_config_id + + first = "00000000-0000-0000-0000-000000000001" + second = "00000000-0000-0000-0000-000000000002" + + _read_config_id.cache_clear() + monkeypatch.delenv("UIPATH_CONFIG_PATH", raising=False) + monkeypatch.chdir(tmp_path) + config = tmp_path / "uipath.json" + + config.write_text(json.dumps({"id": first})) + assert _read_config_id() == first + + # A later edit is not observed: the value is read once and cached. + config.write_text(json.dumps({"id": second})) + assert _read_config_id() == first + + _read_config_id.cache_clear() + assert _read_config_id() == second class TestNormalizeIds: @@ -363,8 +649,8 @@ def test_uipath_span_missing_execution_type_and_agent_version(self): assert span_dict["AgentVersion"] is None @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) - def test_uipath_span_source_defaults_to_robots(self): - """Test that Source defaults to 4 (Robots) and ignores attributes.source.""" + def test_uipath_span_source_defaults_to_coded_agents(self): + """Test that Source defaults to 10 (CodedAgents) and ignores attributes.source.""" mock_span = Mock(spec=OTelSpan) trace_id = 0x123456789ABCDEF0123456789ABCDEF0 @@ -387,9 +673,9 @@ def test_uipath_span_source_defaults_to_robots(self): uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) span_dict = uipath_span.to_dict() - # Top-level Source should be 4 (Robots), string "runtime" is ignored - assert uipath_span.source == 4 - assert span_dict["Source"] == 4 + # Top-level Source should be 10 (CodedAgents), string "runtime" is ignored + assert uipath_span.source == 10 + assert span_dict["Source"] == 10 # attributes.source string should still be in Attributes JSON attrs = json.loads(span_dict["Attributes"]) @@ -408,7 +694,7 @@ def test_uipath_span_source_override_with_uipath_source(self): mock_span.name = "test-span" mock_span.parent = None mock_span.status.status_code = StatusCode.OK - # uipath.source=1 (Agents) overrides default of 4 (Robots) + # uipath.source=1 (Agents) overrides default of 10 (CodedAgents) mock_span.attributes = {"uipath.source": 1, "source": "runtime"} mock_span.events = [] mock_span.links = [] diff --git a/packages/uipath-platform/tests/services/test_uipath_llm_integration.py b/packages/uipath-platform/tests/services/test_uipath_llm_integration.py index 124ccad8b..9e2292c60 100644 --- a/packages/uipath-platform/tests/services/test_uipath_llm_integration.py +++ b/packages/uipath-platform/tests/services/test_uipath_llm_integration.py @@ -7,6 +7,7 @@ from uipath.platform.chat import ( AutoToolChoice, ChatModels, + RequiredToolChoice, SpecificToolChoice, ToolDefinition, ToolFunctionDefinition, @@ -369,6 +370,87 @@ async def test_tool_call_required_mocked(self, mock_request, llm_service): assert result.choices[0].message.tool_calls[0].arguments["name"] == "John" assert result.choices[0].message.tool_calls[0].arguments["password"] == "1234" + @pytest.mark.asyncio + @patch.object(UiPathLlmChatService, "request_async") + async def test_raw_dict_tool_passthrough_mocked(self, mock_request, llm_service): + """A tool supplied as a raw dict is sent unchanged, preserving nested schema. + + ToolDefinition's converter only emits flat properties, so callers that need + an arbitrary nested JSON schema (e.g. the eval mockers) pass the tool as a + dict already in UiPath wire format. It must reach the gateway verbatim. + """ + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "chatcmpl-raw", + "object": "chat.completion", + "created": 1677858242, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_raw", + "name": "submit_tool_response", + "arguments": {"response": {"items": [{"sku": "A1"}]}}, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + "cache_read_input_tokens": None, + }, + } + mock_request.return_value = mock_response + + nested_tool = { + "name": "submit_tool_response", + "description": "Return the simulated response matching the schema.", + "parameters": { + "type": "object", + "properties": { + "response": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": {"sku": {"type": "string"}}, + }, + } + }, + } + }, + "required": ["response"], + }, + } + + result = await llm_service.chat_completions( + messages=[{"role": "user", "content": "go"}], + model=ChatModels.gpt_4_1_mini_2025_04_14, + tools=[nested_tool], + tool_choice=RequiredToolChoice(), + ) + + mock_request.assert_called_once() + _, kwargs = mock_request.call_args + body = kwargs["json"] + # The dict tool is forwarded byte-for-byte, nested array schema intact. + assert body["tools"] == [nested_tool] + assert body["tool_choice"] == {"type": "required"} + assert result.choices[0].message.tool_calls[0].arguments == { + "response": {"items": [{"sku": "A1"}]} + } + @pytest.mark.asyncio @patch.object(UiPathLlmChatService, "request_async") async def test_chat_with_conversation_history_mocked( diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 0ea4a2f63..2c3e5d025 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -2,6 +2,13 @@ version = 1 revision = 3 requires-python = ">=3.11" +[options] +exclude-newer = "2026-06-22T13:55:56.0776194Z" +exclude-newer-span = "P2D" + +[options.exclude-newer-package] +uipath-core = false + [[package]] name = "annotated-types" version = "0.7.0" @@ -1056,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.10" +version = "0.5.22" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -1088,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.18" +version = "0.1.76" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/docs/FAQ.md b/packages/uipath/docs/FAQ.md index c1cd1aec2..602a42196 100644 --- a/packages/uipath/docs/FAQ.md +++ b/packages/uipath/docs/FAQ.md @@ -1,6 +1,6 @@ # Frequently Asked Questions (FAQ) -### Q: Why am I getting a "Failed to prepare environment" error when deploying my python agent to UiPath Cloud Platform? +### Q: Why am I getting a "Failed to prepare environment" error when deploying my Python project to UiPath Cloud Platform? #### Error Message @@ -30,7 +30,7 @@ #### Description -This error might occur when deploying coded-agents to UiPath Cloud Platform, even though the same project might work correctly in your local environment. The issue is often related to how Python packages are discovered and distributed during the cloud deployment process. +This error might occur when deploying coded functions or coded agents to UiPath Cloud Platform, even though the same project might work correctly in your local environment. The issue is often related to how Python packages are discovered and distributed during the cloud deployment process. #### Common Causes @@ -283,7 +283,7 @@ If you encounter SSL certificate errors: //// -### Q: Why are my agent runs hanging on UiPath Cloud Platform? +### Q: Why are my job runs hanging on UiPath Cloud Platform? #### Error Message @@ -298,7 +298,7 @@ You may see errors like these in the logs panel: #### Description -If your Python agent runs are hanging or not completing when deployed to UiPath Cloud Platform's serverless environment, this may be caused by a library incompatibility issue from an outdated version of the UiPath Python library. +If your Python job runs are hanging or not completing when deployed to UiPath Cloud Platform's serverless environment, this may be caused by a library incompatibility issue from an outdated version of the UiPath Python library. #### Solution diff --git a/packages/uipath/docs/assets/llms.txt b/packages/uipath/docs/assets/llms.txt deleted file mode 100644 index ac9907c58..000000000 --- a/packages/uipath/docs/assets/llms.txt +++ /dev/null @@ -1,58 +0,0 @@ -# UiPath Python SDK Documentation -> https://uipath.github.io/uipath-python/ - -A Python SDK for programmatic interaction with UiPath Cloud Platform services, featuring CLI tools for automation creation, packaging, and deployment. Includes support for LangChain, LlamaIndex, and Model Context Protocol (MCP) agent frameworks. - -## Core Documentation - -### Getting Started -- https://uipath.github.io/uipath-python/ - Main landing page -- https://uipath.github.io/uipath-python/core/getting_started - SDK quickstart guide -- https://uipath.github.io/uipath-python/FAQ - Frequently Asked Questions -- https://uipath.github.io/uipath-python/CONTRIBUTING - Contribution guidelines -- https://uipath.github.io/uipath-python/release_policy - Release policy - -### SDK Features -- https://uipath.github.io/uipath-python/core/processes - Process automation -- https://uipath.github.io/uipath-python/core/jobs - Job management -- https://uipath.github.io/uipath-python/core/assets - Asset storage and retrieval -- https://uipath.github.io/uipath-python/core/queues - Queue operations -- https://uipath.github.io/uipath-python/core/resource_catalog - Resources search -- https://uipath.github.io/uipath-python/core/buckets - Cloud storage buckets -- https://uipath.github.io/uipath-python/core/attachments - File attachments -- https://uipath.github.io/uipath-python/core/actions - Action Center integration -- https://uipath.github.io/uipath-python/core/entities - Data Service integration -- https://uipath.github.io/uipath-python/core/connections - External connections -- https://uipath.github.io/uipath-python/core/documents - Document handling -- https://uipath.github.io/uipath-python/core/documents_models - Document data models -- https://uipath.github.io/uipath-python/core/environment_variables - Environment configuration -- https://uipath.github.io/uipath-python/core/guardrails - Guardrails validation -- https://uipath.github.io/uipath-python/core/traced - Tracing and observability - -### LLM & AI Features -- https://uipath.github.io/uipath-python/core/llm_gateway - LLM Gateway for model access -- https://uipath.github.io/uipath-python/core/context_grounding - RAG and semantic search - -### Agent Frameworks -- https://uipath.github.io/uipath-python/mcp/quick_start - Model Context Protocol (MCP) SDK -- https://uipath.github.io/uipath-python/langchain/quick_start - LangChain integration -- https://uipath.github.io/uipath-python/llamaindex/quick_start - LlamaIndex integration - -### CLI Tools -- https://uipath.github.io/uipath-python/cli/ - Command-line interface reference - -## Supported LLM Models - -The following LLM models are referenced in examples and evaluations throughout the repository: - -### OpenAI Models -- gpt-4o-mini-2024-07-18 - Used in samples and evaluations -- gpt-4o-2024-08-06 - Primary model for agent examples -- gpt-4 - General purpose examples -- gpt-4.1-2025-04-14 - LLM-as-judge evaluator - -### Google Models -- gemini-1.5-flash - Used in Google ADK agent sample - -### Embedding Models -- text-embedding-3-large - Azure OpenAI embeddings for RAG diff --git a/packages/uipath/docs/cli/index.md b/packages/uipath/docs/cli/index.md index deb875353..e74a4d83e 100644 --- a/packages/uipath/docs/cli/index.md +++ b/packages/uipath/docs/cli/index.md @@ -1,5 +1,7 @@ # CLI Reference +The following commands apply to both **coded functions** and **coded agents**. The entry point name (`main`, `agent`, or any key you define in `uipath.json`) is the first argument to `run`, `debug`, `eval`, and `invoke`. + ::: mkdocs-click :module: uipath._cli :command: auth @@ -32,6 +34,37 @@ Select tenant number: 0 Selected tenant: Tenant1 ✓ Authentication successful. ``` + +/// info | Unattended Authentication (Client Credentials) + +For CI/CD pipelines and other non-interactive contexts, authenticate with the OAuth client credentials flow by passing all three of `--client-id`, `--client-secret`, and `--base-url`. The CLI exchanges them for an access token and writes it to the same on-disk session used by interactive logins, so subsequent commands like `uipath publish` and `uipath invoke` work without further setup. + +The `--base-url` must point at the tenant scope (`https:////`). The optional `--scope` flag controls the OAuth scopes requested and defaults to `OR.Execution`. Pass a space-separated list (for example `"OR.Execution OR.Queues"`) to request additional scopes — match the scopes you granted to the External Application and the operations you intend to run. + +**Setup:** + +1. In the Automation Cloud **Admin** page, open **External Applications** and create one of type *Confidential*. Grant it the Orchestrator scopes you need (for example `OR.Execution`). See the [External Applications guide](https://docs.uipath.com/automation-cloud/automation-cloud/latest/admin-guide/managing-external-applications) for details. +2. Copy the generated **App ID** and **App Secret** — these become `--client-id` and `--client-secret`. + +**Example:** + + +```shell +> uipath auth --client-id 12345678-c4c5-4f1f-93ff-4f5ab47d57ea \ + --client-secret 'your-secret' \ + --base-url https://cloud.uipath.com/your-org/your-tenant +✓ Authentication successful. +> uipath publish --tenant +``` + +/// warning +Treat `--client-secret` as a credential. In CI, prefer reading it from a secret store and passing it on the command line, rather than committing it to source control or leaving it in shell history. +/// + +**Configuring the same flow in code:** if you would rather skip the CLI session and pass credentials directly to the SDK, the [`asset-modifier-agent` sample](https://github.com/UiPath/uipath-python/tree/main/packages/uipath/samples/asset-modifier-agent) shows how to construct a `UiPath` client with `client_id`, `client_secret`, `scope`, and `base_url` from environment variables. + +/// + --- ::: mkdocs-click @@ -82,6 +115,20 @@ Running `uipath init` will process these function definitions and create the cor ✓ Created '.uipath/studio_metadata.json' file. ✓ Created: CLAUDE.md, CLI_REFERENCE.md, SDK_REFERENCE.md, AGENTS.md, REQUIRED_STRUCTURE.md. ``` + +/// info +### About the `.mermaid` files + +`uipath init` generates one `.mermaid` file per function/agent containing a static call graph, rendered in the UiPath Orchestrator UI. These files are regenerated on every `uipath init`. +/// + +/// warning +### About the `id` field + +The first `uipath init` mints a stable `id` (GUID) into `uipath.json` and preserves it across subsequent runs. It is what identifies your project consistently wherever it is deployed and run. + +Do not change or remove it. Changing it makes the project look like a brand-new, unrelated one, so you lose the link to everything previously published and tracked under the old id. `uipath pack` rejects an `id` that is not a valid GUID. +/// --- ::: mkdocs-click @@ -95,7 +142,7 @@ For step-by-step debugging with breakpoints and variable inspection (supported f ```console # Install debugpy package uv pip install debugpy -# Run agent with debugging enabled +# Run with debugging enabled uipath run [ENTRYPOINT] [INPUT] --debug ``` For vscode: @@ -117,19 +164,19 @@ Depending on the shell you are using, it may be necessary to escape the input js /// tab | Bash/ZSH ```console -uipath run agent '{"topic": "UiPath"}' +uipath run main '{"message": "hello"}' ``` /// /// tab | Windows CMD ```console -uipath run agent "{""topic"": ""UiPath""}" +uipath run main "{""message"": ""hello""}" ``` /// /// tab | Windows PowerShell ```console -uipath run agent '{\"topic\":\"uipath\"}' +uipath run main '{\"message\":\"hello\"}' ``` /// @@ -161,6 +208,7 @@ By default, the following file types are included in the `.nupkg` file: - `.json` - `.yaml` - `.yml` +- `.md` --- @@ -175,7 +223,7 @@ To include additional files, update the `uipath.json` file by adding a `packOpti "" ], "fileExtensionsIncluded": [ - "" + "" ] } } @@ -197,6 +245,25 @@ authors = [{name = "Your Name", email = "your.email@example.com"}] ``` /// +/// info +### Dependency Locking + +By default, `uipath pack` includes `uv.lock` in the `.nupkg` (creating it if it does not exist). The executor then installs the pinned versions from the lock file, so every run uses the exact same dependency versions. + +Use `--nolock` to opt out — `uv.lock` is not added to the package. With no lock file present, the executor resolves dependencies on each run and picks the latest versions compatible with the constraints in your `pyproject.toml`. + + +```shell +> uipath pack --nolock +⠋ Packaging project ... +✓ Project successfully packaged. +``` + +**When to lock (default):** you want reproducible runs and protection against breaking changes or malicious upgrades in your dependencies. The versions you tested with are the versions that run. + +**When to use `--nolock`:** you want each run to pick up the latest patches automatically within your declared constraints, or your project does not use uv. +/// + ```shell > uipath pack @@ -255,7 +322,7 @@ Selected feed: Orchestrator Tenant Processes Feed ```shell -> uipath invoke agent '{"topic": "UiPath"}' +> uipath invoke main '{"message": "hello"}' ⠴ Loading configuration ... ⠴ Starting job ... ✨ Job started successfully! @@ -283,6 +350,19 @@ Importing referenced resources to Studio Web project... 🔵 Resource import summary: 0 total resources - 0 created, 0 updated, 0 unchanged, 0 not found ``` + +/// info +### Dependency Locking + +By default, `uipath push` includes `uv.lock` in the upload (creating it if it does not exist). The executor then installs the pinned versions from the lock file, so every run uses the exact same dependency versions. + +Use `--nolock` to opt out — `uv.lock` is not uploaded. With no lock file present, the executor resolves dependencies on each run and picks the latest versions compatible with the constraints in your `pyproject.toml`. + +**When to lock (default):** you want reproducible runs and protection against breaking changes or malicious upgrades in your dependencies. The versions you tested with are the versions that run. + +**When to use `--nolock`:** you want each run to pick up the latest patches automatically within your declared constraints, or your project does not use uv. +/// + --- ::: mkdocs-click @@ -302,3 +382,92 @@ Processing: uipath.json File 'uipath.json' is up to date ✓ Project pulled successfully ``` +--- + +::: mkdocs-click + :module: uipath._cli + :command: debug + :depth: 1 + :style: table + +Runs your project under the debug runtime, with a debug bridge attached. Locally, the bridge is the interactive **console** (read commands from stdin, stop at breakpoints). In the cloud, the bridge is **SignalR** (driven by Studio Web / Orchestrator). The `--attach` flag lets you override that default, including `none` for executors that need the debug command's surrounding behavior (bindings fetch, state streaming) but cannot speak the interactive debug protocol. + +### Attach modes + +| Mode | When to use | +|------|-------------| +| `signalr` | Remote runs driven by Studio Web / Orchestrator. Default when `job_id` is set. | +| `console` | Local interactive debugging from the terminal. Default when no `job_id`. | +| `none` | Run under the debug command without attaching a debugger. No wait-for-start gate, no breakpoints, no step mode. | + +/// info +`--attach` selects the **debug bridge**. It's unrelated to `--debug`, which starts a `debugpy` server for Python-level breakpoints in your IDE. The two can be combined. +/// + + + +```shell +> uipath debug main '{"message": "test"}' +Debug Mode Commands + c, continue Continue until next breakpoint + s, step Step to next node + b Set breakpoint at + l, list List all breakpoints + r Remove breakpoint at + h, help Show help + q, quit Exit debugger +▶ START +> b analyze_sentiment +✓ Breakpoint set at: analyze_sentiment +> c +──────────────────────────────────────── +■ BREAKPOINT analyze_sentiment (before) +Next: analyze_sentiment +──────────────────────────────────────── +> s +● analyze_sentiment +> c +✓ Execution completed +``` +--- + +::: mkdocs-click + :module: uipath._cli + :command: eval + :depth: 1 + :style: table + +Runs an evaluation set against your project. Entry point and eval set are auto-discovered from the project if not passed explicitly. Evaluations run in parallel (see `--workers`) and, unless `--no-report` is passed, results are reported back to Studio Web when `UIPATH_PROJECT_ID` is set. + +### Common flags + +| Flag | Purpose | +|------|---------| +| `--eval-ids` | Run only a subset of evaluations by id. | +| `--workers` | Parallel workers for running evaluations (default 1). | +| `--no-report` | Skip reporting results back to UiPath. | +| `--enable-mocker-cache` | Cache LLM mocker responses across runs. | +| `--input-overrides` | Per-eval input overrides, merged into the eval's input. | +| `--trace-file` | Write OpenTelemetry traces to a JSONL file for offline inspection. | +| `--resume` | Resume evaluation from a previous suspended state. | + + + +```shell +> uipath eval +⠋ Running evaluations ... + Weather in Paris + LLM Judge Output 0.7 + Tool Call Arguments 1.0 + Tool Call Count 1.0 + Tool Call Order 1.0 + +Evaluation Results +┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓ +┃ Evaluation ┃ LLM Judge Output ┃ Tool Call Args ┃ Tool Call Count ┃ Tool Call Order ┃ +┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩ +│ Weather in Paris │ 0.7 │ 1.0 │ 1.0 │ 1.0 │ +├────────────────────┼────────────────────┼────────────────────┼────────────────────┼────────────────────┤ +│ Average │ 0.7 │ 1.0 │ 1.0 │ 1.0 │ +└────────────────────┴────────────────────┴────────────────────┴────────────────────┴────────────────────┘ +``` diff --git a/packages/uipath/docs/core/agents.md b/packages/uipath/docs/core/agents.md new file mode 100644 index 000000000..21b812647 --- /dev/null +++ b/packages/uipath/docs/core/agents.md @@ -0,0 +1,244 @@ +# Python Coded Agents + +A coded agent is Python code that uses an LLM reasoning loop to make decisions, call tools, and produce a result. You write the agent logic using a framework of your choice — the `uipath` SDK provides the platform layer: authentication, assets, buckets, connections, tracing, and human-in-the-loop. Package it with the CLI and deploy it as an Orchestrator job. + +Use a coded agent when your automation needs multi-step reasoning, dynamic tool selection, or LLM-driven decisions. Use a [coded function](./functions.md) when your logic is deterministic and no LLM is required. + +--- + +## Architecture + +Every coded agent is built from two layers: + +| Layer | Package | Responsibility | +|-------|---------|---------------| +| **Platform** | `uipath` | Auth, assets, buckets, connections, tracing, human-in-the-loop, CLI, packaging | +| **Framework** | one extension (see below) | LLM calls, tool routing, agent loop, memory | + +The `uipath` package is always required. Add one framework extension on top: + +| Framework | Package | Best for | +|-----------|---------|---------| +| LangChain / LangGraph | `uipath-langchain` | Graph-based agents, complex multi-step flows | +| LlamaIndex | `uipath-llamaindex` | RAG-heavy agents, document reasoning | +| OpenAI Agents SDK | `uipath-openai-agents` | OpenAI-native tool use, handoffs | +| PydanticAI | `uipath-pydantic-ai` | Type-safe agents with Pydantic models | +| Google ADK | `uipath-google-adk` | Gemini models, Google ecosystem | +| UiPath Agent Framework | `uipath-agent-framework` | UiPath-native agent primitives | + +--- + +## Quickstart + +The example below uses LangChain. Swap `uipath-langchain` for the framework of your choice. + +//// tab | uv + + + +```shell +> mkdir my-agent && cd my-agent +> uv init . --python 3.11 +> uv add uipath uipath-langchain + +> uipath auth +⠋ Authenticating with UiPath ... +✓ Authentication successful. + +> uipath new agent +✓ Created new agent project. + +> uipath init +⠋ Initializing UiPath project ... +✓ Created 'entry-points.json' file. +✓ Created 'bindings.json' file. + +> uipath run agent '{"message": "hello"}' +``` + +//// + +//// tab | pip + + + +```shell +> mkdir my-agent && cd my-agent +> python -m venv .venv +> source .venv/bin/activate +> pip install uipath uipath-langchain + +> uipath auth +⠋ Authenticating with UiPath ... +✓ Authentication successful. + +> uipath new agent +✓ Created new agent project. + +> uipath init +⠋ Initializing UiPath project ... +✓ Created 'entry-points.json' file. +✓ Created 'bindings.json' file. + +> uipath run agent '{"message": "hello"}' +``` + +//// + +--- + +## Project Structure + +``` +my-agent/ +├── main.py # agent graph +├── langgraph.json # graph entry points (framework-specific) +├── pyproject.toml # project metadata and dependencies +├── entry-points.json # generated — I/O JSON Schema +└── bindings.json # generated — resource binding overrides +``` + +### `langgraph.json` + +```json +{ + "graphs": { + "agent": "./main.py:graph" + } +} +``` + +Declares the agent's graph entry points. The filename is framework-specific — `langgraph.json` for LangChain/LangGraph, `llamaindex.json` for LlamaIndex, and so on. Its presence, together with the framework dependency below, is what marks the project as a coded agent. + +### `pyproject.toml` + +```toml +[project] +name = "my-agent" +version = "0.1.0" +description = "..." +authors = [{ name = "Your Name", email = "you@example.com" }] +requires-python = ">=3.11" +dependencies = ["uipath>=2.0", "uipath-langchain>=2.0"] +``` + +Standard metadata plus the framework dependency (`uipath-langchain` here). The framework graph file and this dependency identify the project as a coded agent — `pyproject.toml` needs no UiPath-specific entries, and `uipath.json` carries no agent entry. + +--- + +## Input & Output + +Define `Input` and `Output` the same way as [coded functions](./functions.md#input--output) — a stdlib `@dataclass`, a pydantic `BaseModel`, or `pydantic.dataclasses.dataclass`: + +```python +from dataclasses import dataclass + + +@dataclass +class Input: + message: str + + +@dataclass +class Output: + response: str + + +def agent(input: Input) -> Output: + ... +``` + +--- + +## Platform Services + +The `uipath` SDK gives your agent access to Orchestrator resources at runtime — credentials are injected automatically when running as a job. + +```python +from uipath.platform import UiPath + +sdk = UiPath() +``` + +The full set of Orchestrator services is available to agents: + +- **Assets** — read credentials and configuration: [Assets reference](./assets.md) +- **Buckets** — download and upload files: [Buckets reference](./buckets.md) +- **Connections** — Integration Service connections for ERP and SaaS: [Connections reference](./connections.md) +- **Context Grounding** — semantic search over enterprise data: [Context Grounding reference](./context_grounding.md) + +--- + +## Tracing + +Apply `@traced` to custom steps inside your agent to make them visible in the Orchestrator job trace view and Maestro dashboards. Do **not** trace the entry point — the runtime wraps it automatically. + +```python +from uipath.tracing import traced + + +@traced(name="lookup_vendor", run_type="uipath") +def lookup_vendor(vendor_id: str) -> dict: + ... +``` + +See [Tracing](./traced.md) for the full decorator reference. + +--- + +## Framework Guides + +Each framework extension has its own getting started guide and sample agents: + +| Framework | Guide | Samples | +|-----------|-------|---------| +| LangChain / LangGraph | [Get Started](../langchain/quick_start.md) | [Samples](https://github.com/UiPath/uipath-langchain-python/tree/main/samples) | +| LlamaIndex | [Get Started](../llamaindex/quick_start.md) | [Samples](https://github.com/UiPath/uipath-integrations-python/tree/main/packages/uipath-llamaindex/samples) | +| OpenAI Agents SDK | [Get Started](../openai-agents/quick_start.md) | [Samples](https://github.com/UiPath/uipath-integrations-python/tree/main/packages/uipath-openai-agents/samples) | +| PydanticAI | [README](https://github.com/UiPath/uipath-integrations-python/blob/main/packages/uipath-pydantic-ai/README.md) | [Samples](https://github.com/UiPath/uipath-integrations-python/tree/main/packages/uipath-pydantic-ai/samples) | +| Google ADK | [README](https://github.com/UiPath/uipath-integrations-python/blob/main/packages/uipath-google-adk/README.md) | [Samples](https://github.com/UiPath/uipath-integrations-python/tree/main/packages/uipath-google-adk/samples) | +| UiPath Agent Framework | [README](https://github.com/UiPath/uipath-integrations-python/blob/main/packages/uipath-agent-framework/README.md) | [Samples](https://github.com/UiPath/uipath-integrations-python/tree/main/packages/uipath-agent-framework/samples) | + + +--- + +## Pack & Publish + +The same CLI workflow applies as for coded functions: + + + +```shell +> uipath pack +⠋ Packaging project ... +Name : my-agent +Version : 0.1.0 +Description: Add your description here +Authors : Your Name +✓ Project successfully packaged. + +> uipath publish +⠋ Fetching available package feeds... +Select feed number: 0 +✓ Package published successfully! +``` + +After publishing, the agent registers as an Orchestrator Process and can be invoked from Maestro, the Orchestrator API, or the CLI. + +See [CLI Reference](../cli/index.md) for full `pack`, `publish`, and `invoke` options. + +--- + +## Studio Web Integration + +Connect your agent to a Studio Web solution for cloud debugging, evaluation, and solution packaging. + +See [Studio Web Integration](./studio_web.md) for setup and sync details. + +--- + +## Evaluations + +Coded agents support evaluations in Studio Web and locally via `uipath eval`. Evaluators cover LLM output quality, tool call correctness, and trajectory analysis. + +See the [Evaluations documentation](../eval/index.md) for available evaluators and how to define evaluation sets. diff --git a/packages/uipath/docs/core/assets/maestro_execution_trace_light.png b/packages/uipath/docs/core/assets/maestro_execution_trace_light.png new file mode 100644 index 000000000..d3d364168 Binary files /dev/null and b/packages/uipath/docs/core/assets/maestro_execution_trace_light.png differ diff --git a/packages/uipath/docs/core/assets/maestro_service_task_light.png b/packages/uipath/docs/core/assets/maestro_service_task_light.png new file mode 100644 index 000000000..314cb383e Binary files /dev/null and b/packages/uipath/docs/core/assets/maestro_service_task_light.png differ diff --git a/packages/uipath/docs/core/assets/orchestrator_processes_light.png b/packages/uipath/docs/core/assets/orchestrator_processes_light.png new file mode 100644 index 000000000..9b17f72cf Binary files /dev/null and b/packages/uipath/docs/core/assets/orchestrator_processes_light.png differ diff --git a/packages/uipath/docs/core/assets/studio_web_select_function_dark.png b/packages/uipath/docs/core/assets/studio_web_select_function_dark.png new file mode 100644 index 000000000..68a66fc92 Binary files /dev/null and b/packages/uipath/docs/core/assets/studio_web_select_function_dark.png differ diff --git a/packages/uipath/docs/core/assets/studio_web_select_function_light.png b/packages/uipath/docs/core/assets/studio_web_select_function_light.png new file mode 100644 index 000000000..617e30c2f Binary files /dev/null and b/packages/uipath/docs/core/assets/studio_web_select_function_light.png differ diff --git a/packages/uipath/docs/core/environment_variables.md b/packages/uipath/docs/core/environment_variables.md index 6c88d3532..bcf1bcc8b 100644 --- a/packages/uipath/docs/core/environment_variables.md +++ b/packages/uipath/docs/core/environment_variables.md @@ -17,12 +17,12 @@ UIPATH_FOLDER_PATH=/default/path export UIPATH_FOLDER_PATH=/system/path ``` /// warning -When deploying your agent to production, ensure that all required environment variables (such as API keys and custom configurations) are properly configured in your process settings. This step is crucial for the successful operation of your published package. +When deploying your project to production, ensure that all required environment variables (such as API keys and custom configurations) are properly configured in your process settings. This step is crucial for the successful operation of your published package. /// ## Design -Create a `.env` file in your project's root directory to manage environment variables locally. When using the `uipath auth` or `uipath new my-agent` commands, this file is automatically created. +Create a `.env` file in your project's root directory to manage environment variables locally. When using the `uipath auth` or `uipath new` commands, this file is automatically created. The `uipath auth` command automatically populates this file with essential variables: diff --git a/packages/uipath/docs/core/functions.md b/packages/uipath/docs/core/functions.md new file mode 100644 index 000000000..6c073f68f --- /dev/null +++ b/packages/uipath/docs/core/functions.md @@ -0,0 +1,423 @@ +# Python Coded Functions + +A coded function is Python code with typed input and output that runs as an Orchestrator job. You write plain Python — no agent framework, no LLM required — package it with the CLI, and invoke it from Maestro processes, Coded Apps, or any UiPath job trigger. + +Use coded functions for deterministic compute steps: document extraction, ERP writes, data validation, external API calls. Use a [coded agent](./agents.md) when your logic needs an LLM decision loop or a multi-step reasoning chain. + +!!! warning "Preview Feature" + This feature is in preview and is subject to changes. + +--- + +## Quickstart + +//// tab | uv + + + +```shell +> mkdir my-function && cd my-function +> uv init . --python 3.11 +> uv add uipath + +> uipath auth +⠋ Authenticating with UiPath ... +✓ Authentication successful. + +> uipath new my-function +✓ Created 'main.py' file. +✓ Created 'pyproject.toml' file. +✓ Created 'uipath.json' file. + +> uipath init +⠋ Initializing UiPath project ... +✓ Created 'entry-points.json' file. +✓ Created 'bindings.json' file. + +> uipath run main '{"message": "hello"}' +{"message": "hello"} +``` + +//// + +//// tab | pip + + + +```shell +> mkdir my-function && cd my-function +> python -m venv .venv +> source .venv/bin/activate +> pip install uipath + +> uipath auth +⠋ Authenticating with UiPath ... +✓ Authentication successful. + +> uipath new my-function +✓ Created 'main.py' file. +✓ Created 'pyproject.toml' file. +✓ Created 'uipath.json' file. + +> uipath init +⠋ Initializing UiPath project ... +✓ Created 'entry-points.json' file. +✓ Created 'bindings.json' file. + +> uipath run main '{"message": "hello"}' +{"message": "hello"} +``` + +//// + +--- + +## Project Structure + +``` +my-function/ +├── main.py # function logic +├── pyproject.toml # project metadata and dependencies +├── uipath.json # entry point declarations +├── entry-points.json # generated — I/O JSON Schema +└── bindings.json # generated — resource binding overrides +``` + +### `uipath.json` + +Declares which Python functions are callable entry points: + +```json +{ + "functions": { + "main": "main.py:main" + } +} +``` + +The key (`"main"`) is the entry point name used in CLI commands. The value (`"main.py:main"`) is `:`. + +### `pyproject.toml` + +```toml +[project] +name = "my-function" +version = "0.1.0" +description = "..." +authors = [{ name = "Your Name", email = "you@example.com" }] +requires-python = ">=3.11" +dependencies = ["uipath>=2.0"] +``` + +Standard project metadata and dependencies. The `functions` map in `uipath.json` (above) is what marks the project as a coded function — `pyproject.toml` needs no UiPath-specific entries. + +### Generated files + +| File | Purpose | +|------|---------| +| `entry-points.json` | Input/output JSON Schema derived from your `Input`/`Output` models — used by Maestro for variable binding | +| `bindings.json` | Resource binding overrides (assets, connections, buckets) for local development | + +/// warning +`uipath init` executes your entrypoint Python file(s) (as declared in `uipath.json`, e.g., `main.py`) to derive the I/O schema. Re-run it after every change to your `Input` or `Output` models. +/// + +--- + +## Input & Output + +Define `Input` and `Output` as typed Python — a stdlib `@dataclass`, a pydantic `BaseModel`, or `pydantic.dataclasses.dataclass`. The runtime uses these type hints to parse the invocation payload and exports them as JSON Schema for Maestro variable binding. The entry point can be a sync `def` or an `async def` — both are supported. + +```python +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class Input: + document_id: str = "" + amount: float = 0.0 + + +@dataclass +class Output: + result_id: str = "" + status: str = "" + error_type: str = "" + error_message: str = "" + + +def main(input: Input) -> Output: + ... +``` + +### Supported types + +| Python type | Notes | +|-------------|-------| +| `str`, `int`, `float`, `bool` | Primitives | +| `list[str]`, `list[dict]` | Arrays | +| `dict[str, Any]` | Freeform object | +| Nested `@dataclass` | Becomes a nested JSON object | +| `X \| None`, `Optional[X]` | Nullable field | + +### Error output pattern + +Return business errors as typed output fields rather than raising exceptions. This lets Maestro inspect the error reason and route the process accordingly: + +```python +@dataclass +class Output: + bill_id: str = "" + error_type: str = "" # e.g. "VENDOR_NOT_FOUND", "VALIDATION_ERROR" + error_message: str = "" # human-readable detail + + +def main(input: Input) -> Output: + try: + bill_id = create_vendor_bill(input) + return Output(bill_id=bill_id) + except VendorNotFoundError as exc: + return Output(error_type="VENDOR_NOT_FOUND", error_message=str(exc)) + except Exception as exc: + return Output(error_type="FAILED", error_message=str(exc)) +``` + +Reserve `raise` for unrecoverable infrastructure failures (network timeout, authentication error) that should mark the Orchestrator job as faulted. + +--- + +## Platform Services + +`UiPath()` gives your function access to Orchestrator resources at runtime. Credentials are injected automatically when running as a job — no configuration needed. + +```python +from uipath.platform import UiPath + +sdk = UiPath() +``` + +### Assets + +Read credential and configuration values stored in Orchestrator: + +```python +# String asset +asset = sdk.assets.retrieve("API_BASE_URL", folder_path="Shared") +base_url = str(asset.string_value or "") + +# Credential asset +creds = sdk.assets.retrieve("ERP_CREDENTIALS", folder_path="Shared") +username = str(creds.credential_username or "") +password = str(creds.credential_password or "") +``` + +See [Assets](./assets.md) for the full API reference. + +### Buckets + +Download and upload files: + +```python +# Download +sdk.buckets.download( + name="Invoices", + blob_file_path="incoming/acme-001.pdf", + destination_path="/tmp/acme-001.pdf", + folder_path="Shared", +) + +# Upload +sdk.buckets.upload( + name="Processed", + blob_file_path="results/acme-001-result.json", + content_file_path="/tmp/result.json", + folder_path="Shared", +) +``` + +See [Buckets](./buckets.md) for the full API reference. + +### Connections + +Access Integration Service connections for ERP and SaaS systems: + +```python +from uipath.platform.connections.connections import ActivityMetadata, ActivityParameterLocationInfo + +conn = sdk.connections.retrieve("your-connection-id") + +result = sdk.connections.invoke_activity( + activity_metadata=ActivityMetadata( + object_path="/your-endpoint", + method_name="POST", + content_type="application/json", + parameter_location_info=ActivityParameterLocationInfo(body_fields=["query"]), + ), + connection_id="your-connection-id", + activity_input={"query": "SELECT id FROM records LIMIT 10"}, +) +``` + +See [Connections](./connections.md) for the full API reference. + +--- + +## Tracing + +Use `@traced` to make individual steps visible as spans in the Orchestrator job trace view and Maestro dashboards. + +```python +from uipath.tracing import traced + + +@traced(name="fetch_document", run_type="uipath") +def fetch_document(document_id: str) -> bytes: + ... + + +@traced(name="extract_fields", run_type="uipath") +def extract_fields(content: bytes) -> dict: + ... + + +@traced(name="post_to_erp", run_type="uipath") +def post_to_erp(data: dict) -> str: + ... + + +def main(input: Input) -> Output: # entry point — NOT traced + content = fetch_document(input.document_id) + data = extract_fields(content) + result_id = post_to_erp(data) + return Output(result_id=result_id) +``` + +/// warning +Do not apply `@traced` to the entry point function. The Orchestrator runtime wraps the entire job in its own span — adding a second trace on the entry point creates a duplicate outer span. +/// + +Use `hide_input=True` or `hide_output=True` to redact sensitive data from trace storage: + +```python +@traced(name="get_api_token", run_type="uipath", hide_input=True, hide_output=True) +def get_api_token(client_id: str, client_secret: str) -> str: + ... +``` + + + Maestro execution trail showing @traced sub-step spans (assets_retrieve, ixp_digitize, netsuite_get_vendor, etc.) with durations and parent-child nesting under a Service Task + + +See [Tracing](./traced.md) for the full decorator reference. + +--- + +## Multiple Entry Points + +One project can expose several callable functions, each with its own `Input`/`Output`. Define them in `uipath.json`: + +```json +{ + "functions": { + "extract": "main.py:extract_data", + "validate": "main.py:validate_data", + "post_erp": "main.py:post_to_erp" + } +} +``` + +Run `uipath init` after adding new entry points. Each can be invoked independently: + + + +```shell +> uipath run extract '{"document_id": "invoice-001.pdf"}' +> uipath run validate '{"vendor_name": "Acme", "total": 1234.56}' +> uipath run post_erp '{"bill_id": "12345"}' +``` + +Each entry point publishes as a separate invocable function in Orchestrator. + +--- + +## Idempotency + +Functions may be retried by Maestro after a transient failure. Always check for an existing result before writing to an external system: + +```python +@traced(name="find_existing", run_type="uipath") +def find_existing(invoice_number: str) -> str | None: + # query external system by stable business key + ... + + +def main(input: Input) -> Output: + existing_id = find_existing(input.invoice_number) + if existing_id: + return Output(result_id=existing_id, status="Already Processed") + + result_id = create_record(input) + return Output(result_id=result_id, status="Created") +``` + +Use a stable, business-meaningful identifier (invoice number, order ID) as the idempotency key — avoid auto-generated IDs that don't exist before the first write. + +--- + +## Pack & Publish + + + +```shell +> uipath pack +⠋ Packaging project ... +Name : my-function +Version : 0.1.0 +Description: ... +Authors : Your Name +✓ Project successfully packaged. + +> uipath publish +⠋ Fetching available package feeds... +👇 Select package feed: + 0: Orchestrator Tenant Processes Feed + 1: Orchestrator Personal Workspace Feed +Select feed number: 0 +✓ Package published successfully! +``` + +After publishing, the function registers as an **Orchestrator Process**. It can then be: + +- Invoked as a **Maestro Service Task** — Maestro binds typed input/output to process variables automatically from the exported JSON Schema +- Triggered via the **Orchestrator API** (`POST /Jobs/StartJobs`) +- Run from the CLI: `uipath invoke main '{"..."}'` +- Started from a **Studio workflow** using the **Run Job** activity + + + Coded functions published as Function (python) type in the Orchestrator Processes list + + + Coded function wired as a Maestro Service Task with typed input/output variable binding + + +See [CLI Reference](../cli/index.md) for full `pack`, `publish`, and `invoke` options. + +--- + +## Studio Web Integration + +Connect your function to a Studio Web solution for cloud debugging or solution packaging: + + + +```shell +> uipath push +Pushing UiPath project to Studio Web... +Uploading 'main.py' +Uploading 'uipath.json' +Updating 'pyproject.toml' +✓ Project pushed successfully +``` + +See [Studio Web Integration](./studio_web.md) for setup and sync details. diff --git a/packages/uipath/docs/core/getting_started.md b/packages/uipath/docs/core/getting_started.md index 6c00b0089..50da82107 100644 --- a/packages/uipath/docs/core/getting_started.md +++ b/packages/uipath/docs/core/getting_started.md @@ -114,6 +114,10 @@ Upon successful authentication, your project will contain a `.env` file with you ### Writing Your Code +/// tip +This walkthrough creates a **coded function** — plain Python with typed input and output, no LLM required. For a complete reference including platform services, tracing, idempotency, and Maestro integration, see [Python Coded Functions](./functions.md). +/// + Open `main.py` in your code editor. You can start with this example code: ```python from dataclasses import dataclass diff --git a/packages/uipath/docs/core/guardrails.md b/packages/uipath/docs/core/guardrails.md index 40aa69abc..4c380c20c 100644 --- a/packages/uipath/docs/core/guardrails.md +++ b/packages/uipath/docs/core/guardrails.md @@ -1 +1,445 @@ +# Guardrails + +Guardrails are safeguards applied before and/or after execution to inspect inputs and outputs for policy violations — PII, harmful content, prompt attacks, intellectual property, and custom rules — and respond by logging, blocking, or modifying the data. + +They can be applied at three scopes: + +- **Tool** — individual tool functions called by an agent +- **LLM** — LLM factory functions or chat model objects (e.g. LangChain `BaseChatModel`) +- **Agent** — agent-level methods and nodes + +The `@guardrail` decorator works with plain Python functions, async functions, and any LangChain/LangGraph object recognised by a registered framework adapter. + +## Usage + +Apply the `@guardrail` decorator to any callable — tool functions, LLM factories, agent factories, or async agent nodes. The decorator intercepts calls at the configured stage and evaluates the data against the provided validator. + +**Tool function:** + +```python +from uipath.platform.guardrails import ( + BlockAction, + GuardrailExecutionStage, + PIIDetectionEntity, + PIIDetectionEntityType, + PIIValidator, + guardrail, +) + +@guardrail( + validator=PIIValidator( + entities=[PIIDetectionEntity(PIIDetectionEntityType.EMAIL, threshold=0.5)] + ), + action=BlockAction(), + name="No PII in output", + stage=GuardrailExecutionStage.POST, +) +def analyze_joke(joke: str) -> str: + ... +``` + +When using LangChain's `@tool`, `@guardrail` must be placed **above** `@tool`: + +```python +from langchain_core.tools import tool + +@guardrail( + validator=PIIValidator( + entities=[PIIDetectionEntity(PIIDetectionEntityType.EMAIL, threshold=0.5)] + ), + action=BlockAction(), + name="No PII in tool input", + stage=GuardrailExecutionStage.PRE, +) +@tool # @guardrail wraps the already-decorated tool object +def analyze_joke(joke: str) -> str: + ... +``` + +**LLM factory function:** + +```python +@guardrail( + validator=UserPromptAttacksValidator(), + action=BlockAction(), + name="LLM User Prompt Attacks Detection", + stage=GuardrailExecutionStage.PRE, +) +def create_llm(): + return UiPathChat(model="gpt-4o-2024-08-06") +``` + +**Agent factory or async node:** + +```python +@guardrail( + validator=PIIValidator( + entities=[PIIDetectionEntity(PIIDetectionEntityType.PERSON, threshold=0.5)] + ), + action=BlockAction( + title="Person name detection", + detail="Person name detected and is not allowed", + ), + name="Agent PII Detection", + stage=GuardrailExecutionStage.PRE, +) +async def joke_node(state: Input) -> Output: + ... +``` + +## Execution Stages + +The `stage` parameter controls when the guardrail evaluates. Not all validators support all stages. + +| Stage | When evaluated | Supported by | +|-------|---------------|--------------| +| `PRE` | Before the function runs | All validators | +| `POST` | After the function runs | All except `UserPromptAttacksValidator` | +| `PRE_AND_POST` | Both before and after | `PIIValidator`, `HarmfulContentValidator`, `CustomValidator` | + +## Built-in Validators + +Built-in validators are backed by the UiPath Guardrails API (powered by Azure Content Safety). They require a UiPath connection with the appropriate entitlements. + +### PII Detection + +Detects personally identifiable information in text. Supports 18 entity types with per-entity confidence thresholds. + +```python +from uipath.platform.guardrails import ( + BlockAction, + PIIDetectionEntity, + PIIDetectionEntityType, + PIIValidator, + guardrail, +) + +@guardrail( + validator=PIIValidator( + entities=[ + PIIDetectionEntity(name=PIIDetectionEntityType.EMAIL, threshold=0.7), + PIIDetectionEntity(name=PIIDetectionEntityType.PHONE_NUMBER, threshold=0.5), + PIIDetectionEntity(name=PIIDetectionEntityType.US_SOCIAL_SECURITY_NUMBER), + ] + ), + action=BlockAction(), + name="No PII", +) +def process_document(content: str) -> str: + ... +``` + +`threshold` is a confidence value between `0.0` and `1.0` (default `0.5`). Lower values increase sensitivity. + +### Harmful Content + +Detects harmful or unsafe content across four Azure Content Safety categories. Each category has a severity threshold from `0` (most sensitive) to `6` (least sensitive), defaulting to `2`. + +```python +from uipath.platform.guardrails import ( + BlockAction, + HarmfulContentEntity, + HarmfulContentEntityType, + HarmfulContentValidator, + guardrail, +) + +@guardrail( + validator=HarmfulContentValidator( + entities=[ + HarmfulContentEntity(name=HarmfulContentEntityType.VIOLENCE, threshold=2), + HarmfulContentEntity(name=HarmfulContentEntityType.HATE, threshold=2), + ] + ), + action=BlockAction(), + name="Safe content only", +) +def generate_response(prompt: str) -> str: + ... +``` + +### User Prompt Attacks + +Detects adversarial user prompt patterns (e.g. jailbreak attempts). No configuration parameters required. Restricted to `PRE` stage only. + +```python +from uipath.platform.guardrails import ( + BlockAction, + GuardrailExecutionStage, + UserPromptAttacksValidator, + guardrail, +) + +@guardrail( + validator=UserPromptAttacksValidator(), + action=BlockAction(), + name="No prompt attacks", + stage=GuardrailExecutionStage.PRE, +) +def chat(message: str) -> str: + ... +``` + +### Intellectual Property + +Detects potential intellectual property violations in generated output. Restricted to `POST` stage only — this is an output concern. + +```python +from uipath.platform.guardrails import ( + BlockAction, + GuardrailExecutionStage, + IntellectualPropertyEntityType, + IntellectualPropertyValidator, + guardrail, +) + +@guardrail( + validator=IntellectualPropertyValidator( + entities=[ + IntellectualPropertyEntityType.TEXT, + IntellectualPropertyEntityType.CODE, + ] + ), + action=BlockAction(), + name="No IP violations", + stage=GuardrailExecutionStage.POST, +) +def generate_code(spec: str) -> str: + ... +``` + +## Actions + +Actions define what happens when a violation is detected. + +### LogAction + +Logs the violation and lets execution continue. The original data is unchanged. + +```python +from uipath.platform.guardrails import LogAction, LoggingSeverityLevel + +action = LogAction(severity_level=LoggingSeverityLevel.WARNING) +action = LogAction(severity_level=LoggingSeverityLevel.ERROR, message="PII found in output") +``` + +### BlockAction + +Raises `GuardrailBlockException` to stop execution immediately. Framework adapters (e.g. LangChain) catch this exception and convert it to their own error type. + +```python +from uipath.platform.guardrails import BlockAction + +action = BlockAction() +action = BlockAction(title="PII detected", detail="Email address found in response") +``` + +### Custom Actions + +Subclass `GuardrailAction` to implement custom behaviour, such as content sanitisation: + +```python +from typing import Any +from uipath.core.guardrails import GuardrailValidationResult +from uipath.platform.guardrails import GuardrailAction + +class RedactAction(GuardrailAction): + def handle_validation_result( + self, + result: GuardrailValidationResult, + data: str | dict[str, Any], + guardrail_name: str, + ) -> str | dict[str, Any] | None: + # Return modified data to replace the original, or None to leave unchanged + if isinstance(data, str): + return "[REDACTED]" + return None +``` + +## Custom Validators + +`CustomValidator` applies an in-process rule function without any API call. The rule receives the input dict (PRE stage) or both input and output dicts (POST stage), and returns `True` to signal a violation. + +```python +from uipath.platform.guardrails import BlockAction, CustomValidator, guardrail + +@guardrail( + validator=CustomValidator(rule=lambda data: "forbidden" in str(data).lower()), + action=BlockAction(), + name="No forbidden words", +) +def my_tool(text: str) -> str: + ... +``` + +For POST-stage rules, accept two parameters to inspect both input and output: + +```python +def check_output(input_data: dict, output_data: dict) -> bool: + # Return True to trigger the guardrail + return len(output_data.get("response", "")) > 5000 + +@guardrail( + validator=CustomValidator(rule=check_output), + action=BlockAction(detail="Response exceeds maximum length"), + name="Length limit", + stage=GuardrailExecutionStage.POST, +) +def summarize(query: str) -> dict: + ... +``` + +For full control, subclass `CustomGuardrailValidator` directly. + +## Excluding Parameters + +Use `GuardrailExclude` with `Annotated` to prevent a specific parameter from being included in the guardrail evaluation payload. Useful for internal context objects, credentials, or other data that should never be inspected. + +```python +from typing import Annotated +from uipath.platform.guardrails import BlockAction, GuardrailExclude, PIIValidator, guardrail + +@guardrail( + validator=PIIValidator(entities=[PIIDetectionEntity(name=PIIDetectionEntityType.EMAIL)]), + action=BlockAction(), + name="No PII", +) +def process( + user_message: str, + internal_config: Annotated[dict, GuardrailExclude()], # excluded from guardrail +) -> str: + ... +``` + +## Stacking Guardrails + +Multiple `@guardrail` decorators can be stacked on the same function. Each is evaluated independently at its configured stage. + +```python +@guardrail( + validator=UserPromptAttacksValidator(), + action=BlockAction(), + name="No prompt attacks", + stage=GuardrailExecutionStage.PRE, +) +@guardrail( + validator=PIIValidator(entities=[PIIDetectionEntity(name=PIIDetectionEntityType.EMAIL)]), + action=LogAction(), + name="PII audit", + stage=GuardrailExecutionStage.POST, +) +def handle_request(user_input: str) -> str: + ... +``` + +## Low-level API + +For direct programmatic use without the decorator, the `GuardrailsService` is available on the `UiPath` client: + +```python +from uipath.platform import UiPath +from uipath.platform.guardrails import BuiltInValidatorGuardrail + +sdk = UiPath() +result = sdk.guardrails.evaluate_guardrail( + input_data="Contact me at user@example.com", + guardrail=BuiltInValidatorGuardrail( + id="my-guardrail", + name="PII check", + guardrail_type="builtInValidator", + validator_type="pii_detection", + ), +) +print(result.result, result.reason) +``` + +--- + +## API Reference + +### Service + ::: uipath.platform.guardrails._guardrails_service + options: + members: + - GuardrailsService + +### Decorator + +::: uipath.platform.guardrails.decorators._guardrail + options: + members: + - guardrail + +### Execution Stage + +::: uipath.platform.guardrails.decorators._enums + options: + members: + - GuardrailExecutionStage + - PIIDetectionEntityType + - HarmfulContentEntityType + - IntellectualPropertyEntityType + +### Actions + +::: uipath.platform.guardrails.decorators._models + options: + members: + - GuardrailAction + - PIIDetectionEntity + - HarmfulContentEntity + +::: uipath.platform.guardrails.decorators._actions + options: + members: + - LoggingSeverityLevel + - LogAction + - BlockAction + +::: uipath.platform.guardrails.decorators._exceptions + options: + members: + - GuardrailBlockException + +### Exclude Marker + +::: uipath.platform.guardrails.decorators._core + options: + members: + - GuardrailExclude + +### Validators + +::: uipath.platform.guardrails.decorators.validators._base + options: + members: + - GuardrailValidatorBase + - BuiltInGuardrailValidator + - CustomGuardrailValidator + +::: uipath.platform.guardrails.decorators.validators.pii + options: + members: + - PIIValidator + +::: uipath.platform.guardrails.decorators.validators.harmful_content + options: + members: + - HarmfulContentValidator + +::: uipath.platform.guardrails.decorators.validators.intellectual_property + options: + members: + - IntellectualPropertyValidator + +::: uipath.platform.guardrails.decorators.validators.user_prompt_attacks + options: + members: + - UserPromptAttacksValidator + +::: uipath.platform.guardrails.decorators.validators.custom + options: + members: + - CustomValidator + - RuleFunction diff --git a/packages/uipath/docs/core/release_notes.md b/packages/uipath/docs/core/release_notes.md index 325baad92..3bdbfb4a0 100644 --- a/packages/uipath/docs/core/release_notes.md +++ b/packages/uipath/docs/core/release_notes.md @@ -1,126 +1,27 @@ -# 🚨 Breaking Changes for UiPath Python SDK (v2.2.0+) - -**Release Date:** November 26, 2025 - -Version 2.2.0 of the **UiPath Python SDK** introduces several breaking changes affecting both the SDK and CLI. - -## Breaking Changes - -### 1. Minimum Python Version: 3.11+ Required - -**What's changing:** Python 3.10 is no longer supported for `uipath-python`, `uipath-langchain-python`, `uipath-llamaindex-python`. - -**Action required:** Upgrade to Python 3.11 or higher. - -### 2. Import Path Change - -**What's changing:** The `UiPath` class has moved from `uipath` to `uipath.platform`. - -**Action required:** Update your imports: - -```python -# Before -from uipath import UiPath -from uipath.models import Job, Asset, Queue -from uipath.models import Entity - -# After -from uipath.platform import UiPath, Job, Asset, Queue - -client = UiPath(...) -``` - -### 3. Transition to LangChain v1 (for `uipath-langchain` only) - -**What's changing:** Minimum required versions are now LangChain 1.0.0+ and LangGraph 1.0.0+ - -**Action required:** Review and update your code according to the [LangChain v1 Migration Guide](https://docs.langchain.com/oss/python/migrate/langchain-v1). - -**Note:** This only applies if you're using the `uipath-langchain` package. - -### 4. Configuration Architecture Redesign - -We've restructured how UiPath projects define and manage their resources: - -**`uipath.json` - Configuration File (Updated Purpose)** -- Previously contained entrypoints and bindings; now serves as a streamlined configuration file -- For **pure Python scripts**, define entrypoints in the `functions` section: - ```json - { - "functions": { - "entrypoint1": "src/main.py:", - "entrypoint2": "src/graph.py:runtime" - } - } - ``` -- For **LangGraph graphs**, define entrypoints in `langgraph.json` (same as before) -- For **LlamaIndex workflows**, define entrypoints in `llamaindex.json` (same as before) - -**`bindings.json` - Manual Binding Definitions (New)** -- Overridable resources (bindings) now stored in a separate file -- Bindings are **no longer automatically inferred** from code -- Must be manually defined by the user for now (we're working on an interactive configurator to simplify this process) - -**`entry-points.json` - I/O Schema (New)** -- Contains the input/output schema for your entrypoints -- Automatically inferred from code based on entrypoints defined in `llamaindex.json`/`langgraph.json`/`uipath.json` - -## Migration Guide - -### Stay on v2.1.x - -To avoid these breaking changes and keep your current setup, pin your dependency in `pyproject.toml`: - -```toml -"uipath>=2.1.x,<2.2.0" -``` - -**For `uipath-langchain` users:** To stay on the current version without LangChain v1: -```toml -"uipath-langchain>=0.0.x,<0.1.0" -``` - -### Migrate to v2.2.0+ +--- +title: Release Notes +--- -1. **Upgrade to v2.2.0+** - - Update the dependencies in `pyproject.toml` with: - ```toml - "uipath>=2.2.x,<2.3.0" - ``` +# Release Notes - Bounding the version to <2.3.0 prevents future breaking changes - - **For `uipath-langchain` users:** - To migrate to LangChain v1: - ```toml - "uipath-langchain>=0.1.0,<0.2.0" - ``` - **For `uipath-langchain`/`uipath-llamaindex` users:** - Make sure to also reference `uipath` in your `pyproject.toml` - future versions will no longer reference the main `uipath` CLI package as a dependency. +A catalog of the releases most relevant to UiPath Python SDK users (breaking changes and notable updates). Full details live in each GitHub release, linked below. -2. **Upgrade the Python version to 3.11+** - - In `pyproject.toml` specify the required Python version by adding or updating the following field: - ```toml - requires-python = ">=3.11" - ``` +## `uipath` (SDK & CLI) -3. **Update imports** - - Change `from uipath import UiPath` to `from uipath.platform import UiPath`. +| Release | Date | What's relevant | Notes | +|---------|------|-----------------|-------| +| [v2.10.0](https://github.com/UiPath/uipath-python/releases/tag/v2.10.0) | 2026-02-27 | Coded function schema `type` changed from `"agent"` to `"function"` | 🚨 Breaking | +| [v2.9.0](https://github.com/UiPath/uipath-python/releases/tag/v2.9.0) | 2026-02-23 | `platform` extracted to `uipath-platform`, context grounding contract changes, `uipath dev` defaults to `web` | 🚨 Breaking | +| [v2.2.0](https://github.com/UiPath/uipath-python/releases/tag/v2.2.0) | 2025-11-26 | Python 3.11+ required, `UiPath` import moved to `uipath.platform`, configuration architecture redesign | 🚨 Breaking | -4. **Review LangChain v1 changes (if using `uipath-langchain`)** - - Review the [LangChain v1 Migration Guide](https://docs.langchain.com/oss/python/migrate/langchain-v1) and update your code accordingly. +## `uipath-langchain` -5. **Update configuration files** - - - **Define your entrypoints** in `scripts` within `uipath.json` (not applicable if you already use `langgraph.json`/`llamaindex.json`) - - **Run `uipath init`** to automatically generate the `entry-points.json` I/O schema from your configuration - - **Create `bindings.json`** and manually define all overridable resources - - **Important:** If you update your script/agent code, run `uipath init` again to regenerate the I/O schema +| Release | Date | What's relevant | Notes | +|---------|------|-----------------|-------| +| [v0.10.0](https://github.com/UiPath/uipath-langchain-python/releases/tag/v0.10.0) | 2026-04-23 | Transport/auth split into new `uipath-llm-client` and `uipath-langchain-client` packages (legacy preserved) | Non-breaking | ---- +## `uipath-runtime` -For questions or issues, please open a ticket: [UiPath Python SDK Submit Issue](https://github.com/UiPath/uipath-python/issues) \ No newline at end of file +| Release | Date | What's relevant | Notes | +|---------|------|-----------------|-------| +| [v0.3.0](https://github.com/UiPath/uipath-runtime-python/releases/tag/v0.3.0) | 2025-12-18 | `UiPathDebugBridgeProtocol` renamed to `UiPathDebugProtocol` | 🚨 Breaking (protocol implementers only) | diff --git a/packages/uipath/docs/core/studio_web.md b/packages/uipath/docs/core/studio_web.md index 762735e48..09286dd73 100644 --- a/packages/uipath/docs/core/studio_web.md +++ b/packages/uipath/docs/core/studio_web.md @@ -1,12 +1,15 @@ # Studio Web Integration -[Studio Web](https://docs.uipath.com/studio-web/automation-cloud/latest/user-guide/overview) is a cloud IDE for building projects such as RPAs, low code agents, and API workflows. It also supports importing coded agents built locally. Bringing your coded agent into Studio Web gives you: +[Studio Web](https://docs.uipath.com/studio-web/automation-cloud/latest/user-guide/overview) is a cloud IDE for building projects such as RPAs, low code agents, and API workflows. It also supports importing coded agents and coded functions built locally. Bringing your project into Studio Web gives you: - Cloud debugging with dynamic breakpoints -- Running and defining evaluations directly in the cloud +- Running and defining evaluations directly in the cloud (coded agents only) - A unified build experience alongside multiple project types - Self contained solution deployment units +!!! warning "Preview Feature" + Coded function support is in preview and is subject to changes. + Coded agent in Studio Web -There are two ways to connect a coded agent to Studio Web: using a [Cloud Workspace](#cloud-workspace) or a [Local Workspace](#local-workspace). +There are two ways to connect your project to Studio Web: using a [Cloud Workspace](#cloud-workspace) or a [Local Workspace](#local-workspace). --- @@ -28,10 +31,14 @@ There are two ways to connect a coded agent to Studio Web: using a [Cloud Worksp In a Cloud Workspace, your project lives in Studio Web and you sync code between your local IDE and the cloud. -### Importing a Coded Agent +### Importing a Coded Agent or Coded Function 1. Open your solution in Studio Web -2. Create a new Agent and select **Coded** +2. Create the project: + + //// tab | Agent + + Create a new Agent and select **Coded**: -3. Choose a sample project to start from, or push an existing local agent - -### Pushing an Existing Agent + //// -If you already have a coded agent locally, you can sync it to Studio Web: + //// tab | Function -1. Copy the `UIPATH_PROJECT_ID` from Studio Web into your `.env` file + Use the **Initial setup screen** to get started: - + + //// + +3. Choose a sample project to start from, or push an existing local project + +### Pushing an Existing Project + +If you already have a project locally, you can sync it to Studio Web: + +1. Copy the `UIPATH_PROJECT_ID` from Studio Web into your `.env` file + + + + + + 2. Push your project: + ```shell > uipath push Pushing UiPath project to Studio Web... @@ -79,7 +107,7 @@ If you already have a coded agent locally, you can sync it to Studio Web: 🔵 Resource import summary: 3 total resources - 1 created, 1 updated, 1 unchanged, 0 not found ``` - Notice the **Resource import summary** at the end. The push command also imports resources defined in `bindings.json` into the Studio Web solution, just like importing resources for a low code agent. This ensures that all required resources are packaged with the solution, so the coded agent works anywhere the solution is deployed. + Notice the **Resource import summary** at the end. The push command also imports resources defined in `bindings.json` into the Studio Web solution, just like importing resources for a low code agent. This ensures that all required resources are packaged with the solution, so the project works anywhere the solution is deployed. See [`uipath push`](../cli/index.md) in the CLI Reference. @@ -88,6 +116,7 @@ If you already have a coded agent locally, you can sync it to Studio Web: To pull the latest version from Studio Web to your local environment: + ```shell > uipath pull Pulling UiPath project from Studio Web... @@ -116,21 +145,24 @@ See [`uipath pull`](../cli/index.md) in the CLI Reference. In a Local Workspace, your project lives on your machine and is linked to a Studio Web solution. See the [Local Workspace documentation](https://docs.uipath.com/studio-web/automation-cloud/latest/user-guide/solutions-in-the-local-workspace) for setup details. -You can either start from a predefined template in Studio Web or set up a new agent from scratch. +You can either start from a predefined template in Studio Web or set up a new project from scratch. ### Starting from a Template -When creating a new Coded agent in Studio Web with a Local Workspace, you can pick one of the predefined templates. This creates the project files directly on your machine. Templates come with sample code and predefined evaluations you can run immediately. +When creating a new coded agent or coded function in Studio Web with a Local Workspace, you can pick one of the predefined templates. This creates the project files directly on your machine. Templates come with sample code and predefined evaluations you can run immediately. -### Setting Up a New Agent +### Setting Up a New Project -You can also create a coded agent from scratch in your local IDE and have it appear in Studio Web. +You can also create a project from scratch in your local IDE and have it appear in Studio Web. + +#### Coded Agent First, install the SDK package for the framework you want to use: //// tab | uv + ```shell # Pick the package that matches your framework: # uipath-langchain - LangChain / LangGraph @@ -149,6 +181,7 @@ Installed 42 packages in 0.8s //// tab | pip + ```shell # Pick the package that matches your framework: # uipath-langchain - LangChain / LangGraph @@ -166,6 +199,7 @@ Successfully installed uipath-langchain Then authenticate, scaffold the agent, and initialize the project: + ```shell > uipath auth ⠋ Authenticating with UiPath ... @@ -187,57 +221,133 @@ Selected tenant: Tenant1 That's it, your agent should now be visible in Studio Web. +#### Coded Function + +A coded function doesn't require an additional framework package. Authenticate, scaffold the project, and initialize it: + + + +```shell +> uipath auth +⠋ Authenticating with UiPath ... +🔗 If a browser window did not open, please open the following URL in your browser: [LINK] +👇 Select tenant: + 0: Tenant1 + 1: Tenant2 +Select tenant number: 0 +Selected tenant: Tenant1 +✓ Authentication successful. + +> uipath new my-function +✓ Created 'main.py' file. +✓ Created 'pyproject.toml' file. +✓ Created 'uipath.json' file. + +> uipath init +⠋ Initializing UiPath project ... +✓ Created 'entry-points.json' file. +``` + +That's it, your coded function should now be visible in Studio Web. + --- ## Publishing -Once your coded agent is in Studio Web, publishing works the same as any other project. Click **Publish** in Studio Web and it will be packaged and deployed through the standard workflow. +Once your project is in Studio Web, publishing works the same as any other project. Click **Publish** in Studio Web and it will be packaged and deployed through the standard workflow. --- ## Running and Debugging -Your agent can be run both in the cloud (via Studio Web) and locally using the CLI. +Your project can be run both in the cloud (via Studio Web) and locally using the CLI. + +The CLI commands below take the entrypoint name as the first argument. For a coded agent, this is the graph name declared in your framework's config (for example, `agent` in `langgraph.json`). For a coded function, this is the key declared in the `functions` map of `uipath.json` (for example, `main`). ### Running Locally +//// tab | Agent + + ```shell > uipath run agent '{"message": "hello"}' ``` +//// + +//// tab | Function + + + +```shell +> uipath run main '{"message": "hello"}' +``` + +//// + See [`uipath run`](../cli/index.md) in the CLI Reference. ### Debugging Locally Use `uipath debug` for an enhanced local debugging experience. Unlike `uipath run`, the debug command: -- Auto polls for trigger responses when the agent suspends (e.g., LangGraph interrupts) +- Auto polls for trigger responses when the project suspends (e.g., LangGraph interrupts) - Fetches binding overwrites from Studio Web (configurable in **Debug > Debug Configuration > Solution resources**) +//// tab | Agent + + ```shell > uipath debug agent '{"message": "hello"}' ``` +//// + +//// tab | Function + + + +```shell +> uipath debug main '{"message": "hello"}' +``` + +//// + See [`uipath debug`](../cli/index.md) in the CLI Reference. ### Evaluating Locally -Run evaluations against your agent using the CLI: +Run evaluations against your project using the CLI: + +//// tab | Agent + ```shell > uipath eval agent .\evaluations\eval-sets\faithfulness-multi-model.json ``` +//// + +//// tab | Function + + + +```shell +> uipath eval main .\evaluations\eval-sets\default.json +``` + +//// + See [`uipath eval`](../cli/index.md) in the CLI Reference and the [Evaluations documentation](../eval/index.md). --- ## Syncing Evaluations -Evaluations can be defined either in Studio Web or locally. They sync automatically when you use `uipath pull` and `uipath push`. +Evaluations can be defined either in Studio Web or locally, and sync automatically when you use `uipath pull` and `uipath push`. Defining and running evaluations in Studio Web is supported for coded agents only; coded functions can still be evaluated locally with `uipath eval`. /// note Custom evaluators must be created locally. See [Custom Evaluators](../eval/custom_evaluators.md) for details. diff --git a/packages/uipath/docs/core/traced.md b/packages/uipath/docs/core/traced.md index da8dbc5dc..195e5751a 100644 --- a/packages/uipath/docs/core/traced.md +++ b/packages/uipath/docs/core/traced.md @@ -71,9 +71,47 @@ def sensitive_operation(secret): - Regular functions (sync/async) - Generator functions (sync/async) -## Example with plain python agents +## Example with coded functions -When used with plain python agents please call `wait_for_tracers()` at the end of the script to ensure all traces are sent, if this is not called the agent could end without sending all the traces. +Apply `@traced` to individual steps inside your function. Do **not** trace the entry point — the Orchestrator runtime wraps the job execution in its own span, so decorating the entry point creates a duplicate outer span. + +```python hl_lines="4 9 14" +from uipath.tracing import traced + + +@traced(name="fetch_document", run_type="uipath") +def fetch_document(document_id: str) -> bytes: + ... + + +@traced(name="extract_fields", run_type="uipath") +def extract_fields(content: bytes) -> dict: + ... + + +@traced(name="post_to_erp", run_type="uipath") +def post_to_erp(data: dict) -> str: + ... + + +def main(input: Input) -> Output: # entry point — NOT traced + content = fetch_document(input.document_id) + data = extract_fields(content) + result_id = post_to_erp(data) + return Output(result_id=result_id) +``` + +Use `hide_input=True` or `hide_output=True` on steps that handle credentials or PII: + +```python hl_lines="1" +@traced(name="get_api_token", run_type="uipath", hide_input=True, hide_output=True) +def get_api_token(client_id: str, client_secret: str) -> str: + ... +``` + +## Example with plain python scripts + +When used outside the Orchestrator runtime (e.g. a standalone script), call `wait_for_tracers()` at the end to ensure all traces are flushed before the process exits. ```python hl_lines="3 8" diff --git a/packages/uipath/docs/index.md b/packages/uipath/docs/index.md index 231add0ca..1a88e6b2a 100644 --- a/packages/uipath/docs/index.md +++ b/packages/uipath/docs/index.md @@ -2,38 +2,32 @@ title: Getting Started --- -
-- __🚨 Breaking changes__ - - --- - - UiPath Python SDK v2.2.0+ will introduce **breaking changes** starting **November 26, 2025** - [See Details](./core/release_notes.md) -
+

What do you want to build?

-- __UiPath SDK__ +- __Python Coded Functions__ --- - Code with full UiPath context to build custom automations and agents from the ground up. + Deterministic Python automation with typed input/output. No LLM required. Runs as an Orchestrator job, invokable from Maestro, Studio, or the CLI. - [Start Building](./core/getting_started.md) + **Requires:** `uipath` -
+ [Build a Function](./core/functions.md) -
-- __UiPath MCP SDK__ +- __Python Coded Agents__ --- - Build and host Coded MCP Servers within UiPath. + AI-driven automation with LLM reasoning loops. Uses the `uipath` SDK for platform services plus a framework extension of your choice. - [Start Building](./mcp/quick_start.md) + **Requires:** `uipath` + one of the extensions below + + [Build an Agent](./core/agents.md)
-

Extensions

+

Agent Framework Extensions

- __UiPath Langchain SDK__ @@ -60,3 +54,15 @@ title: Getting Started [Get Started](./openai-agents/quick_start.md)
+ +

Other SDKs

+
+- __UiPath MCP SDK__ + + --- + + Build and host Coded MCP Servers within UiPath. + + [Start Building](./mcp/quick_start.md) + +
diff --git a/packages/uipath/mkdocs.yml b/packages/uipath/mkdocs.yml index 0b7fafd34..382e0fdd5 100644 --- a/packages/uipath/mkdocs.yml +++ b/packages/uipath/mkdocs.yml @@ -56,11 +56,13 @@ nav: - Home: index.md - UiPath SDK: - Getting Started: core/getting_started.md - - Release Notes: core/release_notes.md - - Environment Variables: core/environment_variables.md + - Python Coded Functions: core/functions.md + - Python Coded Agents: core/agents.md - CLI Reference: cli/index.md - Tracing: core/traced.md - Studio Web Integration: core/studio_web.md + - Environment Variables: core/environment_variables.md + - Release Notes: core/release_notes.md - Services: - Assets: core/assets.md - Attachments: core/attachments.md @@ -101,6 +103,7 @@ nav: - Getting Started: langchain/quick_start.md - Chat Models: langchain/chat_models.md - Context Grounding: langchain/context_grounding.md + - Guardrails: langchain/guardrails.md - Human In The Loop: langchain/human_in_the_loop.md - Sample Agents: https://github.com/UiPath/uipath-langchain-python/tree/main/samples - UiPath LlamaIndex SDK: @@ -120,10 +123,12 @@ nav: plugins: - search - llmstxt: + full_output: llms-full.txt sections: "UiPath SDK": - core/*.md - cli/*.md + - eval/*.md "UiPath MCP SDK": - mcp/*.md "UiPath LangChain SDK": diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 49a172d07..7acd8465d 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.10.40" +version = "2.11.12" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.8, <0.6.0", - "uipath-runtime>=0.10.0, <0.11.0", - "uipath-platform>=0.1.13, <0.2.0", + "uipath-core>=0.5.21, <0.6.0", + "uipath-runtime>=0.11.0, <0.12.0", + "uipath-platform>=0.1.76, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", @@ -137,15 +137,38 @@ warn_required_dynamic_aliases = true [tool.pytest.ini_options] testpaths = ["tests"] python_files = "test_*.py" -addopts = "-ra -q --cov" +addopts = "-ra -q --cov=src --cov-report=term-missing" asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" +[tool.coverage.run] +source = ["src"] +relative_files = true +omit = [ + "*/tests/*", + "*/__pycache__/*", + "*/site-packages/*", + "*/conftest.py", +] + [tool.coverage.report] show_missing = true +precision = 2 +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "@(abc\\.)?abstractmethod", +] -[tool.coverage.run] -source = ["src"] +[tool.uv] +exclude-newer = "2 days" + +[tool.uv.exclude-newer-package] +uipath-core = false +uipath-runtime = false +uipath-platform = false [tool.uv.sources] uipath-core = { path = "../uipath-core", editable = true } diff --git a/packages/uipath/samples/list_target_output_key_test/bindings.json b/packages/uipath/samples/list_target_output_key_test/bindings.json new file mode 100644 index 000000000..6122d0e77 --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/bindings.json @@ -0,0 +1,4 @@ +{ + "version": "2.0", + "resources": [] +} diff --git a/packages/uipath/samples/list_target_output_key_test/entry-points.json b/packages/uipath/samples/list_target_output_key_test/entry-points.json new file mode 100644 index 000000000..55ce49fba --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/entry-points.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/entry-point", + "$id": "entry-points.json", + "entryPoints": [ + { + "filePath": "main", + "uniqueId": "main", + "type": "function", + "input": { + "type": "object", + "properties": { + "product_id": { + "type": "string" + } + }, + "description": "Input schema.", + "required": [ + "product_id" + ] + }, + "output": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "price": { "type": "number" }, + "category": { "type": "string" }, + "in_stock": { "type": "boolean" }, + "rating": { "type": "number" } + }, + "description": "Output schema.", + "required": [ + "name", + "price", + "category", + "in_stock", + "rating" + ] + } + } + ] +} diff --git a/packages/uipath/samples/list_target_output_key_test/evaluations/eval-sets/default.json b/packages/uipath/samples/list_target_output_key_test/evaluations/eval-sets/default.json new file mode 100644 index 000000000..87c49fdc0 --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/evaluations/eval-sets/default.json @@ -0,0 +1,74 @@ +{ + "version": "1.0", + "id": "list-target-output-key-tests", + "name": "List Target Output Key Tests", + "evaluatorRefs": [ + "ListKeysExactMatch", + "ListKeysJsonSimilarity" + ], + "evaluations": [ + { + "id": "headphones-all-match", + "name": "Headphones - all keys match", + "inputs": { + "product_id": "p001" + }, + "evaluationCriterias": { + "ListKeysExactMatch": { + "expectedOutput": { + "name": "Wireless Headphones", + "price": 79.99 + } + }, + "ListKeysJsonSimilarity": { + "expectedOutput": { + "category": "Electronics", + "in_stock": true + } + } + } + }, + { + "id": "shoes-all-match", + "name": "Running Shoes - all keys match", + "inputs": { + "product_id": "p002" + }, + "evaluationCriterias": { + "ListKeysExactMatch": { + "expectedOutput": { + "name": "Running Shoes", + "price": 120.0 + } + }, + "ListKeysJsonSimilarity": { + "expectedOutput": { + "category": "Sports", + "in_stock": false + } + } + } + }, + { + "id": "headphones-wrong-price", + "name": "Headphones - wrong price (should fail)", + "inputs": { + "product_id": "p001" + }, + "evaluationCriterias": { + "ListKeysExactMatch": { + "expectedOutput": { + "name": "Wireless Headphones", + "price": 999.0 + } + }, + "ListKeysJsonSimilarity": { + "expectedOutput": { + "category": "Electronics", + "in_stock": true + } + } + } + } + ] +} diff --git a/packages/uipath/samples/list_target_output_key_test/evaluations/evaluators/list-keys-exact-match.json b/packages/uipath/samples/list_target_output_key_test/evaluations/evaluators/list-keys-exact-match.json new file mode 100644 index 000000000..65e77a8b1 --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/evaluations/evaluators/list-keys-exact-match.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "ListKeysExactMatch", + "description": "Asserts 'name' and 'price' together using a list of target output keys", + "evaluatorTypeId": "uipath-exact-match", + "evaluatorConfig": { + "name": "ListKeysExactMatch", + "targetOutputKey": ["name", "price"] + } +} diff --git a/packages/uipath/samples/list_target_output_key_test/evaluations/evaluators/list-keys-json-similarity.json b/packages/uipath/samples/list_target_output_key_test/evaluations/evaluators/list-keys-json-similarity.json new file mode 100644 index 000000000..f36f1fdea --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/evaluations/evaluators/list-keys-json-similarity.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "ListKeysJsonSimilarity", + "description": "Checks 'category' and 'in_stock' together using a list of target output keys", + "evaluatorTypeId": "uipath-json-similarity", + "evaluatorConfig": { + "name": "ListKeysJsonSimilarity", + "targetOutputKey": ["category", "in_stock"] + } +} diff --git a/packages/uipath/samples/list_target_output_key_test/main.py b/packages/uipath/samples/list_target_output_key_test/main.py new file mode 100644 index 000000000..ef7e56c73 --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/main.py @@ -0,0 +1,74 @@ +"""Agent demonstrating list targetOutputKey evaluation. + +This agent simulates a product lookup: given a product ID it returns +a structured response with several fields. The evaluators use a list +of keys so that multiple fields can be asserted in a single evaluator +configuration, without comparing the entire output dict. +""" + +from pydantic import BaseModel + +CATALOG: dict[str, dict[str, object]] = { + "p001": { + "name": "Wireless Headphones", + "price": 79.99, + "category": "Electronics", + "in_stock": True, + "rating": 4.5, + }, + "p002": { + "name": "Running Shoes", + "price": 120.0, + "category": "Sports", + "in_stock": False, + "rating": 4.8, + }, + "p003": { + "name": "Coffee Maker", + "price": 49.99, + "category": "Kitchen", + "in_stock": True, + "rating": 4.2, + }, +} + + +class Input(BaseModel): + """Input schema.""" + + product_id: str + + +class Output(BaseModel): + """Output schema.""" + + name: str + price: float + category: str + in_stock: bool + rating: float + + +def main(input_data: Input) -> Output: + """Look up a product by ID and return its details. + + Args: + input_data: Input containing the product ID. + + Returns: + Output with product details. + + Raises: + ValueError: If the product ID is not found. + """ + product = CATALOG.get(input_data.product_id) + if product is None: + raise ValueError(f"Product '{input_data.product_id}' not found") + + return Output( + name=str(product["name"]), + price=float(product["price"]), # type: ignore[arg-type] + category=str(product["category"]), + in_stock=bool(product["in_stock"]), + rating=float(product["rating"]), # type: ignore[arg-type] + ) diff --git a/packages/uipath/samples/list_target_output_key_test/pyproject.toml b/packages/uipath/samples/list_target_output_key_test/pyproject.toml new file mode 100644 index 000000000..ef529b400 --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "list-target-output-key-test" +version = "0.1.0" +description = "Sample agent demonstrating list targetOutputKey evaluation" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath" +] + +[tool.uv.sources] +uipath = { path = "../..", editable = true } diff --git a/packages/uipath/samples/list_target_output_key_test/uipath.json b/packages/uipath/samples/list_target_output_key_test/uipath.json new file mode 100644 index 000000000..e2a331e84 --- /dev/null +++ b/packages/uipath/samples/list_target_output_key_test/uipath.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "runtimeOptions": { + "isConversational": false + }, + "packOptions": { + "fileExtensionsIncluded": [], + "filesIncluded": [], + "filesExcluded": [], + "directoriesExcluded": [], + "includeUvLock": true + }, + "functions": { + "main": "main.py:main" + }, + "agents": {} +} diff --git a/packages/uipath/samples/multi-output-agent/evaluations/eval-sets/list-keys.json b/packages/uipath/samples/multi-output-agent/evaluations/eval-sets/list-keys.json new file mode 100644 index 000000000..d1f6e177b --- /dev/null +++ b/packages/uipath/samples/multi-output-agent/evaluations/eval-sets/list-keys.json @@ -0,0 +1,51 @@ +{ + "version": "1.0", + "id": "list-keys-eval-set", + "name": "List Target Output Key Tests", + "evaluatorRefs": [ + "list-keys-exact-match" + ], + "evaluations": [ + { + "id": "list-keys-basic", + "name": "Check multiple keys - order completed", + "inputs": { + "customer_name": "John Doe", + "items": [ + {"name": "Widget", "quantity": 2, "price": 9.99}, + {"name": "Gadget", "quantity": 1, "price": 24.99} + ] + }, + "evaluationCriterias": { + "list-keys-exact-match": { + "expectedOutput": { + "summary": { + "status": "completed", + "total": 44.97 + } + } + } + } + }, + { + "id": "list-keys-mismatch", + "name": "Check multiple keys - wrong total (should fail)", + "inputs": { + "customer_name": "Jane Smith", + "items": [ + {"name": "Book", "quantity": 1, "price": 15.0} + ] + }, + "evaluationCriterias": { + "list-keys-exact-match": { + "expectedOutput": { + "summary": { + "status": "completed", + "total": 999.0 + } + } + } + } + } + ] +} diff --git a/packages/uipath/samples/multi-output-agent/evaluations/evaluators/list-keys-exact-match.json b/packages/uipath/samples/multi-output-agent/evaluations/evaluators/list-keys-exact-match.json new file mode 100644 index 000000000..d2b685bb3 --- /dev/null +++ b/packages/uipath/samples/multi-output-agent/evaluations/evaluators/list-keys-exact-match.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "list-keys-exact-match", + "description": "Exact match on multiple output keys at once (summary.status and summary.total)", + "evaluatorTypeId": "uipath-exact-match", + "evaluatorConfig": { + "name": "ListKeysExactMatch", + "targetOutputKey": ["summary.status", "summary.total"] + } +} diff --git a/packages/uipath/samples/runtime-simulations-agent/input.json b/packages/uipath/samples/runtime-simulations-agent/input.json new file mode 100644 index 000000000..9bfb2eef8 --- /dev/null +++ b/packages/uipath/samples/runtime-simulations-agent/input.json @@ -0,0 +1,4 @@ +{ + "code": "def add(a, b):\n return a+b\n\ndef divide(a,b):\n return a/b", + "language": "python" +} diff --git a/packages/uipath/samples/runtime-simulations-agent/main.py b/packages/uipath/samples/runtime-simulations-agent/main.py new file mode 100644 index 000000000..46440b459 --- /dev/null +++ b/packages/uipath/samples/runtime-simulations-agent/main.py @@ -0,0 +1,186 @@ +"""Coding agent that reviews code and suggests improvements. + +This sample demonstrates the --simulation flag: the three tool functions +(check_syntax, check_style, suggest_improvements) are decorated with @mockable, +so they can be intercepted by an LLM during a simulated run instead of +requiring a real linter or compiler to be installed. + +Run with real tools: + uipath run main.py:main -f input.json + +Run with simulation (no real tools needed): + uipath run main.py:main -f input.json --simulation "$(cat simulation.json)" +""" + +import logging + +from pydantic import BaseModel +from pydantic.dataclasses import dataclass + +from uipath.eval.mocks import ExampleCall, mockable +from uipath.tracing import traced + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Input / Output models +# --------------------------------------------------------------------------- + + +@dataclass +class CodeReviewInput: + code: str + language: str = "python" + + +class SyntaxResult(BaseModel): + valid: bool + errors: list[str] = [] + + +class StyleResult(BaseModel): + score: int # 0-100 + violations: list[str] = [] + + +class ImprovementResult(BaseModel): + suggestions: list[str] = [] + refactored_snippet: str = "" + + +class CodeReviewOutput(BaseModel): + syntax: SyntaxResult + style: StyleResult + improvements: ImprovementResult + summary: str + + +# --------------------------------------------------------------------------- +# Mockable tool functions +# --------------------------------------------------------------------------- + +CHECK_SYNTAX_EXAMPLES = [ + ExampleCall( + id="valid-python", + input='{"code": "def hello():\\n return 42", "language": "python"}', + output='{"valid": true, "errors": []}', + ), + ExampleCall( + id="syntax-error", + input='{"code": "def hello(\\n return 42", "language": "python"}', + output='{"valid": false, "errors": ["SyntaxError: unexpected EOF"]}', + ), +] + + +@traced(name="check_syntax", span_type="tool") +@mockable(example_calls=CHECK_SYNTAX_EXAMPLES) +async def check_syntax(code: str, language: str = "python") -> SyntaxResult: + """Check code for syntax errors using the language's parser. + + Args: + code: Source code to check. + language: Programming language (default: python). + + Returns: + SyntaxResult with valid flag and list of error messages. + """ + if language != "python": + return SyntaxResult(valid=True, errors=[]) + + try: + compile(code, "", "exec") + return SyntaxResult(valid=True, errors=[]) + except SyntaxError as exc: + return SyntaxResult(valid=False, errors=[str(exc)]) + + +CHECK_STYLE_EXAMPLES = [ + ExampleCall( + id="clean-code", + input='{"code": "def hello():\\n return 42\\n", "language": "python"}', + output='{"score": 95, "violations": []}', + ), + ExampleCall( + id="style-issues", + input='{"code": "def hello( ):\\n return 42", "language": "python"}', + output='{"score": 60, "violations": ["E211 whitespace before \'(\'", "W291 trailing whitespace"]}', + ), +] + + +@traced(name="check_style", span_type="tool") +@mockable(example_calls=CHECK_STYLE_EXAMPLES) +async def check_style(code: str, language: str = "python") -> StyleResult: + """Run style checks (e.g. PEP 8 for Python) on the provided code. + + Args: + code: Source code to check. + language: Programming language (default: python). + + Returns: + StyleResult with a 0-100 score and list of style violations. + """ + # Real implementation would call ruff / pycodestyle / eslint etc. + # For demo purposes we return a perfect score when not simulated. + return StyleResult(score=100, violations=[]) + + +SUGGEST_IMPROVEMENTS_EXAMPLES = [ + ExampleCall( + id="basic-function", + input='{"code": "def add(a, b):\\n return a + b"}', + output=( + '{"suggestions": ["Add type annotations", "Add a docstring"],' + ' "refactored_snippet": "def add(a: int, b: int) -> int:\\n ' + "'''Return the sum of a and b.'''\\n return a + b\"}" + ), + ) +] + + +@traced(name="suggest_improvements", span_type="tool") +@mockable(example_calls=SUGGEST_IMPROVEMENTS_EXAMPLES) +async def suggest_improvements(code: str) -> ImprovementResult: + """Analyse code and return actionable improvement suggestions. + + Args: + code: Source code to analyse. + + Returns: + ImprovementResult with suggestions and an optional refactored snippet. + """ + # Real implementation would call an LLM or static analysis tool. + return ImprovementResult(suggestions=[], refactored_snippet=code) + + +# --------------------------------------------------------------------------- +# Agent entrypoint +# --------------------------------------------------------------------------- + + +@traced(name="main") +async def main(input: CodeReviewInput) -> CodeReviewOutput: + """Orchestrate three code-review tools and produce a unified report. + + Each tool call creates its own OpenTelemetry span with span_type="tool", + which enables trajectory-based evaluation and simulation. + """ + syntax = await check_syntax(input.code, input.language) + style = await check_style(input.code, input.language) + improvements = await suggest_improvements(input.code) + + issues = len(syntax.errors) + len(style.violations) + summary = ( + f"Found {issues} issue(s). " + f"Style score: {style.score}/100. " + f"{len(improvements.suggestions)} improvement suggestion(s)." + ) + + return CodeReviewOutput( + syntax=syntax, + style=style, + improvements=improvements, + summary=summary, + ) diff --git a/packages/uipath/samples/runtime-simulations-agent/pyproject.toml b/packages/uipath/samples/runtime-simulations-agent/pyproject.toml new file mode 100644 index 000000000..335c55783 --- /dev/null +++ b/packages/uipath/samples/runtime-simulations-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "runtime-simulations-agent" +version = "0.0.1" +description = "Code review agent demonstrating runtime simulation" +authors = [{ name = "UiPath", email = "python-sdk@uipath.com" }] +dependencies = [ + "uipath", +] +requires-python = ">=3.11" + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath/samples/runtime-simulations-agent/simulation.json b/packages/uipath/samples/runtime-simulations-agent/simulation.json new file mode 100644 index 000000000..d89bb253f --- /dev/null +++ b/packages/uipath/samples/runtime-simulations-agent/simulation.json @@ -0,0 +1,15 @@ +{ + "enabled": true, + "toolsToSimulate": [ + { + "name": "check_syntax" + }, + { + "name": "check_style" + }, + { + "name": "suggest_improvements" + } + ], + "instructions": "You are simulating a code review system. Given a tool name and its input arguments, produce a realistic JSON response that matches the tool's output schema.\n\n- check_syntax: return {\"valid\": , \"errors\": [, ...]}. If the code looks syntactically correct return valid=true and an empty errors list. Otherwise list the syntax errors.\n- check_style: return {\"score\": <0-100>, \"violations\": [, ...]}. Evaluate PEP 8 compliance for Python code. Deduct points for missing spaces, missing type annotations, etc.\n- suggest_improvements: return {\"suggestions\": [, ...], \"refactored_snippet\": \"\"}. Suggest concrete improvements such as adding type hints, docstrings, or handling edge cases (e.g. division by zero)." +} \ No newline at end of file diff --git a/packages/uipath/samples/runtime-simulations-agent/uipath.json b/packages/uipath/samples/runtime-simulations-agent/uipath.json new file mode 100644 index 000000000..9b02c2654 --- /dev/null +++ b/packages/uipath/samples/runtime-simulations-agent/uipath.json @@ -0,0 +1,5 @@ +{ + "functions": { + "main": "main.py:main" + } +} diff --git a/packages/uipath/samples/simulate-component-agent/input.json b/packages/uipath/samples/simulate-component-agent/input.json new file mode 100644 index 000000000..68093d7d0 --- /dev/null +++ b/packages/uipath/samples/simulate-component-agent/input.json @@ -0,0 +1,4 @@ +{ + "city": "London", + "days": 3 +} diff --git a/packages/uipath/samples/simulate-component-agent/main.py b/packages/uipath/samples/simulate-component-agent/main.py new file mode 100644 index 000000000..0acd8f0cb --- /dev/null +++ b/packages/uipath/samples/simulate-component-agent/main.py @@ -0,0 +1,118 @@ +"""Weather forecast agent demonstrating per-component simulation. + +This sample shows the new ``components`` simulation format where each tool +has its own simulation strategy and instructions, routed to the +simulate-component API instead of a local LLM. + +Run with real tools (no weather API — returns hardcoded defaults): + uipath run main -f input.json + +Run with per-component simulation (routes each tool call to the API): + uipath run main -f input.json --simulation "$(cat simulation.json)" +""" + +from pydantic import BaseModel +from pydantic.dataclasses import dataclass + +from uipath.eval.mocks import mockable +from uipath.tracing import traced + +# --------------------------------------------------------------------------- +# Input / Output models +# --------------------------------------------------------------------------- + + +@dataclass +class WeatherInput: + city: str + days: int = 3 + + +class CurrentWeather(BaseModel): + city: str + temperature: float # Celsius + condition: str + humidity: int # percent + + +class ForecastDay(BaseModel): + date: str # YYYY-MM-DD + high: float + low: float + condition: str + + +class WeatherReport(BaseModel): + current: CurrentWeather + forecast: list[ForecastDay] + summary: str + + +# --------------------------------------------------------------------------- +# Mockable tool functions +# --------------------------------------------------------------------------- + + +@traced(name="get_current_weather", span_type="tool") +@mockable() +async def get_current_weather(city: str) -> CurrentWeather: + """Fetch current weather conditions for a city from an external weather API. + + Args: + city: Name of the city (e.g. "London", "New York"). + + Returns: + CurrentWeather with temperature, condition, and humidity. + """ + # Real implementation would call a weather API such as OpenWeatherMap. + # Returns hardcoded defaults when not simulated. + return CurrentWeather(city=city, temperature=20.0, condition="unknown", humidity=50) + + +@traced(name="get_forecast", span_type="tool") +@mockable() +async def get_forecast(city: str, days: int = 3) -> list[ForecastDay]: + """Retrieve a multi-day weather forecast for a city. + + Args: + city: Name of the city. + days: Number of forecast days to retrieve (default: 3). + + Returns: + List of ForecastDay objects, one per requested day. + """ + # Real implementation would call a forecast API. + # Returns an empty list when not simulated. + return [] + + +# --------------------------------------------------------------------------- +# Agent entry point +# --------------------------------------------------------------------------- + + +@traced(name="main") +async def main(input: WeatherInput) -> WeatherReport: + """Fetch current weather and forecast for a city and produce a report. + + Args: + input: WeatherInput with city name and number of forecast days. + + Returns: + WeatherReport combining current conditions, forecast, and a summary. + """ + current = await get_current_weather(input.city) + forecast = await get_forecast(input.city, input.days) + + issues = [] + if current.humidity > 80: + issues.append("high humidity") + if current.temperature < 0: + issues.append("freezing temperatures") + + alert = f" Alerts: {', '.join(issues)}." if issues else "" + summary = ( + f"{input.city}: {current.temperature}°C, {current.condition}." + f" {len(forecast)}-day forecast available.{alert}" + ) + return WeatherReport(current=current, forecast=forecast, summary=summary) diff --git a/packages/uipath/samples/simulate-component-agent/pyproject.toml b/packages/uipath/samples/simulate-component-agent/pyproject.toml new file mode 100644 index 000000000..c4228e861 --- /dev/null +++ b/packages/uipath/samples/simulate-component-agent/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "simulate-component-agent" +version = "0.0.1" +description = "Weather forecast agent demonstrating per-component simulation" +authors = [{ name = "UiPath", email = "python-sdk@uipath.com" }] +dependencies = [ + "uipath", +] +requires-python = ">=3.11" + +[dependency-groups] +dev = [ + "uipath-dev", +] + +[tool.uv.sources] +uipath = { path = "../..", editable = true } diff --git a/packages/uipath/samples/simulate-component-agent/simulation.json b/packages/uipath/samples/simulate-component-agent/simulation.json new file mode 100644 index 000000000..fc87bd527 --- /dev/null +++ b/packages/uipath/samples/simulate-component-agent/simulation.json @@ -0,0 +1,73 @@ +{ + "enabled": true, + "components": [ + { + "componentId": "get_current_weather", + "componentType": "tool", + "simulationStrategy": 0, + "simulationInstruction": "Return realistic current weather for the given city. Use typical seasonal temperatures for the Northern Hemisphere in winter. the humidity should always be 70%", + "outputSchema": { + "type": "object", + "properties": { + "city": { + "type": "string" + }, + "temperature": { + "type": "number", + "description": "Temperature in Celsius" + }, + "condition": { + "type": "string", + "description": "e.g. cloudy, rainy, sunny" + }, + "humidity": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + }, + "required": [ + "city", + "temperature", + "condition", + "humidity" + ] + } + }, + { + "componentId": "get_forecast", + "componentType": "tool", + "simulationStrategy": 0, + "simulationInstruction": "Return a realistic multi-day weather forecast for the given city.", + "outputSchema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "description": "YYYY-MM-DD" + }, + "high": { + "type": "number", + "description": "High temperature in Celsius" + }, + "low": { + "type": "number", + "description": "Low temperature in Celsius" + }, + "condition": { + "type": "string" + } + }, + "required": [ + "date", + "high", + "low", + "condition" + ] + } + } + } + ] +} \ No newline at end of file diff --git a/packages/uipath/samples/simulate-component-agent/uipath.json b/packages/uipath/samples/simulate-component-agent/uipath.json new file mode 100644 index 000000000..a991b6914 --- /dev/null +++ b/packages/uipath/samples/simulate-component-agent/uipath.json @@ -0,0 +1,6 @@ +{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "functions": { + "main": "main.py:main" + } +} diff --git a/packages/uipath/specs/uipath.schema.json b/packages/uipath/specs/uipath.schema.json index 8f2a550f8..2ee966e82 100644 --- a/packages/uipath/specs/uipath.schema.json +++ b/packages/uipath/specs/uipath.schema.json @@ -9,6 +9,11 @@ "type": "string", "description": "Reference to this JSON schema for editor support" }, + "id": { + "type": "string", + "format": "uuid", + "description": "Stable unique identifier for the project, minted once on the first 'uipath init' and preserved for its lifetime. Used as the package 'projectId' at pack time. Do not change it." + }, "runtimeOptions": { "type": "object", "description": "Runtime behavior configuration", diff --git a/packages/uipath/specs/uipath.spec.md b/packages/uipath/specs/uipath.spec.md index 5c7599faa..9679bf223 100644 --- a/packages/uipath/specs/uipath.spec.md +++ b/packages/uipath/specs/uipath.spec.md @@ -9,6 +9,7 @@ The `uipath.json` file is a configuration file for UiPath projects that defines ```json { "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "id": "00000000-0000-0000-0000-000000000000", "runtimeOptions": { ... }, "designOptions": { ... }, "packOptions": { ... }, @@ -20,7 +21,29 @@ The `uipath.json` file is a configuration file for UiPath projects that defines ## Configuration Sections -### 1. `runtimeOptions` +### 1. `id` + +Stable unique identifier (GUID) for the project, minted once on the first `uipath init` and preserved for its lifetime. Used as the package `projectId` at pack time. + +**Properties:** + +| Property | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `id` | `string` (uuid) | No | minted on first `uipath init` | Stable identifier for the project. Do not change it. | + +> Do not change or remove `id`. It identifies your project consistently wherever it is deployed and run. Changing it makes the project look like a brand-new, unrelated one, so you lose the link to everything previously published and tracked under the old id. `uipath pack` rejects an `id` that is not a valid GUID. + +**Example:** + +```json +{ + "id": "00000000-0000-0000-0000-000000000001" +} +``` + +--- + +### 2. `runtimeOptions` Controls runtime behavior of your UiPath project. @@ -42,7 +65,7 @@ Controls runtime behavior of your UiPath project. --- -### 2. `designOptions` +### 3. `designOptions` Design-time configuration and preferences. @@ -57,7 +80,7 @@ Design-time configuration and preferences. --- -### 3. `packOptions` +### 4. `packOptions` Controls which files and directories are included or excluded when packaging your project. @@ -87,7 +110,7 @@ Controls which files and directories are included or excluded when packaging you --- -### 4. `functions` +### 5. `functions` Defines entrypoints for pure Python scripts. Each key is a friendly name for the entrypoint, and each value specifies the file path and function name. @@ -128,6 +151,7 @@ Defines entrypoints for pure Python scripts. Each key is a friendly name for the ```json { "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "id": "00000000-0000-0000-0000-000000000001", "runtimeOptions": { "isConversational": false }, @@ -218,6 +242,11 @@ The complete JSON Schema is available in `uipath.schema.json`: "type": "string", "description": "Reference to this JSON schema for editor support" }, + "id": { + "type": "string", + "format": "uuid", + "description": "Stable unique identifier for the project, minted once on the first 'uipath init' and preserved for its lifetime. Used as the package 'projectId' at pack time. Do not change it." + }, "runtimeOptions": { "type": "object", "description": "Runtime behavior configuration", diff --git a/packages/uipath/src/uipath/_cli/__init__.py b/packages/uipath/src/uipath/_cli/__init__.py index aa6e177e8..d8d3a8a46 100644 --- a/packages/uipath/src/uipath/_cli/__init__.py +++ b/packages/uipath/src/uipath/_cli/__init__.py @@ -45,6 +45,7 @@ "server": "cli_server", "register": "cli_register", "debug": "cli_debug", + "list-models": "cli_list_models", "assets": "services.cli_assets", "buckets": "services.cli_buckets", "context-grounding": "services.cli_context_grounding", diff --git a/packages/uipath/src/uipath/_cli/_auth/_auth_server.py b/packages/uipath/src/uipath/_cli/_auth/_auth_server.py index 4433b1c20..673d545b3 100644 --- a/packages/uipath/src/uipath/_cli/_auth/_auth_server.py +++ b/packages/uipath/src/uipath/_cli/_auth/_auth_server.py @@ -1,4 +1,5 @@ import asyncio +import hmac import http.server import json import os @@ -10,15 +11,6 @@ PORT = 6234 -# Custom exception for token received -class TokenReceivedSignal(Exception): - """Exception raised when a token is successfully received.""" - - def __init__(self, token_data): - self.token_data = token_data - super().__init__("Token received successfully") - - def make_request_handler_class( state, code_verifier, token_callback, domain, redirect_uri, client_id ): @@ -29,12 +21,60 @@ def log_message(self, format, *args) -> None: # do nothing pass + def _is_host_allowed(self) -> bool: + """Reject requests whose Host header is not loopback. + + Defends against DNS rebinding since the legitimate flow + always lands on localhost. + """ + host = self.headers.get("Host", "") + hostname = host.rsplit(":", 1)[0] + return hostname in ("localhost", "127.0.0.1") + + def _handle_host_error(self) -> bool: + """Return True if a host error was identified and handled (403).""" + if not self._is_host_allowed(): + self.send_error(403, "Invalid host") + return True + return False + + def _state_is_valid(self) -> bool: + """Validate the OAuth state supplied.""" + received = self.headers.get("X-Auth-State", "") + return hmac.compare_digest(received, state) + + def _handle_state_error(self) -> bool: + """Return True if a state error was identified and handled (403).""" + if not self._state_is_valid(): + self.send_error(403, "Invalid or missing state") + return True + return False + + def _read_json_body(self): + """Read and parse the JSON request body. + + Returns the decoded object, or None if the + expected headers are missing or body is malformed. + """ + try: + content_length = int(self.headers["Content-Length"]) + post_data = self.rfile.read(content_length) + return json.loads(post_data.decode("utf-8")) + except (KeyError, TypeError, ValueError): + self.send_error(400, "Invalid request") + return None + def do_POST(self): """Handle POST requests to /set_token.""" + if self._handle_host_error(): + return if self.path == "/set_token": - content_length = int(self.headers["Content-Length"]) - post_data = self.rfile.read(content_length) - token_data = json.loads(post_data.decode("utf-8")) + if self._handle_state_error(): + return + + token_data = self._read_json_body() + if token_data is None: + return self.send_response(200) self.end_headers() @@ -44,9 +84,13 @@ def do_POST(self): token_callback(token_data) elif self.path == "/log": - content_length = int(self.headers["Content-Length"]) - post_data = self.rfile.read(content_length) - logs = json.loads(post_data.decode("utf-8")) + if self._handle_state_error(): + return + + logs = self._read_json_body() + if logs is None: + return + # Write logs to .uipath/.error_log file uipath_dir = os.path.join(os.getcwd(), ".uipath") os.makedirs(uipath_dir, exist_ok=True) @@ -66,6 +110,8 @@ def do_POST(self): def do_GET(self): """Handle GET requests by serving index.html.""" + if self._handle_host_error(): + return # Always serve index.html regardless of the path try: index_path = os.path.join(os.path.dirname(__file__), "index.html") @@ -86,16 +132,6 @@ def do_GET(self): except FileNotFoundError: self.send_error(404, "File not found") - def end_headers(self): - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type") - super().end_headers() - - def do_OPTIONS(self): - self.send_response(200) - self.end_headers() - return SimpleHTTPSRequestHandler @@ -149,7 +185,7 @@ def create_server(self, state, code_verifier, domain): self.redirect_uri, self.client_id, ) - self.httpd = socketserver.TCPServer(("", self.port), handler) + self.httpd = socketserver.TCPServer(("127.0.0.1", self.port), handler) return self.httpd def _run_server(self): diff --git a/packages/uipath/src/uipath/_cli/_auth/index.html b/packages/uipath/src/uipath/_cli/_auth/index.html index a361e73de..08f81d81b 100644 --- a/packages/uipath/src/uipath/_cli/_auth/index.html +++ b/packages/uipath/src/uipath/_cli/_auth/index.html @@ -519,6 +519,9 @@

Authenticate CLI

async function sendLogs(logs) { await fetch(`${baseUrl}/log`, { method: 'POST', + headers: { + 'X-Auth-State': "__PY_REPLACE_EXPECTED_STATE__" + }, body: JSON.stringify(logs) }); } @@ -559,6 +562,9 @@

Authenticate CLI

await sendLogs(logs); await fetch(`${baseUrl}/set_token`, { method: 'POST', + headers: { + 'X-Auth-State': state + }, body: JSON.stringify(tokenData) }); diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index 24c1be024..442322e36 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -4,7 +4,6 @@ import json import logging import os -import uuid from typing import Any from urllib.parse import urlparse @@ -14,11 +13,11 @@ UiPathConversationEvent, UiPathConversationExchangeEndEvent, UiPathConversationExchangeEvent, - UiPathConversationInterruptEndEvent, - UiPathConversationInterruptEvent, + UiPathConversationExecutingToolCallEvent, UiPathConversationMessageEvent, - UiPathConversationToolCallConfirmationInterruptStartEvent, - UiPathConversationToolCallConfirmationValue, + UiPathConversationToolCallConfirmationEvent, + UiPathConversationToolCallEndEvent, + UiPathConversationToolCallEvent, ) from uipath.core.triggers import UiPathResumeTrigger from uipath.runtime.chat import UiPathChatProtocol @@ -107,6 +106,7 @@ def __init__( exchange_id: str, headers: dict[str, str], auth: dict[str, Any] | None = None, + end_exchange: bool = True, ): """Initialize the WebSocket chat bridge. @@ -116,6 +116,8 @@ def __init__( exchange_id: The exchange ID for this session headers: HTTP headers to send during connection auth: Optional authentication data to send during connection + end_exchange: Whether to send the exchange-end event to CAS on + completion. """ self.websocket_url = websocket_url self.websocket_path = websocket_path @@ -123,12 +125,16 @@ def __init__( self.exchange_id = exchange_id self.auth = auth self.headers = headers + self.end_exchange = end_exchange self._client: Any | None = None self._connected_event = asyncio.Event() - # Interrupt state for HITL round-trip - self._interrupt_end_event = asyncio.Event() - self._interrupt_end_value: UiPathConversationInterruptEndEvent | None = None + self._tool_resume_event = asyncio.Event() + self._tool_resume_value: ( + UiPathConversationToolCallConfirmationEvent + | UiPathConversationToolCallEndEvent + | None + ) = None self._current_message_id: str | None = None # Set CAS_WEBSOCKET_DISABLED when using the debugger to prevent websocket errors from @@ -281,9 +287,16 @@ async def emit_message_event( async def emit_exchange_end_event(self) -> None: """Send an exchange end event. + When end_exchange is False the exchange is left open — the event is not + sent to CAS so a downstream consumer can continue and end it later. + Raises: RuntimeError: If client is not connected """ + if not self.end_exchange: + logger.info("end_exchange is False; leaving the exchange open.") + return + if self._client is None: raise RuntimeError("WebSocket client not connected. Call connect() first.") @@ -363,67 +376,54 @@ async def emit_exchange_error_event(self, error: Exception) -> None: raise RuntimeError(f"Failed to send exchange error event: {e}") from e async def emit_interrupt_event(self, resume_trigger: UiPathResumeTrigger): - if self._client and self._connected_event.is_set(): - try: - # Clear previous interrupt state and generate new interrupt_id - self._interrupt_id = str(uuid.uuid4()) - - # Ensure we have a valid message_id - if self._current_message_id is None: - raise RuntimeError( - "Cannot emit interrupt event: no current message_id set" - ) - - # Ensure api_resume is not None - if resume_trigger.api_resume is None: - raise RuntimeError( - "Cannot emit interrupt event: api_resume is None" - ) - - interrupt_event = UiPathConversationEvent( - conversation_id=self.conversation_id, - exchange=UiPathConversationExchangeEvent( - exchange_id=self.exchange_id, - message=UiPathConversationMessageEvent( - message_id=self._current_message_id, - interrupt=UiPathConversationInterruptEvent( - interrupt_id=self._interrupt_id, - start=UiPathConversationToolCallConfirmationInterruptStartEvent( - type="uipath_cas_tool_call_confirmation", - value=UiPathConversationToolCallConfirmationValue( - **resume_trigger.api_resume.request - ), - ), - ), - ), - ), - ) + """No-op. - event_data = interrupt_event.model_dump( - mode="json", exclude_none=True, by_alias=True - ) - if self._websocket_disabled: - logger.info( - f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}" - ) - else: - await self._client.emit("ConversationEvent", event_data) - except Exception as e: - logger.warning(f"Error sending interrupt event: {e}") + Tool confirmation is handled end-to-end via ``startToolCall`` with + ``requireConfirmation: true`` paired with ``wait_for_resume()``. + executingToolCall is emitted by the MessageMapper (non-confirmed + tools) and the runtime loop post-confirmation (confirmed tools). + """ + return None - async def wait_for_resume(self) -> dict[str, Any]: - """Wait for the interrupt_end event to be received. + async def emit_executing_tool_call_event( + self, + tool_call_id: str, + tool_input: dict[str, Any] | None = None, + ) -> None: + """Emit an executingToolCall event. - Returns: - Resume data from the interrupt end event + Called by the runtime loop after a tool-call confirmation resumes + to signal that the tool is about to execute with the final input. """ - self._interrupt_end_event.clear() - self._interrupt_end_value = None + if not self._current_message_id: + return - await self._interrupt_end_event.wait() + executing_event = UiPathConversationMessageEvent( + message_id=self._current_message_id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=tool_call_id, + executing=UiPathConversationExecutingToolCallEvent( + input=tool_input, + ), + ), + ) + await self.emit_message_event(executing_event) - if self._interrupt_end_value: - return self._interrupt_end_value.model_dump(mode="python", by_alias=False) + async def wait_for_resume(self) -> dict[str, Any]: + """Wait for a tool resume event (confirmToolCall or endToolCall) to be received.""" + if self._tool_resume_value is None: + self._tool_resume_event.clear() + await self._tool_resume_event.wait() + + value = self._tool_resume_value + self._tool_resume_value = None + self._tool_resume_event.clear() + + """For the case where there's no tool confirmation and the client side tool sends endToolCall back before wait_for_resume is called. + Unlikely in practice, but possible in theory, since executingToolCall is emitted during the streaming. + """ + if value: + return value.model_dump(mode="python", by_alias=False) return {} @property @@ -458,17 +458,14 @@ async def _handle_conversation_event( if ( parsed_event.exchange and parsed_event.exchange.message - and parsed_event.exchange.message.interrupt - and parsed_event.exchange.message.interrupt.end + and (tool_call := parsed_event.exchange.message.tool_call) ): - interrupt = parsed_event.exchange.message.interrupt - - if interrupt.interrupt_id == self._interrupt_id: - logger.info( - f"Received endInterrupt for interrupt_id: {self._interrupt_id}" - ) - self._interrupt_end_value = interrupt.end - self._interrupt_end_event.set() + if confirm := tool_call.confirm: + self._tool_resume_value = confirm + self._tool_resume_event.set() + elif end := tool_call.end: + self._tool_resume_value = end + self._tool_resume_event.set() except Exception as e: logger.warning(f"Error parsing conversation event: {e}") @@ -532,19 +529,29 @@ def get_chat_bridge( # Build headers from context headers = { "Authorization": f"Bearer {os.environ.get('UIPATH_ACCESS_TOKEN', '')}", - "X-UiPath-Internal-TenantId": f"{context.tenant_id}" + "X-UiPath-Internal-TenantId": context.tenant_id or os.environ.get("UIPATH_TENANT_ID", ""), - "X-UiPath-Internal-AccountId": f"{context.org_id}" + "X-UiPath-Internal-AccountId": context.org_id or os.environ.get("UIPATH_ORGANIZATION_ID", ""), "X-UiPath-ConversationId": context.conversation_id, } + # Conversation owner id (conversationalService.conversationalUserId) that CAS forwards via + # FpsProperties; always sent when present. It's there for RunAsMe=false, where the unattended + # robot's token subject is the robot account rather than the conversation owner, so CAS validates + # this presented id against conversation.user_id on the handshake instead of the token subject. + # Sent as a header (not a query param) to keep it out of access / load-balancer logs. + conversational_user_id = getattr(context, "conversational_user_id", None) + if conversational_user_id: + headers["X-UiPath-Internal-ConversationalUserId"] = conversational_user_id + return SocketIOChatBridge( websocket_url=websocket_url, websocket_path=websocket_path, conversation_id=context.conversation_id, exchange_id=context.exchange_id, headers=headers, + end_exchange=getattr(context, "end_exchange", True), ) diff --git a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py new file mode 100644 index 000000000..8473c1574 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py @@ -0,0 +1,266 @@ +"""Voice tool-call session — persistent socket.io connection to CAS.""" + +from __future__ import annotations + +import asyncio +import logging +import os +from collections.abc import Awaitable, Callable +from enum import Enum +from typing import Any +from urllib.parse import urlparse + +from pydantic import ValidationError + +from uipath.core.chat import ( + UiPathVoiceToolCallMessage, + UiPathVoiceToolCallRequest, + UiPathVoiceToolCallResult, +) +from uipath.runtime.context import UiPathRuntimeContext + +logger = logging.getLogger(__name__) + + +_ATTEMPT_CAS_SOCKET_CONNECTION_TIMEOUT_SECONDS = 15.0 +_INFLIGHT_TOOL_DRAIN_AFTER_AGENT_END_TIMEOUT_SECONDS = 30.0 + + +class VoiceToolCallSessionError(RuntimeError): + pass + + +class VoiceSessionEndReason(str, Enum): + COMPLETED = "completed" + DISCONNECTED = "disconnected" + READY_EMIT_FAILED = "ready_emit_failed" + + +class VoiceEvent(str, Enum): + """CAS voice-session protocol events (excludes socket.io lifecycle).""" + + TOOL_CALL = "voice_tool_call" # received + SESSION_ENDED = "voice_session_ended" # received + TOOLS_READY = "voice_tools_ready" # sent + TOOL_RESULT = "voice_tool_result" # sent + + +ToolHandler = Callable[ + [UiPathVoiceToolCallRequest], Awaitable[UiPathVoiceToolCallResult] +] + + +class VoiceToolCallSession: + """Socket.io session with CAS for tool-call traffic. + + Receives `voice_tool_call` batches, emits one `voice_tool_result` per + `callId`, exits on `voice_session_ended` or disconnect. CAS pulls + agent config from Orchestrator directly; this session carries only + tool calls. + """ + + def __init__( + self, + url: str, + socketio_path: str, + headers: dict[str, str], + tool_handler: ToolHandler, + ) -> None: + self._url = url + self._socketio_path = socketio_path + self._headers = headers + self._tool_handler = tool_handler + self._client: Any = None + self._done = asyncio.Event() + self._in_flight: set[asyncio.Task[None]] = set() + self._end_reason: VoiceSessionEndReason | None = None + + async def run(self) -> VoiceSessionEndReason: + """Connect, dispatch tool calls until session ends, then disconnect. + + Raises: + VoiceToolCallSessionError: If connecting to CAS fails. + """ + from socketio import AsyncClient # type: ignore[import-untyped] + + self._client = AsyncClient(logger=False, engineio_logger=False) + self._client.on("connect", self._handle_connect) + self._client.on("disconnect", self._handle_disconnect) + self._client.on(VoiceEvent.TOOL_CALL, self._handle_tool_call) + self._client.on(VoiceEvent.SESSION_ENDED, self._handle_session_ended) + + try: + await asyncio.wait_for( + self._client.connect( + url=self._url, + socketio_path=self._socketio_path, + headers=self._headers, + transports=["websocket"], + ), + timeout=_ATTEMPT_CAS_SOCKET_CONNECTION_TIMEOUT_SECONDS, + ) + except Exception as exc: + await self._safe_disconnect("after connect-failure") + raise VoiceToolCallSessionError( + f"Failed to connect to CAS voice endpoint: {exc}" + ) from exc + + try: + await self._done.wait() + await self._drain_in_flight() + finally: + await self._safe_disconnect("on shutdown") + + return self._end_reason or VoiceSessionEndReason.DISCONNECTED + + async def _safe_disconnect(self, when: str) -> None: + try: + await self._client.disconnect() + except Exception as exc: + logger.debug("[Voice] disconnect %s raised: %s", when, exc) + + def _end_session(self, reason: VoiceSessionEndReason) -> None: + # First writer wins: a late disconnect must not overwrite COMPLETED. + if self._end_reason is None: + self._end_reason = reason + self._done.set() + + async def _drain_in_flight(self) -> None: + """Wait for in-flight tool tasks to finish, capped by the drain timeout.""" + if not self._in_flight: + return + logger.info( + "[Voice] Session ended with %d in-flight tool task(s); draining (max %.0fs)", + len(self._in_flight), + _INFLIGHT_TOOL_DRAIN_AFTER_AGENT_END_TIMEOUT_SECONDS, + ) + try: + await asyncio.wait_for( + asyncio.gather(*self._in_flight, return_exceptions=True), + timeout=_INFLIGHT_TOOL_DRAIN_AFTER_AGENT_END_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + unfinished = sum(1 for t in self._in_flight if not t.done()) + logger.warning( + "[Voice] %d tool task(s) did not complete within %.0fs of session end", + unfinished, + _INFLIGHT_TOOL_DRAIN_AFTER_AGENT_END_TIMEOUT_SECONDS, + ) + + async def _handle_connect(self) -> None: + logger.info("[Voice] Socket.io connected to CAS") + try: + await self._client.emit(VoiceEvent.TOOLS_READY, {}) + except Exception as exc: + # CAS gates tool dispatch on this event; without it the session is dead. + logger.warning("[Voice] emit voice_tools_ready failed: %s", exc) + self._end_session(VoiceSessionEndReason.READY_EMIT_FAILED) + + async def _handle_disconnect(self) -> None: + logger.info("[Voice] Socket.io disconnected from CAS") + self._end_session(VoiceSessionEndReason.DISCONNECTED) + + async def _handle_tool_call(self, data: dict[str, Any], *_: Any) -> None: + """Spawn a task per call and return — the reader must stay free for `voice_session_ended`.""" + if self._done.is_set(): + return + + try: + message = UiPathVoiceToolCallMessage.model_validate(data) + except ValidationError as exc: + logger.warning("[Voice] invalid voice_tool_call payload: %s", exc) + return + + for call in message.calls: + task = asyncio.create_task(self._execute_tool_call(call)) + self._in_flight.add(task) + task.add_done_callback(self._in_flight.discard) + + async def _execute_tool_call(self, call: UiPathVoiceToolCallRequest) -> None: + """Run one tool call and emit its `voice_tool_result`.""" + logger.info( + "[Voice] voice_tool_call dispatched: %s (%s) args=%s", + call.tool_name, + call.call_id, + call.args, + ) + try: + tool_result = await self._tool_handler(call) + except Exception as exc: + logger.exception("[Voice] Tool call execution failed: %s", call.tool_name) + tool_result = UiPathVoiceToolCallResult(result=str(exc), is_error=True) + + try: + await self._client.emit( + VoiceEvent.TOOL_RESULT, + {"callId": call.call_id, **tool_result.model_dump(by_alias=True)}, + ) + except Exception as exc: + logger.debug( + "[Voice] emit voice_tool_result failed for %s: %s", call.call_id, exc + ) + return + logger.info( + "[Voice] voice_tool_result sent: %s (isError=%s)", + call.call_id, + tool_result.is_error, + ) + + async def _handle_session_ended(self, _data: Any, *_: Any) -> None: + logger.info("[Voice] voice_session_ended received") + self._end_session(VoiceSessionEndReason.COMPLETED) + + +def get_voice_bridge( + context: UiPathRuntimeContext, + tool_handler: ToolHandler, +) -> VoiceToolCallSession: + """Factory for a CAS voice tool-call session. + + Raises: + RuntimeError: If UIPATH_URL is not set or invalid. + """ + assert context.conversation_id is not None, "conversation_id must be set in context" + + if cas_host := os.environ.get("CAS_WEBSOCKET_HOST"): + url = f"ws://{cas_host}?conversationId={context.conversation_id}" + socketio_path = "/socket.io" + logger.warning( + f"CAS_WEBSOCKET_HOST is set. Using websocket_url '{url}{socketio_path}'." + ) + else: + base_url = os.environ.get("UIPATH_URL") + if not base_url: + raise RuntimeError( + "UIPATH_URL environment variable required for conversational mode" + ) + parsed = urlparse(base_url) + if not parsed.netloc: + raise RuntimeError(f"Invalid UIPATH_URL format: {base_url}") + url = f"wss://{parsed.netloc}?conversationId={context.conversation_id}" + socketio_path = "autopilotforeveryone_/websocket_/socket.io" + + headers = { + "Authorization": f"Bearer {os.environ.get('UIPATH_ACCESS_TOKEN', '')}", + "X-UiPath-Internal-TenantId": context.tenant_id + or os.environ.get("UIPATH_TENANT_ID", ""), + "X-UiPath-Internal-AccountId": context.org_id + or os.environ.get("UIPATH_ORGANIZATION_ID", ""), + "X-UiPath-ConversationId": context.conversation_id, + } + + # Conversation owner id (conversationalService.conversationalUserId) that CAS forwards via + # FpsProperties; always sent when present. It's there for RunAsMe=false, where the unattended + # robot's token subject is the robot account rather than the conversation owner, so CAS validates + # this presented id against conversation.user_id on the handshake instead of the token subject. + # Sent as a header (not a query param) to keep it out of access / load-balancer logs. + conversational_user_id = getattr(context, "conversational_user_id", None) + if conversational_user_id: + headers["X-UiPath-Internal-ConversationalUserId"] = conversational_user_id + + return VoiceToolCallSession( + url=url, + socketio_path=socketio_path, + headers=headers, + tool_handler=tool_handler, + ) diff --git a/packages/uipath/src/uipath/_cli/_debug/_bridge.py b/packages/uipath/src/uipath/_cli/_debug/_bridge.py index 9607398a0..cfd286b5c 100644 --- a/packages/uipath/src/uipath/_cli/_debug/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_debug/_bridge.py @@ -19,9 +19,15 @@ UiPathRuntimeResult, UiPathRuntimeStatus, ) -from uipath.runtime.debug import UiPathDebugProtocol, UiPathDebugQuitError +from uipath.runtime.debug import ( + DetachedDebugBridge, + UiPathDebugProtocol, + UiPathDebugQuitError, +) from uipath.runtime.events import UiPathRuntimeStateEvent, UiPathRuntimeStatePhase +DebugAttachMode = Literal["signalr", "console", "none"] + logger = logging.getLogger(__name__) @@ -741,6 +747,9 @@ async def _handle_start(self, args: list[Any]) -> None: f"Debug started: breakpoints={self.state.breakpoints}, step_mode={step_mode}" ) + # handle race conditions, runtime connected to debug bridge before the receiver + await self.emit_execution_started() + async def _handle_resume(self, args: list[Any]) -> None: """Handle Resume command from SignalR server. @@ -871,18 +880,27 @@ def get_remote_debug_bridge(context: UiPathRuntimeContext) -> UiPathDebugProtoco def get_debug_bridge( - context: UiPathRuntimeContext, verbose: bool = True + context: UiPathRuntimeContext, + verbose: bool = True, + attach: DebugAttachMode | None = None, ) -> UiPathDebugProtocol: """Factory to get appropriate debug bridge based on context. Args: context: The runtime context containing debug configuration. verbose: If True, console bridge shows all state updates. If False, only breakpoints. + attach: Explicit attach mode. When None, falls back to + ``context.job_id``-based selection. Returns: An instance of UiPathDebugBridge suitable for the context. """ - if context.job_id: + if attach == "none": + return DetachedDebugBridge() + if attach == "signalr": return get_remote_debug_bridge(context) - else: + if attach == "console": return ConsoleDebugBridge(verbose=verbose) + if context.job_id: + return get_remote_debug_bridge(context) + return ConsoleDebugBridge(verbose=verbose) diff --git a/packages/uipath/src/uipath/_cli/_errors.py b/packages/uipath/src/uipath/_cli/_errors.py new file mode 100644 index 000000000..feb7006a4 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_errors.py @@ -0,0 +1,20 @@ +class EntrypointDiscoveryException(Exception): + """Raised when entrypoint auto-discovery fails.""" + + def __init__(self, entrypoints: list[str]): + self.entrypoints = entrypoints + + def get_usage_help(self) -> list[str]: + if self.entrypoints: + lines = ["Available entrypoints:"] + for name in self.entrypoints: + lines.append(f" - {name}") + return lines + return [ + "No entrypoints found.", + "", + "To configure entrypoints, use one of the following:", + " 1. Functions project (uipath.json)", + " 2. Framework-specific project (e.g. langgraph.json, llamaindex.json, openai_agents.json)", + " 3. MCP project (mcp.json)", + ] diff --git a/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py b/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py index 7c5114516..fd4849076 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py +++ b/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py @@ -102,11 +102,18 @@ def __init__(self): self._console = console_logger self._rich_console = Console() self._project_id = os.getenv("UIPATH_PROJECT_ID", None) - if not self._project_id: + self._agent_id = os.getenv("UIPATH_AGENT_ID") or self._project_id + if not self._agent_id: logger.warning( "Cannot report data to StudioWeb. Please set UIPATH_PROJECT_ID." ) + # Map UIPATH_PROJECT_FILES_SOURCE (Local/Cloud) to the backend's + # ProjectFilesSource enum integer. Without this every row the worker + # creates lands as Cloud, and the UI's `?projectFilesSource=1` filter + # never matches local-workspace runs. + self._project_files_source = self._resolve_project_files_source() + self.eval_set_ids: dict[str, str] = {} # Track eval_set_id per execution self.eval_set_run_ids: dict[str, str] = {} self.evaluators: dict[str, Any] = {} @@ -1089,6 +1096,29 @@ def _collect_coded_results( evaluator_runs.append(evaluator_run) return evaluator_runs, evaluator_scores_list + @staticmethod + def _resolve_project_files_source() -> int | None: + raw = os.getenv("UIPATH_PROJECT_FILES_SOURCE") + if not raw: + return None + normalized = raw.strip().lower() + if normalized == "local": + return 1 + if normalized == "cloud": + return 0 + try: + return int(normalized) + except ValueError: + logger.warning( + f"Unrecognized UIPATH_PROJECT_FILES_SOURCE value: {raw!r}; ignoring." + ) + return None + + def _project_files_source_field(self) -> dict[str, int]: + if self._project_files_source is None: + return {} + return {"projectFilesSource": self._project_files_source} + def _update_eval_run_spec( self, assertion_runs: list[dict[str, Any]], @@ -1115,6 +1145,7 @@ def _update_eval_run_spec( }, "completionMetrics": {"duration": int(execution_time * 1000)}, "assertionRuns": assertion_runs, + **self._project_files_source_field(), } # Legacy backend expects payload wrapped in "request" field @@ -1133,7 +1164,7 @@ def _update_eval_run_spec( return RequestSpec( method="PUT", endpoint=Endpoint( - f"{self._get_endpoint_prefix()}execution/agents/{self._project_id}/{endpoint_suffix}evalRun" + f"{self._get_endpoint_prefix()}execution/agents/{self._agent_id}/{endpoint_suffix}evalRun" ), json=payload, headers=self._tenant_header(), @@ -1166,6 +1197,7 @@ def _update_coded_eval_run_spec( }, "completionMetrics": {"duration": int(execution_time * 1000)}, "evaluatorRuns": evaluator_runs, + **self._project_files_source_field(), } # Log the payload for debugging coded eval run updates @@ -1181,7 +1213,7 @@ def _update_coded_eval_run_spec( return RequestSpec( method="PUT", endpoint=Endpoint( - f"{self._get_endpoint_prefix()}execution/agents/{self._project_id}/{endpoint_suffix}evalRun" + f"{self._get_endpoint_prefix()}execution/agents/{self._agent_id}/{endpoint_suffix}evalRun" ), json=payload, headers=self._tenant_header(), @@ -1235,6 +1267,7 @@ def _create_eval_run_spec( "evalSnapshot": eval_snapshot, # Backend expects integer status "status": EvaluationStatus.IN_PROGRESS.value, + **self._project_files_source_field(), } # Legacy backend expects payload wrapped in "request" field @@ -1253,7 +1286,7 @@ def _create_eval_run_spec( return RequestSpec( method="POST", endpoint=Endpoint( - f"{self._get_endpoint_prefix()}execution/agents/{self._project_id}/{endpoint_suffix}evalRun" + f"{self._get_endpoint_prefix()}execution/agents/{self._agent_id}/{endpoint_suffix}evalRun" ), json=payload, headers=self._tenant_header(), @@ -1283,7 +1316,7 @@ def _create_eval_set_run_spec( eval_set_id_value = str(uuid.uuid5(uuid.NAMESPACE_DNS, eval_set_id)) inner_payload: dict[str, Any] = { - "agentId": self._project_id, + "agentId": self._agent_id, "evalSetId": eval_set_id_value, "agentSnapshot": agent_snapshot.model_dump(by_alias=True), # Backend expects integer status @@ -1291,6 +1324,7 @@ def _create_eval_set_run_spec( "numberOfEvalsExecuted": no_of_evals, # Source is required by the backend (0 = coded SDK) "source": 0, + **self._project_files_source_field(), } # Both coded and legacy send payload directly at root level @@ -1309,7 +1343,7 @@ def _create_eval_set_run_spec( return RequestSpec( method="POST", endpoint=Endpoint( - f"{self._get_endpoint_prefix()}execution/agents/{self._project_id}/{endpoint_suffix}evalSetRun" + f"{self._get_endpoint_prefix()}execution/agents/{self._agent_id}/{endpoint_suffix}evalSetRun" ), json=payload, headers=self._tenant_header(), @@ -1353,6 +1387,7 @@ def _update_eval_set_run_spec( # Backend expects integer status "status": status.value, "evaluatorScores": evaluator_scores_list, + **self._project_files_source_field(), } # Legacy backend expects payload wrapped in "request" field @@ -1374,7 +1409,7 @@ def _update_eval_set_run_spec( return RequestSpec( method="PUT", endpoint=Endpoint( - f"{self._get_endpoint_prefix()}execution/agents/{self._project_id}/{endpoint_suffix}evalSetRun" + f"{self._get_endpoint_prefix()}execution/agents/{self._agent_id}/{endpoint_suffix}evalSetRun" ), json=payload, headers=self._tenant_header(), @@ -1406,12 +1441,12 @@ def _get_eval_runs_spec( if is_coded: endpoint_path = ( - f"{prefix}execution/agents/{self._project_id}/coded/" + f"{prefix}execution/agents/{self._agent_id}/coded/" f"evalSets/{eval_set_id}/evalSetRuns/{eval_set_run_id}/evalRuns" ) else: endpoint_path = ( - f"{prefix}execution/agents/{self._project_id}/" + f"{prefix}execution/agents/{self._agent_id}/" f"evalSets/{eval_set_id}/evalSetRuns/{eval_set_run_id}/evalRuns" ) @@ -1420,10 +1455,14 @@ def _get_eval_runs_spec( f"eval_set_run_id={eval_set_run_id}, evaluation_id={evaluation_id}, coded={is_coded}" ) + # The backend's listing endpoint filters by projectFilesSource + + # cloudUserId so the UI only shows the caller's local rows. Mirror + # that here so resume lookups match the row written by the same + # worker session. return RequestSpec( method="GET", endpoint=Endpoint(endpoint_path), - params={}, # No query params needed - evalSetRunId is in the path + params=self._project_files_source_field(), headers=self._tenant_header(), ) diff --git a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py index ad9549a6c..bdbdc67f7 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py @@ -308,28 +308,38 @@ def _enrich_properties(self, properties: dict[str, Any]) -> None: Args: properties: The properties dictionary to enrich. """ - # Add UiPath context + from uipath.platform.common._span_utils import resolve_project_id + if UiPathConfig.project_id: properties["ProjectId"] = UiPathConfig.project_id - properties["AgentId"] = UiPathConfig.project_id + if agent_id := resolve_project_id(): + properties["AgentId"] = agent_id - # Get organization ID from UiPathConfig if UiPathConfig.organization_id: properties["CloudOrganizationId"] = UiPathConfig.organization_id - # Get CloudUserId from JWT token - try: - cloud_user_id = get_claim_from_token("sub") - if cloud_user_id: - properties["CloudUserId"] = cloud_user_id - except Exception: - pass # CloudUserId is optional + cloud_user_id = UiPathConfig.cloud_user_id + if not cloud_user_id: + try: + cloud_user_id = get_claim_from_token("sub") + except Exception: + cloud_user_id = None + if cloud_user_id: + properties["CloudUserId"] = cloud_user_id - # Get tenant ID from environment tenant_id = os.getenv("UIPATH_TENANT_ID") if tenant_id: properties["TenantId"] = tenant_id - # Add source identifier + # Origin of the eval-set run as classified by the caller (e.g. Manual, + # Protegi, FirstSuccessfulRun). The Agents backend forwards the value + # via UIPATH_EVAL_RUN_SOURCE so adoption dashboards can exclude + # auto-triggered runs (e.g. first-successful-run) from user-driven counts. + # Distinct from the `Source` dimension below, which categorises the SDK + # emitter ("uipath-python-cli"), not the run origin. + run_source = os.getenv("UIPATH_EVAL_RUN_SOURCE") + if run_source: + properties["RunSource"] = run_source + properties["Source"] = "uipath-python-cli" properties["ApplicationName"] = "UiPath.Eval" diff --git a/packages/uipath/src/uipath/_cli/_push/_resolvers.py b/packages/uipath/src/uipath/_cli/_push/_resolvers.py new file mode 100644 index 000000000..e609014d5 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_push/_resolvers.py @@ -0,0 +1,177 @@ +from typing import AsyncIterator + +from uipath.platform.connections import ConnectionsService +from uipath.platform.errors import EnrichedException, FolderNotFoundException +from uipath.platform.resource_catalog import ( + Resource, + ResourceCatalogService, + ResourceType, +) + +from .._utils._studio_project import ( + ReferencedResourceFolder, + ReferencedResourceRequest, + VirtualResourceRequest, +) +from ..models.runtime_schema import BindingResource, Bindings +from ._resource_actions import CreateReference, CreateVirtual, ResourceAction, Skip + +_NOT_FOUND_SUFFIX = "was not found and will not be added to the solution." + + +async def resolve_bindings( + bindings: Bindings, + resource_catalog: ResourceCatalogService, + connections: ConnectionsService, + supported_virtual_kinds: set[str], +) -> AsyncIterator[ResourceAction]: + """Yield one ResourceAction per importable binding. + + Bindings that should be silently ignored (e.g. guardrail bindings without a + folderPath) are filtered out here. + """ + for binding in bindings.resources: + action = await _resolve_binding( + binding, resource_catalog, connections, supported_virtual_kinds + ) + if action is not None: + yield action + + +async def _resolve_binding( + binding: BindingResource, + resource_catalog: ResourceCatalogService, + connections: ConnectionsService, + supported_virtual_kinds: set[str], +) -> ResourceAction | None: + if binding.resource == "connection": + return await _resolve_connection(binding, resource_catalog, connections) + return await _resolve_regular(binding, resource_catalog, supported_virtual_kinds) + + +async def _resolve_connection( + binding: BindingResource, + resource_catalog: ResourceCatalogService, + connections: ConnectionsService, +) -> ResourceAction | None: + connection_id_value = binding.value.get("ConnectionId") + if connection_id_value is None: + raise ValueError( + f"Connection binding {binding.key!r} is missing required field 'ConnectionId'" + ) + connection_key = connection_id_value.default_value + + try: + connection = await connections.retrieve_async(connection_key) + except EnrichedException: + connector_name = (binding.metadata or {}).get("Connector") + return Skip( + message=( + f"Connection with key '{connection_key}' of type " + f"'{connector_name}' {_NOT_FOUND_SUFFIX}" + ) + ) + + resource_name: str = connection.name + folder_path: str = connection.folder.get("path") + + found = await _find_in_resource_catalog( + resource_catalog, "connection", resource_name, folder_path + ) + if found is None: + return Skip( + message=( + f"Resource '{resource_name}' of type 'connection' at folder path " + f"'{folder_path}' {_NOT_FOUND_SUFFIX}" + ) + ) + return _build_create_reference(found, resource_name) + + +async def _resolve_regular( + binding: BindingResource, + resource_catalog: ResourceCatalogService, + supported_virtual_kinds: set[str], +) -> ResourceAction | None: + name_value = binding.value.get("name") + folder_path_value = binding.value.get("folderPath") + if not folder_path_value: + # guardrail resource, nothing to import + return None + if name_value is None: + raise ValueError(f"Binding {binding.key!r} is missing required field 'name'") + resource_name: str = name_value.default_value + folder_path: str = folder_path_value.default_value + resource_type: str = binding.resource + + found = await _find_in_resource_catalog( + resource_catalog, resource_type, resource_name, folder_path + ) + if found is not None: + return _build_create_reference(found, resource_name) + + if resource_type not in supported_virtual_kinds: + return Skip( + message=( + f"Cannot create virtual resource '{resource_name}' — " + f"kind '{resource_type}' is not supported." + ) + ) + + sub_type: str | None = (binding.metadata or {}).get("SubType") + return CreateVirtual( + request=VirtualResourceRequest( + kind=resource_type, + name=resource_name, + type=sub_type, + ) + ) + + +async def _find_in_resource_catalog( + resource_catalog: ResourceCatalogService, + resource_type: str, + name: str, + folder_path: str, +) -> Resource | None: + """Look up a single resource in the Resource Catalog. + + Returns the first match or None if the catalog can't search this kind, the + folder is unknown, or no resource matches. + """ + catalog_type = next( + (m for m in ResourceType if m.value == resource_type.lower()), None + ) + if catalog_type is None: + return None + + resources = resource_catalog.list_by_type_async( + resource_type=catalog_type, name=name, folder_path=folder_path + ) + try: + return await anext(resources, None) + except FolderNotFoundException: + return None + finally: + await resources.aclose() + + +def _build_create_reference( + found_resource: Resource, resource_name: str +) -> CreateReference: + folder = next(iter(found_resource.folders)) + return CreateReference( + request=ReferencedResourceRequest( + key=found_resource.resource_key, + kind=found_resource.resource_type, + type=found_resource.resource_sub_type, + folder=ReferencedResourceFolder( + folder_key=folder.key, + fully_qualified_name=folder.fully_qualified_name, + path=folder.path, + ), + ), + resource_name=resource_name, + kind=found_resource.resource_type, + sub_type=found_resource.resource_sub_type, + ) diff --git a/packages/uipath/src/uipath/_cli/_push/_resource_actions.py b/packages/uipath/src/uipath/_cli/_push/_resource_actions.py new file mode 100644 index 000000000..4eb76bd57 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_push/_resource_actions.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass + +from .._utils._studio_project import ( + ReferencedResourceRequest, + VirtualResourceRequest, +) + + +@dataclass(frozen=True, slots=True) +class CreateReference: + request: ReferencedResourceRequest + resource_name: str + kind: str + sub_type: str | None + + +@dataclass(frozen=True, slots=True) +class CreateVirtual: + request: VirtualResourceRequest + + +@dataclass(frozen=True, slots=True) +class Skip: + message: str + + +ResourceAction = CreateReference | CreateVirtual | Skip diff --git a/packages/uipath/src/uipath/_cli/_push/_summary.py b/packages/uipath/src/uipath/_cli/_push/_summary.py new file mode 100644 index 000000000..2dcbca57e --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_push/_summary.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass + +import click + + +@dataclass +class ResourceImportSummary: + created: int = 0 + updated: int = 0 + unchanged: int = 0 + virtual_created: int = 0 + virtual_existing: int = 0 + not_found: int = 0 + + @property + def total(self) -> int: + return ( + self.created + + self.updated + + self.unchanged + + self.virtual_created + + self.virtual_existing + + self.not_found + ) + + def __str__(self) -> str: + return ( + f"\n \U0001f535 Resource import summary: {self.total} total resources - " + f"{click.style(str(self.created), fg='green')} created, " + f"{click.style(str(self.updated), fg='blue')} updated, " + f"{click.style(str(self.unchanged), fg='yellow')} unchanged, " + f"{click.style(str(self.virtual_created), fg='green')} virtual-created, " + f"{click.style(str(self.virtual_existing), fg='yellow')} virtual-existing, " + f"{click.style(str(self.not_found), fg='red')} not found" + ) diff --git a/packages/uipath/src/uipath/_cli/_push/_virtual_kinds.py b/packages/uipath/src/uipath/_cli/_push/_virtual_kinds.py new file mode 100644 index 000000000..bc1b20f73 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_push/_virtual_kinds.py @@ -0,0 +1,34 @@ +import logging + +from .._utils._studio_project import ResourceBuilderMetadataEntry, StudioClient + +logger = logging.getLogger(__name__) + +_FALLBACK: frozenset[str] = frozenset( + {"app", "asset", "bucket", "process", "queue", "taskCatalog", "trigger"} +) + + +async def fetch_supported_virtual_kinds(studio_client: StudioClient) -> set[str]: + """Return the set of resource kinds that support inline creation. + + Falls back to a static list on any failure — the caller shouldn't have to + care whether the metadata endpoint was reachable. + """ + try: + metadata = await studio_client.get_resource_builder_metadata() + except Exception as e: + logger.debug("Resource Builder metadata fetch failed, using fallback: %s", e) + return set(_FALLBACK) + return _extract_supported_kinds(metadata) + + +def _extract_supported_kinds( + metadata: list[ResourceBuilderMetadataEntry], +) -> set[str]: + # metadata has one entry per (kind, type), so a kind may appear multiple times + return { + entry.kind + for entry in metadata + if any(version.supports_in_line_creation for version in entry.versions) + } diff --git a/packages/uipath/src/uipath/_cli/_telemetry.py b/packages/uipath/src/uipath/_cli/_telemetry.py index 7adc54410..d245076a4 100644 --- a/packages/uipath/src/uipath/_cli/_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_telemetry.py @@ -41,28 +41,26 @@ def _enrich_properties(self, properties: Dict[str, Any]) -> None: Args: properties: The properties dictionary to enrich. """ - # Add UiPath context - project_key = _get_project_key() - if project_key: - properties["AgentId"] = project_key + agent_id = os.getenv("UIPATH_AGENT_ID") or _get_project_key() + if agent_id: + properties["AgentId"] = agent_id - # Get organization ID if UiPathConfig.organization_id: properties["CloudOrganizationId"] = UiPathConfig.organization_id - # Get tenant ID if UiPathConfig.tenant_id: properties["CloudTenantId"] = UiPathConfig.tenant_id - # Get CloudUserId from JWT token - try: - cloud_user_id = get_claim_from_token("sub") - if cloud_user_id: - properties["CloudUserId"] = cloud_user_id - except Exception: - pass + cloud_user_id = UiPathConfig.cloud_user_id + if not cloud_user_id: + try: + cloud_user_id = get_claim_from_token("sub") + except Exception: + cloud_user_id = None + if cloud_user_id: + properties["CloudUserId"] = cloud_user_id - properties["SessionId"] = "nosession" # Placeholder for session ID + properties["SessionId"] = "nosession" try: properties["SDKVersion"] = version("uipath") @@ -71,7 +69,6 @@ def _enrich_properties(self, properties: Dict[str, Any]) -> None: properties["IsGithubCI"] = bool(os.getenv("GITHUB_ACTIONS")) - # Add source identifier properties["Source"] = "uipath-python-cli" properties["ApplicationName"] = "UiPath.AgentCli" diff --git a/packages/uipath/src/uipath/_cli/_utils/_common.py b/packages/uipath/src/uipath/_cli/_utils/_common.py index 784192ab6..c24bccff0 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_common.py +++ b/packages/uipath/src/uipath/_cli/_utils/_common.py @@ -214,6 +214,14 @@ async def read_resource_overwrites_from_file( .get("internalArguments", {}) .get("resourceOverwrites", {}) ) + + logger.info( + "Resource overwrites read from %s (%d entries):\n%s", + file_path, + len(resource_overwrites), + json.dumps(resource_overwrites, indent=2, sort_keys=True), + ) + for key, value in resource_overwrites.items(): try: overwrites_dict[key] = ResourceOverwriteParser.parse(key, value) @@ -224,15 +232,9 @@ async def read_resource_overwrites_from_file( e, ) - logger.debug( - "Loaded %d resource overwrite(s) from file %s", - len(overwrites_dict), - file_path, - ) - # Return empty dict if file doesn't exist or invalid json except FileNotFoundError: - logger.debug("Resource overwrites config file not found: %s", file_path) + logger.info("Resource overwrites config file not found: %s", file_path) except json.JSONDecodeError as e: logger.warning("Failed to parse resource overwrites from %s: %s", file_path, e) diff --git a/packages/uipath/src/uipath/_cli/_utils/_project_files.py b/packages/uipath/src/uipath/_cli/_utils/_project_files.py index c7c025197..15f5e53c5 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_project_files.py +++ b/packages/uipath/src/uipath/_cli/_utils/_project_files.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any, AsyncIterator, Dict, Literal, Optional, Tuple +import anyio from pydantic import BaseModel, Field, TypeAdapter from uipath._cli.models.uipath_json_schema import PackOptions, UiPathJsonConfig @@ -25,6 +26,34 @@ logger = logging.getLogger(__name__) +def resolve_existing_project_id(directory: str = ".") -> Optional[str]: + """Return an already-established project id for this project, if any. + + Checks the Studio Web project env var first, then falls back to the legacy + ``ProjectKey`` stored in ``.uipath/.telemetry.json``. Returns ``None`` when + neither is present. + + Args: + directory: The project root directory to look for the telemetry file in. + """ + from ...telemetry._constants import _PROJECT_KEY, _TELEMETRY_CONFIG_FILE + + if project_id := UiPathConfig.project_id: + return project_id + + telemetry_file = os.path.join(directory, ".uipath", _TELEMETRY_CONFIG_FILE) + if os.path.exists(telemetry_file): + try: + with open(telemetry_file, "r") as f: + telemetry_data = json.load(f) + if project_id := telemetry_data.get(_PROJECT_KEY): + return project_id + except (json.JSONDecodeError, IOError): + pass + + return None + + class Severity(IntEnum): LOG = 0 WARNING = 1 @@ -592,21 +621,21 @@ async def download_folder_files( collect_files_from_folder(folder, "", files_dict) for file_path, remote_file in files_dict.items(): - local_path = base_path / file_path - local_path.parent.mkdir(parents=True, exist_ok=True) + local_path = anyio.Path(base_path / file_path) + await local_path.parent.mkdir(parents=True, exist_ok=True) response = await studio_client.download_project_file_async(remote_file) remote_content = response.read().decode("utf-8") remote_hash = compute_normalized_hash(remote_content) - if os.path.exists(local_path): - with open(local_path, "r", encoding="utf-8") as f: - local_content = f.read() - local_hash = compute_normalized_hash(local_content) + if await local_path.exists(): + local_content = await local_path.read_text(encoding="utf-8") + local_hash = compute_normalized_hash(local_content) if local_hash != remote_hash: - with open(local_path, "w", encoding="utf-8", newline="\n") as f: - f.write(remote_content) + await local_path.write_text( + remote_content, encoding="utf-8", newline="\n" + ) yield UpdateEvent( file_path=file_path, @@ -620,8 +649,7 @@ async def download_folder_files( message=f"File '{file_path}' is up to date", ) else: - with open(local_path, "w", encoding="utf-8", newline="\n") as f: - f.write(remote_content) + await local_path.write_text(remote_content, encoding="utf-8", newline="\n") yield UpdateEvent( file_path=file_path, diff --git a/packages/uipath/src/uipath/_cli/_utils/_studio_project.py b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py index 63ddceb37..f70c3e96a 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_studio_project.py +++ b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py @@ -6,7 +6,6 @@ from pathlib import PurePath from typing import Any, Callable, List, Optional, Union -import click from pydantic import BaseModel, ConfigDict, Field, field_validator from uipath._utils.constants import ( @@ -152,12 +151,20 @@ class LockInfo(BaseModel): solution_lock_key: Optional[str] = Field(alias="solutionLockKey") -class Severity(str, Enum): - """Severity level for virtual resource operation results.""" +class ResourceBuilderMetadataVersion(BaseModel): + model_config = ConfigDict(extra="allow") - SUCCESS = "success" - ATTENTION = "attention" - WARN = "warn" + supports_in_line_creation: bool = Field( + default=False, alias="supportsInLineCreation" + ) + + +class ResourceBuilderMetadataEntry(BaseModel): + model_config = ConfigDict(extra="allow") + + kind: str + type: str | None = None + versions: list[ResourceBuilderMetadataVersion] = Field(default_factory=list) class VirtualResourceRequest(BaseModel): @@ -173,16 +180,20 @@ class VirtualResourceRequest(BaseModel): api_version: Optional[str] = Field(default=None, alias="apiVersion") +class Status(str, Enum): + ADDED = "ADDED" + UNCHANGED = "UNCHANGED" + UPDATED = "UPDATED" + + class VirtualResourceResult(BaseModel): - """Result of a virtual resource creation operation. + """Structured outcome of a virtual resource creation attempt. - Attributes: - severity: The severity level (log, warn or attention) - message: The result message with styling + Only `ADDED` and `UNCHANGED` are possible — virtual resources are never + updated in place. """ - severity: Severity - message: str + status: Status class ReferencedResourceFolder(BaseModel): @@ -362,12 +373,6 @@ class ProjectLockUnavailableError(RuntimeError): pass -class Status(str, Enum): - ADDED = "ADDED" - UNCHANGED = "UNCHANGED" - UPDATED = "UPDATED" - - class ReferencedResourceResponse(BaseModel): """Response from creating a referenced resource. @@ -528,6 +533,19 @@ async def get_project_metadata_async(self) -> Optional[StudioProjectMetadata]: response.read().decode("utf-8") ) + async def get_resource_builder_metadata( + self, + ) -> list[ResourceBuilderMetadataEntry]: + response = await self.uipath.api_client.request_async( + "GET", + url="/studio_/backend/api/resourcebuilder/metadata", + scoped="org", + ) + return [ + ResourceBuilderMetadataEntry.model_validate(entry) + for entry in response.json() + ] + async def _get_existing_resources(self) -> List[dict[str, Any]]: if self._resources_cache is not None: return self._resources_cache @@ -560,6 +578,12 @@ async def get_resource_overwrites(self) -> dict[str, ResourceOverwrite]: with open(UiPathConfig.bindings_file_path, "rb") as f: file_content = f.read() + logger.info( + "Resource bindings (%s):\n%s", + UiPathConfig.bindings_file_path, + file_content.decode(), + ) + solution_id = await self._get_solution_id() tenant_id = os.getenv(ENV_TENANT_ID, None) @@ -582,85 +606,36 @@ async def get_resource_overwrites(self) -> dict[str, ResourceOverwrite]: files=files, ) data = response.json() - overwrites = {} - - for key, value in data.items(): - overwrites[key] = ResourceOverwriteParser.parse(key, value) logger.info( - "Loaded %d resource overwrite(s) from Studio API for solution %s: %s", - len(overwrites), + "Resource overwrites received for solution %s (%d entries):\n%s", solution_id, - overwrites, + len(data), + json.dumps(data, indent=2), ) + overwrites = {} + for key, value in data.items(): + overwrites[key] = ResourceOverwriteParser.parse(key, value) + return overwrites async def create_virtual_resource( self, virtual_resource_request: VirtualResourceRequest ) -> VirtualResourceResult: - """Create a virtual resource or return appropriate status if it already exists. - - Args: - virtual_resource_request: The virtual resource request details + """Create a virtual resource, or report UNCHANGED if already present. - Returns: - VirtualResourceResult: Result indicating the operation status and a formatted message + Returns UNCHANGED when the same name+kind already exists in the + solution. Name collisions with a different kind are not checked + client-side — they surface as a server error via EnrichedException. """ - # Build base message with resource details - base_message_parts = [ - f"Resource {click.style(virtual_resource_request.name, fg='cyan')}", - f" (kind: {click.style(virtual_resource_request.kind, fg='yellow')}", - ] - - if virtual_resource_request.type: - base_message_parts.append( - f", type: {click.style(virtual_resource_request.type, fg='yellow')}" - ) - - if virtual_resource_request.activity_name: - base_message_parts.append( - f", activity: {click.style(virtual_resource_request.activity_name, fg='yellow')}" - ) - - base_message_parts.append(")") - base_message = "".join(base_message_parts) - + name = virtual_resource_request.name + kind = virtual_resource_request.kind existing_resources = await self._get_existing_resources() - # Check if resource with same kind and name exists - existing_same_kind = next( - ( - r - for r in existing_resources - if r["name"] == virtual_resource_request.name - and r["kind"] == virtual_resource_request.kind - ), - None, - ) - if existing_same_kind: - message = f"{base_message} already exists. Skipping..." - return VirtualResourceResult(severity=Severity.ATTENTION, message=message) - - # Check if resource with same name but different kind exists - existing_diff_kind = next( - ( - r - for r in existing_resources - if r["name"] == virtual_resource_request.name - and r["kind"] != virtual_resource_request.kind - ), - None, - ) - if existing_diff_kind: - message = ( - f"Cannot create {base_message}. " - f"A resource with this name already exists with kind {click.style(existing_diff_kind['kind'], fg='yellow')}. " - f"Consider renaming the resource in code." - ) - return VirtualResourceResult(severity=Severity.WARN, message=message) + if any(r["name"] == name and r["kind"] == kind for r in existing_resources): + return VirtualResourceResult(status=Status.UNCHANGED) - # Create the virtual resource solution_id = await self._get_solution_id() response = await self.uipath.api_client.request_async( "POST", @@ -669,21 +644,12 @@ async def create_virtual_resource( json=virtual_resource_request.model_dump(exclude_none=True), ) resource_key = response.json()["key"] - await self._update_resource_specs( - resource_key, new_specs={"name": virtual_resource_request.name} - ) + await self._update_resource_specs(resource_key, new_specs={"name": name}) - # Update cache with newly created resource if self._resources_cache is not None: - self._resources_cache.append( - { - "name": virtual_resource_request.name, - "kind": virtual_resource_request.kind, - } - ) + self._resources_cache.append({"name": name, "kind": kind}) - message = f"{base_message} created successfully." - return VirtualResourceResult(severity=Severity.SUCCESS, message=message) + return VirtualResourceResult(status=Status.ADDED) async def create_referenced_resource( self, referenced_resource_request: ReferencedResourceRequest diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index 44415dd26..92b8ea454 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -1,14 +1,16 @@ import asyncio import logging +from typing import cast, get_args import click from uipath._cli._chat._bridge import get_chat_bridge -from uipath._cli._debug._bridge import get_debug_bridge +from uipath._cli._debug._bridge import DebugAttachMode, get_debug_bridge from uipath._cli._utils._debug import setup_debugging from uipath._cli._utils._studio_project import StudioClient from uipath.core.tracing import UiPathTraceManager from uipath.eval.mocks import UiPathMockRuntime +from uipath.eval.mocks._mock_runtime import load_simulation_config from uipath.platform.common import ResourceOverwritesContext, UiPathConfig from uipath.runtime import ( UiPathExecuteOptions, @@ -63,6 +65,15 @@ default=5678, help="Port for the debug server (default: 5678)", ) +@click.option( + "--attach", + type=click.Choice(list(get_args(DebugAttachMode)), case_sensitive=False), + default=None, + help=( + "Debugger attach mode. Defaults to 'signalr' for cloud runs, " + "'console' for local runs." + ), +) @track_command("debug") def debug( entrypoint: str | None, @@ -73,6 +84,7 @@ def debug( output_file: str | None, debug: bool, debug_port: int, + attach: str | None, ) -> None: """Debug the project.""" input_file = file or input_file @@ -80,6 +92,10 @@ def debug( if not setup_debugging(debug, debug_port): console.error(f"Failed to start debug server on port {debug_port}") + attach_mode: DebugAttachMode | None = ( + cast(DebugAttachMode, attach.lower()) if attach else None + ) + result = Middlewares.next( "debug", entrypoint, @@ -89,6 +105,7 @@ def debug( output_file=output_file, debug=debug, debug_port=debug_port, + attach=attach_mode, ) if result.error_message: @@ -140,8 +157,9 @@ async def execute_debug_runtime(): async def execute_debug_runtime(): chat_runtime: UiPathRuntimeProtocol | None = None - debug_bridge: UiPathDebugProtocol = get_debug_bridge(ctx) - + debug_bridge: UiPathDebugProtocol = get_debug_bridge( + ctx, attach=attach_mode + ) runtime = await factory.new_runtime( entrypoint, ctx.conversation_id or ctx.job_id or "default", @@ -163,8 +181,19 @@ async def execute_debug_runtime(): trigger_poll_interval=trigger_poll_interval, ) + # Build mocking context with agent model for simulations + schema = await runtime.get_schema() + agent_model = None + if schema.metadata and "settings" in schema.metadata: + agent_model = schema.metadata["settings"].get("model") + + mocking_context = load_simulation_config( + agent_model=agent_model + ) + mock_runtime = UiPathMockRuntime( delegate=debug_runtime, + mocking_context=mocking_context, ) try: diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index d0bdc730c..e101717d6 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -8,6 +8,7 @@ import click +from uipath._cli._errors import EntrypointDiscoveryException from uipath._cli._evals._console_progress_reporter import ConsoleProgressReporter from uipath._cli._evals._progress_reporter import StudioWebProgressReporter from uipath._cli._evals._telemetry import EvalTelemetrySubscriber @@ -16,7 +17,7 @@ from uipath._cli.middlewares import Middlewares from uipath.core.events import EventBus from uipath.core.tracing import UiPathTraceManager -from uipath.eval.helpers import EVAL_SETS_DIRECTORY_NAME, EvalHelpers +from uipath.eval.helpers import EVAL_SETS_DIRECTORY_NAME, EvalHelpers, get_agent_model from uipath.eval.models.evaluation_set import EvaluationSet from uipath.eval.runtime import UiPathEvalContext, evaluate from uipath.platform.chat import set_llm_concurrency @@ -24,7 +25,6 @@ from uipath.runtime import ( UiPathRuntimeContext, UiPathRuntimeFactoryRegistry, - UiPathRuntimeSchema, ) from uipath.telemetry._track import flush_events from uipath.tracing import ( @@ -64,27 +64,6 @@ def setup_reporting_prereq(no_report: bool) -> bool: return True -def _get_agent_model(schema: UiPathRuntimeSchema) -> str | None: - """Get agent model from the runtime schema metadata. - - The model is read from schema.metadata["settings"]["model"] which is - populated by the low-code agents runtime from agent.json. - - Returns: - The model name from agent settings, or None if not found. - """ - try: - if schema.metadata and "settings" in schema.metadata: - settings = schema.metadata["settings"] - model = settings.get("model") - if model: - logger.debug(f"Got agent model from schema.metadata: {model}") - return model - return None - except Exception: - return None - - def _resolve_model_settings_override( model_settings_id: str, evaluation_set: EvaluationSet ) -> dict[str, Any] | None: @@ -135,13 +114,35 @@ def _resolve_model_settings_override( return override if override else None -class _EvalDiscoveryError(Exception): +class _EvalDiscoveryError(EntrypointDiscoveryException): """Raised when auto-discovery of entrypoint or eval set fails.""" def __init__(self, entrypoints: list[str], eval_sets: list[Path]): - self.entrypoints = entrypoints + super().__init__(entrypoints) self.eval_sets = eval_sets + def get_usage_help(self) -> list[str]: + lines = super().get_usage_help() + + if self.eval_sets: + lines.append("") + lines.append("Available eval sets:") + for f in self.eval_sets: + lines.append(f" - {f}") + else: + lines.append("") + lines.append( + f"No eval sets found in '{EVAL_SETS_DIRECTORY_NAME}/' directory." + ) + + lines.append("") + lines.append("Usage: uipath eval ") + if self.entrypoints and self.eval_sets: + lines.append( + f"Example: uipath eval {self.entrypoints[0]} {self.eval_sets[0]}" + ) + return lines + def _discover_eval_sets() -> list[Path]: """Discover available eval set files.""" @@ -151,39 +152,6 @@ def _discover_eval_sets() -> list[Path]: return [] -def _show_eval_usage_help(entrypoints: list[str], eval_set_files: list[Path]) -> None: - """Show available entrypoints and eval sets with usage examples.""" - lines: list[str] = [] - - if entrypoints: - lines.append("Available entrypoints:") - for name in entrypoints: - lines.append(f" - {name}") - else: - lines.append( - "No entrypoints found. " - "Add a 'functions' or 'agents' section to your config file " - "(e.g. uipath.json, langgraph.json)." - ) - - if eval_set_files: - lines.append("\nAvailable eval sets:") - for f in eval_set_files: - lines.append(f" - {f}") - else: - lines.append( - f"\nNo eval sets found in '{EVAL_SETS_DIRECTORY_NAME}/' directory." - ) - - lines.append("\nUsage: uipath eval ") - if entrypoints and eval_set_files: - ep_name = entrypoints[0] - es_path = eval_set_files[0] - lines.append(f"Example: uipath eval {ep_name} {es_path}") - - click.echo("\n".join(lines)) - - @click.command() @click.argument("entrypoint", required=False) @click.argument("eval_set", required=False) @@ -441,7 +409,7 @@ async def execute_eval(): eval_context.evaluators = await EvalHelpers.load_evaluators( resolved_eval_set_path, eval_context.evaluation_set, - _get_agent_model(eval_context.runtime_schema), + get_agent_model(eval_context.runtime_schema), ) # Runtime is not required anymore. @@ -475,7 +443,13 @@ async def execute_eval(): asyncio.run(execute_eval()) except _EvalDiscoveryError as e: - _show_eval_usage_help(e.entrypoints, e.eval_sets) + click.echo("\n".join(e.get_usage_help())) + if not e.entrypoints: + click.echo() + console.link( + "uipath.json spec:", + "https://github.com/UiPath/uipath-python/blob/main/packages/uipath/specs/uipath.spec.md", + ) except ValueError as e: console.error(str(e)) except Exception as e: diff --git a/packages/uipath/src/uipath/_cli/cli_init.py b/packages/uipath/src/uipath/_cli/cli_init.py index f86e30f06..90a4ca117 100644 --- a/packages/uipath/src/uipath/_cli/cli_init.py +++ b/packages/uipath/src/uipath/_cli/cli_init.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any +import anyio import click from graphtty import RenderOptions, render from graphtty.themes import TOKYO_NIGHT @@ -30,13 +31,11 @@ ) from uipath.runtime.schema import UiPathRuntimeGraph, UiPathRuntimeSchema -from .._utils.constants import ENV_TELEMETRY_ENABLED -from ..telemetry._constants import _PROJECT_KEY, _TELEMETRY_CONFIG_FILE from ._telemetry import track_command from ._utils._common import determine_project_type from ._utils._console import ConsoleLogger from ._utils._constants import AGENT_INITIAL_CODE_VERSION, SCHEMA_VERSION -from ._utils._project_files import read_toml_project +from ._utils._project_files import read_toml_project, resolve_existing_project_id from .middlewares import Middlewares from .models.runtime_schema import Bindings, EntryPoint from .models.uipath_json_schema import UiPathJsonConfig @@ -54,30 +53,6 @@ class Action(str, enum.Enum): UPDATED = "Updated" -def create_telemetry_config_file(target_directory: str) -> None: - """Create telemetry file if telemetry is enabled. - - Args: - target_directory: The directory where the .uipath folder should be created. - """ - telemetry_enabled = os.getenv(ENV_TELEMETRY_ENABLED, "true").lower() == "true" - - if not telemetry_enabled: - return - - uipath_dir = os.path.join(target_directory, ".uipath") - telemetry_file = os.path.join(uipath_dir, _TELEMETRY_CONFIG_FILE) - - if os.path.exists(telemetry_file): - return - - os.makedirs(uipath_dir, exist_ok=True) - telemetry_data = {_PROJECT_KEY: UiPathConfig.project_id or str(uuid.uuid4())} - - with open(telemetry_file, "w") as f: - json.dump(telemetry_data, f, indent=4) - - def generate_env_file(target_directory): env_path = os.path.join(target_directory, ".env") @@ -277,6 +252,12 @@ def write_studio_metadata_file(directory: str) -> None: ) +MERMAID_FILE_HEADER = ( + "%% AUTO-GENERATED by `uipath init`. Do not edit manually.\n" + "%% Regenerated on every `uipath init`.\n" +) + + def write_mermaid_files(entry_points: list[UiPathRuntimeSchema]) -> list[Path]: """Write mermaid diagram files for each entrypoint. @@ -299,6 +280,7 @@ def write_mermaid_files(entry_points: list[UiPathRuntimeSchema]) -> list[Path]: mermaid_file_path = Path(os.getcwd()) / f"{ep.file_path}.mermaid" with open(mermaid_file_path, "w") as f: + f.write(MERMAID_FILE_HEADER) f.write(str(chart)) mermaid_paths.append(mermaid_file_path) @@ -414,7 +396,6 @@ def init(no_agents_md_override: bool) -> None: with console.spinner("Initializing UiPath project ..."): current_directory = os.getcwd() generate_env_file(current_directory) - create_telemetry_config_file(current_directory) async def initialize() -> list[UiPathRuntimeSchema]: try: @@ -422,10 +403,25 @@ async def initialize() -> list[UiPathRuntimeSchema]: config_path = UiPathConfig.config_file_path if not config_path.exists(): config = UiPathJsonConfig.create_default() + config.id = resolve_existing_project_id(current_directory) or str( + uuid.uuid4() + ) config.save_to_file(config_path) console.success(f"{Action.CREATED.value} '{config_path}' file.") else: - console.info(f"'{config_path}' already exists, skipping.") + # backfill id if not present + async_config_path = anyio.Path(config_path) + raw_config = json.loads(await async_config_path.read_text()) + if not raw_config.get("id"): + raw_config["id"] = resolve_existing_project_id( + current_directory + ) or str(uuid.uuid4()) + await async_config_path.write_text( + json.dumps(raw_config, indent=2) + ) + console.success( + f"{Action.UPDATED.value} '{config_path}' file with 'id'." + ) # Create bindings.json if it doesn't exist bindings_path = UiPathConfig.bindings_file_path diff --git a/packages/uipath/src/uipath/_cli/cli_list_models.py b/packages/uipath/src/uipath/_cli/cli_list_models.py new file mode 100644 index 000000000..7c14686a4 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/cli_list_models.py @@ -0,0 +1,64 @@ +from collections.abc import Iterable + +import click +from rich.console import Console +from rich.table import Table + +from ..platform.agenthub import LlmModel +from ._utils._context import get_cli_context +from ._utils._service_base import ServiceCommandBase, service_command + + +@click.command(name="list-models") +@click.option( + "--format", + type=click.Choice(["json", "table", "csv"]), + help="Output format (overrides global)", +) +@click.option( + "--output", + "--output-file", + "-o", + type=click.Path(), + help="File path where the output will be written", +) +@service_command +async def list_models(ctx, format, output): + """List available LLM models.""" + client = ServiceCommandBase.get_client(ctx) + models = await client.agenthub.get_available_llm_models_async() + + fmt = format or get_cli_context(ctx).output_format + if fmt == "table" and not output: + _render_rich_table(models) + return None + return models + + +def _render_rich_table(models: Iterable[LlmModel]) -> None: + """Render models as a rich table with one column per vendor.""" + by_vendor: dict[str, list[str]] = {} + for model in models: + vendor = model.vendor or "Unknown" + by_vendor.setdefault(vendor, []).append(model.model_name) + + console = Console() + if not by_vendor: + console.print("Available LLM Models: none") + return + + for names in by_vendor.values(): + names.sort() + + vendors = sorted(by_vendor.keys()) + + table = Table(title="Available LLM Models", show_lines=False) + for vendor in vendors: + table.add_column(vendor, style="cyan", no_wrap=True) + + max_rows = max(len(by_vendor[v]) for v in vendors) + for i in range(max_rows): + row = [by_vendor[v][i] if i < len(by_vendor[v]) else "" for v in vendors] + table.add_row(*row) + + console.print(table) diff --git a/packages/uipath/src/uipath/_cli/cli_new.py b/packages/uipath/src/uipath/_cli/cli_new.py index 390c581c5..d273055d5 100644 --- a/packages/uipath/src/uipath/_cli/cli_new.py +++ b/packages/uipath/src/uipath/_cli/cli_new.py @@ -28,7 +28,7 @@ def generate_pyproject(target_directory, project_name): description = "{project_name}" authors = [{{ name = "John Doe", email = "john.doe@myemail.com" }}] dependencies = [ - "uipath>=2.2.0, <2.3.0" + "uipath>=2.10.0, <2.11.0" ] requires-python = ">=3.11" """ diff --git a/packages/uipath/src/uipath/_cli/cli_pack.py b/packages/uipath/src/uipath/_cli/cli_pack.py index 83cedf870..d51eec53c 100644 --- a/packages/uipath/src/uipath/_cli/cli_pack.py +++ b/packages/uipath/src/uipath/_cli/cli_pack.py @@ -8,11 +8,10 @@ from pydantic import TypeAdapter from uipath._cli.models.runtime_schema import Bindings, EntryPoint, EntryPoints -from uipath._cli.models.uipath_json_schema import RuntimeOptions, UiPathJsonConfig +from uipath._cli.models.uipath_json_schema import UiPathJsonConfig from uipath.eval.constants import EVALS_FOLDER, LEGACY_EVAL_FOLDER from uipath.platform.common import UiPathConfig -from ..telemetry._constants import _PROJECT_KEY, _TELEMETRY_CONFIG_FILE from ._telemetry import track_command from ._utils._common import determine_project_type from ._utils._console import ConsoleLogger @@ -21,6 +20,7 @@ files_to_include, get_project_config, read_toml_project, + resolve_existing_project_id, validate_config, ) from ._utils._uv_helpers import handle_uv_operations @@ -30,31 +30,6 @@ schema = "https://cloud.uipath.com/draft/2024-12/entry-point" -def get_project_id() -> str: - """Get project ID from telemetry file if it exists, otherwise generate a new one. - - Returns: - Project ID string (either from telemetry file or newly generated). - """ - # first check if this is a studio project - if project_id := UiPathConfig.project_id: - return project_id - - telemetry_file = os.path.join(".uipath", _TELEMETRY_CONFIG_FILE) - - if os.path.exists(telemetry_file): - try: - with open(telemetry_file, "r") as f: - telemetry_data = json.load(f) - project_id = telemetry_data.get(_PROJECT_KEY) - if project_id: - return project_id - except (json.JSONDecodeError, IOError): - pass - - return str(uuid.uuid4()) - - def get_project_version(directory): toml_path = os.path.join(directory, "pyproject.toml") if not os.path.exists(toml_path): @@ -72,14 +47,27 @@ def validate_config_structure(config_data): def generate_operate_file( - entrypoints: list[EntryPoint], runtimeOptions: RuntimeOptions, dependencies=None + entrypoints: list[EntryPoint], + config: UiPathJsonConfig, + dependencies=None, + directory: str = ".", ): if not entrypoints: raise ValueError( "No entry points found in entry-points.json. Please run 'uipath init' to generate valid entry points." ) - project_id = get_project_id() + # prefer id from uipath.json; fall back to the legacy + # .telemetry.json or SW project id. + if config.id: + try: + uuid.UUID(config.id) + except ValueError: + console.error(f"uipath.json 'id' must be a valid GUID, got '{config.id}'.") + + project_id = ( + config.id or resolve_existing_project_id(directory) or str(uuid.uuid4()) + ) project_type = determine_project_type(entrypoints) first_entry = entrypoints[0] @@ -94,7 +82,7 @@ def generate_operate_file( "runtimeOptions": { "requiresUserInteraction": False, "isAttended": False, - "isConversational": runtimeOptions.is_conversational, + "isConversational": config.runtime_options.is_conversational, }, } @@ -239,7 +227,7 @@ def pack_fn( config_data = TypeAdapter(UiPathJsonConfig).validate_python(json.load(f)) operate_file = generate_operate_file( - entrypoints, config_data.runtime_options, dependencies + entrypoints, config_data, dependencies, directory ) # try to read bindings from bindings.json diff --git a/packages/uipath/src/uipath/_cli/cli_push.py b/packages/uipath/src/uipath/_cli/cli_push.py index 61e46cbc6..9082d747f 100644 --- a/packages/uipath/src/uipath/_cli/cli_push.py +++ b/packages/uipath/src/uipath/_cli/cli_push.py @@ -5,9 +5,17 @@ import click from uipath.platform.common import UiPathConfig -from uipath.platform.errors import EnrichedException, FolderNotFoundException - -from ..platform.resource_catalog import ResourceType +from uipath.platform.errors import EnrichedException + +from ._push._resolvers import resolve_bindings +from ._push._resource_actions import ( + CreateReference, + CreateVirtual, + ResourceAction, + Skip, +) +from ._push._summary import ResourceImportSummary +from ._push._virtual_kinds import fetch_supported_virtual_kinds from ._push.sw_file_handler import SwFileHandler from ._telemetry import track_command from ._utils._common import ensure_coded_agent_project, may_override_files @@ -22,10 +30,9 @@ ) from ._utils._studio_project import ( ProjectLockUnavailableError, - ReferencedResourceFolder, - ReferencedResourceRequest, Status, StudioClient, + VirtualResourceRequest, ) from ._utils._uv_helpers import handle_uv_operations from .models.runtime_schema import Bindings @@ -42,133 +49,104 @@ def get_org_scoped_url(base_url: str) -> str: return org_scoped_url -async def create_resources(studio_client: StudioClient): +async def create_resources(studio_client: StudioClient) -> None: console.info("\nImporting referenced resources to Studio Web project...") from uipath.platform import UiPath uipath = UiPath() - resource_catalog = uipath.resource_catalog - connections = uipath.connections with open(UiPathConfig.bindings_file_path, "r") as f: - bindings_file_content = f.read() - - bindings = Bindings.model_validate_json(bindings_file_content) - - resources_not_found = 0 - resources_unchanged = 0 - resources_created = 0 - resource_updated = 0 - - for bindings_resource in bindings.resources: - not_found_warning = "was not found and will not be added to the solution." - found_resource = None - resource_type = bindings_resource.resource - if resource_type == "connection": - connection_key_resource_value = bindings_resource.value.get("ConnectionId") - assert connection_key_resource_value - connection_key = connection_key_resource_value.default_value - try: - connection = await connections.retrieve_async(connection_key) - except EnrichedException: - resources_not_found += 1 - assert bindings_resource.metadata is not None - connector_name = bindings_resource.metadata.get("Connector") - console.warning( - f"Connection with key '{connection_key}' of type '{connector_name}' " - f"{not_found_warning}" - ) - continue - resource_name = connection.name - folder_path = connection.folder.get("path") - else: - name_resource_value = bindings_resource.value.get("name") - folder_path_resource_value = bindings_resource.value.get("folderPath") - - if not folder_path_resource_value: - # guardrail resource, nothing to import - continue - - assert name_resource_value - resource_name = name_resource_value.default_value - folder_path = folder_path_resource_value.default_value - - resources = resource_catalog.list_by_type_async( - resource_type=ResourceType.from_string(resource_type), - name=resource_name, - folder_path=folder_path, - ) + bindings = Bindings.model_validate_json(f.read()) - try: - async for resource in resources: - found_resource = resource - break - await resources.aclose() + supported_virtual_kinds = await fetch_supported_virtual_kinds(studio_client) - except FolderNotFoundException: - pass + summary = ResourceImportSummary() + async for action in resolve_bindings( + bindings, + uipath.resource_catalog, + uipath.connections, + supported_virtual_kinds, + ): + await _execute_action(action, studio_client, summary) - if not found_resource: - console.warning( - f"Resource '{resource_name}' of type '{resource_type}' at folder path '{folder_path}' " - f"{not_found_warning}" - ) - resources_not_found += 1 - continue - - referenced_resource_request = ReferencedResourceRequest( - key=found_resource.resource_key, - kind=found_resource.resource_type, - type=found_resource.resource_sub_type, - folder=next( - ReferencedResourceFolder( - folder_key=folder.key, - fully_qualified_name=folder.fully_qualified_name, - path=folder.path, - ) - for folder in found_resource.folders - ), - ) - response = await studio_client.create_referenced_resource( - referenced_resource_request - ) + console.info(str(summary)) - resource_details = ( - f"(kind = {click.style(found_resource.resource_type, fg='cyan')}, " - f"type = {click.style(found_resource.resource_sub_type, fg='cyan')})" - ) - match response.status: - case Status.ADDED: - console.success( - f"Created reference for resource: {click.style(resource_name, fg='cyan')} " - f"{resource_details}" - ) - resources_created += 1 - case Status.UNCHANGED: - console.info( - f"Resource reference already exists ({click.style('unchanged', fg='yellow')}): {click.style(resource_name, fg='cyan')} " - f"{resource_details}" - ) - resources_unchanged += 1 - case Status.UPDATED: - console.info( - f"Resource reference already exists ({click.style('updated', fg='blue')}): {click.style(resource_name, fg='cyan')} " - f"{resource_details}" - ) - resource_updated += 1 +async def _execute_action( + action: ResourceAction, + studio_client: StudioClient, + summary: ResourceImportSummary, +) -> None: + match action: + case Skip(message=message): + console.warning(message) + summary.not_found += 1 - total_resources = ( - resources_created + resources_unchanged + resources_not_found + resource_updated - ) - console.info( - f"\n \U0001f535 Resource import summary: {total_resources} total resources - " - f"{click.style(str(resources_created), fg='green')} created, " - f"{click.style(str(resource_updated), fg='blue')} updated, " - f"{click.style(str(resources_unchanged), fg='yellow')} unchanged, " - f"{click.style(str(resources_not_found), fg='red')} not found" - ) + case CreateVirtual(request=request): + try: + result = await studio_client.create_virtual_resource(request) + except EnrichedException as e: + console.warning( + f"Failed to create virtual resource '{request.name}' of type " + f"'{request.kind}': {e}" + ) + summary.not_found += 1 + return + label = _format_virtual_label(request) + match result.status: + case Status.ADDED: + console.success(f"{label} created successfully.") + summary.virtual_created += 1 + case Status.UNCHANGED: + console.info(f"{label} already exists. Skipping...") + summary.virtual_existing += 1 + + case CreateReference( + request=request, + resource_name=resource_name, + kind=kind, + sub_type=sub_type, + ): + response = await studio_client.create_referenced_resource(request) + details = ( + f"(kind = {click.style(kind, fg='cyan')}, " + f"type = {click.style(sub_type, fg='cyan')})" + ) + match response.status: + case Status.ADDED: + console.success( + f"Created reference for resource: " + f"{click.style(resource_name, fg='cyan')} {details}" + ) + summary.created += 1 + case Status.UNCHANGED: + console.info( + f"Resource reference already exists " + f"({click.style('unchanged', fg='yellow')}): " + f"{click.style(resource_name, fg='cyan')} {details}" + ) + summary.unchanged += 1 + case Status.UPDATED: + console.info( + f"Resource reference already exists " + f"({click.style('updated', fg='blue')}): " + f"{click.style(resource_name, fg='cyan')} {details}" + ) + summary.updated += 1 + + +def _format_virtual_label(request: VirtualResourceRequest) -> str: + parts = [ + f"Resource {click.style(request.name, fg='cyan')}", + f" (kind: {click.style(request.kind, fg='yellow')}", + ] + if request.type: + parts.append(f", type: {click.style(request.type, fg='yellow')}") + if request.activity_name: + parts.append(f", activity: {click.style(request.activity_name, fg='yellow')}") + parts.append(")") + return "".join(parts) async def upload_source_files_to_project( @@ -262,7 +240,6 @@ def push(root: str, ignore_resources: bool, nolock: bool, overwrite: bool) -> No project_id = UiPathConfig.project_id if not project_id: console.error("UIPATH_PROJECT_ID environment variable not found.") - return studio_client = StudioClient(project_id=project_id) diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 6b5152ed4..48f42018b 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -1,12 +1,14 @@ import asyncio import click +from pydantic import ValidationError from uipath._cli._chat._bridge import get_chat_bridge from uipath._cli._debug._bridge import ConsoleDebugBridge from uipath._cli._utils._common import read_resource_overwrites_from_file from uipath._cli._utils._debug import setup_debugging from uipath.core.tracing import UiPathTraceManager +from uipath.eval.mocks import SimulationConfig, UiPathMockRuntime, build_mocking_context from uipath.platform.common import ResourceOverwritesContext, UiPathConfig from uipath.runtime import ( UiPathExecuteOptions, @@ -27,6 +29,7 @@ LlmOpsHttpExporter, ) +from ._errors import EntrypointDiscoveryException from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares @@ -34,6 +37,21 @@ console = ConsoleLogger() +class _RunDiscoveryError(EntrypointDiscoveryException): + """Raised when entrypoint auto-discovery fails.""" + + def get_usage_help(self) -> list[str]: + lines = super().get_usage_help() + lines.append("") + lines.append( + "Usage: uipath run " + " [-f ]" + ) + if self.entrypoints: + lines.append(f"Example: uipath run {self.entrypoints[0]}") + return lines + + @click.command() @click.argument("entrypoint", required=False) @click.argument("input", required=False, default=None) @@ -85,6 +103,12 @@ is_flag=True, help="Keep the temporary state file even when not resuming and no job id is provided", ) +@click.option( + "--simulation", + required=False, + default=None, + help="Simulation config as a JSON object (same schema as simulation.json)", +) @track_command("run") def run( entrypoint: str | None, @@ -98,6 +122,7 @@ def run( debug: bool, debug_port: int, keep_state_file: bool, + simulation: str | None, ) -> None: """Execute the project.""" input_file = file or input_file @@ -106,6 +131,14 @@ def run( if not setup_debugging(debug, debug_port): console.error(f"Failed to start debug server on port {debug_port}") + simulation_config: SimulationConfig | None = None + if simulation: + try: + simulation_config = SimulationConfig.model_validate_json(simulation) + except (ValidationError, ValueError) as e: + console.error(f"Invalid --simulation config: {e}") + return + result = Middlewares.next( "run", entrypoint, @@ -125,11 +158,6 @@ def run( return if result.should_continue: - if not entrypoint: - console.error("""No entrypoint specified. Please provide the path to the Python function. - Usage: `uipath run [-f ]`""") - return - try: async def execute_runtime( @@ -182,21 +210,48 @@ async def execute() -> None: lambda: read_resource_overwrites_from_file(ctx.runtime_dir) ): with ctx: + base_runtime: UiPathRuntimeProtocol | None = None runtime: UiPathRuntimeProtocol | None = None chat_runtime: UiPathRuntimeProtocol | None = None factory: UiPathRuntimeFactoryProtocol | None = None try: factory = UiPathRuntimeFactoryRegistry.get(context=ctx) + + resolved_entrypoint = entrypoint + if not resolved_entrypoint: + available = factory.discover_entrypoints() + if len(available) == 1: + resolved_entrypoint = available[0] + else: + raise _RunDiscoveryError(available) + factory_settings = await factory.get_settings() trace_settings = ( factory_settings.trace_settings if factory_settings else None ) - runtime = await factory.new_runtime( - entrypoint, + base_runtime = await factory.new_runtime( + resolved_entrypoint, ctx.conversation_id or ctx.job_id or "default", ) + runtime = base_runtime + + if simulation_config: + schema = await base_runtime.get_schema() + agent_model = None + if schema.metadata and "settings" in schema.metadata: + agent_model = schema.metadata["settings"].get( + "model" + ) + mocking_context = build_mocking_context( + simulation_config, agent_model + ) + if mocking_context: + runtime = UiPathMockRuntime( + delegate=base_runtime, + mocking_context=mocking_context, + ) if ctx.job_id: if UiPathConfig.is_tracing_enabled: @@ -223,13 +278,24 @@ async def execute() -> None: finally: if chat_runtime: await chat_runtime.dispose() - if runtime: + if runtime is not None and runtime is not base_runtime: await runtime.dispose() + if base_runtime is not None: + await base_runtime.dispose() if factory: await factory.dispose() asyncio.run(execute()) + except _RunDiscoveryError as e: + click.echo("\n".join(e.get_usage_help())) + if not e.entrypoints: + click.echo() + console.link( + "uipath.json spec:", + "https://github.com/UiPath/uipath-python/blob/main/packages/uipath/specs/uipath.spec.md", + ) + return except UiPathRuntimeError as e: console.error(f"{e.error_info.title} - {e.error_info.detail}") except Exception as e: diff --git a/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py b/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py index f1cd30202..4dd2f6700 100644 --- a/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py +++ b/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py @@ -68,6 +68,12 @@ class UiPathJsonConfig(BaseModelWithDefaultConfig): alias="$schema", description="Reference to the JSON schema for editor support", ) + id: str | None = Field( + default=None, + description="Stable unique identifier for the agent. Minted once at " + "project creation (by 'uipath init' or Studio Web) and preserved for the " + "lifetime of the project. Used as the package 'projectId' at pack time.", + ) runtime_options: RuntimeOptions = Field( default_factory=RuntimeOptions, alias="runtimeOptions", diff --git a/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py b/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py index 075ffe869..1e25def5e 100644 --- a/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py +++ b/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py @@ -460,9 +460,9 @@ def ingest_index( ) @click.option( "--search-mode", - type=click.Choice(["Auto", "Semantic"]), - default="Auto", - help="Search mode (default: Auto)", + type=click.Choice(["Semantic"]), + default="Semantic", + help="Search mode (default: Semantic)", ) @common_service_options @service_command diff --git a/packages/uipath/src/uipath/_resources/CLI_REFERENCE.md b/packages/uipath/src/uipath/_resources/CLI_REFERENCE.md index 98524ddfd..ff4009b22 100644 --- a/packages/uipath/src/uipath/_resources/CLI_REFERENCE.md +++ b/packages/uipath/src/uipath/_resources/CLI_REFERENCE.md @@ -923,7 +923,7 @@ Options: - `--query`: Search query in natural language (default: `Sentinel.UNSET`) - `--limit`: Maximum number of results (default: 10) (default: `10`) - `--threshold`: Minimum similarity threshold (default: 0.0) (default: `0.0`) -- `--search-mode`: Search mode (default: Auto) (default: `Auto`) +- `--search-mode`: Search mode (default: Semantic) (default: `Semantic`) - `--folder-path`: Folder path (e.g., "Shared"). Can also be set via UIPATH_FOLDER_PATH environment variable. (default: `Sentinel.UNSET`) - `--folder-key`: Folder key (UUID) (default: `Sentinel.UNSET`) - `--format`: Output format (overrides global) (default: `Sentinel.UNSET`) diff --git a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md index 4af1b60ae..02e9c0676 100644 --- a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md +++ b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md @@ -62,12 +62,18 @@ sdk.assets.retrieve(name: str, folder_key: Optional[str]=None, folder_path: Opti # Asynchronously retrieve an asset by its name. sdk.assets.retrieve_async(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.orchestrator.assets.UserAsset | uipath.platform.orchestrator.assets.Asset -# Gets a specified Orchestrator credential. +# Get the decrypted value of a Secret asset. sdk.assets.retrieve_credential(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> typing.Optional[str] -# Asynchronously gets a specified Orchestrator credential. +# Asynchronously get the decrypted value of a Secret asset. sdk.assets.retrieve_credential_async(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> typing.Optional[str] +# Get the decrypted value of a Secret asset. +sdk.assets.retrieve_secret(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> typing.Optional[str] + +# Asynchronously get the decrypted value of a Secret asset. +sdk.assets.retrieve_secret_async(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> typing.Optional[str] + # Update an asset's value. sdk.assets.update(robot_asset: uipath.platform.orchestrator.assets.UserAsset, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> httpx.Response @@ -113,6 +119,19 @@ sdk.attachments.upload_async(name: str, content: str | bytes | None=None, source ``` +### Automation Ops + +Automation Ops service + +```python +# Retrieve the deployed policy. +sdk.automation_ops.get_deployed_policy() -> dict[str, typing.Any] + +# Retrieve the deployed policy (async). +sdk.automation_ops.get_deployed_policy_async() -> dict[str, typing.Any] + +``` + ### Automation Tracker Automation Tracker service @@ -272,10 +291,10 @@ sdk.context_grounding.add_to_index(name: str, blob_file_path: str, content_type: sdk.context_grounding.add_to_index_async(name: str, blob_file_path: str, content_type: Optional[str]=None, content: Union[str, bytes, NoneType]=None, source_path: Optional[str]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None, ingest_data: bool=True) -> None # Create a new ephemeral context grounding index. -sdk.context_grounding.create_ephemeral_index(usage: uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex +sdk.context_grounding.create_ephemeral_index(usage: uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex # Create a new ephemeral context grounding index. -sdk.context_grounding.create_ephemeral_index_async(usage: uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex +sdk.context_grounding.create_ephemeral_index_async(usage: uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex # Create a new context grounding index. sdk.context_grounding.create_index(name: str, source: Union[uipath.platform.context_grounding.context_grounding_payloads.BucketSourceConfig, uipath.platform.context_grounding.context_grounding_payloads.GoogleDriveSourceConfig, uipath.platform.context_grounding.context_grounding_payloads.DropboxSourceConfig, uipath.platform.context_grounding.context_grounding_payloads.OneDriveSourceConfig, uipath.platform.context_grounding.context_grounding_payloads.ConfluenceSourceConfig], description: Optional[str]=None, extraction_strategy: Optional[str]=None, embeddings_enabled: Optional[bool]=None, is_encrypted: Optional[bool]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex @@ -326,7 +345,7 @@ sdk.context_grounding.list_indexes(folder_key: Optional[str]=None, folder_path: sdk.context_grounding.list_indexes_async(folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> typing.List[uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex] # Retrieve context grounding index information by its name. -sdk.context_grounding.retrieve(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex +sdk.context_grounding.retrieve(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None, include_system_indexes: bool=False) -> uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex # Retrieve all context grounding indexes across all folders. sdk.context_grounding.retrieve_across_folders(name: Optional[str]=None) -> typing.List[uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex] @@ -335,7 +354,7 @@ sdk.context_grounding.retrieve_across_folders(name: Optional[str]=None) -> typin sdk.context_grounding.retrieve_across_folders_async(name: Optional[str]=None) -> typing.List[uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex] # Asynchronously retrieve context grounding index information by its name. -sdk.context_grounding.retrieve_async(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex +sdk.context_grounding.retrieve_async(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None, include_system_indexes: bool=False) -> uipath.platform.context_grounding.context_grounding_index.ContextGroundingIndex # Retrieves a Batch Transform task status. sdk.context_grounding.retrieve_batch_transform(id: str, index_name: str | None=None) -> uipath.platform.context_grounding.context_grounding.BatchTransformResponse @@ -386,10 +405,10 @@ sdk.context_grounding.start_deep_rag_ephemeral(name: str, prompt: Annotated[str, sdk.context_grounding.start_deep_rag_ephemeral_async(name: str, prompt: Annotated[str, FieldInfo(annotation=NoneType, required=True, metadata=[MaxLen(max_length=250000)])], glob_pattern: Annotated[str, FieldInfo(annotation=NoneType, required=False, default='*', metadata=[MaxLen(max_length=512)])]="**", citation_mode: uipath.platform.context_grounding.context_grounding.DeepRagCreationResponse # Perform a unified search on a context grounding index. -sdk.context_grounding.unified_search(name: str, query: str, search_mode: uipath.platform.context_grounding.context_grounding.UnifiedQueryResult +sdk.context_grounding.unified_search(name: str, query: str, search_mode: uipath.platform.context_grounding.context_grounding.UnifiedQueryResult # Asynchronously perform a unified search on a context grounding index. -sdk.context_grounding.unified_search_async(name: str, query: str, search_mode: uipath.platform.context_grounding.context_grounding.UnifiedQueryResult +sdk.context_grounding.unified_search_async(name: str, query: str, search_mode: uipath.platform.context_grounding.context_grounding.UnifiedQueryResult ``` @@ -475,17 +494,77 @@ sdk.documents.start_ixp_extraction_validation_async(extraction_response: uipath. Entities service ```python +# Create a new entity with the given schema and return its id. +sdk.entities.create_entity(name: str, fields: List[uipath.platform.entities.entities.EntityCreateFieldOptions], options: Optional[uipath.platform.entities.entities.EntityCreateOptions]=None) -> str + +# Asynchronously create a new entity with the given schema. +sdk.entities.create_entity_async(name: str, fields: List[uipath.platform.entities.entities.EntityCreateFieldOptions], options: Optional[uipath.platform.entities.entities.EntityCreateOptions]=None) -> str + +# Remove the file attached to a File-type field on a record. +sdk.entities.delete_attachment(entity_id: str, record_id: str, field_name: str, expansion_level: Optional[int]=None) -> typing.Dict[str, typing.Any] + +# Asynchronously remove the file attached to a File-type field. +sdk.entities.delete_attachment_async(entity_id: str, record_id: str, field_name: str, expansion_level: Optional[int]=None) -> typing.Dict[str, typing.Any] + +# Delete an entity and all of its records. +sdk.entities.delete_entity(entity_id: str) -> None + +# Asynchronously delete an entity and all of its records. +sdk.entities.delete_entity_async(entity_id: str) -> None + +# Delete a single record by id. +sdk.entities.delete_record(entity_key: str, record_id: str) -> None + +# Asynchronously delete a single record by id. +sdk.entities.delete_record_async(entity_key: str, record_id: str) -> None + # Delete multiple records from an entity in a single batch operation. -sdk.entities.delete_records(entity_key: str, record_ids: List[str]) -> uipath.platform.entities.entities.EntityRecordsBatchResponse +sdk.entities.delete_records(entity_key: str, record_ids: List[str], fail_on_first: Optional[bool]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse # Asynchronously delete multiple records from an entity in a single batch operation. -sdk.entities.delete_records_async(entity_key: str, record_ids: List[str]) -> uipath.platform.entities.entities.EntityRecordsBatchResponse +sdk.entities.delete_records_async(entity_key: str, record_ids: List[str], fail_on_first: Optional[bool]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse + +# Download a file attached to a record and return its raw bytes. +sdk.entities.download_attachment(entity_id: str, record_id: str, field_name: str) -> bytes + +# Asynchronously download a file attached to a record. +sdk.entities.download_attachment_async(entity_id: str, record_id: str, field_name: str) -> bytes + +# Get the values of a choice set by its ID. +sdk.entities.get_choiceset_values(choiceset_id: str, start: Optional[int]=None, limit: Optional[int]=None) -> typing.List[uipath.platform.entities.entities.ChoiceSetValue] + +# Asynchronously get the values of a choice set by its ID. +sdk.entities.get_choiceset_values_async(choiceset_id: str, start: Optional[int]=None, limit: Optional[int]=None) -> typing.List[uipath.platform.entities.entities.ChoiceSetValue] + +# Fetch a single entity record by its id. +sdk.entities.get_record(entity_key: str, record_id: str, expansion_level: Optional[int]=None) -> uipath.platform.entities.entities.EntityRecord + +# Asynchronously fetch a single entity record by its id. +sdk.entities.get_record_async(entity_key: str, record_id: str, expansion_level: Optional[int]=None) -> uipath.platform.entities.entities.EntityRecord + +# Bulk-import records into an entity from a CSV file. +sdk.entities.import_records(entity_id: str, file: Union[bytes, bytearray, memoryview, NoneType]=None, file_path: Optional[str]=None) -> uipath.platform.entities.entities.EntityImportRecordsResponse + +# Asynchronously bulk-import records into an entity from a CSV file. +sdk.entities.import_records_async(entity_id: str, file: Union[bytes, bytearray, memoryview, NoneType]=None, file_path: Optional[str]=None) -> uipath.platform.entities.entities.EntityImportRecordsResponse + +# Insert a single record into an entity and return the inserted row. +sdk.entities.insert_record(entity_key: str, data: Any, expansion_level: Optional[int]=None) -> uipath.platform.entities.entities.EntityRecord + +# Asynchronously insert a single record into an entity. +sdk.entities.insert_record_async(entity_key: str, data: Any, expansion_level: Optional[int]=None) -> uipath.platform.entities.entities.EntityRecord # Insert multiple records into an entity in a single batch operation. -sdk.entities.insert_records(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse +sdk.entities.insert_records(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None, expansion_level: Optional[int]=None, fail_on_first: Optional[bool]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse # Asynchronously insert multiple records into an entity in a single batch operation. -sdk.entities.insert_records_async(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse +sdk.entities.insert_records_async(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None, expansion_level: Optional[int]=None, fail_on_first: Optional[bool]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse + +# List all choice sets in Data Service. +sdk.entities.list_choicesets() -> typing.List[uipath.platform.entities.entities.Entity] + +# Asynchronously list all choice sets in Data Service. +sdk.entities.list_choicesets_async() -> typing.List[uipath.platform.entities.entities.Entity] # List all entities in Data Service. sdk.entities.list_entities() -> typing.List[uipath.platform.entities.entities.Entity] @@ -494,16 +573,22 @@ sdk.entities.list_entities() -> typing.List[uipath.platform.entities.entities.En sdk.entities.list_entities_async() -> typing.List[uipath.platform.entities.entities.Entity] # List records from an entity with optional pagination and schema validation. -sdk.entities.list_records(entity_key: str, schema: Optional[Type[Any]]=None, start: Optional[int]=None, limit: Optional[int]=None) -> typing.List[uipath.platform.entities.entities.EntityRecord] +sdk.entities.list_records(entity_key: str, schema: Optional[Type[Any]]=None, start: Optional[int]=None, limit: Optional[int]=None, expansion_level: Optional[int]=None, filter: Optional[str]=None, orderby: Optional[str]=None, select: Optional[List[str]]=None, expand: Optional[List[str]]=None) -> uipath.platform.entities.entities.EntityRecordsListResponse # Asynchronously list records from an entity with optional pagination and schema validation. -sdk.entities.list_records_async(entity_key: str, schema: Optional[Type[Any]]=None, start: Optional[int]=None, limit: Optional[int]=None) -> typing.List[uipath.platform.entities.entities.EntityRecord] +sdk.entities.list_records_async(entity_key: str, schema: Optional[Type[Any]]=None, start: Optional[int]=None, limit: Optional[int]=None, expansion_level: Optional[int]=None, filter: Optional[str]=None, orderby: Optional[str]=None, select: Optional[List[str]]=None, expand: Optional[List[str]]=None) -> uipath.platform.entities.entities.EntityRecordsListResponse # Query entity records using a validated SQL query. -sdk.entities.query_entity_records(sql_query: str, routing_context: Optional[uipath.platform.entities.entities.QueryRoutingOverrideContext]=None) -> typing.List[typing.Dict[str, typing.Any]] +sdk.entities.query_entity_records(sql_query: str) -> typing.List[typing.Dict[str, typing.Any]] # Asynchronously query entity records using a validated SQL query. -sdk.entities.query_entity_records_async(sql_query: str, routing_context: Optional[uipath.platform.entities.entities.QueryRoutingOverrideContext]=None) -> typing.List[typing.Dict[str, typing.Any]] +sdk.entities.query_entity_records_async(sql_query: str) -> typing.List[typing.Dict[str, typing.Any]] + +# Resolve an agent entity set, applying resource overwrites. +sdk.entities.resolve_entity_set(items: List[uipath.platform.entities.entities.DataFabricEntityItem]) -> uipath.platform.entities.entities.EntitySetResolution + +# Resolve an agent entity set, applying resource overwrites. +sdk.entities.resolve_entity_set_async(items: List[uipath.platform.entities.entities.DataFabricEntityItem]) -> uipath.platform.entities.entities.EntitySetResolution # Retrieve an entity by its key. sdk.entities.retrieve(entity_key: str) -> uipath.platform.entities.entities.Entity @@ -511,11 +596,44 @@ sdk.entities.retrieve(entity_key: str) -> uipath.platform.entities.entities.Enti # Asynchronously retrieve an entity by its key. sdk.entities.retrieve_async(entity_key: str) -> uipath.platform.entities.entities.Entity +# Retrieve an entity by its name. +sdk.entities.retrieve_by_name(entity_name: str, folder_key: Optional[str]=None) -> uipath.platform.entities.entities.Entity + +# Asynchronously retrieve an entity by its name. +sdk.entities.retrieve_by_name_async(entity_name: str, folder_key: Optional[str]=None) -> uipath.platform.entities.entities.Entity + +# Retrieve records with structured filters, sorting, expansion, joins, and aggregates. +sdk.entities.retrieve_records(entity_key: str, filter_group: Optional[uipath.platform.entities.entities.EntityQueryFilterGroup]=None, sort_options: Optional[List[uipath.platform.entities.entities.EntityQuerySortOption]]=None, selected_fields: Optional[List[str]]=None, expansions: Optional[List[Any]]=None, expansion_level: Optional[int]=None, aggregates: Optional[List[uipath.platform.entities.entities.EntityAggregate]]=None, group_by: Optional[List[str]]=None, joins: Optional[List[uipath.platform.entities.entities.EntityJoin]]=None, binnings: Optional[List[uipath.platform.entities.entities.EntityBinning]]=None, start: Optional[int]=None, limit: Optional[int]=None) -> uipath.platform.entities.entities.RetrieveEntityRecordsResponse + +# Asynchronously retrieve records with structured filters, sorting, expansion, joins, and aggregates. +sdk.entities.retrieve_records_async(entity_key: str, filter_group: Optional[uipath.platform.entities.entities.EntityQueryFilterGroup]=None, sort_options: Optional[List[uipath.platform.entities.entities.EntityQuerySortOption]]=None, selected_fields: Optional[List[str]]=None, expansions: Optional[List[Any]]=None, expansion_level: Optional[int]=None, aggregates: Optional[List[uipath.platform.entities.entities.EntityAggregate]]=None, group_by: Optional[List[str]]=None, joins: Optional[List[uipath.platform.entities.entities.EntityJoin]]=None, binnings: Optional[List[uipath.platform.entities.entities.EntityBinning]]=None, start: Optional[int]=None, limit: Optional[int]=None) -> uipath.platform.entities.entities.RetrieveEntityRecordsResponse + +# Update an entity's display name, description, and/or RBAC flag. +sdk.entities.update_entity_metadata(entity_id: str, metadata: Union[uipath.platform.entities.entities.EntityMetadataUpdateOptions, Dict[str, Any]]) -> None + +# Asynchronously update an entity's display name, description, and/or RBAC flag. +sdk.entities.update_entity_metadata_async(entity_id: str, metadata: Union[uipath.platform.entities.entities.EntityMetadataUpdateOptions, Dict[str, Any]]) -> None + +# Update a single record by id and return the updated row. +sdk.entities.update_record(entity_key: str, record_id: str, data: Any, expansion_level: Optional[int]=None) -> uipath.platform.entities.entities.EntityRecord + +# Asynchronously update a single record by id. +sdk.entities.update_record_async(entity_key: str, record_id: str, data: Any, expansion_level: Optional[int]=None) -> uipath.platform.entities.entities.EntityRecord + # Update multiple records in an entity in a single batch operation. -sdk.entities.update_records(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse +sdk.entities.update_records(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None, expansion_level: Optional[int]=None, fail_on_first: Optional[bool]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse # Asynchronously update multiple records in an entity in a single batch operation. -sdk.entities.update_records_async(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse +sdk.entities.update_records_async(entity_key: str, records: List[Any], schema: Optional[Type[Any]]=None, expansion_level: Optional[int]=None, fail_on_first: Optional[bool]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse + +# Upload a file attachment to a File-type field on a record. +sdk.entities.upload_attachment(entity_id: str, record_id: str, field_name: str, file: Union[bytes, bytearray, memoryview, NoneType]=None, file_path: Optional[str]=None, expansion_level: Optional[int]=None) -> typing.Dict[str, typing.Any] + +# Asynchronously upload a file attachment to a File-type field on a record. +sdk.entities.upload_attachment_async(entity_id: str, record_id: str, field_name: str, file: Union[bytes, bytearray, memoryview, NoneType]=None, file_path: Optional[str]=None, expansion_level: Optional[int]=None) -> typing.Dict[str, typing.Any] + +# Parse a batch response, optionally validating success records against ``schema``. +sdk.entities.validate_entity_batch(batch_response: httpx.Response, schema: Optional[Type[Any]]=None) -> uipath.platform.entities.entities.EntityRecordsBatchResponse ``` @@ -619,6 +737,12 @@ sdk.jobs.retrieve_api_payload_async(inbox_id: str) -> typing.Any # Asynchronously retrieve a job identified by its key. sdk.jobs.retrieve_async(job_key: str, folder_key: str | None=None, folder_path: str | None=None, process_name: str | None=None) -> uipath.platform.orchestrator.job.Job +# Fetch payload data for Integration Services (Inbox) triggers. +sdk.jobs.retrieve_inbox_payload(inbox_id: str) -> typing.Any + +# Asynchronously fetch payload data for Integration Services (Inbox) triggers. +sdk.jobs.retrieve_inbox_payload_async(inbox_id: str) -> typing.Any + # Stop one or more jobs with specified strategy. sdk.jobs.stop(job_keys: List[str], strategy: str="SoftStop", folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> None @@ -633,7 +757,7 @@ Llm service ```python # Generate chat completions using UiPath's normalized LLM Gateway API. -sdk.llm.chat_completions(messages: list[dict[str, str]] | list[tuple[str, str]], model: str="gpt-4.1-mini-2025-04-14", max_tokens: int=4096, temperature: float=0, n: int=1, frequency_penalty: float=0, presence_penalty: float=0, top_p: float | None=1, top_k: int | None=None, tools: list[uipath.platform.chat.llm_gateway.ToolDefinition] | None=None, tool_choice: Union[uipath.platform.chat.llm_gateway.AutoToolChoice, uipath.platform.chat.llm_gateway.RequiredToolChoice, uipath.platform.chat.llm_gateway.SpecificToolChoice, Literal['auto', 'none'], NoneType]=None, response_format: dict[str, Any] | type[pydantic.main.BaseModel] | None=None, api_version: str="2024-08-01-preview") +sdk.llm.chat_completions(messages: list[dict[str, str]] | list[tuple[str, str]], model: str="gpt-4.1-mini-2025-04-14", max_tokens: int=4096, temperature: float=0, n: int=1, frequency_penalty: float=0, presence_penalty: float=0, top_p: float | None=1, top_k: int | None=None, tools: list[uipath.platform.chat.llm_gateway.ToolDefinition | dict[str, Any]] | None=None, tool_choice: Union[uipath.platform.chat.llm_gateway.AutoToolChoice, uipath.platform.chat.llm_gateway.RequiredToolChoice, uipath.platform.chat.llm_gateway.SpecificToolChoice, Literal['auto', 'none'], NoneType]=None, response_format: dict[str, Any] | type[pydantic.main.BaseModel] | None=None, api_version: str="2024-08-01-preview") ``` @@ -669,6 +793,43 @@ sdk.mcp.retrieve_async(slug: str, folder_path: str | None=None) -> uipath.platfo ``` +### Memory + +Memory service + +```python +# Create a new memory space. +sdk.memory.create(name: str, description: Optional[str]=None, is_encrypted: Optional[bool]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.MemorySpace + +# Asynchronously create a new memory space. +sdk.memory.create_async(name: str, description: Optional[str]=None, is_encrypted: Optional[bool]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.MemorySpace + +# Ingest a resolved escalation outcome into memory. +sdk.memory.escalation_ingest(memory_space_id: str, request: uipath.platform.memory.memory.EscalationMemoryIngestRequest, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> None + +# Asynchronously ingest a resolved escalation outcome into memory. +sdk.memory.escalation_ingest_async(memory_space_id: str, request: uipath.platform.memory.memory.EscalationMemoryIngestRequest, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> None + +# Search escalation memory for previously resolved outcomes. +sdk.memory.escalation_search(memory_space_id: str, request: uipath.platform.memory.memory.MemorySearchRequest, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.EscalationMemorySearchResponse + +# Asynchronously search escalation memory for previously resolved outcomes. +sdk.memory.escalation_search_async(memory_space_id: str, request: uipath.platform.memory.memory.MemorySearchRequest, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.EscalationMemorySearchResponse + +# List memory spaces with optional OData query parameters. +sdk.memory.list(filter: Optional[str]=None, orderby: Optional[str]=None, top: Optional[int]=None, skip: Optional[int]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.MemorySpaceListResponse + +# Asynchronously list memory spaces. +sdk.memory.list_async(filter: Optional[str]=None, orderby: Optional[str]=None, top: Optional[int]=None, skip: Optional[int]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.MemorySpaceListResponse + +# Search a memory space via LLMOps. +sdk.memory.search(memory_space_id: str, request: uipath.platform.memory.memory.MemorySearchRequest, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.MemorySearchResponse + +# Asynchronously search a memory space via LLMOps. +sdk.memory.search_async(memory_space_id: str, request: uipath.platform.memory.memory.MemorySearchRequest, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.platform.memory.memory.MemorySearchResponse + +``` + ### Orchestrator Setup Orchestrator Setup service @@ -682,16 +843,29 @@ sdk.orchestrator_setup.enable_first_run_async() -> None ``` +### Pii Detection + +Pii Detection service + +```python +# Detect PII in the provided documents and/or files. +sdk.pii_detection.detect_pii(request: uipath.platform.pii_detection.pii_detection.PiiDetectionRequest) -> uipath.platform.pii_detection.pii_detection.PiiDetectionResponse + +# Detect PII in the provided documents and/or files (async). +sdk.pii_detection.detect_pii_async(request: uipath.platform.pii_detection.pii_detection.PiiDetectionRequest) -> uipath.platform.pii_detection.pii_detection.PiiDetectionResponse + +``` + ### Processes Processes service ```python # Start execution of a process by its name. -sdk.processes.invoke(name: str, input_arguments: Optional[Dict[str, Any]]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None, attachments: Optional[list[uipath.platform.attachments.attachments.Attachment]]=None, parent_operation_id: Optional[str]=None, **kwargs) -> uipath.platform.orchestrator.job.Job +sdk.processes.invoke(name: str, input_arguments: Optional[Dict[str, Any]]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None, attachments: Optional[list[uipath.platform.attachments.attachments.Attachment]]=None, parent_operation_id: Optional[str]=None, run_as_me: Optional[bool]=None, **kwargs) -> uipath.platform.orchestrator.job.Job # Asynchronously start execution of a process by its name. -sdk.processes.invoke_async(name: str, input_arguments: Optional[Dict[str, Any]]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None, attachments: Optional[list[uipath.platform.attachments.attachments.Attachment]]=None, parent_operation_id: Optional[str]=None, **kwargs) -> uipath.platform.orchestrator.job.Job +sdk.processes.invoke_async(name: str, input_arguments: Optional[Dict[str, Any]]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None, attachments: Optional[list[uipath.platform.attachments.attachments.Attachment]]=None, parent_operation_id: Optional[str]=None, run_as_me: Optional[bool]=None, **kwargs) -> uipath.platform.orchestrator.job.Job ``` @@ -766,19 +940,32 @@ Resource Catalog service sdk.resource_catalog.list(resource_types: Optional[List[uipath.platform.resource_catalog.resource_catalog.ResourceType]]=None, resource_sub_types: Optional[List[str]]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None, page_size: int=20) -> typing.Iterator[uipath.platform.resource_catalog.resource_catalog.Resource] # Asynchronously get tenant scoped resources and folder scoped resources (accessible to the user). -sdk.resource_catalog.list_async(resource_types: Optional[List[uipath.platform.resource_catalog.resource_catalog.ResourceType]]=None, resource_sub_types: Optional[List[str]]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None, page_size: int=20) -> typing.AsyncIterator[uipath.platform.resource_catalog.resource_catalog.Resource] +sdk.resource_catalog.list_async(resource_types: Optional[List[uipath.platform.resource_catalog.resource_catalog.ResourceType]]=None, resource_sub_types: Optional[List[str]]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None, page_size: int=20) -> typing.AsyncGenerator[uipath.platform.resource_catalog.resource_catalog.Resource, NoneType] # Get resources of a specific type (tenant scoped or folder scoped). sdk.resource_catalog.list_by_type(resource_type: typing.Iterator[uipath.platform.resource_catalog.resource_catalog.Resource] # Asynchronously get resources of a specific type (tenant scoped or folder scoped). -sdk.resource_catalog.list_by_type_async(resource_type: typing.AsyncIterator[uipath.platform.resource_catalog.resource_catalog.Resource] +sdk.resource_catalog.list_by_type_async(resource_type: typing.AsyncGenerator[uipath.platform.resource_catalog.resource_catalog.Resource, NoneType] # Search for tenant scoped resources and folder scoped resources (accessible to the user). sdk.resource_catalog.search(name: Optional[str]=None, resource_types: Optional[List[uipath.platform.resource_catalog.resource_catalog.ResourceType]]=None, resource_sub_types: Optional[List[str]]=None, page_size: int=20) -> typing.Iterator[uipath.platform.resource_catalog.resource_catalog.Resource] # Asynchronously search for tenant scoped resources and folder scoped resources (accessible to the user). -sdk.resource_catalog.search_async(name: Optional[str]=None, resource_types: Optional[List[uipath.platform.resource_catalog.resource_catalog.ResourceType]]=None, resource_sub_types: Optional[List[str]]=None, page_size: int=20) -> typing.AsyncIterator[uipath.platform.resource_catalog.resource_catalog.Resource] +sdk.resource_catalog.search_async(name: Optional[str]=None, resource_types: Optional[List[uipath.platform.resource_catalog.resource_catalog.ResourceType]]=None, resource_sub_types: Optional[List[str]]=None, page_size: int=20) -> typing.AsyncGenerator[uipath.platform.resource_catalog.resource_catalog.Resource, NoneType] + +``` + +### Semantic Proxy + +Semantic Proxy service + +```python +# Detect PII in the provided documents and/or files. +sdk.semantic_proxy.detect_pii(request: uipath.platform.semantic_proxy.semantic_proxy.PiiDetectionRequest) -> uipath.platform.semantic_proxy.semantic_proxy.PiiDetectionResponse + +# Detect PII in the provided documents and/or files (async). +sdk.semantic_proxy.detect_pii_async(request: uipath.platform.semantic_proxy.semantic_proxy.PiiDetectionRequest) -> uipath.platform.semantic_proxy.semantic_proxy.PiiDetectionResponse ``` @@ -793,6 +980,12 @@ sdk.tasks.create(title: str, data: Optional[Dict[str, Any]]=None, app_name: Opti # Creates a new action asynchronously. sdk.tasks.create_async(title: str, data: Optional[Dict[str, Any]]=None, app_name: Optional[str]=None, app_key: Optional[str]=None, app_folder_path: Optional[str]=None, app_folder_key: Optional[str]=None, assignee: Optional[str]=None, recipient: Optional[uipath.platform.action_center.tasks.TaskRecipient]=None, priority: Optional[str]=None, labels: Optional[List[str]]=None, is_actionable_message_enabled: Optional[bool]=None, actionable_message_metadata: Optional[Dict[str, Any]]=None, source_name: str="Agent") -> uipath.platform.action_center.tasks.Task +# Create a new QuickForm task synchronously. +sdk.tasks.create_quickform(title: str, task_schema_key: str, schema: Dict[str, Any], data: Optional[Dict[str, Any]]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None, assignee: Optional[str]=None, recipient: Optional[uipath.platform.action_center.tasks.TaskRecipient]=None, priority: Optional[str]=None, labels: Optional[List[str]]=None, is_actionable_message_enabled: Optional[bool]=None, actionable_message_metadata: Optional[Dict[str, Any]]=None, creator_job_key: Optional[str]=None, source_name: str="Agent") -> uipath.platform.action_center.tasks.Task + +# Creates a new QuickForm task asynchronously. +sdk.tasks.create_quickform_async(title: str, task_schema_key: str, schema: Dict[str, Any], data: Optional[Dict[str, Any]]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None, assignee: Optional[str]=None, recipient: Optional[uipath.platform.action_center.tasks.TaskRecipient]=None, priority: Optional[str]=None, labels: Optional[List[str]]=None, is_actionable_message_enabled: Optional[bool]=None, actionable_message_metadata: Optional[Dict[str, Any]]=None, creator_job_key: Optional[str]=None, source_name: str="Agent") -> uipath.platform.action_center.tasks.Task + # Retrieves a task by its key synchronously. sdk.tasks.retrieve(action_key: str, app_folder_path: Optional[str]=None, app_folder_key: Optional[str]=None, app_name: str | None=None) -> uipath.platform.action_center.tasks.Task diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 901ce7ca9..916417c94 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -35,6 +35,7 @@ ) from uipath.eval.mocks import ExampleCall from uipath.platform.connections import Connection +from uipath.platform.entities import DataFabricEntityItem from uipath.platform.guardrails import ( BuiltInValidatorGuardrail, ) @@ -111,9 +112,12 @@ class AgentToolType(str, CaseInsensitiveEnum): PROCESS = "Process" API = "Api" PROCESS_ORCHESTRATION = "ProcessOrchestration" + FLOW = "Flow" + FUNCTION = "Function" INTEGRATION = "Integration" INTERNAL = "Internal" IXP = "Ixp" + CLIENT_SIDE = "ClientSide" UNKNOWN = "Unknown" # fallback branch discriminator @@ -134,6 +138,18 @@ class AgentEscalationRecipientType(str, CaseInsensitiveEnum): ASSET_USER_EMAIL = "AssetUserEmail" GROUP_NAME = "GroupName" ASSET_GROUP_NAME = "AssetGroupName" + ARGUMENT_EMAIL = "ArgumentEmail" + ARGUMENT_GROUP_NAME = "ArgumentGroupName" + WORKLOAD = "Workload" + ROUND_ROBIN = "RoundRobin" + CUSTOM_ASSIGNEES = "CustomAssignees" + + +class AgentEscalationChannelType(str, CaseInsensitiveEnum): + """Agent escalation channel type enumeration.""" + + ACTION_CENTER = "actionCenter" + ACTION_CENTER_QUICK_FORM = "actionCenterQuickForm" class AgentContextRetrievalMode(str, CaseInsensitiveEnum): @@ -162,6 +178,12 @@ class AgentMessageRole(str, CaseInsensitiveEnum): USER = "user" +class AgentVariant(str, CaseInsensitiveEnum): + """Agent variant enumeration.""" + + CASE_MANAGER = "caseManager" + + class AgentGuardrailActionType(str, CaseInsensitiveEnum): """Agent guardrail action type enumeration.""" @@ -179,6 +201,7 @@ class AgentToolArgumentPropertiesVariant(str, CaseInsensitiveEnum): ARGUMENT = "argument" STATIC = "static" TEXT_BUILDER = "textBuilder" + ARRAY_BUILDER = "arrayBuilder" class TextTokenType(str, CaseInsensitiveEnum): @@ -267,11 +290,21 @@ class AgentToolTextBuilderArgumentProperties(BaseAgentToolArgumentProperties): tokens: List[TextToken] +class AgentToolArrayBuilderArgumentProperties(BaseCfg): + """Agent array builder argument properties model.""" + + variant: Literal[AgentToolArgumentPropertiesVariant.ARRAY_BUILDER] = Field( + default=AgentToolArgumentPropertiesVariant.ARRAY_BUILDER, + frozen=True, + ) + + AgentToolArgumentProperties = Annotated[ Union[ AgentToolStaticArgumentProperties, AgentToolArgumentArgumentProperties, AgentToolTextBuilderArgumentProperties, + AgentToolArrayBuilderArgumentProperties, ], Field(discriminator="variant"), _case_insensitive_enum_validator("variant", AgentToolArgumentPropertiesVariant), @@ -394,16 +427,6 @@ class AgentContextSettings(BaseCfg): ) -class DataFabricEntityItem(BaseCfg): - """A single Data Fabric entity reference.""" - - id: str - reference_key: Optional[str] = Field(None, alias="referenceKey") - name: str - folder_id: str = Field(alias="folderId") - description: Optional[str] = None - - class AgentContextResourceConfig(BaseAgentResourceConfig): """Agent context resource configuration model.""" @@ -447,13 +470,57 @@ class AgentMcpTool(BaseCfg): class DynamicToolsMode(str, CaseInsensitiveEnum): - """Dynamic tools mode enumeration.""" + """Dynamic tools mode enumeration. + + Deprecated: kept for backwards compatibility with older ``agent.json`` files + that still serialize the ``dynamicTools`` field. New code should use + :class:`ToolsConfiguration` (see ``AgentMcpResourceConfig.tools_configuration``). + """ NONE = "none" SCHEMA = "schema" ALL = "all" +class CachedToolsConfig(BaseCfg): + """Cached tools configuration: use the tools saved in the agent definition snapshot. + + When ``refresh_schema_before_call`` is true, the live tool schema is fetched + from the MCP server immediately before a tool is invoked. The agent still uses + the cached schema to decide which tool to call; the fresh schema is applied only + at invocation time. + """ + + type: Literal["cached"] = Field(default="cached", frozen=True) + refresh_schema_before_call: bool = Field( + default=True, alias="refreshSchemaBeforeCall" + ) + + +class DynamicToolsConfig(BaseCfg): + """Dynamic tools configuration: fetch the tool list from the MCP server at runtime. + + When ``allow_all`` is true, every tool the server exposes is forwarded + to the agent. When false, the live list is filtered by the snapshot's + ``available_tools`` allowlist (live schemas, curated tool set). + """ + + type: Literal["dynamic"] = Field(default="dynamic", frozen=True) + allow_all: bool = Field(alias="allowAll") + + +DiscoveryMode = Annotated[ + Union[CachedToolsConfig, DynamicToolsConfig], + Field(discriminator="type"), +] + + +class ToolsConfiguration(BaseCfg): + """Configuration describing how tools are sourced for an MCP resource.""" + + discovery_mode: DiscoveryMode = Field(alias="discoveryMode") + + class AgentMcpResourceConfig(BaseAgentResourceConfig): """Agent MCP resource configuration model.""" @@ -463,8 +530,8 @@ class AgentMcpResourceConfig(BaseAgentResourceConfig): folder_path: str = Field(alias="folderPath") slug: str = Field(..., alias="slug") available_tools: List[AgentMcpTool] = Field(..., alias="availableTools") - dynamic_tools: DynamicToolsMode = Field( - default=DynamicToolsMode.NONE, alias="dynamicTools" + tools_configuration: Optional[ToolsConfiguration] = Field( + default=None, alias="toolsConfiguration" ) @@ -476,15 +543,10 @@ class AgentA2aResourceConfig(BaseAgentResourceConfig): ) id: str slug: str = Field(..., alias="slug") - agent_card_url: str = Field(default="", alias="agentCardUrl") - is_active: bool = Field(default=True, alias="isActive") + folder_path: str = Field(alias="folderPath") cached_agent_card: Optional[Dict[str, Any]] = Field( default=None, alias="cachedAgentCard" ) - created_at: Optional[str] = Field(default=None, alias="createdAt") - created_by: Optional[str] = Field(default=None, alias="createdBy") - updated_at: Optional[str] = Field(default=None, alias="updatedAt") - updated_by: Optional[str] = Field(default=None, alias="updatedBy") _RECIPIENT_TYPE_NORMALIZED_MAP: Mapping[int | str, AgentEscalationRecipientType] = { @@ -495,6 +557,11 @@ class AgentA2aResourceConfig(BaseAgentResourceConfig): 5: AgentEscalationRecipientType.GROUP_NAME, "staticgroupname": AgentEscalationRecipientType.GROUP_NAME, 6: AgentEscalationRecipientType.ASSET_GROUP_NAME, + 7: AgentEscalationRecipientType.ARGUMENT_EMAIL, + 8: AgentEscalationRecipientType.ARGUMENT_GROUP_NAME, + 9: AgentEscalationRecipientType.WORKLOAD, + 10: AgentEscalationRecipientType.ROUND_ROBIN, + 11: AgentEscalationRecipientType.CUSTOM_ASSIGNEES, } @@ -550,9 +617,129 @@ class AssetRecipient(BaseEscalationRecipient): folder_path: str = Field(..., alias="folderPath") +class ArgumentEmailRecipient(BaseEscalationRecipient): + """Argument email recipient resolved from a named input argument. + + The argument_path supports dot-notation for nested input fields (e.g. "user.email"). + """ + + type: Literal[AgentEscalationRecipientType.ARGUMENT_EMAIL,] = Field( + ..., alias="type" + ) + argument_path: str = Field(..., alias="argumentName") + + +class ArgumentGroupNameRecipient(BaseEscalationRecipient): + """Argument group name recipient resolved from a named input argument. + + The argument_path supports dot-notation for nested input fields (e.g. "team.groupName"). + """ + + type: Literal[AgentEscalationRecipientType.ARGUMENT_GROUP_NAME,] = Field( + ..., alias="type" + ) + argument_path: str = Field(..., alias="argumentName") + + +class WorkloadRecipient(BaseEscalationRecipient): + """Workload-based group assignment. + + The Action Center distributes tasks to the group member with the lightest workload. + """ + + type: Literal[AgentEscalationRecipientType.WORKLOAD,] = Field(..., alias="type") + value: str = Field(..., alias="value") + display_name: str = Field(..., alias="displayName") + + +class RoundRobinRecipient(BaseEscalationRecipient): + """Round-robin group assignment. + + The Action Center cycles through group members in order on each new task. + """ + + type: Literal[AgentEscalationRecipientType.ROUND_ROBIN,] = Field(..., alias="type") + value: str = Field(..., alias="value") + display_name: str = Field(..., alias="displayName") + + +class CustomAssigneesRecipient(BaseEscalationRecipient): + """Custom multi-user assignment. + + A channel can carry multiple instances, one per assignee email. All are passed + to Action Center together using a Workload assignment criteria. + """ + + type: Literal[AgentEscalationRecipientType.CUSTOM_ASSIGNEES,] = Field( + ..., alias="type" + ) + value: str = Field(..., alias="value") + display_name: Optional[str] = Field(default=None, alias="displayName") + + +class ToolOutputRecipient(BaseEscalationRecipient): + """Recipient whose value is resolved at runtime from a named tool's output. + + Instead of a literal value entered at design time, this binding points at a + field within a named tool's output. The runtime walks the agent's message + history, finds the most recent ToolMessage matching `tool_name`, parses its + content as JSON, and extracts `output_path` (a top-level field for v1). + + Only the assignment-criteria recipient types that accept a runtime-computed + value are supported: USER_ID, GROUP_ID, WORKLOAD, ROUND_ROBIN, + CUSTOM_ASSIGNEES. The asset/static/argument types do not participate in + tool-output binding (they have their own design-time resolution rules). + """ + + type: Literal[ + AgentEscalationRecipientType.USER_ID, + AgentEscalationRecipientType.GROUP_ID, + AgentEscalationRecipientType.WORKLOAD, + AgentEscalationRecipientType.ROUND_ROBIN, + AgentEscalationRecipientType.CUSTOM_ASSIGNEES, + ] = Field(..., alias="type") + source: Literal["toolOutput"] = Field(..., alias="source") + tool_name: str = Field(..., alias="toolName") + output_path: str = Field(..., alias="outputPath") + + +# ────────────────────────────────────────────────────────────────────────────── +# AgentEscalationRecipient — Union ordering & invariants +# ────────────────────────────────────────────────────────────────────────────── +# Pydantic evaluates Union members left-to-right and stops at the first +# successful match, so member order determines which class a payload resolves +# to when multiple members share the same `type` value (e.g. WORKLOAD is valid +# on both WorkloadRecipient and ToolOutputRecipient). +# +# How dispatching works: +# - Payload with `source: "toolOutput"` → matches ToolOutputRecipient +# (it is the only class declaring `source` as a required Literal field). +# - Payload without `source` → ToolOutputRecipient validation fails +# (`source` missing), so it falls through to the literal class below +# that owns its `type`. +# +# Why we don't use `Field(discriminator="type")`: +# The `type` values are NOT unique across the Union — both WorkloadRecipient +# and ToolOutputRecipient declare `type=WORKLOAD`, same for the other +# tool-output-capable criteria. A typed discriminator requires unique +# discriminator values across members, which this union violates by design. +# +# Critical invariants (any of these breaking causes silent mis-typing): +# 1. ToolOutputRecipient remains the FIRST member of the Union. +# 2. ToolOutputRecipient.source remains a required `Literal["toolOutput"]` +# (NOT `Optional`, NOT a default value). +# 3. No literal class below it gains an optional `source` field. AgentEscalationRecipient = Annotated[ - Union[StandardRecipient, AssetRecipient], - Field(discriminator="type"), + Union[ + ToolOutputRecipient, + StandardRecipient, + AssetRecipient, + ArgumentEmailRecipient, + ArgumentGroupNameRecipient, + WorkloadRecipient, + RoundRobinRecipient, + CustomAssigneesRecipient, + ], BeforeValidator(_normalize_recipient_type), ] @@ -617,13 +804,9 @@ def _resolve_task_title(v: Any) -> Any: return v -class AgentEscalationChannelProperties(BaseResourceProperties): - """Agent escalation channel properties model.""" +class BaseEscalationChannelProperties(BaseResourceProperties): + """Fields shared by every escalation channel's properties.""" - app_name: str | None = Field(default=None, alias="appName") - app_version: int = Field(..., alias="appVersion") - folder_name: Optional[str] = Field(None, alias="folderName") - resource_key: str | None = Field(default=None, alias="resourceKey") is_actionable_message_enabled: Optional[bool] = Field( None, alias="isActionableMessageEnabled" ) @@ -632,12 +815,31 @@ class AgentEscalationChannelProperties(BaseResourceProperties): ) -class AgentEscalationChannel(BaseCfg): - """Agent escalation channel model.""" +class AgentEscalationChannelProperties(BaseEscalationChannelProperties): + """Action Center app-task channel properties (channel type ``actionCenter``).""" + + app_name: str | None = Field(default=None, alias="appName") + app_version: int = Field(..., alias="appVersion") + folder_name: Optional[str] = Field(None, alias="folderName") + resource_key: str | None = Field(default=None, alias="resourceKey") + + +class AgentQuickFormChannelProperties(BaseEscalationChannelProperties): + """Quick Form channel properties (channel type ``actionCenterQuickForm``).""" + + form_schema: Dict[str, Any] = Field(..., alias="schema") + + @property + def schema_id(self) -> str | None: + """Return the schema id nested inside the form schema body.""" + return self.form_schema.get("schemaId") + + +class BaseAgentEscalationChannel(BaseCfg): + """Fields shared by every escalation channel variant.""" id: Optional[str] = Field(None, alias="id") name: str = Field(..., alias="name") - type: str = Field(alias="type") description: str = Field(..., alias="description") input_schema: Dict[str, Any] = Field(..., alias="inputSchema") output_schema: Dict[str, Any] = Field(EMPTY_SCHEMA, alias="outputSchema") @@ -645,7 +847,6 @@ class AgentEscalationChannel(BaseCfg): {}, alias="argumentProperties" ) outcome_mapping: Optional[Dict[str, str]] = Field(None, alias="outcomeMapping") - properties: AgentEscalationChannelProperties = Field(..., alias="properties") recipients: List[AgentEscalationRecipient] = Field(..., alias="recipients") task_title: Optional[Union[str, TaskTitle]] = Field( default="Escalation Task", alias="taskTitle" @@ -660,6 +861,34 @@ def _apply_task_title_resolution(cls, v: Any) -> Any: return _resolve_task_title(v) +class AgentEscalationChannel(BaseAgentEscalationChannel): + """Action Center app-task escalation channel (channel type ``actionCenter``).""" + + type: Literal[AgentEscalationChannelType.ACTION_CENTER] = Field( + default=AgentEscalationChannelType.ACTION_CENTER, alias="type" + ) + properties: AgentEscalationChannelProperties = Field(..., alias="properties") + + +class AgentQuickFormEscalationChannel(BaseAgentEscalationChannel): + """Quick Form escalation channel; FormLib schema lives in ``properties.form_schema``.""" + + type: Literal[AgentEscalationChannelType.ACTION_CENTER_QUICK_FORM] = Field( + default=AgentEscalationChannelType.ACTION_CENTER_QUICK_FORM, alias="type" + ) + properties: AgentQuickFormChannelProperties = Field(..., alias="properties") + + +EscalationChannel = Annotated[ + Union[ + AgentEscalationChannel, + AgentQuickFormEscalationChannel, + ], + Field(discriminator="type"), + _case_insensitive_enum_validator("type", AgentEscalationChannelType), +] + + class AgentEscalationResourceConfig(BaseAgentResourceConfig): """Agent escalation resource configuration model.""" @@ -667,7 +896,7 @@ class AgentEscalationResourceConfig(BaseAgentResourceConfig): resource_type: Literal[AgentResourceType.ESCALATION] = Field( alias="$resourceType", default=AgentResourceType.ESCALATION, frozen=True ) - channels: List[AgentEscalationChannel] = Field(alias="channels") + channels: List[EscalationChannel] = Field(alias="channels") is_agent_memory_enabled: bool = Field(default=False, alias="isAgentMemoryEnabled") escalation_type: Literal[0] = Field(default=0, alias="escalationType") @@ -687,7 +916,7 @@ class AgentIxpVsEscalationResourceConfig(BaseAgentResourceConfig): resource_type: Literal[AgentResourceType.ESCALATION] = Field( alias="$resourceType", default=AgentResourceType.ESCALATION, frozen=True ) - channels: List[AgentEscalationChannel] = Field(alias="channels") + channels: List[EscalationChannel] = Field(alias="channels") is_agent_memory_enabled: bool = Field(default=False, alias="isAgentMemoryEnabled") escalation_type: Literal[1] = Field(default=1, alias="escalationType") vs_escalation_properties: AgentIxpVsEscalationProperties = Field( @@ -719,6 +948,8 @@ class AgentProcessToolResourceConfig(BaseAgentToolResourceConfig): AgentToolType.PROCESS, AgentToolType.API, AgentToolType.PROCESS_ORCHESTRATION, + AgentToolType.FLOW, + AgentToolType.FUNCTION, ] output_schema: Dict[str, Any] = Field(EMPTY_SCHEMA, alias="outputSchema") properties: AgentProcessToolProperties @@ -870,6 +1101,15 @@ class AgentInternalToolResourceConfig(BaseAgentToolResourceConfig): ) +class AgentClientSideToolResourceConfig(BaseAgentToolResourceConfig): + """Resource config for client-side tools executed by the client SDK.""" + + type: Literal[AgentToolType.CLIENT_SIDE] = AgentToolType.CLIENT_SIDE + properties: BaseResourceProperties = Field(default_factory=BaseResourceProperties) + output_schema: Optional[Dict[str, Any]] = Field(None, alias="outputSchema") + arguments: Optional[Dict[str, Any]] = Field(default_factory=dict) + + class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): """Fallback for unknown tool types (parent normalizer sets type='Unknown').""" @@ -883,11 +1123,13 @@ class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): AgentIntegrationToolResourceConfig, AgentInternalToolResourceConfig, AgentIxpExtractionResourceConfig, + AgentClientSideToolResourceConfig, AgentUnknownToolResourceConfig, # when parent sets type="Unknown" ], Field(discriminator="type"), ] + EscalationResourceConfig = Annotated[ Union[ Annotated[AgentEscalationResourceConfig, Tag(0)], @@ -1137,6 +1379,7 @@ class AgentMetadata(BaseCfg): """Agent metadata model.""" is_conversational: bool = Field(alias="isConversational") + variant: Optional[AgentVariant] = Field(default=None, alias="variant") storage_version: str = Field(alias="storageVersion") @@ -1197,6 +1440,13 @@ def is_conversational(self) -> bool: return metadata.is_conversational return False + @property + def is_case_manager(self) -> bool: + """Checks if the agent is a case manager agent.""" + if not self.metadata: + return False + return self.metadata.variant == AgentVariant.CASE_MANAGER + @staticmethod def _normalize_guardrails(v: Dict[str, Any]) -> None: guards = v.get("guardrails") @@ -1254,9 +1504,12 @@ def _normalize_resources(v: Dict[str, Any]) -> None: "process": "Process", "api": "Api", "processorchestration": "ProcessOrchestration", + "flow": "Flow", + "function": "Function", "integration": "Integration", "internal": "Internal", "ixp": "Ixp", + "clientside": "ClientSide", "unknown": "Unknown", } CONTEXT_MODE_MAP = { diff --git a/packages/uipath/src/uipath/agent/react/conversational_prompts.py b/packages/uipath/src/uipath/agent/react/conversational_prompts.py index 7732c7f69..f79de6185 100644 --- a/packages/uipath/src/uipath/agent/react/conversational_prompts.py +++ b/packages/uipath/src/uipath/agent/react/conversational_prompts.py @@ -62,6 +62,8 @@ class PromptUserSettings(BaseModel): - Never attempt calls with incomplete data - On errors: modify parameters or change approach (never retry identical calls) +{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_conversationIdPrompt}} + ===================================================================== TOOL RESULTS ===================================================================== @@ -136,18 +138,26 @@ class PromptUserSettings(BaseModel): {user_settings_json} ```""" +_CONVERSATION_ID_TEMPLATE = """ +The current conversation ID is {conversation_id}. This may be useful to include in tool-calls when tool parameters specify passing in the conversation ID. Other than tool-call inputs, this ID should not be mentioned to the user. +""" + def get_chat_system_prompt( model: str, system_message: str, agent_name: str | None, user_settings: PromptUserSettings | None = None, + conversation_id: str | None = None, ) -> str: """Generate a system prompt for a conversational agent. Args: - agent_definition: Conversational agent definition + model: Model identifier. + system_message: The agent system prompt content. + agent_name: The agent display name; defaults to "Unnamed Agent" when None. user_settings: Optional user data that is injected into the system prompt. + conversation_id: Optional conversation identifier that is injected into the system prompt. Returns: The complete system prompt string @@ -177,6 +187,10 @@ def get_chat_system_prompt( "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_userSettingsPrompt}}", get_user_settings_template(user_settings), ) + prompt = prompt.replace( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_conversationIdPrompt}}", + get_conversation_id_template(conversation_id), + ) return prompt @@ -190,7 +204,7 @@ def get_user_settings_template( user_settings: User profile information Returns: - The user context template with JSON or empty string + The filled-in user settings template if user_settings is provided, otherwise an empty string """ if user_settings is None: return "" @@ -205,3 +219,17 @@ def get_user_settings_template( user_settings_json = json.dumps(settings_dict, ensure_ascii=False) return _USER_CONTEXT_TEMPLATE.format(user_settings_json=user_settings_json) + + +def get_conversation_id_template(conversation_id: str | None) -> str: + """Get the conversation ID prompt section. + + Args: + conversation_id: The ID of the current conversation, if any + + Returns: + The filled-in conversation ID template if conversation_id is provided, otherwise an empty string + """ + if not conversation_id: + return "" + return _CONVERSATION_ID_TEMPLATE.format(conversation_id=conversation_id) diff --git a/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py b/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py index d64954b99..b639e631d 100644 --- a/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py +++ b/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py @@ -1,5 +1,6 @@ import ast import json +import re from collections.abc import Mapping, Sequence from datetime import datetime from typing import Any @@ -11,6 +12,19 @@ ToolOutput, ) +TOOL_NAME_ATTR = "tool.name" + +# Mirrors uipath_langchain.agent.tools.utils.sanitize_tool_name; pinned by TestSanitizedNameMatch. +_TOOL_NAME_DISALLOWED = re.compile(r"[^a-zA-Z0-9_-]") + + +def _sanitize_tool_name(name: str | None) -> str: + """Sanitise a tool name the same way the LangChain runtime does.""" + if not name: + return "" + return _TOOL_NAME_DISALLOWED.sub("", "_".join(name.split()))[:64] + + COMPARATOR_MAPPINGS = { ">": "gt", "<": "lt", @@ -24,6 +38,84 @@ COMMUNITY_agents_SUFFIX = "-community-agents" +def _unsynthesized_tool_attrs(span: ReadableSpan) -> Mapping[str, Any] | None: + """Return span.attributes if this is a real tool invocation, else None.""" + attrs = span.attributes + if ( + not attrs + or attrs.get("tool.synthesized", False) + or not attrs.get(TOOL_NAME_ATTR) + ): + return None + return attrs + + +def _match_key(actual_name: str, actual_id: str | None, expected_key: str) -> bool: + """Strict per-call kind: id-only when actual has one, sanitised-name otherwise — never cross-kind.""" + if actual_id is not None: + return expected_key == actual_id + return _sanitize_tool_name(expected_key) == _sanitize_tool_name(actual_name) + + +def _calls_match(actual, expected) -> bool: + """Strict per-call kind: id-only when actual has one, sanitised-name otherwise — never cross-kind.""" + if actual.id is not None: + # Picker stores the id under `expected.name` when an id was chosen — honour either field. + expected_key = expected.id if expected.id is not None else expected.name + return actual.id == expected_key + return _sanitize_tool_name(actual.name) == _sanitize_tool_name(expected.name) + + +def _parse_tool_args(input_value: Any) -> dict[str, Any]: + """Coerce a span's `input.value` into a dict of tool args. + + Tries JSON first (handles `true`/`false`/`null` and double-quoted keys), + falls back to `ast.literal_eval` for Python literal syntax (single-quoted + dict repr). Returns `{}` for non-dict parsed values or any parse failure. + """ + if isinstance(input_value, dict): + return input_value + if not isinstance(input_value, str): + return {} + try: + try: + parsed = json.loads(input_value) + except ValueError: # JSONDecodeError is a ValueError + parsed = ast.literal_eval(input_value) + except (SyntaxError, ValueError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _tool_id_from(attrs: Mapping[str, Any]) -> str | None: + """Return the span's `tool.id` as a string when present, else None. + + Uses `is not None` (not truthiness) so an id of 0 or '' isn't dropped. + """ + tool_id = attrs.get("tool.id") + return str(tool_id) if tool_id is not None else None + + +def _build_tool_call(span: ReadableSpan, include_args: bool) -> ToolCall | None: + """Build a ToolCall from a span, or None for synthesized / non-tool spans.""" + attrs = _unsynthesized_tool_attrs(span) + if attrs is None: + return None + tool_name = str(attrs[TOOL_NAME_ATTR]) + tool_id = _tool_id_from(attrs) + args = _parse_tool_args(attrs.get("input.value", {})) if include_args else {} + return ToolCall(name=tool_name, args=args, id=tool_id) + + +def count_tool_calls_by_name_and_id(tool_calls: Sequence[ToolCall]) -> dict[str, int]: + """Bucket each call under its id when present, else its name — strict per-call kind, no cross-kind matching.""" + counts: dict[str, int] = {} + for c in tool_calls: + key = c.id if c.id is not None else c.name + counts[key] = counts.get(key, 0) + 1 + return counts + + def extract_tool_calls_names(spans: Sequence[ReadableSpan]) -> list[str]: """Extract the tool call names from execution spans IN ORDER. @@ -36,41 +128,29 @@ def extract_tool_calls_names(spans: Sequence[ReadableSpan]) -> list[str]: tool_calls_names = [] for span in spans: - # Check for tool.name attribute first - if span.attributes and (tool_name := span.attributes.get("tool.name")): - tool_calls_names.append(str(tool_name)) + if (attrs := _unsynthesized_tool_attrs(span)) is not None: + tool_calls_names.append(str(attrs[TOOL_NAME_ATTR])) return tool_calls_names -def extract_tool_calls(spans: Sequence[ReadableSpan]) -> list[ToolCall]: - """Extract the tool calls from execution spans with their arguments. +def extract_tool_calls( + spans: Sequence[ReadableSpan], + include_args: bool = True, +) -> list[ToolCall]: + """Extract the tool calls from execution spans. Args: spans: List of ReadableSpan objects from agent execution. + include_args: When False, skip parsing `input.value` and return + ToolCall objects with `args={}`. Use for evaluators that only + need name/id (count, order) — avoids a parse per span on large + traces. Returns: - Dict of tool calls with their arguments. + List of tool calls with their arguments. """ - tool_calls = [] - - for span in spans: - if span.attributes and (tool_name := span.attributes.get("tool.name")): - try: - input_value: Any = span.attributes.get("input.value", {}) - # Ensure input_value is a string before parsing - if isinstance(input_value, str): - arguments = ast.literal_eval(input_value) - elif isinstance(input_value, dict): - arguments = input_value - else: - arguments = {} - tool_calls.append(ToolCall(name=str(tool_name), args=arguments)) - except (json.JSONDecodeError, SyntaxError, ValueError): - # Handle case where input.value is not valid JSON/Python syntax - tool_calls.append(ToolCall(name=str(tool_name), args={})) - - return tool_calls + return [c for s in spans if (c := _build_tool_call(s, include_args)) is not None] def extract_tool_calls_outputs(spans: Sequence[ReadableSpan]) -> list[ToolOutput]: @@ -87,8 +167,10 @@ def extract_tool_calls_outputs(spans: Sequence[ReadableSpan]) -> list[ToolOutput potential_output_keys = ["content"] tool_calls_outputs = [] for span in spans: - if span.attributes and (tool_name := span.attributes.get("tool.name")): - output = span.attributes.get("output.value", "") + if (attrs := _unsynthesized_tool_attrs(span)) is not None: + tool_name = str(attrs[TOOL_NAME_ATTR]) + tool_id = _tool_id_from(attrs) + output = attrs.get("output.value", "") final_output = "" # Handle different output formats @@ -118,8 +200,9 @@ def extract_tool_calls_outputs(spans: Sequence[ReadableSpan]) -> list[ToolOutput tool_calls_outputs.append( ToolOutput( - name=str(tool_name), + name=tool_name, output=str(final_output) if final_output else "", + id=tool_id, ) ) return tool_calls_outputs @@ -196,6 +279,105 @@ def tool_calls_order_score( return lcs_length / n, justification +def _strict_order_score( + actual: Sequence[ToolCall], + expected: Sequence[str], + justification: dict[str, Any], +) -> tuple[float, dict[str, Any]]: + """Strict-mode evaluation — only an exact positional match scores 1.0.""" + if len(actual) != len(expected): + return 0.0, justification + for i, key in enumerate(expected): + if not _match_key(actual[i].name, actual[i].id, key): + return 0.0, justification + justification["lcs"] = list(expected) + return 1.0, justification + + +def _build_lcs_dp( + actual: Sequence[ToolCall], expected: Sequence[str] +) -> list[list[int]]: + """Fill the LCS dynamic-programming table for id-aware matching.""" + m, n = len(actual), len(expected) + dp = [[0] * (n + 1) for _ in range(m + 1)] + for i in range(1, m + 1): + for j in range(1, n + 1): + if _match_key(actual[i - 1].name, actual[i - 1].id, expected[j - 1]): + dp[i][j] = dp[i - 1][j - 1] + 1 + else: + dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + return dp + + +def _reconstruct_lcs( + actual: Sequence[ToolCall], + expected: Sequence[str], + dp: list[list[int]], +) -> list[str]: + """Walk the DP table backwards to recover the LCS as a list of expected keys.""" + lcs: list[str] = [] + i, j = len(actual), len(expected) + while i > 0 and j > 0: + if _match_key(actual[i - 1].name, actual[i - 1].id, expected[j - 1]): + lcs.append(expected[j - 1]) + i -= 1 + j -= 1 + elif dp[i - 1][j] > dp[i][j - 1]: + i -= 1 + else: + j -= 1 + lcs.reverse() + return lcs + + +def tool_calls_order_score_with_ids( + actual_tool_calls: Sequence[ToolCall], + expected_tool_calls_keys: Sequence[str], + strict: bool = False, +) -> tuple[float, dict[str, Any]]: + """LCS-based ordering score with id-aware matching. + + Identical scoring algorithm to `tool_calls_order_score`, but each expected + key string is allowed to match either the actual call's `id` or its + `name`. Use this when eval-set criteria may be authored against the + stable tool id so renames of `name` don't silently break ordering checks. + + Args: + actual_tool_calls: ToolCall objects in the actual order. Each may carry + an `id` from the runtime's `tool.id` span attribute. + expected_tool_calls_keys: List of names OR ids in the expected order. + strict: When True, only perfect matches score above 0. + + Returns: + Same shape as `tool_calls_order_score`. The "actual" justification + renders the resolved match-key sequence (id when available, else name) + so the LCS reconstruction reads clearly. + """ + actual_keys: list[str] = [ + (c.id if c.id is not None else c.name) for c in actual_tool_calls + ] + justification: dict[str, Any] = { + "actual": str(list(actual_keys)), + "expected": str(list(expected_tool_calls_keys)), + "lcs": [], + } + + if not expected_tool_calls_keys and not actual_tool_calls: + return 1.0, justification + if not expected_tool_calls_keys or not actual_tool_calls: + return 0.0, justification + + if strict: + return _strict_order_score( + actual_tool_calls, expected_tool_calls_keys, justification + ) + + dp = _build_lcs_dp(actual_tool_calls, expected_tool_calls_keys) + lcs = _reconstruct_lcs(actual_tool_calls, expected_tool_calls_keys, dp) + justification["lcs"] = lcs + return len(lcs) / len(expected_tool_calls_keys), justification + + def tool_calls_count_score( actual_tool_calls_count: Mapping[str, int], expected_tool_calls_count: Mapping[str, tuple[str, int]], @@ -240,7 +422,12 @@ def tool_calls_count_score( expected_comparator, expected_count, ) in expected_tool_calls_count.items(): - actual_count = actual_tool_calls_count.get(tool_name, 0.0) + # Raw key first (id-keyed / exact-match), then sanitised (legacy display-name). `is None` not `or`: count of 0 is a hit. + actual_count = actual_tool_calls_count.get(tool_name) + if actual_count is None: + actual_count = actual_tool_calls_count.get( + _sanitize_tool_name(tool_name), 0 + ) comparator = f"__{COMPARATOR_MAPPINGS[expected_comparator]}__" to_add = float(getattr(actual_count, comparator)(expected_count)) @@ -310,7 +497,7 @@ def tool_calls_args_score( for expected_tool_call in expected_tool_calls: for idx, call in enumerate(actual_tool_calls): - if call.name == expected_tool_call.name and idx not in visited: + if _calls_match(call, expected_tool_call) and idx not in visited: # Get or initialize counter for this tool name tool_counters[call.name] = tool_counters.get(call.name, 0) tool_key = f"{call.name}_{tool_counters[call.name]}" @@ -402,7 +589,7 @@ def tool_calls_output_score( for idx, actual_tool_call_output in enumerate(actual_tool_calls_outputs): if idx in visited: continue - if actual_tool_call_output.name == expected_tool_call_output.name: + if _calls_match(actual_tool_call_output, expected_tool_call_output): # Get or initialize counter for this tool name tool_counters[actual_tool_call_output.name] = tool_counters.get( actual_tool_call_output.name, 0 @@ -465,7 +652,7 @@ def trace_to_str(agent_trace: Sequence[ReadableSpan]) -> str: seen_tool_calls = set() for span in agent_trace: - if span.attributes and (tool_name := span.attributes.get("tool.name")): + if span.attributes and (tool_name := span.attributes.get(TOOL_NAME_ATTR)): # Get span timing information start_time = span.start_time end_time = span.end_time diff --git a/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py index 17b69d0d0..cff0e8788 100644 --- a/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py @@ -12,13 +12,13 @@ from ..._utils.constants import COMMUNITY_agents_SUFFIX from .._execution_context import eval_set_run_id_context +from .._helpers.evaluators_helpers import trace_to_str from .._helpers.helpers import is_empty_value from ..models import EvaluationResult from ..models.models import ( AgentExecution, LLMResponse, NumericEvaluationResult, - TrajectoryEvaluationTrace, UiPathEvaluationError, UiPathEvaluationErrorCategory, ) @@ -140,10 +140,7 @@ def _create_evaluation_prompt( and agent_run_history and isinstance(agent_run_history[0], ReadableSpan) ): - trajectory_trace = TrajectoryEvaluationTrace.from_readable_spans( - agent_run_history - ) - agent_run_history = str(trajectory_trace.spans) + agent_run_history = trace_to_str(agent_run_history) else: agent_run_history = str(agent_run_history) diff --git a/packages/uipath/src/uipath/eval/evaluators/output_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/output_evaluator.py index 27dd05d9f..5eb54e9e3 100644 --- a/packages/uipath/src/uipath/eval/evaluators/output_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/output_evaluator.py @@ -86,8 +86,9 @@ class OutputEvaluatorConfig(BaseEvaluatorConfig[T]): specific output evaluation criteria types while maintaining type safety. """ - target_output_key: str = Field( - default="*", description="Key to extract output from agent execution" + target_output_key: str | list[str] = Field( + default="*", + description="Key or list of keys to extract output from agent execution", ) line_by_line_evaluator: bool = Field( default=False, @@ -133,12 +134,35 @@ def _get_actual_output(self, agent_execution: AgentExecution) -> Any: If the output is a job attachment URI, downloads the attachment and returns its content as a string. """ - if self.evaluator_config.target_output_key != "*": - try: - result = resolve_output_path( - agent_execution.agent_output, - self.evaluator_config.target_output_key, + key = self.evaluator_config.target_output_key + + if isinstance(key, list): + if not isinstance(agent_execution.agent_output, dict): + raise UiPathEvaluationError( + code="INVALID_ACTUAL_OUTPUT", + title="When target output keys are specified, actual output must be a dictionary", + detail=f"Got {type(agent_execution.agent_output).__name__}", + category=UiPathEvaluationErrorCategory.USER, ) + try: + list_result: dict[str, Any] = { + k: resolve_output_path(agent_execution.agent_output, k) for k in key + } + except (KeyError, IndexError, TypeError) as e: + raise UiPathEvaluationError( + code="TARGET_OUTPUT_KEY_NOT_FOUND", + title="One or more target output keys not found in actual output", + detail=f"Error: {e}", + category=UiPathEvaluationErrorCategory.USER, + ) from e + for k, v in list_result.items(): + if is_job_attachment_uri(v): + attachment_id = extract_attachment_id(v) + list_result[k] = download_attachment_as_string(attachment_id) + return self._normalize_numbers(list_result) + elif key != "*": + try: + result = resolve_output_path(agent_execution.agent_output, key) except (KeyError, IndexError, TypeError) as e: raise UiPathEvaluationError( code="TARGET_OUTPUT_KEY_NOT_FOUND", @@ -149,7 +173,6 @@ def _get_actual_output(self, agent_execution: AgentExecution) -> Any: else: result = agent_execution.agent_output - # Check if result is a job attachment URI and download if so if is_job_attachment_uri(result): attachment_id = extract_attachment_id(result) result = download_attachment_as_string(attachment_id) @@ -165,32 +188,67 @@ def _get_full_expected_output(self, evaluation_criteria: T) -> Any: category=UiPathEvaluationErrorCategory.SYSTEM, ) - def _get_expected_output(self, evaluation_criteria: T) -> Any: - """Load the expected output from the evaluation criteria.""" - expected_output = self._get_full_expected_output(evaluation_criteria) - if self.evaluator_config.target_output_key != "*": - if isinstance(expected_output, str): - try: - expected_output = json.loads(expected_output) - except json.JSONDecodeError as e: - raise UiPathEvaluationError( - code="INVALID_EXPECTED_OUTPUT", - title="When target output key is not '*', expected output must be a dictionary or a valid JSON string", - detail=f"Error: {e}", - category=UiPathEvaluationErrorCategory.USER, - ) from e + def _resolve_list_key_expected( + self, expected_output: Any, keys: list[str] + ) -> dict[str, Any]: + """Parse and resolve expected output for a list of keys.""" + if isinstance(expected_output, str): try: - expected_output = resolve_output_path( - expected_output, - self.evaluator_config.target_output_key, - ) - except (KeyError, IndexError, TypeError) as e: + expected_output = json.loads(expected_output) + except json.JSONDecodeError as e: raise UiPathEvaluationError( - code="TARGET_OUTPUT_KEY_NOT_FOUND", - title="Target output key not found in expected output", + code="INVALID_EXPECTED_OUTPUT", + title="When target output keys are specified, expected output must be a dictionary or a valid JSON string", + detail=f"Error: {e}", + category=UiPathEvaluationErrorCategory.USER, + ) from e + if not isinstance(expected_output, dict): + raise UiPathEvaluationError( + code="INVALID_EXPECTED_OUTPUT", + title="When target output keys are specified, expected output must be a dictionary", + detail=f"Got {type(expected_output).__name__}", + category=UiPathEvaluationErrorCategory.USER, + ) + try: + return {k: resolve_output_path(expected_output, k) for k in keys} + except (KeyError, IndexError, TypeError) as e: + raise UiPathEvaluationError( + code="TARGET_OUTPUT_KEY_NOT_FOUND", + title="One or more target output keys not found in expected output", + detail=f"Error: {e}", + category=UiPathEvaluationErrorCategory.USER, + ) from e + + def _resolve_scalar_key_expected(self, expected_output: Any, key: str) -> Any: + """Parse and resolve expected output for a single key.""" + if isinstance(expected_output, str): + try: + expected_output = json.loads(expected_output) + except json.JSONDecodeError as e: + raise UiPathEvaluationError( + code="INVALID_EXPECTED_OUTPUT", + title="When target output key is not '*', expected output must be a dictionary or a valid JSON string", detail=f"Error: {e}", category=UiPathEvaluationErrorCategory.USER, ) from e + try: + return resolve_output_path(expected_output, key) + except (KeyError, IndexError, TypeError) as e: + raise UiPathEvaluationError( + code="TARGET_OUTPUT_KEY_NOT_FOUND", + title="Target output key not found in expected output", + detail=f"Error: {e}", + category=UiPathEvaluationErrorCategory.USER, + ) from e + + def _get_expected_output(self, evaluation_criteria: T) -> Any: + """Load the expected output from the evaluation criteria.""" + expected_output = self._get_full_expected_output(evaluation_criteria) + key = self.evaluator_config.target_output_key + if isinstance(key, list): + expected_output = self._resolve_list_key_expected(expected_output, key) + elif key != "*": + expected_output = self._resolve_scalar_key_expected(expected_output, key) return self._normalize_numbers(expected_output) async def validate_and_evaluate_criteria( @@ -225,7 +283,9 @@ async def validate_and_evaluate_criteria( validated_criteria = self.validate_evaluation_criteria(evaluation_criteria) # Check if line-by-line evaluation is enabled - if not self.evaluator_config.line_by_line_evaluator: + if not self.evaluator_config.line_by_line_evaluator or isinstance( + self.evaluator_config.target_output_key, list + ): # Standard evaluation return await self.evaluate(agent_execution, validated_criteria) @@ -248,51 +308,44 @@ async def _evaluate_line_by_line( """ from .line_by_line_utils import build_line_by_line_result, evaluate_lines - # Get the full actual and expected outputs before splitting + key_str = ( + self.evaluator_config.target_output_key + if isinstance(self.evaluator_config.target_output_key, str) + else "*" + ) + actual_output = self._get_actual_output(agent_execution) expected_output = self._get_expected_output(evaluation_criteria) - # Split into lines using utility function actual_lines = split_into_lines( - actual_output, - self.evaluator_config.line_delimiter, - self.evaluator_config.target_output_key, + actual_output, self.evaluator_config.line_delimiter, key_str ) expected_lines = split_into_lines( - expected_output, - self.evaluator_config.line_delimiter, - self.evaluator_config.target_output_key, + expected_output, self.evaluator_config.line_delimiter, key_str ) - # Store original agent execution data original_agent_output = agent_execution.agent_output - # Create function to build line criteria def create_line_criteria(expected_line: str) -> Any: from .line_by_line_utils import wrap_line_in_structure - line_expected_output = wrap_line_in_structure( - expected_line, self.evaluator_config.target_output_key - ) + line_expected_output = wrap_line_in_structure(expected_line, key_str) line_criteria_dict = evaluation_criteria.model_dump() if "expected_output" in line_criteria_dict: line_criteria_dict["expected_output"] = line_expected_output return type(evaluation_criteria).model_validate(line_criteria_dict) - # Evaluate all lines using utility function line_details, line_results = await evaluate_lines( actual_lines=actual_lines, expected_lines=expected_lines, - target_output_key=self.evaluator_config.target_output_key, + target_output_key=key_str, agent_execution=agent_execution, evaluate_fn=self.evaluate, create_line_criteria_fn=create_line_criteria, ) - # Restore original agent output agent_execution.agent_output = original_agent_output - # Build and return the aggregated result using utility function return build_line_by_line_result( line_details=line_details, line_results=line_results, diff --git a/packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py index 11d684ae1..4df237e7e 100644 --- a/packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py @@ -1,9 +1,8 @@ """Tool call count evaluator for validating expected tool usage patterns.""" -from collections import Counter - from .._helpers.evaluators_helpers import ( - extract_tool_calls_names, + count_tool_calls_by_name_and_id, + extract_tool_calls, tool_calls_count_score, ) from ..models import AgentExecution, EvaluationResult, NumericEvaluationResult @@ -72,8 +71,8 @@ async def evaluate( Returns: EvaluationResult: Boolean result indicating correct tool call order (True/False) """ - tool_calls_count = Counter( - extract_tool_calls_names(agent_execution.agent_trace) + tool_calls_count = count_tool_calls_by_name_and_id( + extract_tool_calls(agent_execution.agent_trace, include_args=False) ) score, justification = tool_calls_count_score( tool_calls_count, diff --git a/packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py index 1050ddc76..4676750d3 100644 --- a/packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py +++ b/packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py @@ -1,8 +1,8 @@ """Tool call order evaluator for validating correct sequence of tool calls.""" from .._helpers.evaluators_helpers import ( - extract_tool_calls_names, - tool_calls_order_score, + extract_tool_calls, + tool_calls_order_score_with_ids, ) from ..models import AgentExecution, EvaluationResult, NumericEvaluationResult from ..models.models import EvaluatorType @@ -69,9 +69,11 @@ async def evaluate( Returns: EvaluationResult: Boolean result indicating correct tool call order (True/False) """ - tool_calls_order = extract_tool_calls_names(agent_execution.agent_trace) - score, justification = tool_calls_order_score( - tool_calls_order, + actual_calls = extract_tool_calls( + agent_execution.agent_trace, include_args=False + ) + score, justification = tool_calls_order_score_with_ids( + actual_calls, evaluation_criteria.tool_calls_order, self.evaluator_config.strict, ) diff --git a/packages/uipath/src/uipath/eval/helpers.py b/packages/uipath/src/uipath/eval/helpers.py index 0a0a0ca7f..8405e4a7a 100644 --- a/packages/uipath/src/uipath/eval/helpers.py +++ b/packages/uipath/src/uipath/eval/helpers.py @@ -7,6 +7,8 @@ from pydantic import ValidationError +from uipath.runtime.schema import UiPathRuntimeSchema + from .evaluators.base_evaluator import GenericBaseEvaluator from .evaluators.evaluator_factory import EvaluatorFactory from .mocks._types import InputMockingStrategy, LLMMockingStrategy @@ -277,3 +279,24 @@ async def load_evaluators( ) return evaluators + + +def get_agent_model(schema: UiPathRuntimeSchema) -> str | None: + """Get agent model from the runtime schema metadata. + + The model is read from schema.metadata["settings"]["model"] which is + populated by the low-code agents runtime from agent.json. + + Returns: + The model name from agent settings, or None if not found. + """ + try: + if schema.metadata and "settings" in schema.metadata: + settings = schema.metadata["settings"] + model = settings.get("model") + if model: + logger.debug(f"Got agent model from schema.metadata: {model}") + return model + return None + except Exception: + return None diff --git a/packages/uipath/src/uipath/eval/mocks/__init__.py b/packages/uipath/src/uipath/eval/mocks/__init__.py index 95dfb877e..ddb76ca70 100644 --- a/packages/uipath/src/uipath/eval/mocks/__init__.py +++ b/packages/uipath/src/uipath/eval/mocks/__init__.py @@ -1,14 +1,39 @@ """Mock interface.""" from ._mock_context import is_tool_simulated -from ._mock_runtime import UiPathMockRuntime -from ._types import ExampleCall, MockingContext +from ._mock_runtime import ( + UiPathMockRuntime, + build_mocking_context, + build_mocking_context_from_dict, +) +from ._types import ( + ComponentSimulationConfig, + ExampleCall, + MockingContext, + RuleOperator, + SimulationAnswer, + SimulationAnswerType, + SimulationBehavior, + SimulationCondition, + SimulationConfig, + SimulationStrategy, +) from .mockable import mockable __all__ = [ + "ComponentSimulationConfig", "ExampleCall", - "UiPathMockRuntime", "MockingContext", - "mockable", + "RuleOperator", + "SimulationAnswer", + "SimulationAnswerType", + "SimulationBehavior", + "SimulationCondition", + "SimulationConfig", + "SimulationStrategy", + "UiPathMockRuntime", + "build_mocking_context", + "build_mocking_context_from_dict", "is_tool_simulated", + "mockable", ] diff --git a/packages/uipath/src/uipath/eval/mocks/_input_mocker.py b/packages/uipath/src/uipath/eval/mocks/_input_mocker.py index f1d253ba8..a542fc7ad 100644 --- a/packages/uipath/src/uipath/eval/mocks/_input_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_input_mocker.py @@ -1,6 +1,7 @@ """LLM Input Mocker implementation.""" import json +import logging from datetime import datetime from typing import Any @@ -9,14 +10,18 @@ from uipath.core.tracing import traced from uipath.platform import UiPath from uipath.platform.chat import UiPathLlmChatService +from uipath.platform.chat._llm_gateway_service import ChatModels from .._execution_context import eval_set_run_id_context from ._mock_context import cache_manager_context from ._mocker import UiPathInputMockingError +from ._structured_output import generate_structured_output from ._types import ( InputMockingStrategy, ) +logger = logging.getLogger(__name__) + def get_input_mocking_prompt( input_schema: str, @@ -101,15 +106,6 @@ async def generate_llm_input( prompt = get_input_mocking_prompt(**prompt_generation_args) - response_format = { - "type": "json_schema", - "json_schema": { - "name": "agent_input", - "strict": False, - "schema": input_schema, - }, - } - model_parameters = mocking_strategy.model if mocking_strategy else None completion_kwargs = ( model_parameters.model_dump(by_alias=False, exclude_none=True) @@ -117,9 +113,14 @@ async def generate_llm_input( else {} ) + simulation_model = completion_kwargs.get( + "model", ChatModels.gpt_4_1_mini_2025_04_14 + ) + logger.info(f"Simulating input generation using model: {simulation_model}") + if cache_manager is not None: cache_key_data = { - "response_format": response_format, + "input_schema": input_schema, "completion_kwargs": completion_kwargs, "prompt_generation_args": prompt_generation_args, } @@ -133,15 +134,15 @@ async def generate_llm_input( if cached_response is not None: return cached_response - response = await llm.chat_completions( + result = await generate_structured_output( + llm, [{"role": "user", "content": prompt}], - response_format=response_format, - **completion_kwargs, + schema=input_schema, + response_format_name="agent_input", + description="Return the simulated agent input matching the required schema.", + completion_kwargs=completion_kwargs, ) - generated_input_str = response.choices[0].message.content - result = json.loads(generated_input_str) - if cache_manager is not None: cache_manager.set( mocker_type="input_mocker", @@ -151,10 +152,6 @@ async def generate_llm_input( ) return result - except json.JSONDecodeError as e: - raise UiPathInputMockingError( - f"Failed to parse LLM response as JSON: {str(e)}" - ) from e except UiPathInputMockingError: raise except Exception as e: diff --git a/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py b/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py index 194aa6c09..a9ab7005e 100644 --- a/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py @@ -10,7 +10,7 @@ from uipath.core.tracing import traced from uipath.platform import UiPath from uipath.platform.chat import UiPathLlmChatService -from uipath.platform.chat._llm_gateway_service import _cleanup_schema +from uipath.platform.chat._llm_gateway_service import ChatModels, _cleanup_schema from .._execution_context import ( eval_set_run_id_context, @@ -28,6 +28,7 @@ UiPathMockResponseGenerationError, UiPathNoMockFoundError, ) +from ._structured_output import generate_structured_output from ._types import ( ExampleCall, LLMMockingStrategy, @@ -96,11 +97,16 @@ def __init__(self, context: MockingContext): @traced(name="__mocker__", recording=False) async def response( - self, func: Callable[[T], R], params: dict[str, Any], *args: T, **kwargs + self, + func: Callable[[T], R], + params: dict[str, Any], + invocation: tuple[tuple[Any, ...], dict[str, Any]], ) -> R: """Respond with mocked response generated by an LLM.""" assert isinstance(self.context.strategy, LLMMockingStrategy) + args, kwargs = invocation + function_name = params.get("name") or func.__name__ if function_name in [x.name for x in self.context.strategy.tools_to_simulate]: uipath = UiPath() @@ -120,14 +126,7 @@ async def response( "output_schema", TypeAdapter(return_type).json_schema() ) - response_format = { - "type": "json_schema", - "json_schema": { - "name": "OutputSchema", - "strict": False, - "schema": _cleanup_schema(output_schema), - }, - } + cleaned_schema = _cleanup_schema(output_schema) try: # Safely pull examples from params. example_calls = params.get("example_calls", []) @@ -182,10 +181,17 @@ async def response( else {} ) + simulation_model = completion_kwargs.get( + "model", ChatModels.gpt_4_1_mini_2025_04_14 + ) + logger.info( + f"Simulating tool '{function_name}' using model: {simulation_model}" + ) + formatted_prompt = PROMPT.format(**prompt_generation_args) cache_key_data = { - "response_format": response_format, + "output_schema": cleaned_schema, "completion_kwargs": completion_kwargs, "prompt_generation_args": prompt_generation_args, } @@ -201,17 +207,17 @@ async def response( if cached_response is not None: return cached_response - response = await llm.chat_completions( - [ - { - "role": "user", - "content": formatted_prompt, - }, - ], - response_format=response_format, - **completion_kwargs, + result = await generate_structured_output( + llm, + [{"role": "user", "content": formatted_prompt}], + schema=cleaned_schema, + response_format_name="OutputSchema", + description=( + "Return the simulated response for tool " + f"'{function_name}' matching the required schema." + ), + completion_kwargs=completion_kwargs, ) - result = json.loads(response.choices[0].message.content) if cache_manager is not None: cache_manager.set( @@ -223,7 +229,7 @@ async def response( return result except Exception as e: - raise UiPathMockResponseGenerationError() from e + raise UiPathMockResponseGenerationError(str(e)) from e else: raise UiPathNoMockFoundError(f"Method '{function_name}' is not simulated.") diff --git a/packages/uipath/src/uipath/eval/mocks/_mock_context.py b/packages/uipath/src/uipath/eval/mocks/_mock_context.py index c2335544f..bd2f80df6 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mock_context.py +++ b/packages/uipath/src/uipath/eval/mocks/_mock_context.py @@ -43,17 +43,25 @@ def is_tool_simulated(tool_name: str) -> bool: to be simulated, False otherwise. """ ctx = mocking_context.get() - strategy = ctx.strategy if ctx else None - if strategy is None: + if ctx is None: return False normalized_tool_name = _normalize_tool_name(tool_name) + if ctx.components: + return any( + _normalize_tool_name(c.component_id) == normalized_tool_name + for c in ctx.components + ) + + strategy = ctx.strategy + if strategy is None: + return False + if isinstance(strategy, LLMMockingStrategy): - simulated_names = [ + return normalized_tool_name in [ _normalize_tool_name(t.name) for t in strategy.tools_to_simulate ] - return normalized_tool_name in simulated_names elif isinstance(strategy, MockitoMockingStrategy): return any( _normalize_tool_name(b.function) == normalized_tool_name @@ -64,11 +72,13 @@ def is_tool_simulated(tool_name: str) -> bool: async def get_mocked_response( - func: Callable[[Any], Any], params: dict[str, Any], *args, **kwargs + func: Callable[[Any], Any], + params: dict[str, Any], + invocation: tuple[tuple[Any, ...], dict[str, Any]], ) -> Any: """Get a mocked response.""" mocker = mocker_context.get() if mocker is None: raise UiPathNoMockFoundError() else: - return await mocker.response(func, params, *args, **kwargs) + return await mocker.response(func, params, invocation) diff --git a/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py b/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py index 512d8d6ee..60cc78c2b 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py +++ b/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import logging import uuid from collections.abc import AsyncGenerator @@ -28,13 +27,88 @@ LLMMockingStrategy, MockingContext, MockingStrategyType, - ToolSimulation, + ModelSettings, + SimulationConfig, ) logger = logging.getLogger(__name__) -def load_simulation_config() -> MockingContext | None: +def build_mocking_context( + config: SimulationConfig, agent_model: str | None = None +) -> MockingContext | None: + """Build a MockingContext from a validated SimulationConfig.""" + if not config.enabled: + return None + + # New per-component format → routes to simulate-component API + if config.components: + from uipath.platform.common._config import UiPathConfig + + workload_id = ( + getattr(UiPathConfig, "agent_id", None) + or getattr(UiPathConfig, "project_id", None) + or str(uuid.uuid4()) + ) + logger.debug( + f"Loaded simulation config for {len(config.components)} component(s)" + ) + return MockingContext( + strategy=None, + name="debug-simulation", + inputs={}, + components=config.components, + workload_id=workload_id, + ) + + # Legacy format (toolsToSimulate + instructions) → routes to local LLM mocker + if not config.tools_to_simulate: + return None + + model = ( + ModelSettings(model=config.model) + if config.model + else ModelSettings(model=agent_model) + if agent_model + else None + ) + + mocking_strategy = LLMMockingStrategy( + type=MockingStrategyType.LLM, + prompt=config.instructions, + tools_to_simulate=config.tools_to_simulate, + model=model, + ) + + logger.debug( + f"Loaded simulation config for {len(config.tools_to_simulate)} tool(s)" + ) + return MockingContext( + strategy=mocking_strategy, + name="debug-simulation", + inputs={}, + ) + + +def build_mocking_context_from_dict( + simulation_data: dict[str, Any], agent_model: str | None = None +) -> MockingContext | None: + """Build a MockingContext from a simulation config dictionary. + + Deprecated: prefer build_mocking_context with a validated SimulationConfig. + + Args: + simulation_data: Parsed simulation config (same schema as simulation.json). + agent_model: Optional agent model name to use as fallback. + + Returns: + MockingContext if valid and enabled, None otherwise. + """ + config = SimulationConfig.model_validate(simulation_data) + return build_mocking_context(config, agent_model) + + +def load_simulation_config(agent_model: str | None = None) -> MockingContext | None: """Load simulation.json from current directory and convert to MockingContext. Returns: @@ -47,38 +121,10 @@ def load_simulation_config() -> MockingContext | None: return None try: - with open(simulation_path, "r", encoding="utf-8") as f: - simulation_data = json.load(f) - - # Check if simulation is enabled - if not simulation_data.get("enabled", True): - return None - - # Extract tools to simulate - tools_to_simulate = [ - ToolSimulation(name=tool["name"]) - for tool in simulation_data.get("toolsToSimulate", []) - ] - - if not tools_to_simulate: - return None - - # Create LLM mocking strategy - mocking_strategy = LLMMockingStrategy( - type=MockingStrategyType.LLM, - prompt=simulation_data.get("instructions", ""), - tools_to_simulate=tools_to_simulate, + config = SimulationConfig.model_validate_json( + simulation_path.read_text(encoding="utf-8") ) - - # Create MockingContext for debugging - mocking_context = MockingContext( - strategy=mocking_strategy, - name="debug-simulation", - inputs={}, - ) - - logger.info(f"Loaded simulation config for {len(tools_to_simulate)} tool(s)") - return mocking_context + return build_mocking_context(config, agent_model) except Exception as e: logger.warning(f"Failed to load simulation.json: {e}") @@ -95,12 +141,16 @@ def set_execution_context( mocking_context.set(context) try: - if context and context.strategy: - mocker_context.set(MockerFactory.create(context)) + if context and (context.strategy or context.components): + mocker = MockerFactory.create(context) + mocker_context.set(mocker) + logger.info( + "simulate-component: mocker created (%s)", type(mocker).__name__ + ) else: mocker_context.set(None) except Exception: - logger.warning("Failed to create mocker.") + logger.warning("Failed to create mocker.", exc_info=True) mocker_context.set(None) span_collector_context.set(span_collector) diff --git a/packages/uipath/src/uipath/eval/mocks/_mocker.py b/packages/uipath/src/uipath/eval/mocks/_mocker.py index 57cb8bcc3..99e5da1b2 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_mocker.py @@ -16,8 +16,7 @@ async def response( self, func: Callable[[T], R], params: dict[str, Any], - *args: T, - **kwargs, + invocation: tuple[tuple[Any, ...], dict[str, Any]], ) -> R: """Respond with mocked response.""" raise NotImplementedError() diff --git a/packages/uipath/src/uipath/eval/mocks/_mocker_factory.py b/packages/uipath/src/uipath/eval/mocks/_mocker_factory.py index 4c001bfe0..2f61162a4 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mocker_factory.py +++ b/packages/uipath/src/uipath/eval/mocks/_mocker_factory.py @@ -3,6 +3,7 @@ from ._llm_mocker import LLMMocker from ._mocker import Mocker from ._mockito_mocker import MockitoMocker +from ._simulate_component_mocker import SimulateComponentMocker from ._types import ( LLMMockingStrategy, MockingContext, @@ -16,6 +17,8 @@ class MockerFactory: @staticmethod def create(context: MockingContext) -> Mocker: """Create a mocker instance.""" + if context.components: + return SimulateComponentMocker(context) match context.strategy: case LLMMockingStrategy(): return LLMMocker(context) diff --git a/packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py b/packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py index 041478baf..a9b30230f 100644 --- a/packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py +++ b/packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py @@ -99,12 +99,17 @@ def __init__(self, context: MockingContext): stubbed = stubbed.thenRaise(_resolve_value(answer_dict["value"])) async def response( - self, func: Callable[[T], R], params: dict[str, Any], *args: T, **kwargs + self, + func: Callable[[T], R], + params: dict[str, Any], + invocation: tuple[tuple[Any, ...], dict[str, Any]], ) -> R: """Return mocked response or raise appropriate errors.""" if not isinstance(self.context.strategy, MockitoMockingStrategy): raise UiPathMockResponseGenerationError("Mocking strategy misconfigured.") + args, kwargs = invocation + # No behavior configured → call real function is_mocked = any( behavior.function == params["name"] diff --git a/packages/uipath/src/uipath/eval/mocks/_simulate_component_mocker.py b/packages/uipath/src/uipath/eval/mocks/_simulate_component_mocker.py new file mode 100644 index 000000000..f35359ec4 --- /dev/null +++ b/packages/uipath/src/uipath/eval/mocks/_simulate_component_mocker.py @@ -0,0 +1,143 @@ +"""Mocker that routes tool calls through the simulate-component API.""" + +from __future__ import annotations + +import logging +from typing import Any, Callable, cast + +from pydantic import TypeAdapter + +from uipath.platform.chat._llm_gateway_service import _cleanup_schema + +from .._execution_context import execution_id_context, span_collector_context +from ._llm_mocker import LLMMocker +from ._mocker import ( + Mocker, + R, + T, + UiPathMockResponseGenerationError, + UiPathNoMockFoundError, +) +from ._simulate_component_service import _create_simulate_component_service +from ._types import ComponentSimulationConfig, MockingContext + +logger = logging.getLogger(__name__) + + +class SimulateComponentMocker(Mocker): + """Routes each tool call to the simulate-component API based on per-component config.""" + + def __init__(self, context: MockingContext) -> None: + self._context = context + self._components: dict[str, ComponentSimulationConfig] = { + c.component_id: c for c in (context.components or []) + } + self._normalized: dict[str, ComponentSimulationConfig] = { + c.component_id.replace("_", " "): c for c in (context.components or []) + } + self._workload_id = context.workload_id or "" + + def _find_component(self, tool_name: str) -> ComponentSimulationConfig | None: + return self._components.get(tool_name) or self._normalized.get( + tool_name.replace("_", " ") + ) + + async def response( + self, + func: Callable[[T], R], + params: dict[str, Any], + invocation: tuple[tuple[Any, ...], dict[str, Any]], + ) -> R: + tool_name = params.get("name") or func.__name__ + component = self._find_component(tool_name) + + if component is None: + raise UiPathNoMockFoundError(f"No simulation config for '{tool_name}'.") + + args, kwargs = invocation + + return_type: Any = func.__annotations__.get("return", None) or Any + raw_output_schema = ( + params.get("output_schema") or TypeAdapter(return_type).json_schema() + ) + output_schema = component.output_schema or _cleanup_schema(raw_output_schema) + input_payload = {"args": list(args), "kwargs": kwargs} + input_schema = component.input_schema or params.get("input_schema") + + execution_history = self._build_execution_history() + trace_id, parent_span_id = self._get_span_context() + workload_info = { + "name": self._context.name, + "userInput": self._context.inputs, + } + + example_calls = [ + {"id": ex.id, "input": ex.input, "output": ex.output} + for ex in (params.get("example_calls") or []) + ] + + payload: dict[str, Any] = { + "workloadId": self._workload_id, + "componentId": component.component_id, + "componentType": component.component_type or "tool", + "componentDescription": component.component_description + or params.get("description"), + "input": input_payload, + "inputSchema": input_schema, + "outputSchema": output_schema, + "simulationInstruction": component.simulation_instruction, + "simulationStrategy": int(component.simulation_strategy), + "mockValue": component.mock_value, + "behaviors": ( + [b.model_dump() for b in component.behaviors] + if component.behaviors + else None + ), + "exampleCalls": example_calls or None, + "executionHistory": execution_history or None, + "workloadInfo": workload_info, + "traceId": trace_id, + "parentSpanId": parent_span_id, + } + + logger.info("simulate-component: calling API for '%s'", tool_name) + try: + service = _create_simulate_component_service() + result = await service.simulate(payload) + except Exception as e: + logger.error( + "simulate-component: API call failed for '%s': %s", tool_name, e + ) + raise UiPathMockResponseGenerationError( + f"simulate-component API call failed for '{tool_name}'" + ) from e + + status = result.get("status") + if status == 1: # Completed + logger.info("simulate-component: '%s' simulated successfully", tool_name) + return cast(R, result.get("simulatedOutput")) + + error = result.get("error") or {} + error_message = error.get("message", f"Simulation failed for '{tool_name}'") + logger.error("simulate-component: '%s' failed — %s", tool_name, error_message) + raise UiPathMockResponseGenerationError(error_message) + + def _build_execution_history(self) -> str | None: + span_collector = span_collector_context.get() + execution_id = execution_id_context.get() + if span_collector and execution_id: + spans = span_collector.get_spans(execution_id) + return LLMMocker.spans_to_llm_context(spans) if spans else None + return None + + @staticmethod + def _get_span_context() -> tuple[str | None, str | None]: + """Return (traceId, parentSpanId) from the current OTel span, or (None, None).""" + from opentelemetry import trace + + span_ctx = trace.get_current_span().get_span_context() + if not span_ctx.is_valid: + return None, None + trace_id = f"{span_ctx.trace_id:032x}" + span_id = f"{span_ctx.span_id:016x}" + return trace_id, span_id diff --git a/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py b/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py new file mode 100644 index 000000000..87a08bd37 --- /dev/null +++ b/packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py @@ -0,0 +1,37 @@ +"""Service for calling the simulate-component API.""" + +from typing import Any + +from uipath._utils import Endpoint +from uipath.platform.common import BaseService + + +class SimulateComponentService(BaseService): + async def simulate(self, payload: dict[str, Any]) -> dict[str, Any]: + from uipath.platform.common import UiPathConfig + + headers: dict[str, str] = {} + if UiPathConfig.tenant_id: + headers["X-UiPath-Internal-TenantId"] = UiPathConfig.tenant_id + if UiPathConfig.organization_id: + headers["X-UiPath-Internal-AccountId"] = UiPathConfig.organization_id + + response = await self.request_async( + "POST", + url=Endpoint( + "/agentsruntime_/api/execution/simulations/simulate-component" + ), + json=payload, + headers=headers, + ) + return response.json() + + +def _create_simulate_component_service() -> SimulateComponentService: + from uipath.platform import UiPath + + uipath = UiPath() + return SimulateComponentService( + config=uipath._config, + execution_context=uipath._execution_context, + ) diff --git a/packages/uipath/src/uipath/eval/mocks/_structured_output.py b/packages/uipath/src/uipath/eval/mocks/_structured_output.py new file mode 100644 index 000000000..599780353 --- /dev/null +++ b/packages/uipath/src/uipath/eval/mocks/_structured_output.py @@ -0,0 +1,259 @@ +"""Provider-aware structured output for the eval mockers. + +The normalized LLM Gateway handles OpenAI-style ``response_format`` +(json_schema) differently per provider — live-verified against the gateway: + +- **OpenAI**: honors ``response_format`` and returns valid JSON content, + including native ``$defs`` support. +- **Anthropic (Claude)**: ignores it and answers with plain prose content. +- **Gemini**: returns empty content. + +Forced function calling works across all three providers, so each provider +gets a small strategy class: OpenAI prefers ``response_format`` (more reliable +for it on some schemas) with a tool-call fallback; Claude and Gemini go +straight to the forced tool call; unknown providers try ``response_format`` +first and fall back. +""" + +import json +import logging +from typing import Any + +from uipath.platform.chat.llm_gateway import RequiredToolChoice + +RESPONSE_TOOL_NAME = "submit_tool_response" +RESPONSE_KEY = "response" +_DEFS_PREFIX = "#/$defs/" + +logger = logging.getLogger(__name__) + + +def _inline_defs( + schema: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + """Inline ``$defs``/``$ref`` into a self-contained schema. + + Nested Pydantic models and enums emit root ``$defs`` referenced by ``$ref``. + The normalized gateway accepts those in ``response_format`` but not inside a + tool's ``parameters``, so they are inlined here. Sibling keys on a ``$ref`` + node (e.g. a field ``description``) are merged over the inlined definition. + Self-referential definitions cannot be inlined without looping; any ``$ref`` + reached while its target is already on the current resolution path is left + untouched and its definitions are returned so the caller can keep them + reachable. + + Returns: + A tuple of (inlined schema, leftover ``$defs`` needed for cyclic refs). + """ + defs = schema.get("$defs", {}) + leftover: dict[str, Any] = {} + + def resolve(node: Any, active: frozenset[str]) -> Any: + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str) and ref.startswith(_DEFS_PREFIX): + name = ref[len(_DEFS_PREFIX) :] + if name in defs and name not in active: + resolved = resolve(defs[name], active | {name}) + siblings = { + key: resolve(value, active) + for key, value in node.items() + if key not in ("$ref", "$defs") + } + if isinstance(resolved, dict): + return {**resolved, **siblings} + return resolved + # Cyclic or unknown ref: keep it and preserve its definition. + if name in defs: + leftover[name] = defs[name] + return dict(node) + return { + key: resolve(value, active) + for key, value in node.items() + if key != "$defs" + } + if isinstance(node, list): + return [resolve(item, active) for item in node] + return node + + root = {key: value for key, value in schema.items() if key != "$defs"} + inlined = resolve(root, frozenset()) + return inlined, leftover + + +def build_response_tool(schema: dict[str, Any], description: str) -> dict[str, Any]: + """Build a normalized-API function tool that wraps ``schema`` under ``response``. + + Tool-call arguments are always a JSON object, so an arbitrary output schema + (which may be a scalar, array, or object) is nested under a single + ``response`` property and unwrapped after the call. ``$defs``/``$ref`` are + inlined so the tool parameters are self-contained, which the gateway requires + for tool schemas (unlike ``response_format``). + """ + response_schema, leftover_defs = _inline_defs(schema) + parameters: dict[str, Any] = { + "type": "object", + "properties": {RESPONSE_KEY: response_schema}, + "required": [RESPONSE_KEY], + } + if leftover_defs: + parameters["$defs"] = leftover_defs + + return { + "name": RESPONSE_TOOL_NAME, + "description": description, + "parameters": parameters, + } + + +def extract_response(response: Any) -> Any: + """Extract the wrapped value from the forced tool call. + + Raises: + ValueError: if the response carries no usable tool call or is missing the + wrapped ``response`` key. + """ + choices = getattr(response, "choices", None) + if not choices: + raise ValueError("LLM response contained no choices") + + message = choices[0].message + tool_calls = getattr(message, "tool_calls", None) + if not tool_calls: + raise ValueError( + f"LLM response contained no tool calls (content={message.content!r})" + ) + + arguments = tool_calls[0].arguments + if RESPONSE_KEY not in arguments: + raise ValueError( + f"Tool call arguments missing '{RESPONSE_KEY}' key: {arguments}" + ) + + return arguments[RESPONSE_KEY] + + +class ToolCallStructuredOutput: + """Structured output via a forced tool call — works on every provider.""" + + async def generate( + self, + llm: Any, + messages: list[dict[str, str]], + *, + schema: dict[str, Any], + response_format_name: str, + description: str, + completion_kwargs: dict[str, Any], + ) -> Any: + """Force a tool call wrapping ``schema`` and unwrap its arguments.""" + tool = build_response_tool(schema, description) + response = await llm.chat_completions( + messages, + tools=[tool], + tool_choice=RequiredToolChoice(), + **completion_kwargs, + ) + return extract_response(response) + + +class ResponseFormatStructuredOutput(ToolCallStructuredOutput): + """Prefer ``response_format`` (json_schema); fall back to a forced tool call. + + The fallback fires when the provider rejects the request, returns empty + content, or returns content that is not valid JSON (Claude's behavior on + the normalized gateway is to answer with plain prose). + """ + + async def generate( + self, + llm: Any, + messages: list[dict[str, str]], + *, + schema: dict[str, Any], + response_format_name: str, + description: str, + completion_kwargs: dict[str, Any], + ) -> Any: + """Try ``response_format`` first, falling back to a forced tool call.""" + response_format = { + "type": "json_schema", + "json_schema": { + "name": response_format_name, + "strict": False, + "schema": schema, + }, + } + + content: str | None = None + try: + response = await llm.chat_completions( + messages, response_format=response_format, **completion_kwargs + ) + choices = getattr(response, "choices", None) + if choices: + content = choices[0].message.content + except Exception as e: + logger.info("response_format path failed, falling back to tools: %s", e) + + if content: + try: + return json.loads(content) + except json.JSONDecodeError: + logger.info( + "response_format content was not JSON, falling back to tools" + ) + + return await super().generate( + llm, + messages, + schema=schema, + response_format_name=response_format_name, + description=description, + completion_kwargs=completion_kwargs, + ) + + +class OpenAIStructuredOutput(ResponseFormatStructuredOutput): + """OpenAI honors ``response_format`` natively (including ``$defs``).""" + + +class AnthropicStructuredOutput(ToolCallStructuredOutput): + """Claude answers ``response_format`` with prose; go straight to tools.""" + + +class GeminiStructuredOutput(ToolCallStructuredOutput): + """Gemini returns empty content for ``response_format``; go straight to tools.""" + + +def _strategy_for_model(model: str | None) -> ToolCallStructuredOutput: + name = (model or "").lower() + if "claude" in name or name.startswith("anthropic"): + return AnthropicStructuredOutput() + if "gemini" in name: + return GeminiStructuredOutput() + if name.startswith(("gpt", "o1", "o3", "o4")): + return OpenAIStructuredOutput() + # Unknown providers: try response_format, fall back to tools. + return ResponseFormatStructuredOutput() + + +async def generate_structured_output( + llm: Any, + messages: list[dict[str, str]], + *, + schema: dict[str, Any], + response_format_name: str, + description: str, + completion_kwargs: dict[str, Any], +) -> Any: + """Generate structured output using the strategy for the requested model.""" + strategy = _strategy_for_model(completion_kwargs.get("model")) + return await strategy.generate( + llm, + messages, + schema=schema, + response_format_name=response_format_name, + description=description, + completion_kwargs=completion_kwargs, + ) diff --git a/packages/uipath/src/uipath/eval/mocks/_types.py b/packages/uipath/src/uipath/eval/mocks/_types.py index 827569879..f1a15312c 100644 --- a/packages/uipath/src/uipath/eval/mocks/_types.py +++ b/packages/uipath/src/uipath/eval/mocks/_types.py @@ -121,12 +121,142 @@ class UnknownMockingStrategy(BaseMockingStrategy): MockingStrategy = Union[KnownMockingStrategy, UnknownMockingStrategy] +# --------------------------------------------------------------------------- +# Per-component simulation types — mirror the simulate-component API contract +# --------------------------------------------------------------------------- + + +class SimulationStrategy(int, Enum): + """Simulation strategy matching the simulate-component API. + + Integer values are part of the cross-language API contract — do not reorder. + """ + + LLM = 0 + MOCKITO = 1 + STATIC = 2 + + +class RuleOperator(int, Enum): + """Comparison operator for Mockito condition matching. + + Integer values are part of the cross-language API contract — do not reorder. + """ + + EQ = 0 + NE = 1 + GT = 2 + GTE = 3 + LT = 4 + LTE = 5 + CONTAINS = 6 + + +class SimulationAnswerType(int, Enum): + """Answer type for a Mockito simulation behavior. + + Integer values are part of the cross-language API contract — do not reorder. + """ + + RETURN = 0 + RAISE = 1 + + +class SimulationAnswer(BaseModel): + type: SimulationAnswerType = SimulationAnswerType.RETURN + value: Any = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class SimulationCondition(BaseModel): + field: str + op: RuleOperator = RuleOperator.EQ + value: Any = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class SimulationBehavior(BaseModel): + when: list[SimulationCondition] | None = None + then: list[SimulationAnswer] + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class ComponentSimulationConfig(BaseModel): + """Per-component simulation config matching the simulate-component API request schema. + + Runtime-injected fields (workloadId, runId, input, traceId, parentSpanId, + folderKey) are supplied at call time. inputSchema and outputSchema can be + overridden here; if omitted they are derived from the function annotations. + """ + + component_id: str = Field(..., alias="componentId") + component_type: str | None = Field(None, alias="componentType") + component_description: str | None = Field(None, alias="componentDescription") + simulation_instruction: str | None = Field(None, alias="simulationInstruction") + simulation_strategy: SimulationStrategy = Field( + SimulationStrategy.LLM, alias="simulationStrategy" + ) + mock_value: Any = Field(None, alias="mockValue") + behaviors: list[SimulationBehavior] | None = None + input_schema: dict[str, Any] | None = Field(None, alias="inputSchema") + output_schema: dict[str, Any] | None = Field(None, alias="outputSchema") + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + class MockingContext(BaseModel): """Execution context for mocking, holding strategy and inputs.""" strategy: MockingStrategy | None inputs: dict[str, Any] = Field(default_factory=lambda: {}) name: str = Field(default="debug") + # When set, SimulateComponentMocker routes each tool call to the simulate-component API. + components: list[ComponentSimulationConfig] | None = None + workload_id: str | None = None + + +class SimulationConfig(BaseModel): + """Top-level schema for simulation.json / --simulation flag. + + New format (routes to simulate-component API): + { + "enabled": true, + "components": [ + { + "componentId": "my_tool", + "componentType": "tool", + "simulationStrategy": 0, + "simulationInstruction": "Simulate this tool by..." + } + ] + } + + Legacy format (routes to local LLM mocker): + { + "enabled": true, + "toolsToSimulate": [{"name": "my_tool"}], + "instructions": "Simulate these tools by..." + } + """ + + enabled: bool = True + # New per-component format — when non-empty, routes to simulate-component API. + components: list[ComponentSimulationConfig] = Field(default_factory=list) + # Legacy flat format — used when components is empty; routes to local LLM mocker. + tools_to_simulate: list[ToolSimulation] = Field( + default_factory=list, alias="toolsToSimulate" + ) + instructions: str = "" + model: str | None = None + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) class ExampleCall(BaseModel): diff --git a/packages/uipath/src/uipath/eval/mocks/mockable.py b/packages/uipath/src/uipath/eval/mocks/mockable.py index 254f88b89..3e9a324b9 100644 --- a/packages/uipath/src/uipath/eval/mocks/mockable.py +++ b/packages/uipath/src/uipath/eval/mocks/mockable.py @@ -39,7 +39,7 @@ def mocked_response_decorator(func, params: dict[str, Any]): """Mocked response decorator.""" async def mock_response_generator(*args, **kwargs): - mocked_response = await get_mocked_response(func, params, *args, **kwargs) + mocked_response = await get_mocked_response(func, params, (args, kwargs)) # Mocking successful. context = UiPathSpanUtils.get_parent_context() diff --git a/packages/uipath/src/uipath/eval/models/_conversational_utils.py b/packages/uipath/src/uipath/eval/models/_conversational_utils.py index d4dbf0cdd..9e3523acc 100644 --- a/packages/uipath/src/uipath/eval/models/_conversational_utils.py +++ b/packages/uipath/src/uipath/eval/models/_conversational_utils.py @@ -168,7 +168,6 @@ def legacy_conversational_eval_input_to_uipath_message_list( role="user", content_parts=content_parts, tool_calls=[], - interrupts=[], created_at=timestamp, updated_at=timestamp, ) @@ -215,7 +214,6 @@ def legacy_conversational_eval_input_to_uipath_message_list( role="assistant", content_parts=content_parts, tool_calls=tool_calls, - interrupts=[], created_at=timestamp, updated_at=timestamp, ) @@ -259,7 +257,6 @@ def legacy_conversational_eval_input_to_uipath_message_list( role="user", content_parts=content_parts, tool_calls=[], - interrupts=[], created_at=timestamp, updated_at=timestamp, ) @@ -301,7 +298,6 @@ def legacy_conversational_eval_output_to_uipath_message_data_list( role="assistant", content_parts=content_parts, tool_calls=tool_calls, - interrupts=[], ) ) diff --git a/packages/uipath/src/uipath/eval/models/evaluation_set.py b/packages/uipath/src/uipath/eval/models/evaluation_set.py index 22e6ce244..c80da8e14 100644 --- a/packages/uipath/src/uipath/eval/models/evaluation_set.py +++ b/packages/uipath/src/uipath/eval/models/evaluation_set.py @@ -1,8 +1,9 @@ """Evaluation set models.""" +import re from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic.alias_generators import to_camel from ..mocks._types import ( @@ -15,6 +16,21 @@ LegacyConversationalEvalOutput, ) +_GUID_RE = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) + + +def normalize_eval_id(value: str) -> str: + """Canonicalize a GUID id to lowercase; leave non-GUID ids unchanged. + + GUIDs are case-insensitive, but downstream correlation (selection, + span/cache keying) compares ids as plain strings, so a mixed-case id + must be normalized at ingestion to stay matchable. + """ + return value.lower() if isinstance(value, str) and _GUID_RE.match(value) else value + class EvaluatorReference(BaseModel): """Reference to an evaluator with optional weight. @@ -96,6 +112,12 @@ class EvaluationItem(BaseModel): alias="inputMockingStrategy", ) + @field_validator("id") + @classmethod + def _normalize_id(cls, value: str) -> str: + """Normalize GUID ids to canonical lowercase.""" + return normalize_eval_id(value) + class LegacyEvaluationItem(BaseModel): """Individual evaluation item within an evaluation set.""" @@ -130,6 +152,12 @@ class LegacyEvaluationItem(BaseModel): default=None, alias="conversationalExpectedOutput" ) + @field_validator("id") + @classmethod + def _normalize_id(cls, value: str) -> str: + """Normalize GUID ids to canonical lowercase.""" + return normalize_eval_id(value) + class EvaluationSet(BaseModel): """Complete evaluation set model.""" @@ -153,7 +181,7 @@ class EvaluationSet(BaseModel): def extract_selected_evals(self, eval_ids) -> None: """Filter evaluations to only include those with specified IDs.""" selected_evals: list[EvaluationItem] = [] - remaining_ids = set(eval_ids) + remaining_ids = {normalize_eval_id(eval_id) for eval_id in eval_ids} for evaluation in self.evaluations: if evaluation.id in remaining_ids: selected_evals.append(evaluation) @@ -187,7 +215,7 @@ class LegacyEvaluationSet(BaseModel): def extract_selected_evals(self, eval_ids) -> None: """Filter evaluations to only include those with specified IDs.""" selected_evals: list[LegacyEvaluationItem] = [] - remaining_ids = set(eval_ids) + remaining_ids = {normalize_eval_id(eval_id) for eval_id in eval_ids} for evaluation in self.evaluations: if evaluation.id in remaining_ids: selected_evals.append(evaluation) diff --git a/packages/uipath/src/uipath/eval/models/models.py b/packages/uipath/src/uipath/eval/models/models.py index d2dc26df9..14c130c92 100644 --- a/packages/uipath/src/uipath/eval/models/models.py +++ b/packages/uipath/src/uipath/eval/models/models.py @@ -303,17 +303,29 @@ class EvaluatorType(str, Enum): class ToolCall(BaseModel): - """Represents a tool call with its arguments.""" + """Represents a tool call with its arguments. + + `id` is the stable identifier from the tool's resource definition (e.g. a + UUID from `bindings.json`). When present on both the actual call and the + expected criterion, scorers match by `id` so a rename of `name` does not + break eval sets. When `id` is absent on either side, scorers fall back to + matching by `name` (the legacy behavior). + """ name: str args: dict[str, Any] + id: str | None = None class ToolOutput(BaseModel): - """Represents a tool output with its output.""" + """Represents a tool output with its output. + + See `ToolCall.id` for the id semantics. + """ name: str output: str + id: str | None = None class UiPathEvaluationErrorCategory(str, Enum): diff --git a/packages/uipath/src/uipath/eval/runtime/runtime.py b/packages/uipath/src/uipath/eval/runtime/runtime.py index 1c32b9efe..7f7614446 100644 --- a/packages/uipath/src/uipath/eval/runtime/runtime.py +++ b/packages/uipath/src/uipath/eval/runtime/runtime.py @@ -47,13 +47,14 @@ from .._execution_context import ExecutionSpanCollector from ..evaluators.base_evaluator import GenericBaseEvaluator from ..evaluators.output_evaluator import OutputEvaluationCriteria +from ..helpers import get_agent_model from ..mocks._cache_manager import CacheManager from ..mocks._input_mocker import ( generate_llm_input, ) from ..mocks._mock_context import cache_manager_context from ..mocks._mock_runtime import UiPathMockRuntime -from ..mocks._types import MockingContext +from ..mocks._types import LLMMockingStrategy, MockingContext, ModelSettings from ..models import EvaluationResult from ..models.evaluation_set import ( EvaluationItem, @@ -526,12 +527,25 @@ async def _execute_eval( eval_item=eval_item, ), ) + # Set agent model on the mocking strategy if not already set + mocking_strategy = eval_item.mocking_strategy + if ( + mocking_strategy + and isinstance(mocking_strategy, LLMMockingStrategy) + and not mocking_strategy.model + ): + mocking_model = get_agent_model(self.context.runtime_schema) + if mocking_model: + mocking_strategy = mocking_strategy.model_copy( + update={"model": ModelSettings(model=mocking_model)} + ) + agent_execution_output = await self.execute_runtime( eval_item, execution_id, input_overrides=self.context.input_overrides, mocking_context=MockingContext( - strategy=eval_item.mocking_strategy, + strategy=mocking_strategy, name=eval_item.name, inputs=eval_item.inputs, ), @@ -811,8 +825,18 @@ async def _generate_input_for_eval( or getattr(eval_item, "expected_output", None) or {} ) + # Set agent model on the input mocking strategy if not already set + input_strategy = eval_item.input_mocking_strategy + # If input strategy does not specify a model, extract it + if input_strategy and not input_strategy.model: + input_generation_model = get_agent_model(self.context.runtime_schema) + if input_generation_model: + input_strategy = input_strategy.model_copy( + update={"model": ModelSettings(model=input_generation_model)} + ) + generated_input = await generate_llm_input( - eval_item.input_mocking_strategy, + input_strategy, (await self.get_schema()).input, expected_behavior=eval_item.expected_agent_behavior or "", expected_output=expected_output, diff --git a/packages/uipath/src/uipath/telemetry/_track.py b/packages/uipath/src/uipath/telemetry/_track.py index 2d3f11ebf..3e585f8e5 100644 --- a/packages/uipath/src/uipath/telemetry/_track.py +++ b/packages/uipath/src/uipath/telemetry/_track.py @@ -105,18 +105,23 @@ def _get_connection_string() -> str | None: def _get_project_key() -> str: - """Get project key from telemetry file if present. + """Get the id used to attribute telemetry. - Returns: - Project key string if available, otherwise empty string. + Resolves ``uipath.json#id`` (then the runtime env var) via the shared + ``resolve_project_id`` helper, falling back to a legacy ``.uipath/.telemetry.json`` + ``ProjectKey`` if present. + Returns ``_UNKNOWN`` when no id is available. """ + from uipath.platform.common._span_utils import resolve_project_id + + if project_id := resolve_project_id(): + return project_id + try: telemetry_file = os.path.join(".uipath", _TELEMETRY_CONFIG_FILE) if os.path.exists(telemetry_file): with open(telemetry_file, "r") as f: - telemetry_data = json.load(f) - project_id = telemetry_data.get(_PROJECT_KEY) - if project_id: + if project_id := json.load(f).get(_PROJECT_KEY): return project_id except (json.JSONDecodeError, IOError, KeyError): pass diff --git a/packages/uipath/src/uipath/tracing/__init__.py b/packages/uipath/src/uipath/tracing/__init__.py index aaef6328c..e6c37bc99 100644 --- a/packages/uipath/src/uipath/tracing/__init__.py +++ b/packages/uipath/src/uipath/tracing/__init__.py @@ -5,6 +5,7 @@ AttachmentDirection, AttachmentProvider, SpanAttachment, + VerbosityLevel, ) from ._live_tracking_processor import LiveTrackingSpanProcessor @@ -23,4 +24,5 @@ "AttachmentDirection", "AttachmentProvider", "SpanAttachment", + "VerbosityLevel", ] diff --git a/packages/uipath/src/uipath/tracing/_otel_exporters.py b/packages/uipath/src/uipath/tracing/_otel_exporters.py index 423473065..d2bf3a7c1 100644 --- a/packages/uipath/src/uipath/tracing/_otel_exporters.py +++ b/packages/uipath/src/uipath/tracing/_otel_exporters.py @@ -389,7 +389,7 @@ def _process_span_attributes(self, span_data: Dict[str, Any]) -> None: def _build_url(self, span_list: list[Dict[str, Any]]) -> str: """Construct the URL for the API request.""" trace_id = str(span_list[0]["TraceId"]) - return f"{self.base_url}/api/Traces/spans?traceId={trace_id}&source=Robots" + return f"{self.base_url}/api/Traces/spans?traceId={trace_id}&source=CodedAgents" def _send_with_retries( self, url: str, payload: list[Dict[str, Any]], max_retries: int = 4 diff --git a/packages/uipath/testcases/langchain-cross/pyproject.toml b/packages/uipath/testcases/langchain-cross/pyproject.toml index ae2717777..0420b6c48 100644 --- a/packages/uipath/testcases/langchain-cross/pyproject.toml +++ b/packages/uipath/testcases/langchain-cross/pyproject.toml @@ -10,4 +10,11 @@ dependencies = [ requires-python = ">=3.11" [tool.uv.sources] -uipath = { path = "../../", editable = true } \ No newline at end of file +uipath = { path = "../../", editable = true } + +# Force the local uipath (the version under test) regardless of the upper bound +# the published uipath-langchain declares, so a backward-compatible minor bump +# does not break resolution purely on that cap. Mirrors the uv override used in +# the cross-repo test workflows. +[tool.uv] +override-dependencies = ["uipath"] \ No newline at end of file diff --git a/packages/uipath/testcases/list-target-output-key-evals/pyproject.toml b/packages/uipath/testcases/list-target-output-key-evals/pyproject.toml new file mode 100644 index 000000000..b3a04339d --- /dev/null +++ b/packages/uipath/testcases/list-target-output-key-evals/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "list-target-output-key-evals" +version = "0.0.1" +description = "Tests for evaluating multiple output keys at once using a list targetOutputKey" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +dependencies = [ + "uipath", +] +requires-python = ">=3.11" + +[tool.uv.sources] +uipath = { path = "../../", editable = true } diff --git a/packages/uipath/testcases/list-target-output-key-evals/run.sh b/packages/uipath/testcases/list-target-output-key-evals/run.sh new file mode 100755 index 000000000..508bd9777 --- /dev/null +++ b/packages/uipath/testcases/list-target-output-key-evals/run.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +echo "Syncing dependencies..." +uv sync + +echo "Authenticating with UiPath..." +uv run uipath auth --client-id="$CLIENT_ID" --client-secret="$CLIENT_SECRET" --base-url="$BASE_URL" + +echo "Running list targetOutputKey evaluations..." +uv run uipath eval main ../../samples/list_target_output_key_test/evaluations/eval-sets/default.json --no-report --output-file default.json + +echo "Test completed successfully!" diff --git a/packages/uipath/testcases/list-target-output-key-evals/src/assert.py b/packages/uipath/testcases/list-target-output-key-evals/src/assert.py new file mode 100644 index 000000000..80f22c195 --- /dev/null +++ b/packages/uipath/testcases/list-target-output-key-evals/src/assert.py @@ -0,0 +1,90 @@ +"""Assertions for list-target-output-key-evals testcase. + +Validates that evaluating multiple output fields at once (targetOutputKey as a +list) works correctly for both ExactMatch and JsonSimilarity evaluators. + +Expected outcomes +----------------- +- headphones-all-match : ListKeysExactMatch=1.0, ListKeysJsonSimilarity=1.0 +- shoes-all-match : ListKeysExactMatch=1.0, ListKeysJsonSimilarity=1.0 +- headphones-wrong-price: ListKeysExactMatch=0.0, ListKeysJsonSimilarity=1.0 +""" + +import json +import os + +# Maps evaluationName → evaluator ID → expected score (1.0 = pass, 0.0 = fail) +EXPECTED: dict[str, dict[str, float]] = { + "Headphones - all keys match": { + "ListKeysExactMatch": 1.0, + "ListKeysJsonSimilarity": 1.0, + }, + "Running Shoes - all keys match": { + "ListKeysExactMatch": 1.0, + "ListKeysJsonSimilarity": 1.0, + }, + "Headphones - wrong price (should fail)": { + "ListKeysExactMatch": 0.0, + "ListKeysJsonSimilarity": 1.0, + }, +} + + +def main() -> None: + output_file = "default.json" + assert os.path.isfile(output_file), f"Output file '{output_file}' not found" + print(f"Found output file: {output_file}") + + with open(output_file, "r", encoding="utf-8") as f: + output_data = json.load(f) + + assert "evaluationSetResults" in output_data, "Missing 'evaluationSetResults'" + evaluation_results = output_data["evaluationSetResults"] + assert len(evaluation_results) > 0, "No evaluation results found" + print(f"Found {len(evaluation_results)} evaluation result(s)") + + failures: list[str] = [] + + for eval_result in evaluation_results: + eval_name = eval_result.get("evaluationName", "") + expected_scores = EXPECTED.get(eval_name) + + if expected_scores is None: + print(f" [skip] '{eval_name}' not in EXPECTED map") + continue + + print(f"\n Validating: {eval_name}") + + run_results = eval_result.get("evaluationRunResults", []) + assert len(run_results) > 0, f"No run results for '{eval_name}'" + + for run in run_results: + evaluator_id = run.get("evaluatorId", run.get("evaluatorName", "")) + score = run.get("result", {}).get("score", None) + + if evaluator_id not in expected_scores: + print(f" [skip] unexpected evaluator '{evaluator_id}'") + continue + + expected = expected_scores[evaluator_id] + ok = score == expected + status = "pass" if ok else "FAIL" + print(f" {evaluator_id}: score={score} expected={expected} ({status})") + if not ok: + failures.append( + f"{eval_name} / {evaluator_id}: got {score}, expected {expected}" + ) + + print(f"\n{'=' * 60}") + if failures: + for f in failures: + print(f" FAIL: {f}") + print(f"{'=' * 60}") + assert False, f"{len(failures)} assertion(s) failed" + + print(" All assertions passed!") + print(f"{'=' * 60}") + + +if __name__ == "__main__": + main() diff --git a/packages/uipath/testcases/list-target-output-key-evals/uipath.json b/packages/uipath/testcases/list-target-output-key-evals/uipath.json new file mode 100644 index 000000000..a50f100df --- /dev/null +++ b/packages/uipath/testcases/list-target-output-key-evals/uipath.json @@ -0,0 +1,5 @@ +{ + "functions": { + "main": "../../samples/list_target_output_key_test/main.py:main" + } +} diff --git a/packages/uipath/testcases/simulation-testcase/pyproject.toml b/packages/uipath/testcases/simulation-testcase/pyproject.toml new file mode 100644 index 000000000..d37877dbf --- /dev/null +++ b/packages/uipath/testcases/simulation-testcase/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "simulation-testcase" +version = "0.0.1" +description = "simulation-testcase" +authors = [{ name = "UiPath", email = "python-sdk@uipath.com" }] +dependencies = [ + "uipath", +] +requires-python = ">=3.11" + +[tool.uv.sources] +uipath = { path = "../../", editable = true } diff --git a/packages/uipath/testcases/simulation-testcase/run.sh b/packages/uipath/testcases/simulation-testcase/run.sh new file mode 100644 index 000000000..0095cc904 --- /dev/null +++ b/packages/uipath/testcases/simulation-testcase/run.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -e + +TESTCASE_DIR="$(cd "$(dirname "$0")" && pwd)" +SAMPLE_DIR="$(cd "$TESTCASE_DIR/../../samples/runtime-simulations-agent" && pwd)" + +echo "Syncing testcase dependencies (local editable uipath)..." +uv sync --project "$TESTCASE_DIR" + +UIPATH_BIN="$TESTCASE_DIR/.venv/bin/uipath" + +# Run auth and agent from the sample dir so credentials are stored and read +# from the same location. +cd "$SAMPLE_DIR" + +echo "Authenticating with UiPath..." +"$UIPATH_BIN" auth \ + --client-id="$CLIENT_ID" \ + --client-secret="$CLIENT_SECRET" \ + --base-url="$BASE_URL" + +echo "Running agent with simulation..." +"$UIPATH_BIN" run main \ + -f input.json \ + --simulation "$(cat simulation.json)" 2>&1 | tee "$TESTCASE_DIR/run.log" + +# Copy the runtime output file back to the testcase dir for assert.py +mkdir -p "$TESTCASE_DIR/__uipath" +cp "$SAMPLE_DIR/__uipath/output.json" "$TESTCASE_DIR/__uipath/output.json" diff --git a/packages/uipath/testcases/simulation-testcase/src/assert.py b/packages/uipath/testcases/simulation-testcase/src/assert.py new file mode 100644 index 000000000..fd7e89697 --- /dev/null +++ b/packages/uipath/testcases/simulation-testcase/src/assert.py @@ -0,0 +1,57 @@ +import json +import os + +# ── 1. Verify agent output exists and succeeded ────────────────────────────── +output_file = "__uipath/output.json" +assert os.path.isfile(output_file), "Agent output file not found" + +with open(output_file, "r", encoding="utf-8") as f: + output_data = json.load(f) + +status = output_data.get("status") +assert status == "successful", f"Agent execution failed with status: {status}" + +output = output_data.get("output", {}) + +assert "syntax" in output, "Missing 'syntax' in output" +assert "style" in output, "Missing 'style' in output" +assert "improvements" in output, "Missing 'improvements' in output" +assert "summary" in output, "Missing 'summary' in output" + +assert isinstance(output["syntax"]["valid"], bool), "'syntax.valid' must be a bool" +assert isinstance(output["syntax"]["errors"], list), "'syntax.errors' must be a list" + +score = output["style"]["score"] +assert isinstance(score, int), "'style.score' must be an int" +assert 0 <= score <= 100, f"'style.score' out of range: {score}" +assert isinstance(output["style"]["violations"], list), ( + "'style.violations' must be a list" +) + +assert isinstance(output["improvements"]["suggestions"], list), ( + "'improvements.suggestions' must be a list" +) +assert isinstance(output["improvements"]["refactored_snippet"], str), ( + "'improvements.refactored_snippet' must be a str" +) + +# ── 2. Verify simulation produced non-default values ───────────────────────── +# Real tool impls always return: score=100, violations=[], suggestions=[]. +# The LLM simulation should detect issues in the input code and return richer output. +simulated_something = ( + score < 100 + or len(output["style"]["violations"]) > 0 + or len(output["improvements"]["suggestions"]) > 0 +) +assert simulated_something, ( + "Output matches hardcoded real-tool defaults — simulation may not have run. " + f"style.score={score}, violations={output['style']['violations']}, " + f"suggestions={output['improvements']['suggestions']}" +) + +print( + f"Simulation confirmed: score={score}, " + f"violations={len(output['style']['violations'])}, " + f"suggestions={len(output['improvements']['suggestions'])}" +) +print("All assertions passed.") diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index fe5c19bb9..202962c64 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -1,13 +1,14 @@ from typing import Any import pytest -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError from uipath.agent.models.agent import ( AgentA2aResourceConfig, AgentBooleanOperator, AgentBooleanRule, AgentBuiltInValidatorGuardrail, + AgentClientSideToolResourceConfig, AgentContextResourceConfig, AgentContextRetrievalMode, AgentContextType, @@ -35,6 +36,7 @@ AgentNumberOperator, AgentNumberRule, AgentProcessToolResourceConfig, + AgentQuickFormChannelProperties, AgentResourceType, AgentToolArgumentPropertiesVariant, AgentToolType, @@ -43,16 +45,23 @@ AgentUnknownToolResourceConfig, AgentWordOperator, AgentWordRule, + ArgumentEmailRecipient, + ArgumentGroupNameRecipient, AssetRecipient, BatchTransformFileExtension, BatchTransformWebSearchGrounding, + CachedToolsConfig, CitationMode, + CustomAssigneesRecipient, DeepRagFileExtension, + RoundRobinRecipient, StandardRecipient, TaskTitleType, TextBuilderTaskTitle, TextToken, TextTokenType, + ToolOutputRecipient, + WorkloadRecipient, ) from uipath.platform.guardrails import ( EnumListParameterValue, @@ -723,6 +732,7 @@ def test_agent_config_loads_guardrails(self): == "This validator is designed to detect personally identifiable information using Azure Cognitive Services" ) assert agent_builtin_guardrail.enabled_for_evals is True + assert agent_builtin_guardrail.selector is not None assert agent_builtin_guardrail.selector.scopes == ["Tool"] assert agent_builtin_guardrail.selector.match_names == ["StringToNumber"] @@ -2029,6 +2039,53 @@ def test_mcp_resource_with_output_schema(self): assert tool2.output_schema is not None assert "content" in tool2.output_schema["properties"] + def test_cached_tools_config_refresh_schema_default(self): + """CachedToolsConfig defaults refresh_schema_before_call to True.""" + + config = CachedToolsConfig() + assert config.type == "cached" + assert config.refresh_schema_before_call is True + + def test_cached_tools_config_refresh_schema_alias_roundtrip(self): + """refresh_schema_before_call parses from and serializes to refreshSchemaBeforeCall.""" + + config = TypeAdapter(CachedToolsConfig).validate_python( + {"type": "cached", "refreshSchemaBeforeCall": False} + ) + assert config.refresh_schema_before_call is False + assert config.model_dump(by_alias=True)["refreshSchemaBeforeCall"] is False + + def test_mcp_resource_with_cached_tools_configuration(self): + """AgentMcpResourceConfig parses a cached toolsConfiguration with the refresh flag.""" + + json_data = { + "$resourceType": "mcp", + "folderPath": "solution_folder", + "slug": "tavily-mcp", + "name": "tavily", + "description": "Tavily search tools", + "isEnabled": True, + "availableTools": [ + { + "name": "tavily-search", + "description": "Search the web", + "inputSchema": {"type": "object", "properties": {}}, + } + ], + "toolsConfiguration": { + "discoveryMode": { + "type": "cached", + "refreshSchemaBeforeCall": False, + } + }, + } + + mcp_resource = TypeAdapter(AgentMcpResourceConfig).validate_python(json_data) + assert mcp_resource.tools_configuration is not None + discovery_mode = mcp_resource.tools_configuration.discovery_mode + assert isinstance(discovery_mode, CachedToolsConfig) + assert discovery_mode.refresh_schema_before_call is False + @pytest.mark.parametrize( "recipient_type_int,value,expected_type", [ @@ -2603,11 +2660,84 @@ def test_agent_with_ixp_vs_escalation(self): assert len(channel.recipients) == 0 # Validate channel properties + assert isinstance(channel, AgentEscalationChannel) assert channel.properties.app_name is None assert channel.properties.app_version == 1 assert channel.properties.folder_name is None assert channel.properties.resource_key is None + def test_quick_form_channel_properties_derive_schema_id_from_body(self): + """schema_id reads the schemaId nested inside the schema body.""" + + props = AgentQuickFormChannelProperties.model_validate( + { + "schema": { + "schemaId": "e74ebb74-80ba-47b9-a370-532a1ba4c41e", + "fields": [], + "outcomes": [], + }, + } + ) + assert props.schema_id == "e74ebb74-80ba-47b9-a370-532a1ba4c41e" + + def test_quick_form_channel_properties_schema_id_none_when_absent(self): + """schema_id is None when the schema body carries no schemaId.""" + + props = AgentQuickFormChannelProperties.model_validate( + {"schema": {"fields": [], "outcomes": []}} + ) + assert props.schema_id is None + + def test_quick_form_channel_properties_require_schema(self): + with pytest.raises(ValidationError): + AgentQuickFormChannelProperties.model_validate( + {"isActionableMessageEnabled": False} + ) + + def test_quick_form_channel_requires_schema(self): + """A quick-form channel without a schema fails to parse.""" + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationResourceConfig).validate_python( + { + "$resourceType": "escalation", + "name": "Escalation", + "description": "", + "channels": [ + { + "name": "c", + "description": "", + "inputSchema": {"type": "object", "properties": {}}, + "type": "actionCenterQuickForm", + "recipients": [], + "properties": {"isActionableMessageEnabled": False}, + } + ], + "isAgentMemoryEnabled": False, + } + ) + + def test_unknown_escalation_channel_type_is_rejected(self): + """An unrecognized channel type fails to parse; the runtime cannot handle it.""" + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationResourceConfig).validate_python( + { + "$resourceType": "escalation", + "name": "Escalation", + "description": "", + "channels": [ + { + "name": "c", + "description": "", + "inputSchema": {"type": "object", "properties": {}}, + "type": "someFutureChannel", + "recipients": [], + "properties": {}, + } + ], + "isAgentMemoryEnabled": False, + } + ) + def test_task_title_text_builder_type(self): """Test TextBuilderTaskTitle with tokens.""" from uipath.agent.models.agent import ( @@ -3280,6 +3410,119 @@ def test_is_conversational_false_by_default(self): assert config.is_conversational is False +class TestAgentDefinitionIsCaseManager: + """Tests for AgentDefinition.is_case_manager property.""" + + def test_is_case_manager_true_when_variant_is_case_manager(self): + """Returns True when metadata.variant is "caseManager".""" + json_data = { + "id": "test-case-manager", + "name": "Case Manager Agent", + "version": "1.0.0", + "metadata": { + "isConversational": False, + "variant": "caseManager", + "storageVersion": "1.0.0", + }, + "settings": { + "model": "gpt-4o", + "maxTokens": 4096, + "temperature": 0, + "engine": "basic-v1", + }, + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "resources": [], + "messages": [ + {"role": "system", "content": "You are a case manager agent."} + ], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.is_case_manager is True + + def test_is_case_manager_false_when_variant_is_none(self): + """Returns False when metadata.variant is None.""" + json_data = { + "id": "test-non-case-manager", + "name": "Regular Agent", + "version": "1.0.0", + "metadata": { + "isConversational": False, + "variant": None, + "storageVersion": "1.0.0", + }, + "settings": { + "model": "gpt-4o", + "maxTokens": 4096, + "temperature": 0, + "engine": "basic-v1", + }, + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "resources": [], + "messages": [{"role": "system", "content": "You are an agent."}], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.is_case_manager is False + + def test_is_case_manager_false_when_variant_not_in_metadata(self): + """Returns False when variant is not present in metadata.""" + json_data = { + "id": "test-no-variant-field", + "name": "Agent Without Variant Field", + "version": "1.0.0", + "metadata": {"isConversational": False, "storageVersion": "1.0.0"}, + "settings": { + "model": "gpt-4o", + "maxTokens": 4096, + "temperature": 0, + "engine": "basic-v1", + }, + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "resources": [], + "messages": [{"role": "system", "content": "You are an agent."}], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.is_case_manager is False + + def test_is_case_manager_false_when_no_metadata(self): + """Returns False when agent has no metadata.""" + json_data = { + "id": "test-no-metadata", + "name": "Agent Without Metadata", + "version": "1.0.0", + "settings": { + "model": "gpt-4o", + "maxTokens": 4096, + "temperature": 0, + "engine": "basic-v1", + }, + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "resources": [], + "messages": [{"role": "system", "content": "You are an agent."}], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.is_case_manager is False + + class TestAgentBuilderConfigResources: """Tests for AgentDefinition resource configuration parsing.""" @@ -3442,6 +3685,144 @@ def test_process_tool_missing_output_schema(self): assert isinstance(tool_resource, AgentProcessToolResourceConfig) assert tool_resource.output_schema == {"type": "object", "properties": {}} + def test_flow_tool_type_enum_value(self): + """AgentToolType.FLOW exists with the wire value 'Flow' and is case-insensitive.""" + assert AgentToolType.FLOW.value == "Flow" + assert AgentToolType("flow") is AgentToolType.FLOW + assert AgentToolType("FLOW") is AgentToolType.FLOW + + def test_flow_tool_resource_deserialization(self): + """A resource with type='Flow' is parsed as AgentProcessToolResourceConfig.""" + resources = [ + { + "$resourceType": "tool", + "type": "Flow", + "id": "flow-tool-1", + "inputSchema": { + "type": "object", + "properties": {"input": {"type": "string"}}, + }, + "outputSchema": {"type": "object", "properties": {}}, + "arguments": {}, + "settings": {"timeout": 0, "maxAttempts": 0, "retryDelay": 0}, + "properties": { + "processName": "MyFlow", + "folderPath": "/Shared/Flows", + }, + "name": "Flow Tool", + "description": "Test Flow tool", + } + ] + + json_data = self._agent_dict_with_resources(resources) + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + tool_resource = config.resources[0] + assert isinstance(tool_resource, AgentProcessToolResourceConfig) + assert tool_resource.type == AgentToolType.FLOW + assert tool_resource.properties.process_name == "MyFlow" + assert tool_resource.properties.folder_path == "/Shared/Flows" + + def test_flow_tool_resource_case_insensitive(self): + """A resource with lowercase type='flow' also deserializes via CaseInsensitiveEnum.""" + resources = [ + { + "$resourceType": "tool", + "type": "flow", + "id": "flow-tool-2", + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "arguments": {}, + "settings": {"timeout": 0, "maxAttempts": 0, "retryDelay": 0}, + "properties": { + "processName": "MyFlow", + "folderPath": "/Shared/Flows", + }, + "name": "Flow Tool", + "description": "Test Flow tool", + } + ] + + json_data = self._agent_dict_with_resources(resources) + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + tool_resource = config.resources[0] + assert isinstance(tool_resource, AgentProcessToolResourceConfig) + assert tool_resource.type == AgentToolType.FLOW + + def test_function_tool_type_enum_value(self): + """AgentToolType.FUNCTION exists with the wire value 'Function' and is case-insensitive.""" + assert AgentToolType.FUNCTION.value == "Function" + assert AgentToolType("function") is AgentToolType.FUNCTION + assert AgentToolType("FUNCTION") is AgentToolType.FUNCTION + + def test_function_tool_resource_deserialization(self): + """A resource with type='Function' is parsed as AgentProcessToolResourceConfig.""" + resources = [ + { + "$resourceType": "tool", + "type": "Function", + "id": "function-tool-1", + "inputSchema": { + "type": "object", + "properties": {"input": {"type": "string"}}, + }, + "outputSchema": {"type": "object", "properties": {}}, + "arguments": {}, + "settings": {"timeout": 0, "maxAttempts": 0, "retryDelay": 0}, + "properties": { + "processName": "MyFunction", + "folderPath": "/Shared/Functions", + }, + "name": "Function Tool", + "description": "Test Function tool", + } + ] + + json_data = self._agent_dict_with_resources(resources) + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + tool_resource = config.resources[0] + assert isinstance(tool_resource, AgentProcessToolResourceConfig) + assert tool_resource.type == AgentToolType.FUNCTION + assert tool_resource.properties.process_name == "MyFunction" + assert tool_resource.properties.folder_path == "/Shared/Functions" + + def test_function_tool_resource_case_insensitive(self): + """A resource with lowercase type='function' also deserializes via CaseInsensitiveEnum.""" + resources = [ + { + "$resourceType": "tool", + "type": "function", + "id": "function-tool-2", + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "arguments": {}, + "settings": {"timeout": 0, "maxAttempts": 0, "retryDelay": 0}, + "properties": { + "processName": "MyFunction", + "folderPath": "/Shared/Functions", + }, + "name": "Function Tool", + "description": "Test Function tool", + } + ] + + json_data = self._agent_dict_with_resources(resources) + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + tool_resource = config.resources[0] + assert isinstance(tool_resource, AgentProcessToolResourceConfig) + assert tool_resource.type == AgentToolType.FUNCTION + def test_escalation_missing_escalation_type_defaults_to_zero(self): """Test that missing escalationType defaults to 0.""" resources = [ @@ -3528,10 +3909,10 @@ def test_datafabric_context_config_parses(self): assert len(parsed.entity_set) == 2 assert parsed.entity_set[0].id == "abc-123" assert parsed.entity_set[0].name == "Customers" - assert parsed.entity_set[0].folder_id == "folder-1" + assert parsed.entity_set[0].folder_key == "folder-1" assert parsed.entity_set[0].description == "Customer records" - assert parsed.entity_set[0].reference_key is None - assert parsed.entity_set[1].reference_key == "orders-ref" + assert parsed.entity_set[0].entity_key is None + assert parsed.entity_set[1].entity_key == "orders-ref" assert parsed.entity_set[1].description is None def test_is_datafabric(self): @@ -3613,8 +3994,7 @@ def test_a2a_resource(self): "name": "Philosopher Agent", "slug": "philosopher-agent", "description": "A philosophical agent that answers questions with wisdom and philosopher quotes", - "agentCardUrl": "", - "isActive": True, + "folderPath": "shared", "cachedAgentCard": { "name": "Philosopher Agent", "description": "Philosopher Agent assistant", @@ -3665,10 +4045,6 @@ def test_a2a_resource(self): ], "version": "0.7.70", }, - "createdAt": "2026-03-15T10:12:47.9073065", - "createdBy": "f4bc4946-baed-4083-82b9-03d334bbacbe", - "updatedAt": None, - "updatedBy": None, } ], "features": [], @@ -3692,13 +4068,8 @@ def test_a2a_resource(self): a2a_resource.description == "A philosophical agent that answers questions with wisdom and philosopher quotes" ) - assert a2a_resource.is_active is True - assert a2a_resource.agent_card_url == "" assert a2a_resource.id == "755e2f7d-5a3d-47f3-8e9d-7ff0bf226357" - assert a2a_resource.created_at == "2026-03-15T10:12:47.9073065" - assert a2a_resource.created_by == "f4bc4946-baed-4083-82b9-03d334bbacbe" - assert a2a_resource.updated_at is None - assert a2a_resource.updated_by is None + assert a2a_resource.folder_path == "shared" # Validate cached agent card is a plain dict card = a2a_resource.cached_agent_card @@ -3736,7 +4107,7 @@ def test_a2a_resource_without_cached_card(self): "name": "Minimal A2A Agent", "slug": "minimal-a2a", "description": "A minimal A2A agent", - "isActive": False, + "folderPath": "shared", } ], "features": [], @@ -3755,10 +4126,8 @@ def test_a2a_resource_without_cached_card(self): assert isinstance(a2a_resource, AgentA2aResourceConfig) assert a2a_resource.name == "Minimal A2A Agent" assert a2a_resource.slug == "minimal-a2a" - assert a2a_resource.is_active is False + assert a2a_resource.folder_path == "shared" assert a2a_resource.cached_agent_card is None - assert a2a_resource.agent_card_url == "" - assert a2a_resource.created_at is None def test_a2a_resource_case_insensitive(self): """Test that A2A resource type is parsed case-insensitively.""" @@ -3784,6 +4153,7 @@ def test_a2a_resource_case_insensitive(self): "name": "Case Test Agent", "slug": "case-test", "description": "Testing case insensitive parsing", + "folderPath": "shared", } ], "features": [], @@ -3799,3 +4169,435 @@ def test_a2a_resource_case_insensitive(self): ] assert len(a2a_resources) == 1 assert isinstance(a2a_resources[0], AgentA2aResourceConfig) + + +class TestArgumentRecipientDeserialization: + def test_argument_email_recipient_by_type_int(self): + payload = {"type": 7, "argumentName": "assigneeEmail"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, ArgumentEmailRecipient) + assert recipient.argument_path == "assigneeEmail" + assert recipient.type == AgentEscalationRecipientType.ARGUMENT_EMAIL + + def test_argument_group_name_recipient_by_type_int(self): + payload = {"type": 8, "argumentName": "assigneeGroup"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, ArgumentGroupNameRecipient) + assert recipient.argument_path == "assigneeGroup" + assert recipient.type == AgentEscalationRecipientType.ARGUMENT_GROUP_NAME + + def test_argument_email_recipient_by_type_string(self): + payload = {"type": "ArgumentEmail", "argumentName": "emailArg"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, ArgumentEmailRecipient) + assert recipient.argument_path == "emailArg" + + def test_argument_group_name_recipient_by_type_string(self): + payload = {"type": "ArgumentGroupName", "argumentName": "groupArg"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, ArgumentGroupNameRecipient) + assert recipient.argument_path == "groupArg" + + def test_argument_email_recipient_missing_argument_name_raises(self): + payload = {"type": 7} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_argument_group_name_recipient_missing_argument_name_raises(self): + payload = {"type": 8} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_agent_with_client_side_tool(self): + """Test agent with ClientSide tool resource.""" + + json_data = { + "version": "1.0.0", + "id": "aaaaaaaa-0000-0000-0000-000000000010", + "name": "Agent with ClientSide Tool", + "metadata": {"isConversational": False, "storageVersion": "26.0.0"}, + "messages": [ + {"role": "System", "content": "You are an agentic assistant."}, + ], + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": { + "type": "object", + "properties": {"content": {"type": "string"}}, + }, + "settings": { + "model": "gpt-4o-2024-11-20", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v2", + }, + "resources": [ + { + "$resourceType": "tool", + "id": "cst-0001-0000-0000-000000000001", + "name": "browser_navigate", + "description": "Navigate to a URL in the browser", + "location": "external", + "type": "ClientSide", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to navigate to", + } + }, + "required": ["url"], + }, + "outputSchema": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "content": {"type": "string"}, + }, + }, + "arguments": {"timeout": 30}, + "properties": {}, + "isEnabled": True, + } + ], + "features": [], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.name == "Agent with ClientSide Tool" + assert len(config.resources) == 1 + + tool = config.resources[0] + assert isinstance(tool, AgentClientSideToolResourceConfig) + assert tool.resource_type == AgentResourceType.TOOL + assert tool.type == AgentToolType.CLIENT_SIDE + assert tool.name == "browser_navigate" + assert tool.description == "Navigate to a URL in the browser" + + # Validate input schema + assert tool.input_schema["type"] == "object" + assert "url" in tool.input_schema["properties"] + assert tool.input_schema["required"] == ["url"] + + # Validate outputSchema alias deserializes to output_schema + assert tool.output_schema is not None + assert tool.output_schema["type"] == "object" + assert "title" in tool.output_schema["properties"] + assert "content" in tool.output_schema["properties"] + + # Validate arguments + assert tool.arguments == {"timeout": 30} + + def test_agent_with_client_side_tool_lowercase_type(self): + """Test that _normalize_resources handles lowercase 'clientside' type.""" + + json_data = { + "version": "1.0.0", + "id": "aaaaaaaa-0000-0000-0000-000000000011", + "name": "Agent with clientside Tool", + "metadata": {"isConversational": False, "storageVersion": "26.0.0"}, + "messages": [ + {"role": "System", "content": "You are an agentic assistant."}, + ], + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": { + "type": "object", + "properties": {"content": {"type": "string"}}, + }, + "settings": { + "model": "gpt-4o-2024-11-20", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v2", + }, + "resources": [ + { + "$resourceType": "tool", + "id": "cst-0002-0000-0000-000000000001", + "name": "clipboard_copy", + "description": "Copy text to clipboard", + "location": "external", + "type": "clientside", + "inputSchema": { + "type": "object", + "properties": { + "text": {"type": "string"}, + }, + }, + "properties": {}, + "isEnabled": True, + } + ], + "features": [], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + tool = config.resources[0] + assert isinstance(tool, AgentClientSideToolResourceConfig) + assert tool.type == AgentToolType.CLIENT_SIDE + assert tool.name == "clipboard_copy" + + # output_schema and arguments should default + assert tool.output_schema is None + assert tool.arguments == {} + + def test_agent_with_client_side_tool_output_schema_alias(self): + """Test that the outputSchema alias correctly maps to output_schema.""" + + json_data = { + "version": "1.0.0", + "id": "aaaaaaaa-0000-0000-0000-000000000012", + "name": "Agent with ClientSide outputSchema alias", + "metadata": {"isConversational": False, "storageVersion": "26.0.0"}, + "messages": [ + {"role": "System", "content": "You are an agentic assistant."}, + ], + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": { + "type": "object", + "properties": {"content": {"type": "string"}}, + }, + "settings": { + "model": "gpt-4o-2024-11-20", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v2", + }, + "resources": [ + { + "$resourceType": "tool", + "id": "cst-0003-0000-0000-000000000001", + "name": "screen_capture", + "description": "Capture a screenshot", + "location": "external", + "type": "ClientSide", + "inputSchema": { + "type": "object", + "properties": { + "region": {"type": "string"}, + }, + }, + "outputSchema": { + "type": "object", + "properties": { + "imageBase64": { + "type": "string", + "description": "Base64-encoded image", + } + }, + "required": ["imageBase64"], + }, + "properties": {}, + "isEnabled": True, + } + ], + "features": [], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + tool = config.resources[0] + assert isinstance(tool, AgentClientSideToolResourceConfig) + + # Access via Python attribute name (snake_case) + assert tool.output_schema is not None + assert tool.output_schema["type"] == "object" + assert "imageBase64" in tool.output_schema["properties"] + assert tool.output_schema["required"] == ["imageBase64"] + + +class TestCustomAssignmentRecipientDeserialization: + def test_workload_recipient_by_type_int(self): + payload = {"type": 9, "value": "group-1", "displayName": "Support Team"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, WorkloadRecipient) + assert recipient.value == "group-1" + assert recipient.display_name == "Support Team" + assert recipient.type == AgentEscalationRecipientType.WORKLOAD + + def test_workload_recipient_by_type_string(self): + payload = { + "type": "Workload", + "value": "group-1", + "displayName": "Support Team", + } + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, WorkloadRecipient) + assert recipient.value == "group-1" + assert recipient.display_name == "Support Team" + + def test_round_robin_recipient_by_type_int(self): + payload = {"type": 10, "value": "group-1", "displayName": "Support Team"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, RoundRobinRecipient) + assert recipient.value == "group-1" + assert recipient.display_name == "Support Team" + assert recipient.type == AgentEscalationRecipientType.ROUND_ROBIN + + def test_round_robin_recipient_by_type_string(self): + payload = { + "type": "RoundRobin", + "value": "group-1", + "displayName": "Support Team", + } + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, RoundRobinRecipient) + + def test_custom_assignees_recipient_by_type_int(self): + payload = { + "type": 11, + "value": "alice@example.com", + "displayName": "Alice", + } + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, CustomAssigneesRecipient) + assert recipient.value == "alice@example.com" + assert recipient.display_name == "Alice" + assert recipient.type == AgentEscalationRecipientType.CUSTOM_ASSIGNEES + + def test_custom_assignees_recipient_by_type_string(self): + payload = {"type": "CustomAssignees", "value": "alice@example.com"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, CustomAssigneesRecipient) + assert recipient.value == "alice@example.com" + assert recipient.display_name is None + + def test_custom_assignees_recipient_accepts_empty_value_sentinel(self): + payload = {"type": 11, "value": ""} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, CustomAssigneesRecipient) + assert recipient.value == "" + + def test_workload_recipient_missing_value_raises(self): + payload = {"type": 9, "displayName": "Support Team"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_workload_recipient_missing_display_name_raises(self): + payload = {"type": 9, "value": "group-1"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_round_robin_recipient_missing_value_raises(self): + payload = {"type": 10, "displayName": "Support Team"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_custom_assignees_recipient_missing_value_raises(self): + payload = {"type": 11} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + +class TestToolOutputRecipientDeserialization: + @pytest.mark.parametrize( + "recipient_type", + [1, 2, 9, 10, 11], + ) + def test_tool_output_recipient_by_type_int_for_supported_types( + self, recipient_type + ): + payload = { + "type": recipient_type, + "source": "toolOutput", + "toolName": "API workflow A", + "outputPath": "includeEmails", + } + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, ToolOutputRecipient) + assert recipient.tool_name == "API workflow A" + assert recipient.output_path == "includeEmails" + assert recipient.source == "toolOutput" + + def test_tool_output_recipient_for_custom_assignees_by_type_string(self): + payload = { + "type": "CustomAssignees", + "source": "toolOutput", + "toolName": "API workflow A", + "outputPath": "includeEmails", + } + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, ToolOutputRecipient) + assert recipient.type == AgentEscalationRecipientType.CUSTOM_ASSIGNEES + + def test_tool_output_recipient_missing_tool_name_raises(self): + payload = {"type": 11, "source": "toolOutput", "outputPath": "emails"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_tool_output_recipient_missing_output_path_raises(self): + payload = {"type": 11, "source": "toolOutput", "toolName": "A"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_tool_output_recipient_unknown_source_raises(self): + payload = { + "type": 11, + "source": "magicBox", + "toolName": "A", + "outputPath": "emails", + } + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + @pytest.mark.parametrize( + "recipient_type", + [3, 4, 5, 6, 7, 8], + ) + def test_tool_output_recipient_not_allowed_for_static_asset_argument_types( + self, recipient_type + ): + # Static/asset/argument types (3, 4, 5, 6, 7, 8) are not supported + # for tool-output binding because they have their own design-time + # resolution rules. + payload = { + "type": recipient_type, + "source": "toolOutput", + "toolName": "A", + "outputPath": "emails", + } + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_literal_recipient_without_source_still_parses_to_literal_class(self): + # Backward compat: a payload without `source` still matches the literal class. + payload = {"type": 11, "value": "alice@example.com", "displayName": "Alice"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, CustomAssigneesRecipient) + assert not isinstance(recipient, ToolOutputRecipient) diff --git a/packages/uipath/tests/agent/react/test_conversational_prompts.py b/packages/uipath/tests/agent/react/test_conversational_prompts.py index a58a94807..434f68465 100644 --- a/packages/uipath/tests/agent/react/test_conversational_prompts.py +++ b/packages/uipath/tests/agent/react/test_conversational_prompts.py @@ -149,6 +149,68 @@ def test_generate_system_prompt_unnamed_agent_uses_default(self): assert "You are Unnamed Agent." in prompt +class TestConversationIdInPrompt: + """Tests for conversation_id in generated prompts.""" + + def test_prompt_includes_conversation_id_when_provided(self): + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + system_message=SYSTEM_MESSAGE, + agent_name="Test Agent", + user_settings=None, + conversation_id="conv-abc-123", + ) + + assert "The current conversation ID is conv-abc-123" in prompt + assert ( + "This may be useful to include in tool-calls when tool parameters specify passing in the conversation ID." + in prompt + ) + assert ( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_conversationIdPrompt}}" not in prompt + ) + + def test_prompt_omits_section_when_none(self): + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + system_message=SYSTEM_MESSAGE, + agent_name="Test Agent", + user_settings=None, + conversation_id=None, + ) + + assert "conversation ID" not in prompt + assert ( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_conversationIdPrompt}}" not in prompt + ) + + def test_prompt_omits_section_when_empty_string(self): + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + system_message=SYSTEM_MESSAGE, + agent_name="Test Agent", + user_settings=None, + conversation_id="", + ) + + assert "conversation ID" not in prompt + + def test_prompt_defaults_to_no_conversation_id(self): + """conversation_id defaults to None — call sites that don't pass it + must not get a dangling placeholder.""" + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + system_message=SYSTEM_MESSAGE, + agent_name="Test Agent", + user_settings=None, + ) + + assert ( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_conversationIdPrompt}}" not in prompt + ) + assert "conversation ID" not in prompt + + class TestCitationFormat: """Tests for citation format in generated prompts.""" diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index 2da4f31ad..2c18640f3 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -1,5 +1,6 @@ """Tests for SocketIOChatBridge and get_chat_bridge.""" +import asyncio import logging from datetime import datetime from typing import Any, cast @@ -9,6 +10,7 @@ from uipath._cli._chat._bridge import SocketIOChatBridge, get_chat_bridge from uipath._cli._debug._bridge import SignalRDebugBridge +from uipath.core.triggers import UiPathApiTrigger, UiPathResumeTrigger class MockRuntimeContext: @@ -20,11 +22,13 @@ def __init__( exchange_id: str = "test-exchange-id", tenant_id: str = "test-tenant-id", org_id: str = "test-org-id", + end_exchange: bool = True, ): self.conversation_id = conversation_id self.exchange_id = exchange_id self.tenant_id = tenant_id self.org_id = org_id + self.end_exchange = end_exchange class TestSocketIOChatBridgeDebugMode: @@ -204,6 +208,53 @@ def test_get_chat_bridge_constructs_correct_headers( assert "X-UiPath-ConversationId" in bridge.headers assert bridge.headers["X-UiPath-ConversationId"] == "conv-789" + def test_get_chat_bridge_falls_back_to_env_when_tenant_and_org_absent( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Tenant/account headers fall back to env vars when context values are None.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "my-access-token") + monkeypatch.setenv("UIPATH_TENANT_ID", "env-tenant") + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "env-org") + + context = MockRuntimeContext( + tenant_id=None, # type: ignore[arg-type] + org_id=None, # type: ignore[arg-type] + conversation_id="conv-789", + ) + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.headers["X-UiPath-Internal-TenantId"] == "env-tenant" + assert bridge.headers["X-UiPath-Internal-AccountId"] == "env-org" + + def test_get_chat_bridge_includes_conversational_user_id_header_when_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Conversation owner id (from FpsProperties) is sent on the handshake for CAS to validate.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "my-access-token") + + context = MockRuntimeContext(conversation_id="conv-789") + context.conversational_user_id = "owner-guid" # type: ignore[attr-defined] + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.headers["X-UiPath-Internal-ConversationalUserId"] == "owner-guid" + + def test_get_chat_bridge_omits_conversational_user_id_header_when_absent( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No header is sent when the runtime has no owner id (backward compatible).""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "my-access-token") + + context = MockRuntimeContext(conversation_id="conv-789") + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert "X-UiPath-Internal-ConversationalUserId" not in bridge.headers + def test_get_chat_bridge_raises_without_uipath_url( self, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -308,6 +359,89 @@ async def test_emit_exchange_end_raises_without_client(self) -> None: assert "not connected" in str(exc_info.value).lower() +class TestSocketIOChatBridgeEndExchange: + """The bridge owns whether to honor the exchange-end event (CAS-specific).""" + + def _make_connected_bridge(self, end_exchange: bool) -> SocketIOChatBridge: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + end_exchange=end_exchange, + ) + bridge._websocket_disabled = False + bridge._client = AsyncMock() + bridge._connected_event.set() + return bridge + + def test_end_exchange_defaults_true(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + assert bridge.end_exchange is True + + @pytest.mark.anyio + async def test_emit_exchange_end_sends_when_end_exchange_true(self) -> None: + bridge = self._make_connected_bridge(end_exchange=True) + + await bridge.emit_exchange_end_event() + + cast(AsyncMock, bridge._client).emit.assert_awaited_once() + assert ( + cast(AsyncMock, bridge._client).emit.await_args.args[0] + == "ConversationEvent" + ) + + @pytest.mark.anyio + async def test_emit_exchange_end_suppressed_when_end_exchange_false(self) -> None: + bridge = self._make_connected_bridge(end_exchange=False) + + await bridge.emit_exchange_end_event() + + cast(AsyncMock, bridge._client).emit.assert_not_awaited() + + @pytest.mark.anyio + async def test_emit_exchange_end_false_does_not_require_client(self) -> None: + """With the exchange kept open, suppression happens before the connection check.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + end_exchange=False, + ) + + # Should not raise even though _client is None. + await bridge.emit_exchange_end_event() + + def test_get_chat_bridge_propagates_end_exchange_false( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + context = MockRuntimeContext(end_exchange=False) + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.end_exchange is False + + def test_get_chat_bridge_defaults_end_exchange_true( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + context = MockRuntimeContext() + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.end_exchange is True + + class TestSignalRDebugBridgeSendMethod: """Tests for SignalRDebugBridge.""" @@ -351,3 +485,254 @@ async def test_send_with_datetime_does_not_raise(self) -> None: assert parsed_data["message"] == "test message" assert isinstance(parsed_data["timestamp"], str) assert isinstance(parsed_data["nested"]["created_at"], str) + + +class TestEmitInterruptEvent: + """Tests for emit_interrupt_event (now a no-op for executingToolCall).""" + + def _make_bridge(self) -> SocketIOChatBridge: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._current_message_id = "msg-100" + return bridge + + @pytest.mark.anyio + async def test_emit_interrupt_event_is_noop(self) -> None: + """emit_interrupt_event no longer emits executingToolCall.""" + bridge = self._make_bridge() + + emitted_events: list[Any] = [] + + async def capture_emit(event: Any) -> None: + emitted_events.append(event) + + bridge.emit_message_event = capture_emit # type: ignore[assignment] + + trigger = UiPathResumeTrigger( + api_resume=UiPathApiTrigger( + request={ + "tool_call_id": "tc-42", + "tool_name": "my_tool", + "input": {"key": "value"}, + } + ) + ) + + await bridge.emit_interrupt_event(trigger) + + assert len(emitted_events) == 0 + + +class TestEmitExecutingToolCall: + """Tests for emit_executing_tool_call_event (post-confirmation executingToolCall emission).""" + + def _make_bridge(self) -> SocketIOChatBridge: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._current_message_id = "msg-100" + return bridge + + @pytest.mark.anyio + async def test_emits_executing_tool_call_event( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Should emit executingToolCall with tool_call_id and input.""" + monkeypatch.setenv("CAS_WEBSOCKET_DISABLED", "true") + bridge = self._make_bridge() + await bridge.connect() + + emitted_events: list[Any] = [] + original_emit = bridge.emit_message_event + + async def capture_emit(event: Any) -> None: + emitted_events.append(event) + await original_emit(event) + + bridge.emit_message_event = capture_emit # type: ignore[assignment] + + await bridge.emit_executing_tool_call_event( + tool_call_id="tc-42", + tool_input={"key": "value"}, + ) + + assert len(emitted_events) == 1 + event = emitted_events[0] + assert event.message_id == "msg-100" + assert event.tool_call is not None + assert event.tool_call.tool_call_id == "tc-42" + assert event.tool_call.executing is not None + assert event.tool_call.executing.input == {"key": "value"} + + @pytest.mark.anyio + async def test_no_message_id_does_not_emit(self) -> None: + """Should not emit if no current message ID is set.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + # _current_message_id is not set + + emitted_events: list[Any] = [] + + async def capture_emit(event: Any) -> None: + emitted_events.append(event) + + bridge.emit_message_event = capture_emit # type: ignore[assignment] + + await bridge.emit_executing_tool_call_event(tool_call_id="tc-42") + + assert len(emitted_events) == 0 + + @pytest.mark.anyio + async def test_none_input_emits_with_none( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Should emit with None input when no input provided.""" + monkeypatch.setenv("CAS_WEBSOCKET_DISABLED", "true") + bridge = self._make_bridge() + await bridge.connect() + + emitted_events: list[Any] = [] + original_emit = bridge.emit_message_event + + async def capture_emit(event: Any) -> None: + emitted_events.append(event) + await original_emit(event) + + bridge.emit_message_event = capture_emit # type: ignore[assignment] + + await bridge.emit_executing_tool_call_event(tool_call_id="tc-42") + + assert len(emitted_events) == 1 + assert emitted_events[0].tool_call.executing.input is None + + +class TestWaitForResumeEndToolCall: + """Tests for wait_for_resume unblocking on endToolCall events.""" + + @pytest.mark.anyio + async def test_end_tool_call_unblocks_wait_for_resume(self) -> None: + """Receiving an endToolCall event unblocks wait_for_resume and returns parsed payload.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + end_event = { + "conversationId": "conv-123", + "exchange": { + "exchangeId": "exch-456", + "message": { + "messageId": "msg-200", + "toolCall": { + "toolCallId": "tc-99", + "endToolCall": { + "output": {"result": "ok"}, + "isError": False, + }, + }, + }, + }, + } + + async def simulate_end_event() -> None: + await asyncio.sleep(0.05) + await bridge._handle_conversation_event(end_event, "sid-1") + + task = asyncio.create_task(simulate_end_event()) + result = await bridge.wait_for_resume() + await task + + assert result["output"] == {"result": "ok"} + assert result["is_error"] is False + + @pytest.mark.anyio + async def test_confirm_tool_call_unblocks_wait_for_resume(self) -> None: + """Receiving a confirmToolCall event also unblocks wait_for_resume.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + confirm_event = { + "conversationId": "conv-123", + "exchange": { + "exchangeId": "exch-456", + "message": { + "messageId": "msg-200", + "toolCall": { + "toolCallId": "tc-99", + "confirmToolCall": { + "approved": True, + "input": {"edited": "data"}, + }, + }, + }, + }, + } + + async def simulate_confirm_event() -> None: + await asyncio.sleep(0.05) + await bridge._handle_conversation_event(confirm_event, "sid-1") + + task = asyncio.create_task(simulate_confirm_event()) + result = await bridge.wait_for_resume() + await task + + assert result["approved"] is True + assert result["input"] == {"edited": "data"} + + @pytest.mark.anyio + async def test_early_end_tool_call_is_not_lost(self) -> None: + """An endToolCall that arrives before wait_for_resume is called must not be lost.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + end_event = { + "conversationId": "conv-123", + "exchange": { + "exchangeId": "exch-456", + "message": { + "messageId": "msg-300", + "toolCall": { + "toolCallId": "tc-100", + "endToolCall": { + "output": {"early": True}, + "isError": False, + }, + }, + }, + }, + } + + # Simulate the event arriving BEFORE wait_for_resume is called + await bridge._handle_conversation_event(end_event, "sid-1") + + result = await bridge.wait_for_resume() + + assert result["output"] == {"early": True} + assert result["is_error"] is False diff --git a/packages/uipath/tests/cli/chat/test_voice_bridge.py b/packages/uipath/tests/cli/chat/test_voice_bridge.py new file mode 100644 index 000000000..39e094bfa --- /dev/null +++ b/packages/uipath/tests/cli/chat/test_voice_bridge.py @@ -0,0 +1,169 @@ +"""Tests for VoiceToolCallSession and get_voice_bridge.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from uipath._cli._chat._voice_bridge import ( + VoiceSessionEndReason, + VoiceToolCallSession, + get_voice_bridge, +) +from uipath.core.chat import ( + UiPathVoiceToolCallRequest, + UiPathVoiceToolCallResult, +) + + +def _make_session(tool_handler: Any = None) -> VoiceToolCallSession: + session = VoiceToolCallSession( + url="wss://example/test", + socketio_path="/socket.io", + headers={}, + tool_handler=tool_handler or AsyncMock(), + ) + session._client = MagicMock() + session._client.emit = AsyncMock() + return session + + +class TestEndSession: + def test_first_writer_wins(self) -> None: + """A late DISCONNECTED must not overwrite COMPLETED.""" + session = _make_session() + session._end_session(VoiceSessionEndReason.COMPLETED) + session._end_session(VoiceSessionEndReason.DISCONNECTED) + assert session._end_reason == VoiceSessionEndReason.COMPLETED + assert session._done.is_set() + + async def test_session_ended_sets_completed(self) -> None: + session = _make_session() + await session._handle_session_ended(None) + assert session._end_reason == VoiceSessionEndReason.COMPLETED + + async def test_disconnect_sets_disconnected(self) -> None: + session = _make_session() + await session._handle_disconnect() + assert session._end_reason == VoiceSessionEndReason.DISCONNECTED + + +class TestHandleToolCall: + async def test_dispatches_handler_and_emits_result(self) -> None: + handler = AsyncMock( + return_value=UiPathVoiceToolCallResult(result="ok", is_error=False) + ) + session = _make_session(handler) + + await session._handle_tool_call( + {"calls": [{"callId": "c1", "toolName": "weather", "args": {"city": "SF"}}]} + ) + # Drain the spawned task. + for task in list(session._in_flight): + await task + + handler.assert_awaited_once() + assert handler.await_args is not None + call_arg = handler.await_args.args[0] + assert isinstance(call_arg, UiPathVoiceToolCallRequest) + assert call_arg.call_id == "c1" + assert call_arg.tool_name == "weather" + + session._client.emit.assert_awaited_once_with( + "voice_tool_result", + {"callId": "c1", "result": "ok", "isError": False}, + ) + + async def test_invalid_payload_is_skipped(self) -> None: + handler = AsyncMock() + session = _make_session(handler) + + await session._handle_tool_call({"calls": []}) # min_length=1 violation + + handler.assert_not_awaited() + session._client.emit.assert_not_awaited() + + async def test_noop_after_session_ended(self) -> None: + handler = AsyncMock() + session = _make_session(handler) + session._done.set() + + await session._handle_tool_call( + {"calls": [{"callId": "c1", "toolName": "x", "args": {}}]} + ) + + handler.assert_not_awaited() + assert not session._in_flight + + async def test_handler_exception_emits_error_result(self) -> None: + handler = AsyncMock(side_effect=RuntimeError("boom")) + session = _make_session(handler) + + await session._handle_tool_call( + {"calls": [{"callId": "c1", "toolName": "x", "args": {}}]} + ) + for task in list(session._in_flight): + await task + + session._client.emit.assert_awaited_once_with( + "voice_tool_result", + {"callId": "c1", "result": "boom", "isError": True}, + ) + + +class TestGetVoiceBridge: + def test_raises_when_uipath_url_missing( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("UIPATH_URL", raising=False) + monkeypatch.delenv("CAS_WEBSOCKET_HOST", raising=False) + ctx = MagicMock(conversation_id="conv-1", tenant_id="t", org_id="o") + + with pytest.raises(RuntimeError, match="UIPATH_URL"): + get_voice_bridge(ctx, AsyncMock()) + + def test_headers_fall_back_to_env_when_context_ids_are_none( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Regression: f"{None}" is truthy ("None"), so the `or` fallback was dead.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + monkeypatch.setenv("UIPATH_TENANT_ID", "env-tenant") + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "env-org") + ctx = MagicMock(conversation_id="conv-1", tenant_id=None, org_id=None) + + bridge = get_voice_bridge(ctx, AsyncMock()) + + assert bridge._headers["X-UiPath-Internal-TenantId"] == "env-tenant" + assert bridge._headers["X-UiPath-Internal-AccountId"] == "env-org" + + def test_includes_conversational_user_id_header_when_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Conversation owner id (from FpsProperties) is sent on the handshake for CAS to validate.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + ctx = MagicMock( + conversation_id="conv-1", + tenant_id="t", + org_id="o", + conversational_user_id="owner-guid", + ) + + bridge = get_voice_bridge(ctx, AsyncMock()) + + assert bridge._headers["X-UiPath-Internal-ConversationalUserId"] == "owner-guid" + + def test_omits_conversational_user_id_header_when_none( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No header is sent when the runtime has no owner id (backward compatible).""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + ctx = MagicMock( + conversation_id="conv-1", + tenant_id="t", + org_id="o", + conversational_user_id=None, + ) + + bridge = get_voice_bridge(ctx, AsyncMock()) + + assert "X-UiPath-Internal-ConversationalUserId" not in bridge._headers diff --git a/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py b/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py index 52699d42e..a690727d4 100644 --- a/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py +++ b/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py @@ -192,9 +192,13 @@ def assert_cli_sdk_alignment( # Used when SDK has optional params that CLI doesn't expose SDK_EXCLUSIONS = { "context-grounding_list": set(), - "context-grounding_retrieve": set(), + "context-grounding_retrieve": {"include_system_indexes"}, "context-grounding_create": {"source", "embeddings_enabled", "is_encrypted"}, - "context-grounding_search": {"scope", "number_of_results"}, + "context-grounding_search": { + "scope", + "number_of_results", + "include_system_indexes", + }, "context-grounding_ingest": set(), "context-grounding_delete": set(), "context-grounding_deep-rag_start": set(), diff --git a/packages/uipath/tests/cli/eval/mocks/test_input_mocker.py b/packages/uipath/tests/cli/eval/mocks/test_input_mocker.py index 72b3765df..a8a8a64ec 100644 --- a/packages/uipath/tests/cli/eval/mocks/test_input_mocker.py +++ b/packages/uipath/tests/cli/eval/mocks/test_input_mocker.py @@ -112,3 +112,10 @@ async def test_generate_llm_input_with_model_settings( assert len(chat_completion_requests) == 1, ( "Expected exactly one chat completion request" ) + + # OpenAI returns content via response_format; no tool-call fallback needed. + import json + + body = json.loads(chat_completion_requests[0].content.decode("utf-8")) + assert "response_format" in body + assert "tools" not in body diff --git a/packages/uipath/tests/cli/eval/mocks/test_input_mocker_span.py b/packages/uipath/tests/cli/eval/mocks/test_input_mocker_span.py index 19a432fef..d02c5d242 100644 --- a/packages/uipath/tests/cli/eval/mocks/test_input_mocker_span.py +++ b/packages/uipath/tests/cli/eval/mocks/test_input_mocker_span.py @@ -212,6 +212,14 @@ async def test_simulate_input_span_on_error(httpx_mock: HTTPXMock, monkeypatch): }, }, ) + # The prose content above triggers the tool-call fallback; an empty + # response there fails the fallback too, producing the error span. + httpx_mock.add_response( + url="https://example.com/llm/api/chat/completions" + "?api-version=2024-08-01-preview", + status_code=200, + json={}, + ) mocking_strategy = InputMockingStrategy( prompt="Generate input", diff --git a/packages/uipath/tests/cli/eval/mocks/test_mockable_arg_collision.py b/packages/uipath/tests/cli/eval/mocks/test_mockable_arg_collision.py new file mode 100644 index 000000000..838e9d835 --- /dev/null +++ b/packages/uipath/tests/cli/eval/mocks/test_mockable_arg_collision.py @@ -0,0 +1,107 @@ +"""Regression tests: @mockable must not collide with user args named `func`/`params`.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from uipath.eval.mocks import mockable +from uipath.eval.mocks._mock_runtime import ( + clear_execution_context, + set_execution_context, +) +from uipath.eval.mocks._types import MockingContext +from uipath.eval.models.evaluation_set import EvaluationItem + +_mock_span_collector = MagicMock() + + +def _build_evaluation( + function_name: str, kwargs: dict[str, Any], value: Any +) -> EvaluationItem: + evaluation_item: dict[str, Any] = { + "id": "evaluation-id", + "name": "Test evaluation", + "inputs": {}, + "evaluationCriterias": {"ExactMatchEvaluator": None}, + "mockingStrategy": { + "type": "mockito", + "behaviors": [ + { + "function": function_name, + "arguments": {"args": [], "kwargs": kwargs}, + "then": [{"type": "return", "value": value}], + } + ], + }, + } + return EvaluationItem(**evaluation_item) + + +class TestMockableArgCollision: + """Ensure `@mockable` works when the wrapped function has args named `func` or `params`.""" + + def test_sync_function_with_func_and_params_args(self): + """A sync mockable function that takes `func` and `params` kwargs should not raise.""" + + @mockable() + def test_function(func: str, params: dict[str, Any]) -> str: + raise NotImplementedError() + + evaluation = _build_evaluation( + "test_function", + kwargs={"func": "some_func", "params": {"k": "v"}}, + value="mocked_result", + ) + + set_execution_context( + MockingContext( + strategy=evaluation.mocking_strategy, + name=evaluation.name, + inputs=evaluation.inputs, + ), + _mock_span_collector, + "test-execution-id", + ) + + try: + with patch("uipath.eval.mocks.mockable.UiPathSpanUtils"): + with patch("uipath.eval.mocks.mockable.trace"): + result = test_function(func="some_func", params={"k": "v"}) + + assert result == "mocked_result" + finally: + clear_execution_context() + + @pytest.mark.asyncio + async def test_async_function_with_func_and_params_args(self): + """An async mockable function that takes `func` and `params` kwargs should not raise.""" + + @mockable() + async def test_function(func: str, params: dict[str, Any]) -> str: + raise NotImplementedError() + + evaluation = _build_evaluation( + "test_function", + kwargs={"func": "some_func", "params": {"k": "v"}}, + value="mocked_result", + ) + + set_execution_context( + MockingContext( + strategy=evaluation.mocking_strategy, + name=evaluation.name, + inputs=evaluation.inputs, + ), + _mock_span_collector, + "test-execution-id", + ) + + try: + with patch("uipath.eval.mocks.mockable.UiPathSpanUtils"): + with patch("uipath.eval.mocks.mockable.trace"): + result = await test_function(func="some_func", params={"k": "v"}) + + assert result == "mocked_result" + finally: + clear_execution_context() diff --git a/packages/uipath/tests/cli/eval/mocks/test_mocks.py b/packages/uipath/tests/cli/eval/mocks/test_mocks.py index bdbdd3dc2..e59b07d2f 100644 --- a/packages/uipath/tests/cli/eval/mocks/test_mocks.py +++ b/packages/uipath/tests/cli/eval/mocks/test_mocks.py @@ -610,12 +610,14 @@ def foofoo(*args, **kwargs): with pytest.raises(NotImplementedError): assert foofoo() - httpx_mock.add_response( - url="https://example.com/llm/api/chat/completions" - "?api-version=2024-08-01-preview", - status_code=200, - json={}, - ) + # Two empty responses: the response_format attempt and the tool-call fallback. + for _ in range(2): + httpx_mock.add_response( + url="https://example.com/llm/api/chat/completions" + "?api-version=2024-08-01-preview", + status_code=200, + json={}, + ) with pytest.raises(UiPathMockResponseGenerationError): assert foo() @@ -720,12 +722,14 @@ async def foofoo(*args, **kwargs): with pytest.raises(NotImplementedError): assert await foofoo() - httpx_mock.add_response( - url="https://example.com/llm/api/chat/completions" - "?api-version=2024-08-01-preview", - status_code=200, - json={}, - ) + # Two empty responses: the response_format attempt and the tool-call fallback. + for _ in range(2): + httpx_mock.add_response( + url="https://example.com/llm/api/chat/completions" + "?api-version=2024-08-01-preview", + status_code=200, + json={}, + ) with pytest.raises(UiPathMockResponseGenerationError): assert await foo() @@ -929,3 +933,230 @@ async def foo(*args, **kwargs) -> dict[str, Any]: }, }, } + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_responses_were_requested=False) +async def test_llm_mockable_uses_tool_call_directly_for_non_openai( + httpx_mock: HTTPXMock, monkeypatch: MonkeyPatch +): + """Tool simulation works for non-OpenAI providers (AE-1646). + + Non-OpenAI providers don't honor ``response_format`` on the normalized + gateway (Claude answers with prose, Gemini with empty content), so their + strategies go straight to a forced tool call — a single request. + """ + monkeypatch.setenv("UIPATH_URL", "https://example.com") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "1234567890") + monkeypatch.setattr(CacheManager, "get", lambda *args, **kwargs: None) + monkeypatch.setattr(CacheManager, "set", lambda *args, **kwargs: None) + + @mockable() + async def foo(*args, **kwargs) -> str: + raise NotImplementedError() + + evaluation_item: dict[str, Any] = { + "id": "evaluation-id", + "name": "Mock foo", + "inputs": {}, + "evaluationCriterias": { + "ExactMatchEvaluator": None, + }, + "mockingStrategy": { + "type": "llm", + "prompt": "response is 'bar1'", + "toolsToSimulate": [{"name": "foo"}], + "model": {"model": "anthropic.claude-sonnet-4-5-20250929-v1:0"}, + }, + } + evaluation = EvaluationItem(**evaluation_item) + assert isinstance(evaluation.mocking_strategy, LLMMockingStrategy) + httpx_mock.add_response( + url="https://example.com/agenthub_/llm/api/capabilities", + status_code=200, + json={}, + ) + httpx_mock.add_response( + url="https://example.com/orchestrator_/llm/api/capabilities", + status_code=200, + json={}, + ) + + def _completion(message: dict[str, Any]) -> dict[str, Any]: + return { + "id": "response-id", + "object": "", + "created": 0, + "model": "anthropic.claude-sonnet-4-5-20250929-v1:0", + "choices": [{"index": 0, "message": message, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + + # Claude goes straight to function calling: one request, one response. + httpx_mock.add_response( + url="https://example.com/llm/api/chat/completions" + "?api-version=2024-08-01-preview", + status_code=200, + json=_completion( + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "name": "submit_tool_response", + "arguments": {"response": "bar1"}, + } + ], + } + ), + ) + + set_execution_context( + MockingContext( + strategy=evaluation.mocking_strategy, + name=evaluation.name, + inputs=evaluation.inputs, + ), + _mock_span_collector, + "test-execution-id", + ) + + assert await foo() == "bar1" + + requests = [ + r for r in httpx_mock.get_requests() if "chat/completions" in str(r.url) + ] + assert len(requests) == 1 + body = json.loads(requests[0].content.decode("utf-8")) + # Non-OpenAI providers use a forced tool call directly — no response_format. + assert body["tool_choice"] == {"type": "required"} + assert body["tools"][0]["name"] == "submit_tool_response" + assert "response_format" not in body + + +class TestUiPathMockRuntime: + """Tests for UiPathMockRuntime execute/stream/get_schema paths.""" + + def _make_context(self) -> MockingContext: + return MockingContext( + strategy=LLMMockingStrategy( + prompt="test", + tools_to_simulate=[ToolSimulation(name="my_tool")], + ), + name="test", + inputs={}, + ) + + async def test_execute_with_mocking_context_sets_and_clears(self): + from unittest.mock import AsyncMock, patch + + from uipath.eval.mocks._mock_runtime import UiPathMockRuntime + + delegate = MagicMock() + mock_result = MagicMock() + delegate.execute = AsyncMock(return_value=mock_result) + + runtime = UiPathMockRuntime( + delegate=delegate, + mocking_context=self._make_context(), + ) + + with ( + patch("uipath.eval.mocks._mock_runtime.set_execution_context") as mock_set, + patch( + "uipath.eval.mocks._mock_runtime.clear_execution_context" + ) as mock_clear, + ): + result = await runtime.execute({"key": "value"}) + + assert result is mock_result + mock_set.assert_called_once() + mock_clear.assert_called_once() + + async def test_stream_with_mocking_context_sets_and_clears(self): + from unittest.mock import patch + + from uipath.eval.mocks._mock_runtime import UiPathMockRuntime + + sentinel = object() + + async def _gen(*args, **kwargs): + yield sentinel + + delegate = MagicMock() + delegate.stream = _gen + + runtime = UiPathMockRuntime( + delegate=delegate, + mocking_context=self._make_context(), + ) + + with ( + patch("uipath.eval.mocks._mock_runtime.set_execution_context") as mock_set, + patch( + "uipath.eval.mocks._mock_runtime.clear_execution_context" + ) as mock_clear, + ): + events = [e async for e in runtime.stream({})] + + assert events == [sentinel] + mock_set.assert_called_once() + mock_clear.assert_called_once() + + async def test_stream_without_mocking_context_passes_through(self): + from unittest.mock import patch + + from uipath.eval.mocks._mock_runtime import UiPathMockRuntime + + sentinel = object() + + async def _gen(*args, **kwargs): + yield sentinel + + delegate = MagicMock() + delegate.stream = _gen + + runtime = UiPathMockRuntime(delegate=delegate, mocking_context=None) + with patch( + "uipath.eval.mocks._mock_runtime.load_simulation_config", return_value=None + ): + runtime._mocking_context = None + events = [e async for e in runtime.stream({})] + + assert events == [sentinel] + + async def test_get_schema_delegates(self): + from unittest.mock import AsyncMock, patch + + from uipath.eval.mocks._mock_runtime import UiPathMockRuntime + + schema = MagicMock() + delegate = MagicMock() + delegate.get_schema = AsyncMock(return_value=schema) + + runtime = UiPathMockRuntime(delegate=delegate, mocking_context=None) + with patch( + "uipath.eval.mocks._mock_runtime.load_simulation_config", return_value=None + ): + result = await runtime.get_schema() + + assert result is schema + + def test_set_execution_context_handles_mocker_creation_failure(self): + from unittest.mock import patch + + from uipath.eval._execution_context import ExecutionSpanCollector + from uipath.eval.mocks._mock_context import mocker_context + from uipath.eval.mocks._mock_runtime import set_execution_context + + context = self._make_context() + with patch( + "uipath.eval.mocks._mock_runtime.MockerFactory.create", + side_effect=RuntimeError("boom"), + ): + set_execution_context(context, ExecutionSpanCollector(), "test-id") + + # mocking_context is set, but mocker_context must be None on failure + assert mocker_context.get() is None + clear_execution_context() diff --git a/packages/uipath/tests/cli/eval/mocks/test_simulate_component.py b/packages/uipath/tests/cli/eval/mocks/test_simulate_component.py new file mode 100644 index 000000000..f4131cf48 --- /dev/null +++ b/packages/uipath/tests/cli/eval/mocks/test_simulate_component.py @@ -0,0 +1,442 @@ +"""Tests for SimulateComponentMocker and SimulateComponentService.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from _pytest.monkeypatch import MonkeyPatch +from pytest_httpx import HTTPXMock + +from uipath.eval.mocks._mock_context import is_tool_simulated +from uipath.eval.mocks._mock_runtime import ( + clear_execution_context, + set_execution_context, +) +from uipath.eval.mocks._mocker import ( + UiPathMockResponseGenerationError, +) +from uipath.eval.mocks._simulate_component_mocker import SimulateComponentMocker +from uipath.eval.mocks._simulate_component_service import ( + SimulateComponentService, + _create_simulate_component_service, +) +from uipath.eval.mocks._types import ( + ComponentSimulationConfig, + MockingContext, + SimulationStrategy, + UnknownMockingStrategy, +) +from uipath.eval.mocks.mockable import mockable + +_mock_span_collector = MagicMock() + +BASE_URL = "https://example.com" +_SIMULATE_PATH = ( + "uipath.eval.mocks._simulate_component_mocker._create_simulate_component_service" +) + + +def _make_context( + component_id: str = "my_tool", + strategy: SimulationStrategy = SimulationStrategy.LLM, + instruction: str = "simulate it", + workload_id: str = "wl-123", +) -> MockingContext: + return MockingContext( + strategy=None, + name="test-run", + inputs={"q": "hello"}, + workload_id=workload_id, + components=[ + ComponentSimulationConfig( + component_id=component_id, + component_type="tool", + simulation_strategy=strategy, + simulation_instruction=instruction, + ) + ], + ) + + +def _make_service_mock(result: dict[str, Any]) -> MagicMock: + svc = MagicMock() + svc.simulate = AsyncMock(return_value=result) + return svc + + +# --------------------------------------------------------------------------- +# is_tool_simulated with components format +# --------------------------------------------------------------------------- + + +class TestIsToolSimulatedWithComponents: + def setup_method(self): + clear_execution_context() + + def teardown_method(self): + clear_execution_context() + + def test_returns_true_for_listed_component(self): + set_execution_context(_make_context("search_tool"), _mock_span_collector, "x") + assert is_tool_simulated("search_tool") is True + + def test_returns_false_for_unlisted_component(self): + set_execution_context(_make_context("search_tool"), _mock_span_collector, "x") + assert is_tool_simulated("other_tool") is False + + def test_underscore_space_normalisation(self): + ctx = MockingContext( + strategy=None, + name="run", + inputs={}, + components=[ + ComponentSimulationConfig( + component_id="web search", + simulation_strategy=SimulationStrategy.LLM, + ) + ], + ) + set_execution_context(ctx, _mock_span_collector, "x") + assert is_tool_simulated("web_search") is True + + def test_returns_false_when_components_list_is_empty(self): + ctx = MockingContext(strategy=None, name="run", inputs={}, components=[]) + set_execution_context(ctx, _mock_span_collector, "x") + # components is set but empty — MockerFactory won't create a mocker (components is not None) + # is_tool_simulated: ctx.components is not None → iterates empty list → False + assert is_tool_simulated("any_tool") is False + + +# --------------------------------------------------------------------------- +# SimulateComponentMocker._find_component +# --------------------------------------------------------------------------- + + +class TestFindComponent: + def test_finds_by_exact_id(self): + mocker = SimulateComponentMocker(_make_context("my_tool")) + assert mocker._find_component("my_tool") is not None + + def test_finds_by_underscore_to_space_normalisation(self): + ctx = MockingContext( + strategy=None, + name="run", + inputs={}, + components=[ + ComponentSimulationConfig( + component_id="web search", + simulation_strategy=SimulationStrategy.LLM, + ) + ], + ) + mocker = SimulateComponentMocker(ctx) + assert mocker._find_component("web_search") is not None + + def test_returns_none_for_unknown_tool(self): + mocker = SimulateComponentMocker(_make_context("my_tool")) + assert mocker._find_component("unknown") is None + + +# --------------------------------------------------------------------------- +# SimulateComponentMocker.response — success path +# --------------------------------------------------------------------------- + + +class TestSimulateComponentMockerResponse: + @pytest.mark.asyncio + async def test_returns_simulated_output_on_status_1(self): + ctx = _make_context("my_tool") + svc_mock = _make_service_mock({"status": 1, "simulatedOutput": "hello"}) + + @mockable() + async def my_tool() -> str: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-1") + with patch(_SIMULATE_PATH, return_value=svc_mock): + result = await my_tool() + + assert result == "hello" + clear_execution_context() + + @pytest.mark.asyncio + async def test_raises_generation_error_on_non_1_status(self): + ctx = _make_context("my_tool") + svc_mock = _make_service_mock( + {"status": 2, "error": {"message": "LLM timeout"}} + ) + + @mockable() + async def my_tool() -> str: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-2") + with patch(_SIMULATE_PATH, return_value=svc_mock): + with pytest.raises(UiPathMockResponseGenerationError, match="LLM timeout"): + await my_tool() + + clear_execution_context() + + @pytest.mark.asyncio + async def test_raises_generic_error_when_error_message_missing(self): + ctx = _make_context("my_tool") + svc_mock = _make_service_mock({"status": 0, "error": {}}) + + @mockable() + async def my_tool() -> str: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-3") + with patch(_SIMULATE_PATH, return_value=svc_mock): + with pytest.raises( + UiPathMockResponseGenerationError, match="Simulation failed" + ): + await my_tool() + + clear_execution_context() + + @pytest.mark.asyncio + async def test_raises_generation_error_when_api_throws(self): + ctx = _make_context("my_tool") + svc_mock = MagicMock() + svc_mock.simulate = AsyncMock(side_effect=RuntimeError("network error")) + + @mockable() + async def my_tool() -> str: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-4") + with patch(_SIMULATE_PATH, return_value=svc_mock): + with pytest.raises( + UiPathMockResponseGenerationError, + match="simulate-component API call failed", + ): + await my_tool() + + clear_execution_context() + + @pytest.mark.asyncio + async def test_raises_no_mock_found_for_unconfigured_tool(self): + ctx = _make_context("my_tool") + + @mockable() + async def other_tool() -> str: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-5") + # other_tool is not in components → falls through to real function + with pytest.raises(NotImplementedError): + await other_tool() + + clear_execution_context() + + +# --------------------------------------------------------------------------- +# Payload construction +# --------------------------------------------------------------------------- + + +class TestPayloadConstruction: + @pytest.mark.asyncio + async def test_payload_fields_sent_to_service(self): + ctx = _make_context("my_tool", instruction="Do something", workload_id="wl-99") + captured: list[dict[str, Any]] = [] + + async def _capture(payload, **kwargs): + captured.append(payload) + return {"status": 1, "simulatedOutput": "ok"} + + svc_mock = MagicMock() + svc_mock.simulate = _capture + + @mockable() + async def my_tool(x: int) -> str: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-6") + with patch(_SIMULATE_PATH, return_value=svc_mock): + await my_tool(x=42) + + assert len(captured) == 1 + p = captured[0] + assert p["workloadId"] == "wl-99" + assert p["componentId"] == "my_tool" + assert p["componentType"] == "tool" + assert p["simulationInstruction"] == "Do something" + assert p["simulationStrategy"] == int(SimulationStrategy.LLM) + assert p["workloadInfo"] == {"name": "test-run", "userInput": {"q": "hello"}} + + clear_execution_context() + + @pytest.mark.asyncio + async def test_sync_mockable_also_works(self): + ctx = _make_context("sync_tool") + svc_mock = _make_service_mock({"status": 1, "simulatedOutput": 42}) + + @mockable() + def sync_tool() -> int: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-7") + with patch(_SIMULATE_PATH, return_value=svc_mock): + result = sync_tool() + + assert result == 42 + clear_execution_context() + + @pytest.mark.asyncio + async def test_payload_uses_configured_component_id_not_invoked_name(self): + """componentId in payload must be the configured ID, not the normalised call name.""" + ctx = MockingContext( + strategy=None, + name="run", + inputs={}, + components=[ + ComponentSimulationConfig( + component_id="web search", + simulation_strategy=SimulationStrategy.LLM, + ) + ], + ) + captured: list[dict[str, Any]] = [] + + async def _capture(payload, **kwargs): + captured.append(payload) + return {"status": 1, "simulatedOutput": "ok"} + + svc_mock = MagicMock() + svc_mock.simulate = _capture + + @mockable() + async def web_search() -> str: + raise NotImplementedError() + + set_execution_context(ctx, _mock_span_collector, "exec-8") + with patch(_SIMULATE_PATH, return_value=svc_mock): + await web_search() + + assert captured[0]["componentId"] == "web search" + clear_execution_context() + + +# --------------------------------------------------------------------------- +# _build_execution_history — uncovered branch (no context vars set) +# --------------------------------------------------------------------------- + + +class TestBuildExecutionHistory: + def test_returns_none_when_context_vars_not_set(self): + clear_execution_context() + mocker = SimulateComponentMocker(_make_context()) + assert mocker._build_execution_history() is None + + def test_returns_none_when_spans_empty(self): + from uipath.eval._execution_context import ( + execution_id_context, + span_collector_context, + ) + + span_collector = MagicMock() + span_collector.get_spans = MagicMock(return_value=[]) + span_collector_context.set(span_collector) + execution_id_context.set("exec-id") + + mocker = SimulateComponentMocker(_make_context()) + assert mocker._build_execution_history() is None + + clear_execution_context() + + +# --------------------------------------------------------------------------- +# MockerFactory — unknown strategy raises ValueError +# --------------------------------------------------------------------------- + + +class TestMockerFactory: + def test_raises_for_unknown_strategy(self): + from uipath.eval.mocks._mocker_factory import MockerFactory + + ctx = MockingContext( + strategy=UnknownMockingStrategy(type="future_strategy"), + name="test", + inputs={}, + components=None, + ) + with pytest.raises(ValueError, match="Unknown mocking strategy"): + MockerFactory.create(ctx) + + def test_raises_for_none_strategy_and_no_components(self): + from uipath.eval.mocks._mocker_factory import MockerFactory + + ctx = MockingContext(strategy=None, name="test", inputs={}, components=None) + with pytest.raises(ValueError, match="Unknown mocking strategy"): + MockerFactory.create(ctx) + + +# --------------------------------------------------------------------------- +# is_tool_simulated — unknown strategy falls through to False +# --------------------------------------------------------------------------- + + +class TestIsToolSimulatedUnknownStrategy: + def setup_method(self): + clear_execution_context() + + def teardown_method(self): + clear_execution_context() + + def test_returns_false_for_unknown_strategy(self): + + ctx = MockingContext( + strategy=UnknownMockingStrategy(type="future_strategy"), + name="test", + inputs={}, + components=None, + ) + set_execution_context(ctx, _mock_span_collector, "x") + assert is_tool_simulated("any_tool") is False + + +# --------------------------------------------------------------------------- +# SimulateComponentService — actual HTTP call +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_responses_were_requested=False) +async def test_simulate_component_service_http_call( + httpx_mock: HTTPXMock, monkeypatch: MonkeyPatch +): + monkeypatch.setenv("UIPATH_URL", "https://example.com/myorg/mytenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "token") + + httpx_mock.add_response( + url="https://example.com/myorg/mytenant/agentsruntime_/api/execution/simulations/simulate-component", + method="POST", + json={"status": 1, "simulatedOutput": "result"}, + ) + + service = _create_simulate_component_service() + assert isinstance(service, SimulateComponentService) + + result = await service.simulate({"componentId": "my_tool"}) + assert result == {"status": 1, "simulatedOutput": "result"} + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_responses_were_requested=False) +async def test_simulate_component_service_no_headers( + httpx_mock: HTTPXMock, monkeypatch: MonkeyPatch +): + monkeypatch.setenv("UIPATH_URL", "https://example.com/myorg/mytenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "token") + + httpx_mock.add_response( + url="https://example.com/myorg/mytenant/agentsruntime_/api/execution/simulations/simulate-component", + method="POST", + json={"status": 0, "error": {"message": "boom"}}, + ) + + service = _create_simulate_component_service() + result = await service.simulate({"componentId": "my_tool"}) + assert result["status"] == 0 diff --git a/packages/uipath/tests/cli/eval/mocks/test_structured_output.py b/packages/uipath/tests/cli/eval/mocks/test_structured_output.py new file mode 100644 index 000000000..79ad31591 --- /dev/null +++ b/packages/uipath/tests/cli/eval/mocks/test_structured_output.py @@ -0,0 +1,295 @@ +"""Unit tests for the provider-agnostic structured-output helpers.""" + +import json +from types import SimpleNamespace +from typing import Any + +import pytest + +from uipath.eval.mocks._structured_output import ( + RESPONSE_KEY, + RESPONSE_TOOL_NAME, + build_response_tool, + extract_response, + generate_structured_output, +) + + +def _response(message: SimpleNamespace | None) -> SimpleNamespace: + choices = [] if message is None else [SimpleNamespace(message=message)] + return SimpleNamespace(choices=choices) + + +class _FakeLLM: + """Records chat_completions calls and replays queued responses in order.""" + + def __init__(self, responses: list[Any]): + self._responses = list(responses) + self.calls: list[dict[str, Any]] = [] + + async def chat_completions(self, messages: Any, **kwargs: Any) -> Any: + self.calls.append(kwargs) + nxt = self._responses.pop(0) + if isinstance(nxt, Exception): + raise nxt + return nxt + + +def test_build_response_tool_wraps_schema_under_response(): + tool = build_response_tool({"type": "string"}, description="desc") + assert tool["name"] == RESPONSE_TOOL_NAME + assert tool["description"] == "desc" + assert tool["parameters"]["properties"][RESPONSE_KEY] == {"type": "string"} + assert tool["parameters"]["required"] == [RESPONSE_KEY] + + +def test_build_response_tool_inlines_refs_into_self_contained_schema(): + # Nested Pydantic models / enums emit $defs + $ref. The normalized gateway + # accepts $ref/$defs in response_format but NOT in a tool's parameters, so the + # schema must be inlined into a self-contained form (no $ref/$defs anywhere). + operator_def = {"enum": ["+", "-", "*", "/"], "type": "string"} + item_def = {"type": "object", "properties": {"sku": {"type": "string"}}} + schema = { + "type": "object", + "properties": { + "operator": {"$ref": "#/$defs/Operator"}, + "items": {"type": "array", "items": {"$ref": "#/$defs/Item"}}, + }, + "required": ["operator"], + "$defs": {"Operator": operator_def, "Item": item_def}, + } + + tool = build_response_tool(schema, description="d") + params = tool["parameters"] + + blob = json.dumps(params) + assert "$ref" not in blob + assert "$defs" not in blob + + response = params["properties"][RESPONSE_KEY] + assert response["properties"]["operator"] == operator_def + assert response["properties"]["items"]["items"] == item_def + # caller's schema is not mutated + assert "$defs" in schema + + +def test_build_response_tool_keeps_defs_for_cyclic_refs(): + # Self-referential schemas can't be fully inlined; keep $defs hoisted so the + # remaining $ref still resolves rather than infinite-looping. + node_def = { + "type": "object", + "properties": {"child": {"$ref": "#/$defs/Node"}}, + } + schema = { + "type": "object", + "properties": {"root": {"$ref": "#/$defs/Node"}}, + "$defs": {"Node": node_def}, + } + + tool = build_response_tool(schema, description="d") + params = tool["parameters"] + + assert "$defs" in params + assert "$ref" in json.dumps(params) + # the caller's schema dict is not mutated + assert "$defs" in schema + + +def test_extract_response_returns_wrapped_value(): + message = SimpleNamespace( + content=None, + tool_calls=[SimpleNamespace(arguments={RESPONSE_KEY: {"a": 1}})], + ) + assert extract_response(_response(message)) == {"a": 1} + + +def test_extract_response_raises_when_no_choices(): + with pytest.raises(ValueError, match="no choices"): + extract_response(_response(None)) + + +def test_extract_response_raises_when_no_tool_calls(): + # Non-OpenAI text response without a tool call: surface a clear error. + message = SimpleNamespace(content="not a tool call", tool_calls=None) + with pytest.raises(ValueError, match="no tool calls"): + extract_response(_response(message)) + + +def test_extract_response_raises_when_response_key_missing(): + message = SimpleNamespace( + content=None, tool_calls=[SimpleNamespace(arguments={"other": 1})] + ) + with pytest.raises(ValueError, match=RESPONSE_KEY): + extract_response(_response(message)) + + +@pytest.mark.asyncio +async def test_generate_structured_output_prefers_response_format_content(): + # OpenAI returns content via response_format; no fallback call is made. + llm = _FakeLLM([_response(SimpleNamespace(content='{"a": 1}', tool_calls=None))]) + result = await generate_structured_output( + llm, + [{"role": "user", "content": "x"}], + schema={"type": "object"}, + response_format_name="OutputSchema", + description="d", + completion_kwargs={}, + ) + assert result == {"a": 1} + assert len(llm.calls) == 1 + assert "response_format" in llm.calls[0] + assert "tools" not in llm.calls[0] + + +@pytest.mark.asyncio +async def test_generate_structured_output_falls_back_on_prose_content(): + # Claude on the normalized gateway answers response_format requests with + # plain prose (e.g. "Tokyo") — truthy but not JSON. Must fall back to tools + # instead of raising JSONDecodeError (AE-1646). + llm = _FakeLLM( + [ + _response(SimpleNamespace(content="Tokyo", tool_calls=None)), + _response( + SimpleNamespace( + content=None, + tool_calls=[SimpleNamespace(arguments={RESPONSE_KEY: {"a": 1}})], + ) + ), + ] + ) + result = await generate_structured_output( + llm, + [{"role": "user", "content": "x"}], + schema={"type": "object"}, + response_format_name="OutputSchema", + description="d", + completion_kwargs={}, + ) + assert result == {"a": 1} + assert len(llm.calls) == 2 + assert "tools" in llm.calls[1] + + +@pytest.mark.asyncio +async def test_generate_structured_output_falls_back_on_empty_content(): + # Non-OpenAI: response_format yields empty content -> fall back to tool call. + llm = _FakeLLM( + [ + _response(SimpleNamespace(content=None, tool_calls=None)), + _response( + SimpleNamespace( + content=None, + tool_calls=[SimpleNamespace(arguments={RESPONSE_KEY: {"a": 1}})], + ) + ), + ] + ) + result = await generate_structured_output( + llm, + [{"role": "user", "content": "x"}], + schema={"type": "object"}, + response_format_name="OutputSchema", + description="d", + completion_kwargs={}, + ) + assert result == {"a": 1} + assert len(llm.calls) == 2 + assert "response_format" in llm.calls[0] + assert "tools" in llm.calls[1] and "tool_choice" in llm.calls[1] + + +@pytest.mark.asyncio +async def test_generate_structured_output_falls_back_when_response_format_raises(): + # A provider that rejects response_format outright still gets a tool fallback. + llm = _FakeLLM( + [ + RuntimeError("response_format unsupported"), + _response( + SimpleNamespace( + content=None, + tool_calls=[SimpleNamespace(arguments={RESPONSE_KEY: "ok"})], + ) + ), + ] + ) + result = await generate_structured_output( + llm, + [{"role": "user", "content": "x"}], + schema={"type": "string"}, + response_format_name="OutputSchema", + description="d", + completion_kwargs={}, + ) + assert result == "ok" + assert len(llm.calls) == 2 + + +def test_build_response_tool_merges_ref_sibling_keys(): + # Pydantic can emit sibling keys (e.g. description) next to $ref; they + # must survive inlining since they guide the LLM. + schema = { + "type": "object", + "properties": { + "op": {"$ref": "#/$defs/Op", "description": "the operator to use"} + }, + "$defs": {"Op": {"type": "string", "enum": ["+", "-"]}}, + } + tool = build_response_tool(schema, description="d") + op = tool["parameters"]["properties"][RESPONSE_KEY]["properties"]["op"] + assert op == { + "type": "string", + "enum": ["+", "-"], + "description": "the operator to use", + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model", + [ + "anthropic.claude-sonnet-4-5-20250929-v1:0", + "claude-haiku-4-5", + "gemini-2.5-pro", + ], +) +async def test_non_openai_models_use_tool_call_directly(model: str): + # Claude/Gemini don't honor response_format on the normalized gateway, so + # their strategies skip it entirely: a single forced tool call. + llm = _FakeLLM( + [ + _response( + SimpleNamespace( + content=None, + tool_calls=[SimpleNamespace(arguments={RESPONSE_KEY: "ok"})], + ) + ) + ] + ) + result = await generate_structured_output( + llm, + [{"role": "user", "content": "x"}], + schema={"type": "string"}, + response_format_name="OutputSchema", + description="d", + completion_kwargs={"model": model}, + ) + assert result == "ok" + assert len(llm.calls) == 1 + assert "tools" in llm.calls[0] and "tool_choice" in llm.calls[0] + assert "response_format" not in llm.calls[0] + + +@pytest.mark.asyncio +async def test_openai_models_prefer_response_format(): + llm = _FakeLLM([_response(SimpleNamespace(content='{"a": 1}', tool_calls=None))]) + result = await generate_structured_output( + llm, + [{"role": "user", "content": "x"}], + schema={"type": "object"}, + response_format_name="OutputSchema", + description="d", + completion_kwargs={"model": "gpt-4.1-mini-2025-04-14"}, + ) + assert result == {"a": 1} + assert len(llm.calls) == 1 + assert "response_format" in llm.calls[0] diff --git a/packages/uipath/tests/cli/eval/test_eval_id_casing.py b/packages/uipath/tests/cli/eval/test_eval_id_casing.py new file mode 100644 index 000000000..e14a88a68 --- /dev/null +++ b/packages/uipath/tests/cli/eval/test_eval_id_casing.py @@ -0,0 +1,69 @@ +"""Tests for case-insensitive eval id handling (PC-4688). + +Eval sets exported by some tools emit uppercase GUID ids. The backend +canonicalizes GUIDs to lowercase, so any case-sensitive correlation on the +runtime side (selection, span/cache keying) silently fails to match. These +tests pin the fix: GUID ids are normalized to lowercase at ingestion and +selection is casing-agnostic. +""" + +from typing import Any + +from uipath.eval.models.evaluation_set import ( + EvaluationItem, + EvaluationSet, + LegacyEvaluationItem, +) + +UPPER_GUID = "B063907C-76AB-4B0A-88A3-EC0FB40698B8" +LOWER_GUID = "b063907c-76ab-4b0a-88a3-ec0fb40698b8" + + +def _make_item(eval_id: str) -> dict[str, Any]: + return { + "id": eval_id, + "name": "item", + "inputs": {"x": 1}, + "evaluationCriterias": {}, + } + + +def test_evaluation_item_normalizes_uppercase_guid_id(): + """An uppercase GUID id is stored in canonical lowercase form.""" + item = EvaluationItem.model_validate(_make_item(UPPER_GUID)) + assert item.id == LOWER_GUID + + +def test_legacy_evaluation_item_normalizes_uppercase_guid_id(): + """LegacyEvaluationItem also normalizes uppercase GUID ids.""" + item = LegacyEvaluationItem.model_validate( + { + "id": UPPER_GUID, + "name": "item", + "inputs": {"x": 1}, + "expectedOutput": {}, + "evalSetId": "set-1", + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:00:00.000Z", + } + ) + assert item.id == LOWER_GUID + + +def test_non_guid_id_is_left_unchanged(): + """Non-GUID ids (e.g. slugs) keep their original value and casing.""" + item = EvaluationItem.model_validate(_make_item("Test-Eval-1")) + assert item.id == "Test-Eval-1" + + +def test_extract_selected_evals_matches_regardless_of_caller_casing(): + """Selecting by an uppercase GUID matches a normalized stored id.""" + eval_set = EvaluationSet.model_validate( + { + "id": "set-1", + "name": "set", + "evaluations": [_make_item(LOWER_GUID), _make_item("other-id")], + } + ) + eval_set.extract_selected_evals([UPPER_GUID]) + assert [e.id for e in eval_set.evaluations] == [LOWER_GUID] diff --git a/packages/uipath/tests/cli/eval/test_eval_runtime_metadata.py b/packages/uipath/tests/cli/eval/test_eval_runtime_metadata.py index 112f8774b..07042cc12 100644 --- a/packages/uipath/tests/cli/eval/test_eval_runtime_metadata.py +++ b/packages/uipath/tests/cli/eval/test_eval_runtime_metadata.py @@ -1,7 +1,7 @@ """Tests for UiPathEvalRuntime metadata loading functionality. This module tests: -- _get_agent_model() - cached agent model retrieval +- get_agent_model() - cached agent model retrieval - get_schema() - cached schema retrieval """ @@ -10,11 +10,9 @@ import pytest -from uipath._cli.cli_eval import ( - _get_agent_model, -) from uipath.core.events import EventBus from uipath.core.tracing import UiPathTraceManager +from uipath.eval.helpers import get_agent_model from uipath.eval.runtime import UiPathEvalContext, UiPathEvalRuntime from uipath.runtime import ( UiPathExecuteOptions, @@ -119,34 +117,34 @@ async def dispose(self) -> None: class TestGetAgentModel: - """Tests for _get_agent_model function.""" + """Tests for get_agent_model function.""" @pytest.mark.asyncio async def test_returns_agent_model(self): - """Test that _get_agent_model returns the correct model from schema.""" + """Test that get_agent_model returns the correct model from schema.""" schema = MockRuntimeSchema() schema.metadata = {"settings": {"model": "gpt-4o-2024-11-20"}} - model = _get_agent_model(schema) + model = get_agent_model(schema) assert model == "gpt-4o-2024-11-20" @pytest.mark.asyncio async def test_returns_none_when_no_model(self): - """Test that _get_agent_model returns None when runtime has no model.""" + """Test that get_agent_model returns None when runtime has no model.""" schema = MockRuntimeSchema() - model = _get_agent_model(schema) + model = get_agent_model(schema) assert model is None @pytest.mark.asyncio async def test_returns_model_consistently(self): - """Test that _get_agent_model returns consistent results.""" + """Test that get_agent_model returns consistent results.""" schema = MockRuntimeSchema() schema.metadata = {"settings": {"model": "consistent-model"}} # Multiple calls should return the same value - model1 = _get_agent_model(schema) - model2 = _get_agent_model(schema) + model1 = get_agent_model(schema) + model2 = get_agent_model(schema) assert model1 == model2 == "consistent-model" diff --git a/packages/uipath/tests/cli/eval/test_eval_telemetry.py b/packages/uipath/tests/cli/eval/test_eval_telemetry.py index fdfe7135c..49a71bc2f 100644 --- a/packages/uipath/tests/cli/eval/test_eval_telemetry.py +++ b/packages/uipath/tests/cli/eval/test_eval_telemetry.py @@ -25,6 +25,7 @@ EvalSetRunCreatedEvent, EvalSetRunUpdatedEvent, ) +from uipath.platform.common.constants import ENV_UIPATH_AGENT_ID class TestEventNameConstants: @@ -422,6 +423,10 @@ def test_enrich_properties_adds_env_vars(self, mock_get_claim): """Test that environment variables are added when present.""" mock_get_claim.return_value = "user-789" + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + subscriber = EvalTelemetrySubscriber() properties: dict[str, Any] = {} @@ -429,23 +434,30 @@ def test_enrich_properties_adds_env_vars(self, mock_get_claim): os.environ, { "UIPATH_PROJECT_ID": "project-123", + ENV_UIPATH_AGENT_ID: "agent-123", "UIPATH_ORGANIZATION_ID": "org-456", "UIPATH_TENANT_ID": "tenant-abc", + "UIPATH_EVAL_RUN_SOURCE": "FirstSuccessfulRun", }, ): subscriber._enrich_properties(properties) assert properties["ProjectId"] == "project-123" - assert properties["AgentId"] == "project-123" + assert properties["AgentId"] == "agent-123" assert properties["CloudOrganizationId"] == "org-456" assert properties["CloudUserId"] == "user-789" assert properties["TenantId"] == "tenant-abc" + assert properties["RunSource"] == "FirstSuccessfulRun" @patch("uipath._cli._evals._telemetry.get_claim_from_token") def test_enrich_properties_skips_missing_env_vars(self, mock_get_claim): """Test that missing environment variables are not added.""" mock_get_claim.side_effect = Exception("No token") + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + subscriber = EvalTelemetrySubscriber() properties: dict[str, Any] = {} @@ -453,8 +465,10 @@ def test_enrich_properties_skips_missing_env_vars(self, mock_get_claim): # Remove env vars if they exist for key in [ "UIPATH_PROJECT_ID", + ENV_UIPATH_AGENT_ID, "UIPATH_ORGANIZATION_ID", "UIPATH_TENANT_ID", + "UIPATH_EVAL_RUN_SOURCE", ]: os.environ.pop(key, None) @@ -465,6 +479,7 @@ def test_enrich_properties_skips_missing_env_vars(self, mock_get_claim): assert "CloudOrganizationId" not in properties assert "CloudUserId" not in properties assert "TenantId" not in properties + assert "RunSource" not in properties class TestExceptionHandling: diff --git a/packages/uipath/tests/cli/eval/test_progress_reporter.py b/packages/uipath/tests/cli/eval/test_progress_reporter.py index 87919c2b3..1fd00bf12 100644 --- a/packages/uipath/tests/cli/eval/test_progress_reporter.py +++ b/packages/uipath/tests/cli/eval/test_progress_reporter.py @@ -927,4 +927,207 @@ def test_build_evaluator_snapshot_skips_non_string_model(self, progress_reporter snapshot = progress_reporter._build_evaluator_snapshot(evaluator) assert snapshot["prompt"] == "Evaluate this" - assert "model" not in snapshot + + +class TestAgentIdRouting: + """Eval-set/eval-run API URLs route by AgentId, not file-source project. + + For local-workspace eval runs the file-source project (UIPATH_PROJECT_ID, + typically the cloud debug project's GUID) differs from the logical agent + (UIPATH_AGENT_ID). The route URL must reflect the logical agent so backend + auth/ownership/telemetry don't see the per-run debug project as the agent. + File fetching (UiPathConfig.project_id) is unaffected. + """ + + def _make_reporter(self, monkeypatch, project_id, agent_id): + monkeypatch.setenv("UIPATH_URL", "https://test.uipath.com") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "test-token") + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + if project_id is not None: + monkeypatch.setenv("UIPATH_PROJECT_ID", project_id) + else: + monkeypatch.delenv("UIPATH_PROJECT_ID", raising=False) + if agent_id is not None: + monkeypatch.setenv("UIPATH_AGENT_ID", agent_id) + else: + monkeypatch.delenv("UIPATH_AGENT_ID", raising=False) + return StudioWebProgressReporter() + + def test_agent_id_used_in_url_when_both_set(self, monkeypatch): + reporter = self._make_reporter( + monkeypatch, project_id="debug-project-guid", agent_id="real-agent-id" + ) + assert reporter._agent_id == "real-agent-id" + assert reporter._project_id == "debug-project-guid" + + from uipath._cli._evals._progress_reporter import StudioWebAgentSnapshot + + spec = reporter._create_eval_set_run_spec( + eval_set_id="test-eval-set", + agent_snapshot=StudioWebAgentSnapshot( + input_schema={"type": "object"}, output_schema={"type": "object"} + ), + no_of_evals=1, + is_coded=False, + ) + assert "/agents/real-agent-id/" in spec.endpoint + assert "/agents/debug-project-guid/" not in spec.endpoint + + def test_agent_id_in_eval_set_run_payload(self, monkeypatch): + reporter = self._make_reporter( + monkeypatch, project_id="debug-project-guid", agent_id="real-agent-id" + ) + + from uipath._cli._evals._progress_reporter import StudioWebAgentSnapshot + + spec = reporter._create_eval_set_run_spec( + eval_set_id="test-eval-set", + agent_snapshot=StudioWebAgentSnapshot( + input_schema={"type": "object"}, output_schema={"type": "object"} + ), + no_of_evals=1, + is_coded=False, + ) + assert spec.json["agentId"] == "real-agent-id" + + def test_falls_back_to_project_id_when_agent_id_unset(self, monkeypatch): + reporter = self._make_reporter( + monkeypatch, project_id="cloud-project-id", agent_id=None + ) + assert reporter._agent_id == "cloud-project-id" + + from uipath._cli._evals._progress_reporter import StudioWebAgentSnapshot + + spec = reporter._create_eval_set_run_spec( + eval_set_id="test-eval-set", + agent_snapshot=StudioWebAgentSnapshot( + input_schema={"type": "object"}, output_schema={"type": "object"} + ), + no_of_evals=1, + is_coded=False, + ) + assert "/agents/cloud-project-id/" in spec.endpoint + + +class TestProjectFilesSourcePropagation: + """Reporter must propagate UIPATH_PROJECT_FILES_SOURCE to backend rows. + + Backend filters listings by `projectFilesSource` (Local=1, Cloud=0). Without + the SDK setting it on POST/PUT payloads and GET query params, every row + lands as Cloud and the UI's `?projectFilesSource=1` filter never matches + local-workspace runs. + """ + + def _make_reporter(self, monkeypatch, project_files_source): + monkeypatch.setenv("UIPATH_URL", "https://test.uipath.com") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "test-token") + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + monkeypatch.setenv("UIPATH_PROJECT_ID", "test-project-id") + if project_files_source is not None: + monkeypatch.setenv("UIPATH_PROJECT_FILES_SOURCE", project_files_source) + else: + monkeypatch.delenv("UIPATH_PROJECT_FILES_SOURCE", raising=False) + return StudioWebProgressReporter() + + @pytest.mark.parametrize( + "raw,expected", + [("Local", 1), ("local", 1), ("Cloud", 0), ("cloud", 0), ("1", 1), ("0", 0)], + ) + def test_resolves_env_var_to_int(self, monkeypatch, raw, expected): + reporter = self._make_reporter(monkeypatch, raw) + assert reporter._project_files_source == expected + + def test_returns_none_when_unset_or_garbage(self, monkeypatch): + reporter = self._make_reporter(monkeypatch, None) + assert reporter._project_files_source is None + reporter2 = self._make_reporter(monkeypatch, "Banana") + assert reporter2._project_files_source is None + + def test_post_eval_set_run_payload_carries_source(self, monkeypatch): + from uipath._cli._evals._progress_reporter import StudioWebAgentSnapshot + + reporter = self._make_reporter(monkeypatch, "Local") + spec = reporter._create_eval_set_run_spec( + eval_set_id="test-eval-set", + agent_snapshot=StudioWebAgentSnapshot( + input_schema={"type": "object"}, output_schema={"type": "object"} + ), + no_of_evals=1, + is_coded=False, + ) + assert spec.json["projectFilesSource"] == 1 + + def test_post_eval_run_payload_carries_source(self, monkeypatch): + from uipath.eval.models.evaluation_set import EvaluationItem + + reporter = self._make_reporter(monkeypatch, "Local") + item = EvaluationItem( + id="11111111-1111-1111-1111-111111111111", + name="t", + inputs={}, + evaluation_criterias={}, + ) + spec = reporter._create_eval_run_spec( + eval_item=item, eval_set_run_id="run-1", is_coded=False + ) + assert spec.json["projectFilesSource"] == 1 + + def test_put_eval_run_payload_carries_source(self, monkeypatch): + reporter = self._make_reporter(monkeypatch, "Local") + spec = reporter._update_eval_run_spec( + assertion_runs=[], + evaluator_scores=[], + eval_run_id="run-1", + actual_output={}, + execution_time=1.0, + success=True, + is_coded=False, + ) + assert spec.json["projectFilesSource"] == 1 + + def test_put_coded_eval_run_payload_carries_source(self, monkeypatch): + reporter = self._make_reporter(monkeypatch, "Local") + spec = reporter._update_coded_eval_run_spec( + evaluator_runs=[], + evaluator_scores=[], + eval_run_id="run-1", + actual_output={}, + execution_time=1.0, + success=True, + is_coded=True, + ) + assert spec.json["projectFilesSource"] == 1 + + def test_put_eval_set_run_payload_carries_source(self, monkeypatch): + reporter = self._make_reporter(monkeypatch, "Local") + spec = reporter._update_eval_set_run_spec( + eval_set_run_id="set-run-1", + evaluator_scores={}, + is_coded=False, + success=True, + ) + assert spec.json["projectFilesSource"] == 1 + + def test_get_eval_runs_query_carries_source(self, monkeypatch): + reporter = self._make_reporter(monkeypatch, "Local") + spec = reporter._get_eval_runs_spec( + eval_set_id="set-1", + eval_set_run_id="run-1", + evaluation_id=None, + is_coded=False, + ) + assert spec.params == {"projectFilesSource": 1} + + def test_unset_source_omits_field_from_payloads(self, monkeypatch): + from uipath._cli._evals._progress_reporter import StudioWebAgentSnapshot + + reporter = self._make_reporter(monkeypatch, None) + spec = reporter._create_eval_set_run_spec( + eval_set_id="test-eval-set", + agent_snapshot=StudioWebAgentSnapshot( + input_schema={"type": "object"}, output_schema={"type": "object"} + ), + no_of_evals=1, + is_coded=False, + ) + assert "projectFilesSource" not in spec.json diff --git a/packages/uipath/tests/cli/integration/test_list_models_commands.py b/packages/uipath/tests/cli/integration/test_list_models_commands.py new file mode 100644 index 000000000..c7cb9f042 --- /dev/null +++ b/packages/uipath/tests/cli/integration/test_list_models_commands.py @@ -0,0 +1,233 @@ +"""Integration tests for the `uipath list-models` CLI command. + +The command renders a rich table grouped by vendor (one column per vendor) +for human terminal use, and falls through to the shared `format_output` +pipeline for `--format json|csv` and `--output `. +""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from click.testing import CliRunner + +from uipath._cli import cli +from uipath.platform.agenthub import LlmModel + + +@pytest.fixture +def runner(): + """Provide a Click CLI test runner.""" + return CliRunner() + + +@pytest.fixture +def mock_client(): + """Provide a mocked UiPath client with an async agenthub service.""" + with patch("uipath.platform._uipath.UiPath") as mock: + client_instance = MagicMock() + mock.return_value = client_instance + + client_instance.agenthub = MagicMock() + client_instance.agenthub.get_available_llm_models_async = AsyncMock() + + yield client_instance + + +def _make_models() -> list[LlmModel]: + """Build a small list of LlmModel instances spanning multiple vendors.""" + return [ + LlmModel(model_name="gpt-4o-mini", vendor="OpenAi"), + LlmModel(model_name="gpt-4.1", vendor="OpenAi"), + LlmModel(model_name="claude-sonnet-4-5", vendor="Anthropic"), + LlmModel(model_name="gemini-2.5-flash", vendor="VertexAi"), + ] + + +class TestRichTable: + def test_renders_each_model_and_vendor(self, runner, mock_client, mock_env_vars): + """All models and vendor headers appear in the rendered table.""" + mock_client.agenthub.get_available_llm_models_async.return_value = ( + _make_models() + ) + + result = runner.invoke(cli, ["list-models"]) + + assert result.exit_code == 0 + for model in _make_models(): + assert model.model_name in result.output + assert (model.vendor or "") in result.output + mock_client.agenthub.get_available_llm_models_async.assert_awaited_once() + + def test_table_title(self, runner, mock_client, mock_env_vars): + """The rich table renders its title for orientation.""" + mock_client.agenthub.get_available_llm_models_async.return_value = ( + _make_models() + ) + + result = runner.invoke(cli, ["list-models"]) + + assert result.exit_code == 0 + assert "Available LLM Models" in result.output + + def test_missing_vendor_grouped_under_unknown( + self, runner, mock_client, mock_env_vars + ): + """A model with no vendor lands in an 'Unknown' column.""" + mock_client.agenthub.get_available_llm_models_async.return_value = [ + LlmModel(model_name="custom-model", vendor=None), + ] + + result = runner.invoke(cli, ["list-models"]) + + assert result.exit_code == 0 + assert "custom-model" in result.output + assert "Unknown" in result.output + + def test_empty(self, runner, mock_client, mock_env_vars): + """An empty model list renders the title without rows or errors.""" + mock_client.agenthub.get_available_llm_models_async.return_value = [] + + result = runner.invoke(cli, ["list-models"]) + + assert result.exit_code == 0 + assert "Available LLM Models" in result.output + + +class TestMachineReadableFormats: + def test_json_format(self, runner, mock_client, mock_env_vars): + """--format json bypasses the rich table and emits parseable JSON.""" + mock_client.agenthub.get_available_llm_models_async.return_value = ( + _make_models() + ) + + result = runner.invoke(cli, ["list-models", "--format", "json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert isinstance(payload, list) + assert {m["model_name"] for m in payload} == { + "gpt-4o-mini", + "gpt-4.1", + "claude-sonnet-4-5", + "gemini-2.5-flash", + } + + def test_csv_format(self, runner, mock_client, mock_env_vars): + """--format csv emits a header row and one row per model.""" + mock_client.agenthub.get_available_llm_models_async.return_value = ( + _make_models() + ) + + result = runner.invoke(cli, ["list-models", "--format", "csv"]) + + assert result.exit_code == 0 + lines = [line for line in result.output.splitlines() if line.strip()] + assert "model_name" in lines[0] + assert "vendor" in lines[0] + assert any("gpt-4o-mini" in line for line in lines[1:]) + + def test_global_json_flag(self, runner, mock_client, mock_env_vars): + """The cli-group --format json is honored too.""" + mock_client.agenthub.get_available_llm_models_async.return_value = ( + _make_models() + ) + + result = runner.invoke(cli, ["--format", "json", "list-models"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert len(payload) == 4 + + def test_output_writes_through_plain_formatter( + self, runner, mock_client, mock_env_vars, tmp_path + ): + """--output writes through format_output (not the rich path).""" + mock_client.agenthub.get_available_llm_models_async.return_value = ( + _make_models() + ) + out_file = tmp_path / "models.json" + + result = runner.invoke( + cli, + ["list-models", "--format", "json", "--output", str(out_file)], + ) + + assert result.exit_code == 0 + assert out_file.exists() + payload = json.loads(out_file.read_text(encoding="utf-8")) + assert {m["model_name"] for m in payload} == { + "gpt-4o-mini", + "gpt-4.1", + "claude-sonnet-4-5", + "gemini-2.5-flash", + } + + def test_output_file_alias(self, runner, mock_client, mock_env_vars, tmp_path): + """`--output-file` works as an alias for `--output` (matches `run`).""" + mock_client.agenthub.get_available_llm_models_async.return_value = ( + _make_models() + ) + out_file = tmp_path / "models.json" + + result = runner.invoke( + cli, + ["list-models", "--format", "json", "--output-file", str(out_file)], + ) + + assert result.exit_code == 0 + assert out_file.exists() + payload = json.loads(out_file.read_text(encoding="utf-8")) + assert len(payload) == 4 + + +class TestErrorPaths: + def test_service_error(self, runner, mock_client, mock_env_vars): + """Exceptions from the service are surfaced as click errors.""" + mock_client.agenthub.get_available_llm_models_async.side_effect = RuntimeError( + "boom" + ) + + result = runner.invoke(cli, ["list-models"]) + + assert result.exit_code != 0 + assert "boom" in result.output + + def test_missing_url(self, runner, monkeypatch): + """Missing UIPATH_URL surfaces an auth-configuration error.""" + monkeypatch.delenv("UIPATH_URL", raising=False) + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "mock_token") + + result = runner.invoke(cli, ["list-models"]) + + assert result.exit_code != 0 + assert "UIPATH_URL not configured" in result.output + + def test_missing_token(self, runner, monkeypatch): + """Missing UIPATH_ACCESS_TOKEN surfaces an auth-configuration error.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False) + + result = runner.invoke(cli, ["list-models"]) + + assert result.exit_code != 0 + assert "Authentication required" in result.output + + +class TestRegistration: + def test_help_text(self, runner): + """--help surfaces the command description and options.""" + result = runner.invoke(cli, ["list-models", "--help"]) + + assert result.exit_code == 0 + assert "List available LLM models" in result.output + assert "--format" in result.output + assert "--output" in result.output + assert "--output-file" in result.output + + def test_registered_in_cli(self, runner): + """The command is wired into the top-level CLI group.""" + result = runner.invoke(cli, ["--help"]) + + assert result.exit_code == 0 + assert "list-models" in result.output diff --git a/packages/uipath/tests/cli/test_auth_server.py b/packages/uipath/tests/cli/test_auth_server.py new file mode 100644 index 000000000..f81d2c08b --- /dev/null +++ b/packages/uipath/tests/cli/test_auth_server.py @@ -0,0 +1,138 @@ +"""Security tests for the OAuth local callback server. + +Covers GHSA-32xc-7x5c-8vmf: the `/set_token` and `/log` endpoints must reject +unauthenticated POSTs (no / wrong OAuth `state`), and the server must bind to +loopback only rather than all interfaces. +""" + +import json +import os +import threading +import urllib.error +import urllib.request + +from uipath._cli._auth._auth_server import HTTPServer + +STATE = "LEGITIMATE_OAUTH_STATE_ABCDE12345" +CODE_VERIFIER = "LEGITIMATE_PKCE_CODE_VERIFIER" +DOMAIN = "cloud.uipath.com" + +ATTACKER_PAYLOAD = { + "access_token": "attacker-token", + "refresh_token": "attacker-refresh", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "offline_access", +} + + +def _request(port, path, data, headers=None, method="POST"): + req = urllib.request.Request( + f"http://127.0.0.1:{port}{path}", + data=data, + headers=headers or {}, + method=method, + ) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status, resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + return exc.code, exc.read().decode("utf-8") + + +def _post(port, path, body, headers=None): + return _request( + port, + path, + json.dumps(body).encode("utf-8"), + {"Content-Type": "application/json", **(headers or {})}, + ) + + +async def test_endpoints_reject_unauthenticated_posts(tmp_path, monkeypatch): + """Only requests carrying the matching OAuth state are accepted. + + Exercises both /set_token and /log with missing, wrong, and valid state. + """ + monkeypatch.chdir(tmp_path) + + # Binding happens in create_server; the listen socket is up before the + # handler thread starts, so connections queue and no readiness sleep is + # needed. redirect_uri/client_id are required by the GET (index.html) path. + server = HTTPServer( + port=0, redirect_uri="http://localhost/callback", client_id="test-client" + ) + httpd = server.create_server(STATE, CODE_VERIFIER, DOMAIN) + port = httpd.server_address[1] + + results = {} + + def client(): + # DNS rebinding + results["rebind_get"] = _request( + port, "/", None, {"Host": "not-localhost.com"}, method="GET" + ) + results["rebind_post"] = _post( + port, + "/set_token", + ATTACKER_PAYLOAD, + {"X-Auth-State": STATE, "Host": "evil.com"}, + ) + # GET serves index.html with the OAuth params substituted in. + results["get"] = _request(port, "/anything", None, method="GET") + # /set_token: missing and wrong state are rejected. + results["set_missing"] = _post(port, "/set_token", ATTACKER_PAYLOAD) + results["set_wrong"] = _post( + port, "/set_token", ATTACKER_PAYLOAD, {"X-Auth-State": "not-the-state"} + ) + # Valid state but a non-JSON body -> graceful 400, not 500. + results["set_malformed"] = _request( + port, "/set_token", b"not json", {"X-Auth-State": STATE} + ) + # Unknown path -> 404. + results["unknown"] = _post(port, "/nope", {"x": 1}, {"X-Auth-State": STATE}) + # /log: missing and valid state. + results["log_missing"] = _post(port, "/log", {"msg": "x"}) + results["log_valid"] = _post( + port, "/log", {"msg": "x"}, {"X-Auth-State": STATE} + ) + # Valid /set_token last, to capture the token and unblock start(). + results["set_valid"] = _post( + port, "/set_token", {"access_token": "real"}, {"X-Auth-State": STATE} + ) + + t = threading.Thread(target=client, daemon=True) + t.start() + token_data = await server.start(STATE, CODE_VERIFIER, DOMAIN) + t.join(timeout=5) + + # DNS rebinding: forged Host is rejected on both GET and POST. + assert results["rebind_get"][0] == 403 + assert results["rebind_post"][0] == 403 + + # GET returns the page with the real state injected, placeholder gone. + assert results["get"][0] == 200 + assert STATE in results["get"][1] + assert "__PY_REPLACE_EXPECTED_STATE__" not in results["get"][1] + + assert results["set_missing"][0] == 403 + assert results["set_wrong"][0] == 403 + assert results["set_malformed"][0] == 400 + assert results["unknown"][0] == 404 + assert results["log_missing"][0] == 403 + assert results["log_valid"][0] == 200 + assert results["set_valid"][0] == 200 + + # Only the valid, state-bearing request was accepted. + assert token_data == {"access_token": "real"} + # The state-protected /log write happened for the valid request only. + assert os.path.exists(tmp_path / ".uipath" / ".error_log") + + +def test_server_binds_to_loopback_only(): + server = HTTPServer(port=0) + httpd = server.create_server(STATE, CODE_VERIFIER, DOMAIN) + try: + assert httpd.server_address[0] == "127.0.0.1" + finally: + httpd.server_close() diff --git a/packages/uipath/tests/cli/test_create_resources.py b/packages/uipath/tests/cli/test_create_resources.py new file mode 100644 index 000000000..aff1c7cab --- /dev/null +++ b/packages/uipath/tests/cli/test_create_resources.py @@ -0,0 +1,456 @@ +"""Unit tests for cli_push.create_resources virtual-resource fallback.""" + +import json +import os +from types import SimpleNamespace +from typing import Any, List, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from uipath._cli._utils._studio_project import ( + Status, + VirtualResourceResult, +) +from uipath.platform.errors import EnrichedException, FolderNotFoundException +from uipath.platform.resource_catalog import ResourceType + + +def _enriched_exc( + status_code: int = 404, body: bytes = b"not found" +) -> EnrichedException: + """Build an EnrichedException backed by a real HTTPStatusError.""" + request = httpx.Request("GET", "https://example.test/x") + response = httpx.Response(status_code, content=body, request=request) + http_err = httpx.HTTPStatusError("x", request=request, response=response) + return EnrichedException(http_err) + + +class _AsyncIterator: + """Minimal async iterator with aclose() to mimic resource_catalog pagination.""" + + def __init__(self, items: List[Any], raise_exc: Optional[Exception] = None): + self._items = iter(items) + self._raise_exc = raise_exc + self.aclose = AsyncMock() + + def __aiter__(self): + return self + + async def __anext__(self): + if self._raise_exc is not None: + raise self._raise_exc + try: + return next(self._items) + except StopIteration: + raise StopAsyncIteration from None + + +def _make_bindings(resources: List[dict[str, Any]]) -> str: + return json.dumps({"version": "2.2", "resources": resources}) + + +def _asset_binding( + name: str = "my_asset", + folder_path: str = "Shared", + metadata: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + return { + "resource": "asset", + "key": f"binding-{name}", + "value": { + "name": { + "defaultValue": name, + "isExpression": False, + "displayName": "name", + }, + "folderPath": { + "defaultValue": folder_path, + "isExpression": False, + "displayName": "folderPath", + }, + }, + "metadata": metadata, + } + + +def _found_resource( + key: str = "resource-key", + resource_type: str = "asset", + resource_sub_type: str = "stringAsset", + folder_path: str = "Shared", +) -> SimpleNamespace: + folder = SimpleNamespace( + key="folder-key", + fully_qualified_name=folder_path, + path=folder_path, + ) + return SimpleNamespace( + resource_key=key, + resource_type=resource_type, + resource_sub_type=resource_sub_type, + folders=[folder], + ) + + +@pytest.fixture +def bindings_file(tmp_path, monkeypatch): + """Write a bindings file to a tmp path and patch UiPathConfig.bindings_file_path.""" + path = tmp_path / "bindings.json" + + def _writer(content: str) -> str: + path.write_text(content, encoding="utf-8") + return str(path) + + from uipath.platform.common._config import ConfigurationManager + + monkeypatch.setattr( + ConfigurationManager, + "bindings_file_path", + property(lambda self: path), + ) + return _writer + + +@pytest.fixture +def mock_uipath(): + """Patch UiPath() in cli_push to return a mock with resource_catalog + connections. + + cli_push imports `UiPath` lazily from `uipath.platform` inside create_resources, + so we patch the source module. + """ + with patch("uipath.platform.UiPath") as mock_cls: + instance = MagicMock() + instance.resource_catalog = MagicMock() + instance.connections = MagicMock() + mock_cls.return_value = instance + yield instance + + +@pytest.fixture +def studio_client(): + from uipath._cli._utils._studio_project import ( + ResourceBuilderMetadataEntry, + ResourceBuilderMetadataVersion, + ) + + client = MagicMock() + client.create_referenced_resource = AsyncMock() + client.create_virtual_resource = AsyncMock() + supported = ResourceBuilderMetadataVersion(supportsInLineCreation=True) + # /metadata response — every kind our tests use supports inline creation. + client.get_resource_builder_metadata = AsyncMock( + return_value=[ + ResourceBuilderMetadataEntry(kind="asset", versions=[supported]), + ResourceBuilderMetadataEntry(kind="bucket", versions=[supported]), + ResourceBuilderMetadataEntry(kind="queue", versions=[supported]), + ResourceBuilderMetadataEntry(kind="taskCatalog", versions=[supported]), + ] + ) + return client + + +async def _run_create_resources(studio_client): + from uipath._cli.cli_push import create_resources + + await create_resources(studio_client) + + +async def test_catalog_hit_calls_referenced_resource_only( + bindings_file, mock_uipath, studio_client +): + bindings_file(_make_bindings([_asset_binding(metadata={"SubType": "stringAsset"})])) + mock_uipath.resource_catalog.list_by_type_async.return_value = _AsyncIterator( + [_found_resource()] + ) + studio_client.create_referenced_resource.return_value = SimpleNamespace( + status=Status.ADDED + ) + + await _run_create_resources(studio_client) + + studio_client.create_referenced_resource.assert_awaited_once() + studio_client.create_virtual_resource.assert_not_awaited() + + +async def test_catalog_miss_with_subtype_creates_virtual_with_type( + bindings_file, mock_uipath, studio_client +): + bindings_file(_make_bindings([_asset_binding(metadata={"SubType": "stringAsset"})])) + mock_uipath.resource_catalog.list_by_type_async.return_value = _AsyncIterator([]) + studio_client.create_virtual_resource.return_value = VirtualResourceResult( + status=Status.ADDED, + ) + + await _run_create_resources(studio_client) + + studio_client.create_virtual_resource.assert_awaited_once() + req = studio_client.create_virtual_resource.call_args.args[0] + assert req.kind == "asset" + assert req.name == "my_asset" + assert req.type == "stringAsset" + studio_client.create_referenced_resource.assert_not_awaited() + + +async def test_catalog_miss_without_subtype_creates_virtual_kind_only( + bindings_file, mock_uipath, studio_client +): + bindings_file(_make_bindings([_asset_binding(metadata=None)])) + mock_uipath.resource_catalog.list_by_type_async.return_value = _AsyncIterator([]) + studio_client.create_virtual_resource.return_value = VirtualResourceResult( + status=Status.ADDED, + ) + + await _run_create_resources(studio_client) + + studio_client.create_virtual_resource.assert_awaited_once() + req = studio_client.create_virtual_resource.call_args.args[0] + assert req.kind == "asset" + assert req.name == "my_asset" + assert req.type is None + # Body that will actually be sent excludes None → no "type" key. + body = req.model_dump(exclude_none=True) + assert "type" not in body + + +async def test_catalog_miss_metadata_without_subtype_key_creates_virtual_kind_only( + bindings_file, mock_uipath, studio_client +): + bindings_file(_make_bindings([_asset_binding(metadata={"Other": "x"})])) + mock_uipath.resource_catalog.list_by_type_async.return_value = _AsyncIterator([]) + studio_client.create_virtual_resource.return_value = VirtualResourceResult( + status=Status.ADDED, + ) + + await _run_create_resources(studio_client) + + req = studio_client.create_virtual_resource.call_args.args[0] + assert req.type is None + + +async def test_unknown_resource_type_skips_catalog_and_creates_virtual( + bindings_file, mock_uipath, studio_client +): + """Bindings with a resource kind unknown to ResourceType enum but supported + by the virtual endpoint (e.g. 'taskCatalog') should skip the resource + catalog lookup and fall through to the virtual fallback instead of raising + ValueError.""" + task_catalog_binding = { + "resource": "taskCatalog", + "key": "live.good.taskcatalog.Shared", + "value": { + "name": { + "defaultValue": "live.good.taskcatalog", + "isExpression": False, + "displayName": "Name", + }, + "folderPath": { + "defaultValue": "Shared", + "isExpression": False, + "displayName": "Folder Path", + }, + }, + "metadata": None, + } + bindings_file(_make_bindings([task_catalog_binding])) + studio_client.create_virtual_resource.return_value = VirtualResourceResult( + status=Status.ADDED, + ) + + await _run_create_resources(studio_client) + + mock_uipath.resource_catalog.list_by_type_async.assert_not_called() + studio_client.create_virtual_resource.assert_awaited_once() + req = studio_client.create_virtual_resource.call_args.args[0] + assert req.kind == "taskCatalog" + assert req.type is None + + +async def test_unsupported_virtual_kind_is_skipped_with_warning( + bindings_file, mock_uipath, studio_client +): + """Bindings whose kind the virtual endpoint cannot materialize (e.g. + 'choiceSet', 'webhook') should be skipped with a warning and + never reach create_virtual_resource.""" + choiceset_binding = { + "resource": "choiceSet", + "key": "live.good.choiceset.Shared", + "value": { + "name": { + "defaultValue": "live.good.choiceset", + "isExpression": False, + "displayName": "Name", + }, + "folderPath": { + "defaultValue": "Shared", + "isExpression": False, + "displayName": "Folder Path", + }, + }, + "metadata": None, + } + bindings_file(_make_bindings([choiceset_binding])) + + await _run_create_resources(studio_client) + + mock_uipath.resource_catalog.list_by_type_async.assert_not_called() + studio_client.create_virtual_resource.assert_not_awaited() + studio_client.create_referenced_resource.assert_not_awaited() + + +async def test_entity_binding_catalog_hit_creates_reference( + bindings_file, mock_uipath, studio_client +): + """Entity bindings should go through the resource catalog lookup. + When found, a referenced resource should be created.""" + entity_binding = { + "resource": "entity", + "key": "live.good.entity.Shared", + "value": { + "name": { + "defaultValue": "live.good.entity", + "isExpression": False, + "displayName": "Name", + }, + "folderPath": { + "defaultValue": "Shared", + "isExpression": False, + "displayName": "Folder Path", + }, + }, + "metadata": None, + } + bindings_file(_make_bindings([entity_binding])) + mock_uipath.resource_catalog.list_by_type_async.return_value = _AsyncIterator( + [_found_resource(resource_type="entity", resource_sub_type="Native")] + ) + studio_client.create_referenced_resource.return_value = SimpleNamespace( + status=Status.ADDED + ) + + await _run_create_resources(studio_client) + + mock_uipath.resource_catalog.list_by_type_async.assert_called_once_with( + resource_type=ResourceType.ENTITY, + name="live.good.entity", + folder_path="Shared", + ) + studio_client.create_referenced_resource.assert_awaited_once() + studio_client.create_virtual_resource.assert_not_awaited() + + +async def test_folder_not_found_falls_back_to_virtual( + bindings_file, mock_uipath, studio_client +): + bindings_file(_make_bindings([_asset_binding(metadata={"SubType": "stringAsset"})])) + mock_uipath.resource_catalog.list_by_type_async.return_value = _AsyncIterator( + [], raise_exc=FolderNotFoundException("missing folder") + ) + studio_client.create_virtual_resource.return_value = VirtualResourceResult( + status=Status.ADDED, + ) + + await _run_create_resources(studio_client) + + studio_client.create_virtual_resource.assert_awaited_once() + req = studio_client.create_virtual_resource.call_args.args[0] + assert req.kind == "asset" + assert req.type == "stringAsset" + + +async def test_virtual_enriched_exception_caught_and_logged_as_warning( + bindings_file, mock_uipath, studio_client +): + bindings_file( + _make_bindings( + [ + _asset_binding(name="first"), + _asset_binding(name="second"), + ] + ) + ) + mock_uipath.resource_catalog.list_by_type_async.return_value = _AsyncIterator([]) + # First binding raises, second succeeds → loop must continue past the raise. + studio_client.create_virtual_resource.side_effect = [ + _enriched_exc(status_code=500, body=b"boom"), + VirtualResourceResult(status=Status.ADDED), + ] + + await _run_create_resources(studio_client) + + assert studio_client.create_virtual_resource.await_count == 2 + + +async def test_connection_branch_unchanged_no_virtual_fallback( + bindings_file, mock_uipath, studio_client +): + """Connection bindings retain old behavior: retrieve_async + warn on miss, no virtual.""" + connection_binding = { + "resource": "connection", + "key": "binding-conn", + "value": { + "ConnectionId": { + "defaultValue": "missing-conn-id", + "isExpression": False, + "displayName": "ConnectionId", + } + }, + "metadata": {"Connector": "salesforce"}, + } + bindings_file(_make_bindings([connection_binding])) + mock_uipath.connections.retrieve_async = AsyncMock(side_effect=_enriched_exc()) + + await _run_create_resources(studio_client) + + mock_uipath.connections.retrieve_async.assert_awaited_once() + studio_client.create_virtual_resource.assert_not_awaited() + studio_client.create_referenced_resource.assert_not_awaited() + + +async def test_guardrail_binding_without_folder_path_is_skipped( + bindings_file, mock_uipath, studio_client +): + # No folderPath in value → guardrail; should be skipped entirely. + guardrail_binding = { + "resource": "asset", + "key": "binding-guard", + "value": { + "name": { + "defaultValue": "g", + "isExpression": False, + "displayName": "name", + } + }, + "metadata": None, + } + bindings_file(_make_bindings([guardrail_binding])) + + await _run_create_resources(studio_client) + + mock_uipath.resource_catalog.list_by_type_async.assert_not_called() + studio_client.create_virtual_resource.assert_not_awaited() + studio_client.create_referenced_resource.assert_not_awaited() + + +# Ensure env doesn't leak solution ids between tests. +@pytest.fixture(autouse=True) +def _reset_solution_id(): + from uipath.platform.common._config import ConfigurationManager + + ConfigurationManager.studio_solution_id = None + yield + ConfigurationManager.studio_solution_id = None + + +# Set minimal env so UiPath() construction inside create_resources (if not mocked +# away cleanly) doesn't trip on missing creds. The mock_uipath fixture patches +# the class, so this is defense-in-depth. +@pytest.fixture(autouse=True) +def _env(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "mock_token") + yield + for k in ("UIPATH_URL", "UIPATH_ACCESS_TOKEN"): + if k in os.environ: + monkeypatch.delenv(k, raising=False) diff --git a/packages/uipath/tests/cli/test_debug_bridge_selection.py b/packages/uipath/tests/cli/test_debug_bridge_selection.py new file mode 100644 index 000000000..732461c77 --- /dev/null +++ b/packages/uipath/tests/cli/test_debug_bridge_selection.py @@ -0,0 +1,64 @@ +"""Tests for `get_debug_bridge()` selection matrix. + +Locks in the non-breaking-change contract: absence of `attach` preserves the +legacy `job_id`-based selection. Explicit `attach` overrides that selection. +""" + +from __future__ import annotations + +import pytest + +from uipath._cli._debug._bridge import ( + ConsoleDebugBridge, + SignalRDebugBridge, + get_debug_bridge, +) +from uipath.runtime import UiPathRuntimeContext +from uipath.runtime.debug import DetachedDebugBridge + + +def _ctx(**overrides) -> UiPathRuntimeContext: + return UiPathRuntimeContext(**overrides) + + +def test_attach_none_returns_detached_bridge_without_job_id(): + bridge = get_debug_bridge(_ctx(), attach="none") + assert isinstance(bridge, DetachedDebugBridge) + + +def test_attach_none_returns_detached_bridge_even_when_job_id_set(monkeypatch): + """'none' wins over job_id — this is the whole point of the flag.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + bridge = get_debug_bridge(_ctx(job_id="job-123"), attach="none") + assert isinstance(bridge, DetachedDebugBridge) + + +def test_attach_signalr_forces_signalr_bridge(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + bridge = get_debug_bridge(_ctx(job_id="job-123"), attach="signalr") + assert isinstance(bridge, SignalRDebugBridge) + + +def test_attach_console_forces_console_bridge_even_when_job_id_set(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + bridge = get_debug_bridge(_ctx(job_id="job-123"), attach="console") + assert isinstance(bridge, ConsoleDebugBridge) + + +def test_legacy_selection_signalr_when_job_id_set_and_no_attach(monkeypatch): + """Non-breaking change assertion: absence of `attach` preserves today's behavior.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + bridge = get_debug_bridge(_ctx(job_id="job-123")) + assert isinstance(bridge, SignalRDebugBridge) + + +def test_legacy_selection_console_when_no_job_id_and_no_attach(): + """Non-breaking change assertion: absence of `attach` preserves today's behavior.""" + bridge = get_debug_bridge(_ctx()) + assert isinstance(bridge, ConsoleDebugBridge) + + +def test_attach_signalr_without_job_id_raises(): + """Explicit signalr without job_id is a user error — surface it loudly.""" + with pytest.raises(ValueError, match="UIPATH_URL and UIPATH_JOB_KEY"): + get_debug_bridge(_ctx(), attach="signalr") diff --git a/packages/uipath/tests/cli/test_debug_simulation.py b/packages/uipath/tests/cli/test_debug_simulation.py index b2d795c79..9e66a1a24 100644 --- a/packages/uipath/tests/cli/test_debug_simulation.py +++ b/packages/uipath/tests/cli/test_debug_simulation.py @@ -82,11 +82,12 @@ def test_loads_valid_simulation_config( assert result is not None assert isinstance(result, MockingContext) assert result.name == "debug-simulation" - assert result.strategy is not None + # Legacy format routes to local LLM mocker via strategy + assert result.components is None or len(result.components) == 0 assert isinstance(result.strategy, LLMMockingStrategy) - assert result.strategy.prompt == valid_simulation_config["instructions"] assert len(result.strategy.tools_to_simulate) == 3 assert result.strategy.tools_to_simulate[0].name == "Web Reader" + assert result.strategy.prompt == valid_simulation_config["instructions"] def test_returns_none_when_disabled( self, temp_dir: str, disabled_simulation_config: dict[str, Any] @@ -241,6 +242,9 @@ def test_debug_always_wraps_with_mock_runtime( ) as mock_factory_get: mock_runtime = Mock() mock_runtime.dispose = AsyncMock() + mock_runtime.get_schema = AsyncMock( + return_value=Mock(metadata=None) + ) mock_factory = Mock() mock_factory.new_runtime = AsyncMock(return_value=mock_runtime) @@ -305,6 +309,9 @@ def test_debug_wraps_with_mock_runtime_on_error( ) as mock_factory_get: mock_runtime = Mock() mock_runtime.dispose = AsyncMock() + mock_runtime.get_schema = AsyncMock( + return_value=Mock(metadata=None) + ) mock_factory = Mock() mock_factory.new_runtime = AsyncMock(return_value=mock_runtime) @@ -416,6 +423,34 @@ def test_enabled_defaults_to_true_when_missing(self, temp_dir: str): # Should load successfully since enabled defaults to true assert result is not None + def test_new_format_loads_components(self, temp_dir: str): + """Test that new per-component format routes to API-based mocker (components set).""" + config = { + "enabled": True, + "components": [ + { + "componentId": "my_tool", + "componentType": "tool", + "simulationStrategy": 0, + "simulationInstruction": "Simulate this tool", + } + ], + } + simulation_path = Path(temp_dir) / "simulation.json" + with open(simulation_path, "w", encoding="utf-8") as f: + json.dump(config, f) + + with patch(f"{MOCK_RUNTIME_PATCH_PATH}.Path.cwd", return_value=Path(temp_dir)): + result = load_simulation_config() + + assert result is not None + assert isinstance(result, MockingContext) + # New format: components set, strategy is None + assert result.components is not None + assert len(result.components) == 1 + assert result.components[0].component_id == "my_tool" + assert result.strategy is None + def test_handles_tool_name_normalization(self, temp_dir: str): """Test that tool names with underscores work correctly.""" config = { diff --git a/packages/uipath/tests/cli/test_init.py b/packages/uipath/tests/cli/test_init.py index fa5b47ab2..afa5d15fa 100644 --- a/packages/uipath/tests/cli/test_init.py +++ b/packages/uipath/tests/cli/test_init.py @@ -1,5 +1,6 @@ import json import os +import uuid from unittest.mock import patch import pytest @@ -57,6 +58,101 @@ def test_init_creates_empty_uipath_json( assert isinstance(config["functions"], dict) assert len(config["functions"]) == 0 + def test_init_mints_agent_id_in_uipath_json( + self, runner: CliRunner, temp_dir: str + ) -> None: + """init writes a valid id into a newly created uipath.json.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._generate_pyproject() + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + + with open("uipath.json", "r") as f: + config = json.load(f) + assert "id" in config + # Must be a valid UUID-shaped identifier. + uuid.UUID(config["id"]) + + def test_init_does_not_create_telemetry_file( + self, runner: CliRunner, temp_dir: str + ) -> None: + """init no longer writes .uipath/.telemetry.json; the id lives in uipath.json.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._generate_pyproject() + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + + assert not os.path.exists(os.path.join(".uipath", ".telemetry.json")) + with open("uipath.json", "r") as f: + uuid.UUID(json.load(f)["id"]) + + def test_init_mints_agent_id_with_telemetry_disabled( + self, runner: CliRunner, temp_dir: str + ) -> None: + """id is still written when telemetry is opted out.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._generate_pyproject() + result = runner.invoke( + cli, ["init"], env={"UIPATH_TELEMETRY_ENABLED": "false"} + ) + assert result.exit_code == 0 + + assert not os.path.exists(os.path.join(".uipath", ".telemetry.json")) + with open("uipath.json", "r") as f: + config = json.load(f) + uuid.UUID(config["id"]) + + def test_init_preserves_existing_agent_id( + self, runner: CliRunner, temp_dir: str + ) -> None: + """init keeps an id already present in uipath.json (first writer wins).""" + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("main.py", "w") as f: + f.write("def main(input: str) -> str: return input") + with open("uipath.json", "w") as f: + json.dump( + { + "id": "existing-agent-id", + "functions": {"main": "main.py:main"}, + }, + f, + ) + self._generate_pyproject() + + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + # Existing id must not be backfilled/overwritten. + assert "with 'id'" not in result.output + + with open("uipath.json", "r") as f: + assert json.load(f)["id"] == "existing-agent-id" + + def test_init_backfills_agent_id_from_telemetry( + self, runner: CliRunner, temp_dir: str + ) -> None: + """init backfills id on an existing uipath.json, reusing the telemetry key.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("main.py", "w") as f: + f.write("def main(input: str) -> str: return input") + with open("uipath.json", "w") as f: + json.dump({"functions": {"main": "main.py:main"}}, f) + os.makedirs(".uipath", exist_ok=True) + with open(os.path.join(".uipath", ".telemetry.json"), "w") as f: + json.dump({"ProjectKey": "legacy-project-key"}, f) + self._generate_pyproject() + + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + assert "Updated 'uipath.json' file with 'id'" in result.output + + with open("uipath.json", "r") as f: + config = json.load(f) + assert config["id"] == "legacy-project-key" + # Existing fields are preserved. + assert config["functions"]["main"] == "main.py:main" + # The backfill is targeted: no defaulted fields are materialized. + assert set(config.keys()) == {"functions", "id"} + def test_init_with_existing_uipath_json( self, runner: CliRunner, temp_dir: str ) -> None: @@ -659,3 +755,29 @@ def test_init_does_not_overwrite_existing_studio_metadata( metadata = json.load(f) assert metadata["schemaVersion"] == 99 assert metadata["codeVersion"] == "5.0.0" + + +class TestWriteMermaidFiles: + def test_mermaid_file_starts_with_header_comment( + self, runner: CliRunner, temp_dir: str + ) -> None: + """Generated .mermaid files begin with the clarifying header comment.""" + from uipath._cli.cli_init import MERMAID_FILE_HEADER, write_mermaid_files + from uipath.runtime.schema import UiPathRuntimeGraph, UiPathRuntimeSchema + + ep = UiPathRuntimeSchema( + filePath="main.py", + uniqueId="main", + type="function", + input={}, + output={}, + graph=UiPathRuntimeGraph(), + ) + + with runner.isolated_filesystem(temp_dir=temp_dir): + paths = write_mermaid_files([ep]) + assert len(paths) == 1 + contents = paths[0].read_text() + assert contents.startswith(MERMAID_FILE_HEADER) + assert "AUTO-GENERATED" in contents + assert "uipath init" in contents diff --git a/packages/uipath/tests/cli/test_pack.py b/packages/uipath/tests/cli/test_pack.py index cf6bd5cc1..c644b3c7d 100644 --- a/packages/uipath/tests/cli/test_pack.py +++ b/packages/uipath/tests/cli/test_pack.py @@ -10,17 +10,17 @@ import uipath._cli.cli_pack as cli_pack from uipath._cli import cli from uipath._cli.middlewares import MiddlewareResult -from uipath._cli.models.uipath_json_schema import RuntimeOptions +from uipath._cli.models.uipath_json_schema import RuntimeOptions, UiPathJsonConfig -def create_bindings_file(): +def create_bindings_file(directory: str = "."): """Helper to create a default bindings.json file for tests.""" bindings_content = {"version": "2.0", "resources": []} - with open("bindings.json", "w") as f: + with open(os.path.join(directory, "bindings.json"), "w") as f: json.dump(bindings_content, f, indent=4) -def create_entry_points_file(entrypoint_type: str = "function"): +def create_entry_points_file(entrypoint_type: str = "function", directory: str = "."): """Helper to create a default entry-points.json file for tests.""" entry_points_content = { "$schema": "https://cloud.uipath.com/draft/2024-12/entry-point", @@ -38,7 +38,7 @@ def create_entry_points_file(entrypoint_type: str = "function"): } ], } - with open("entry-points.json", "w") as f: + with open(os.path.join(directory, "entry-points.json"), "w") as f: json.dump(entry_points_content, f, indent=4) @@ -1096,14 +1096,17 @@ def test_generate_operate_file(self, runner: CliRunner, temp_dir: str) -> None: ) ] - operate_data = cli_pack.generate_operate_file( - entrypoints, RuntimeOptions(is_conversational=False) + config = UiPathJsonConfig( + runtimeOptions=RuntimeOptions(is_conversational=False), + id="00000000-0000-0000-0000-000000000001", ) + operate_data = cli_pack.generate_operate_file(entrypoints, config) assert ( operate_data["$schema"] == "https://cloud.uipath.com/draft/2024-12/entry-point" ) + assert operate_data["projectId"] == "00000000-0000-0000-0000-000000000001" assert operate_data["main"] == "agent1.py" assert operate_data["contentType"] == "agent" assert operate_data["targetFramework"] == "Portable" @@ -1114,6 +1117,119 @@ def test_generate_operate_file(self, runner: CliRunner, temp_dir: str) -> None: "isConversational": False, } + def test_pack_uses_agent_id_as_project_id( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """operate.json projectId is sourced from uipath.json#id.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + config = create_uipath_json() + config["id"] = "00000000-0000-0000-0000-000000000001" + with open("uipath.json", "w") as f: + json.dump(config, f) + with open("pyproject.toml", "w") as f: + f.write(project_details.to_toml()) + with open("main.py", "w") as f: + f.write("def main(input): return input") + create_bindings_file() + create_entry_points_file() + + result = runner.invoke(cli, ["pack", "./"], env={}) + assert result.exit_code == 0 + + with zipfile.ZipFile( + f".uipath/{project_details.name}.{project_details.version}.nupkg", "r" + ) as z: + operate_data = json.loads(z.read("content/operate.json")) + assert operate_data["projectId"] == "00000000-0000-0000-0000-000000000001" + + def test_pack_fails_when_id_is_not_a_guid( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """pack fails when uipath.json#id is set but is not a valid GUID.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + config = create_uipath_json() + config["id"] = "not-a-guid" + with open("uipath.json", "w") as f: + json.dump(config, f) + with open("pyproject.toml", "w") as f: + f.write(project_details.to_toml()) + with open("main.py", "w") as f: + f.write("def main(input): return input") + create_bindings_file() + create_entry_points_file() + + result = runner.invoke(cli, ["pack", "./"], env={}) + assert result.exit_code != 0 + assert "must be a valid GUID" in result.output + + def test_pack_falls_back_to_telemetry_project_key( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """Without id, operate.json projectId falls back to the telemetry key.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + # uipath.json deliberately has no id (legacy project). + with open("uipath.json", "w") as f: + json.dump(create_uipath_json(), f) + with open("pyproject.toml", "w") as f: + f.write(project_details.to_toml()) + with open("main.py", "w") as f: + f.write("def main(input): return input") + create_bindings_file() + create_entry_points_file() + os.makedirs(".uipath", exist_ok=True) + with open(os.path.join(".uipath", ".telemetry.json"), "w") as f: + json.dump({"ProjectKey": "telemetry-fallback-key"}, f) + + result = runner.invoke(cli, ["pack", "./"], env={}) + assert result.exit_code == 0 + + with zipfile.ZipFile( + f".uipath/{project_details.name}.{project_details.version}.nupkg", "r" + ) as z: + operate_data = json.loads(z.read("content/operate.json")) + assert operate_data["projectId"] == "telemetry-fallback-key" + + def test_pack_telemetry_fallback_from_outside_project_dir( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """The legacy telemetry fallback resolves against the packed directory, not CWD.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + os.makedirs("project") + with open(os.path.join("project", "uipath.json"), "w") as f: + json.dump(create_uipath_json(), f) + with open(os.path.join("project", "pyproject.toml"), "w") as f: + f.write(project_details.to_toml()) + with open(os.path.join("project", "main.py"), "w") as f: + f.write("def main(input): return input") + create_bindings_file(directory="project") + create_entry_points_file(directory="project") + os.makedirs(os.path.join("project", ".uipath"), exist_ok=True) + with open(os.path.join("project", ".uipath", ".telemetry.json"), "w") as f: + json.dump({"ProjectKey": "telemetry-fallback-key"}, f) + + result = runner.invoke(cli, ["pack", "./project"], env={}) + assert result.exit_code == 0 + + # the package itself is written under the caller's CWD + with zipfile.ZipFile( + f".uipath/{project_details.name}.{project_details.version}.nupkg", + "r", + ) as z: + operate_data = json.loads(z.read("content/operate.json")) + assert operate_data["projectId"] == "telemetry-fallback-key" + def test_generate_bindings_content(self, runner: CliRunner, temp_dir: str) -> None: """Test generating bindings content.""" bindings_data = cli_pack.generate_bindings_content() diff --git a/packages/uipath/tests/cli/test_push.py b/packages/uipath/tests/cli/test_push.py index d2189098d..d4d30a166 100644 --- a/packages/uipath/tests/cli/test_push.py +++ b/packages/uipath/tests/cli/test_push.py @@ -1928,6 +1928,14 @@ def test_push_with_resources_imports_referenced_resources( json=mock_structure, ) + httpx_mock.add_response( + method="GET", + url=f"{base_url}/studio_/backend/api/resourcebuilder/metadata", + json=[ + {"kind": "asset", "versions": [{"supportsInLineCreation": True}]}, + ], + ) + # Mock getting the solution ID httpx_mock.add_response( method="GET", @@ -2171,7 +2179,7 @@ def test_push_with_ignore_resources_flag_skips_resource_import( assert "Created reference for resource" not in result.output assert "Resource import summary" not in result.output - def test_push_with_resource_not_found_shows_warning( + def test_push_with_resource_not_found_creates_virtual( self, runner: CliRunner, temp_dir: str, @@ -2179,9 +2187,11 @@ def test_push_with_resource_not_found_shows_warning( mock_env_vars: dict[str, str], httpx_mock: HTTPXMock, ) -> None: - """Test that push shows warning when referenced resource is not found in catalog.""" + """When catalog lookup misses, push creates a virtual resource placeholder.""" base_url = "https://cloud.uipath.com/organization" project_id = "test-project-id" + solution_id = "test-solution-id" + tenant_id = "test-tenant-id" mock_structure = { "id": "root", @@ -2226,6 +2236,43 @@ def test_push_with_resource_not_found_shows_warning( json=mock_structure, ) + # Resource Builder metadata — declares which kinds support inline creation. + httpx_mock.add_response( + method="GET", + url=f"{base_url}/studio_/backend/api/resourcebuilder/metadata", + json=[ + {"kind": "asset", "versions": [{"supportsInLineCreation": True}]}, + ], + ) + + # Solution ID lookup for the virtual-resource fallback + httpx_mock.add_response( + method="GET", + url=f"{base_url}/studio_/backend/api/Project/{project_id}", + json={"solutionId": solution_id}, + ) + + # Existing resources in the solution (empty → no conflict with our new virtual) + httpx_mock.add_response( + method="GET", + url=f"{base_url}/studio_/backend/api/resourcebuilder/solutions/{solution_id}/entities", + json={"resources": []}, + ) + + # Virtual resource POST + httpx_mock.add_response( + method="POST", + url=f"{base_url}/studio_/backend/api/resourcebuilder/solutions/{solution_id}/resources/virtual", + json={"key": "virtual-resource-key-123"}, + ) + + # Configuration PATCH after virtual creation + httpx_mock.add_response( + method="PATCH", + url=f"{base_url}/studio_/backend/api/resourcebuilder/solutions/{solution_id}/resources/virtual-resource-key-123/configuration", + json={}, + ) + with runner.isolated_filesystem(temp_dir=temp_dir): # Create required files with open("uipath.json", "w") as f: @@ -2255,6 +2302,7 @@ def test_push_with_resource_not_found_shows_warning( "ActivityName": "retrieve_async", "BindingsVersion": "2.2", "DisplayLabel": "FullName", + "SubType": "stringAsset", }, } ], @@ -2273,6 +2321,7 @@ def test_push_with_resource_not_found_shows_warning( configure_env_vars(mock_env_vars) os.environ["UIPATH_PROJECT_ID"] = project_id + os.environ["UIPATH_TENANT_ID"] = tenant_id # Mock resource catalog list_by_type_async to return no resources async def mock_list_by_type_async_empty(*args, **kwargs): @@ -2291,16 +2340,14 @@ async def mock_list_by_type_async_empty(*args, **kwargs): result = runner.invoke(cli, ["push", "./"]) assert result.exit_code == 0 - # Check that warning was shown for missing resource + # Check that the virtual-resource fallback ran and succeeded assert ( "Importing referenced resources to Studio Web project" in result.output ) - assert ( - "Resource 'missing.asset' of type 'asset' at folder path 'Default' was not found" - in result.output - ) + assert "missing.asset" in result.output + assert "created successfully" in result.output assert "Resource import summary:" in result.output - assert "1 not found" in result.output + assert "1 virtual-created" in result.output def test_push_with_resource_already_exists_shows_unchanged( self, @@ -2359,6 +2406,14 @@ def test_push_with_resource_already_exists_shows_unchanged( json=mock_structure, ) + httpx_mock.add_response( + method="GET", + url=f"{base_url}/studio_/backend/api/resourcebuilder/metadata", + json=[ + {"kind": "asset", "versions": [{"supportsInLineCreation": True}]}, + ], + ) + # Mock getting the solution ID httpx_mock.add_response( method="GET", diff --git a/packages/uipath/tests/cli/test_run.py b/packages/uipath/tests/cli/test_run.py index 9069c5426..aa182c7c5 100644 --- a/packages/uipath/tests/cli/test_run.py +++ b/packages/uipath/tests/cli/test_run.py @@ -1,6 +1,8 @@ # type: ignore +import json import os -from unittest.mock import patch +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, Mock, patch import pytest from click.testing import CliRunner @@ -9,6 +11,41 @@ from uipath._cli.middlewares import MiddlewareResult +def _middleware_continue(): + return MiddlewareResult( + should_continue=True, + error_message=None, + should_include_stacktrace=False, + ) + + +async def _empty_async_gen(*args, **kwargs): + """An async generator that yields nothing (simulates empty runtime.stream).""" + if False: # pragma: no cover + yield + + +def _make_mock_factory(entrypoints: list[str]): + """Create a mock runtime factory with given entrypoints.""" + mock_factory = Mock() + mock_factory.discover_entrypoints.return_value = entrypoints + mock_factory.get_settings = AsyncMock(return_value=None) + mock_factory.dispose = AsyncMock() + + mock_runtime = Mock() + mock_runtime.execute = AsyncMock(return_value=Mock(status="SUCCESSFUL")) + mock_runtime.stream = Mock(side_effect=_empty_async_gen) + mock_runtime.dispose = AsyncMock() + mock_factory.new_runtime = AsyncMock(return_value=mock_runtime) + + return mock_factory + + +@asynccontextmanager +async def _mock_resource_overwrites_context(*args, **kwargs): + yield + + @pytest.fixture def entrypoint(): return "main" @@ -142,14 +179,81 @@ def test_run_input_file_success( assert "Successful execution." in result.output class TestMiddleware: - def test_no_entrypoint(self, runner: CliRunner, temp_dir: str): + def test_autodiscover_entrypoint(self, runner: CliRunner, temp_dir: str): + """When exactly one entrypoint exists, it is auto-resolved.""" with runner.isolated_filesystem(temp_dir=temp_dir): - result = runner.invoke(cli, ["run"]) - assert result.exit_code == 1 - assert ( - "No entrypoint specified" in result.output - or "Missing argument" in result.output + mock_factory = _make_mock_factory(["my_agent"]) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=mock_factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + ): + result = runner.invoke(cli, ["run"]) + + assert result.exit_code == 0, ( + f"output: {result.output!r}, exception: {result.exception}" ) + assert "Successful execution." in result.output + mock_factory.new_runtime.assert_awaited_once() + assert mock_factory.new_runtime.call_args[0][0] == "my_agent" + + def test_no_entrypoint_multiple_available( + self, runner: CliRunner, temp_dir: str + ): + """When multiple entrypoints exist and none specified, show usage help.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + mock_factory = _make_mock_factory(["agent_a", "agent_b"]) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=mock_factory, + ), + ): + result = runner.invoke(cli, ["run"]) + + assert result.exit_code == 0 + assert "Available entrypoints:" in result.output + assert "agent_a" in result.output + assert "agent_b" in result.output + assert "Usage: uipath run" in result.output + mock_factory.new_runtime.assert_not_awaited() + + def test_no_entrypoint_none_available(self, runner: CliRunner, temp_dir: str): + """When no entrypoints exist and none specified, show usage help.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + mock_factory = _make_mock_factory([]) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=mock_factory, + ), + ): + result = runner.invoke(cli, ["run"]) + + assert result.exit_code == 0 + assert "No entrypoints found" in result.output + assert "Usage: uipath run" in result.output + mock_factory.new_runtime.assert_not_awaited() def test_script_not_found( self, runner: CliRunner, temp_dir: str, entrypoint: str @@ -345,3 +449,113 @@ def main(input_data: PersonIn) -> PersonOut: assert output_data["email"] == "john@example.com" assert output_data["is_adult"] is True assert output_data["greeting"] == "Hello, John Doe!" + + +_SIMULATION_JSON = { + "enabled": True, + "toolsToSimulate": [{"name": "check_syntax"}, {"name": "check_style"}], + "instructions": "Simulate.", +} + + +class TestRunSimulation: + """Tests for the --simulation flag on the run command.""" + + def _make_factory(self): + factory = Mock() + runtime = Mock() + runtime.stream = Mock(side_effect=_empty_async_gen) + runtime.dispose = AsyncMock() + runtime.get_schema = AsyncMock(return_value=Mock(metadata=None)) + factory.discover_entrypoints.return_value = ["main"] + factory.get_settings = AsyncMock(return_value=None) + factory.dispose = AsyncMock() + factory.new_runtime = AsyncMock(return_value=runtime) + return factory, runtime + + def test_invalid_simulation_json_exits_with_error( + self, runner: CliRunner, temp_dir: str + ): + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as f: + json.dump({"functions": {"main": "main.py:main"}}, f) + with open("main.py", "w") as f: + f.write("async def main(input): return {}") + + result = runner.invoke( + cli, ["run", "main", "--simulation", "{ not valid json }"] + ) + assert result.exit_code == 1 + assert "Invalid JSON" in result.output + + def test_simulation_wraps_runtime_with_mock_runtime( + self, runner: CliRunner, temp_dir: str + ): + factory, _ = self._make_factory() + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as f: + json.dump({"functions": {"main": "main.py:main"}}, f) + with open("main.py", "w") as f: + f.write("async def main(input): return {}") + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + patch("uipath._cli.cli_run.UiPathMockRuntime") as mock_cls, + ): + mock_cls.return_value = Mock( + stream=Mock(side_effect=_empty_async_gen), + dispose=AsyncMock(), + get_schema=AsyncMock(return_value=Mock(metadata=None)), + ) + runner.invoke( + cli, + ["run", "main", "--simulation", json.dumps(_SIMULATION_JSON)], + ) + + assert mock_cls.called + assert mock_cls.call_args.kwargs["mocking_context"] is not None + + def test_simulation_disabled_does_not_wrap_runtime( + self, runner: CliRunner, temp_dir: str + ): + factory, _ = self._make_factory() + disabled = {**_SIMULATION_JSON, "enabled": False} + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as f: + json.dump({"functions": {"main": "main.py:main"}}, f) + with open("main.py", "w") as f: + f.write("async def main(input): return {}") + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + patch("uipath._cli.cli_run.UiPathMockRuntime") as mock_cls, + ): + runner.invoke( + cli, ["run", "main", "--simulation", json.dumps(disabled)] + ) + + assert not mock_cls.called diff --git a/packages/uipath/tests/evaluators/test_documentation_examples.py b/packages/uipath/tests/evaluators/test_documentation_examples.py index c75d94329..559df3a01 100644 --- a/packages/uipath/tests/evaluators/test_documentation_examples.py +++ b/packages/uipath/tests/evaluators/test_documentation_examples.py @@ -340,6 +340,52 @@ async def test_using_default_criteria(self) -> None: assert result.score == 1.0 + @pytest.mark.asyncio + async def test_list_target_output_key(self) -> None: + """Test evaluating multiple output fields at once using a list of keys. + + Mirrors the multi-output-agent sample (list-keys-exact-match evaluator). + """ + # Agent returns a rich nested output; we only care about two summary fields. + agent_execution = AgentExecution( + agent_input={"customer_name": "John Doe", "items": []}, + agent_output={ + "order_id": "ORD-001", + "summary": {"status": "completed", "total": 44.97, "item_count": 3}, + "tags": ["priority", "express"], + }, + agent_trace=[], + ) + + evaluator = TypeAdapter(ExactMatchEvaluator).validate_python( + dict( + id="list-keys-exact-match", + evaluatorConfig={ + "name": "ListKeysExactMatch", + # Pass a list to assert multiple fields in a single evaluator run. + "target_output_key": ["summary.status", "summary.total"], + }, + ) + ) + + # Both keys match → score 1.0 + result = await evaluator.validate_and_evaluate_criteria( + agent_execution=agent_execution, + evaluation_criteria={ + "expected_output": {"summary": {"status": "completed", "total": 44.97}} + }, + ) + assert result.score == 1.0 + + # One key differs → score 0.0 + result = await evaluator.validate_and_evaluate_criteria( + agent_execution=agent_execution, + evaluation_criteria={ + "expected_output": {"summary": {"status": "completed", "total": 999.0}} + }, + ) + assert result.score == 0.0 + class TestJsonSimilarityExamples: """Test examples from docs/eval/json_similarity.md.""" diff --git a/packages/uipath/tests/evaluators/test_evaluator_helpers.py b/packages/uipath/tests/evaluators/test_evaluator_helpers.py index 84eb1159f..6064381cb 100644 --- a/packages/uipath/tests/evaluators/test_evaluator_helpers.py +++ b/packages/uipath/tests/evaluators/test_evaluator_helpers.py @@ -12,6 +12,9 @@ import pytest from uipath.eval._helpers.evaluators_helpers import ( + _calls_match, + _match_key, + _sanitize_tool_name, extract_tool_calls, extract_tool_calls_names, extract_tool_calls_outputs, @@ -681,6 +684,54 @@ def test_extract_tool_calls_outputs_filters_non_tool_spans( assert "non_tool_span" not in output_names assert len(result) == 3 + def test_extractors_skip_synthesized_tool_spans(self) -> None: + """Spans tagged with tool.synthesized=True (BPMN container spans + synthesized for trajectory rendering) must be filtered from all three + per-call extractors so they don't pollute tool-call evaluator actuals. + """ + from opentelemetry.sdk.trace import ReadableSpan + + synth_process = ReadableSpan( + name="Instance: abc", + start_time=0, + end_time=1, + attributes={ + "tool.name": "FlowExecution", + "tool.synthesized": True, + "input.value": '{"instanceId": "abc"}', + "output.value": "Status: Completed", + }, + ) + synth_element = ReadableSpan( + name="Autonomous Agent", + start_time=2, + end_time=3, + attributes={ + "tool.name": "ServiceTask: Autonomous Agent", + "tool.synthesized": True, + "input.value": "{}", + "output.value": "Status: Completed", + }, + ) + real_tool = ReadableSpan( + name="Tool call - web_search", + start_time=4, + end_time=5, + attributes={ + "tool.name": "web_search", + "input.value": '{"query": "x"}', + "output.value": '{"content": "ok"}', + }, + ) + + spans = [synth_process, synth_element, real_tool] + + assert extract_tool_calls_names(spans) == ["web_search"] + calls = extract_tool_calls(spans) + assert [c.name for c in calls] == ["web_search"] + outputs = extract_tool_calls_outputs(spans) + assert [o.name for o in outputs] == ["web_search"] + def test_all_extraction_functions_consistent(self, sample_spans: list[Any]) -> None: """Test that all extraction functions return consistent results.""" names = extract_tool_calls_names(sample_spans) @@ -820,3 +871,330 @@ def test_extract_tool_calls_outputs_with_json_non_dict_value(self) -> None: assert result[0].name == "json_array_tool" # Should use the original string when parsed JSON is not a dict assert result[0].output == '["item1", "item2", "item3"]' + + +class TestIdAwareExtraction: + """Verify tool.id propagation through the three extractors, plus the + include_args=False optimization and the JSON-first parse fallback. + """ + + def test_extractors_read_tool_id_when_present(self) -> None: + """When a span carries `tool.id`, it must surface on ToolCall/ToolOutput.id.""" + from opentelemetry.sdk.trace import ReadableSpan + + span = ReadableSpan( + name="Tool call - Web_Search", + start_time=0, + end_time=1, + attributes={ + "tool.name": "Web_Search", + "tool.id": "7abae702-f898-4cc9-95f1-c365b9a857f9", + "input.value": "{}", + "output.value": '{"content": "ok"}', + }, + ) + calls = extract_tool_calls([span]) + outputs = extract_tool_calls_outputs([span]) + assert calls[0].id == "7abae702-f898-4cc9-95f1-c365b9a857f9" + assert calls[0].name == "Web_Search" + assert outputs[0].id == "7abae702-f898-4cc9-95f1-c365b9a857f9" + assert outputs[0].name == "Web_Search" + + def test_extractors_preserve_falsy_but_present_tool_id(self) -> None: + """tool.id of 0 or empty string is unusual but legal — must not be silently dropped. + + Original code used `if tool_id` which would treat 0 / '' as missing. + Fix uses `is not None`. + """ + from opentelemetry.sdk.trace import ReadableSpan + + for falsy_id in (0, "", False): + span = ReadableSpan( + name="t", + start_time=0, + end_time=1, + attributes={ + "tool.name": "f", + "tool.id": falsy_id, + "input.value": "{}", + "output.value": '{"content": "ok"}', + }, + ) + calls = extract_tool_calls([span]) + outputs = extract_tool_calls_outputs([span]) + assert calls[0].id == str(falsy_id), f"falsy id {falsy_id!r} was dropped" + assert outputs[0].id == str(falsy_id), f"falsy id {falsy_id!r} was dropped" + + def test_extract_tool_calls_parses_json_literals(self) -> None: + """input.value with JSON `true`/`false`/`null` should parse cleanly. + + `ast.literal_eval` doesn't recognise those tokens (Python uses + True/False/None); the extractor now tries `json.loads` first and only + falls back to `ast.literal_eval` on JSON parse failure. + """ + from opentelemetry.sdk.trace import ReadableSpan + + span = ReadableSpan( + name="t", + start_time=0, + end_time=1, + attributes={ + "tool.name": "t", + "input.value": '{"a": true, "b": false, "c": null}', + }, + ) + calls = extract_tool_calls([span]) + assert calls[0].args == {"a": True, "b": False, "c": None} + + def test_extract_tool_calls_falls_back_to_python_literal(self) -> None: + """Single-quoted Python dict repr (the historical input shape) still parses.""" + from opentelemetry.sdk.trace import ReadableSpan + + span = ReadableSpan( + name="t", + start_time=0, + end_time=1, + attributes={ + "tool.name": "t", + "input.value": "{'a': 1, 'b': 'two'}", # JSON-invalid, Python-valid + }, + ) + calls = extract_tool_calls([span]) + assert calls[0].args == {"a": 1, "b": "two"} + + def test_extract_tool_calls_non_dict_parsed_result_yields_empty_args(self) -> None: + """If input.value parses to a non-dict (e.g. a bare string), args→{}. + + Avoids pydantic validation failures from feeding a non-dict into + ToolCall(args=...). + """ + from opentelemetry.sdk.trace import ReadableSpan + + span = ReadableSpan( + name="t", + start_time=0, + end_time=1, + attributes={ + "tool.name": "t", + "input.value": '"hello"', # JSON-valid string, not a dict + }, + ) + calls = extract_tool_calls([span]) + assert calls[0].args == {} + + def test_extract_tool_calls_include_args_false_skips_parse(self) -> None: + """With include_args=False, broken input.value is not parsed and doesn't raise. + + Used by count / order evaluators that don't need args. + """ + from opentelemetry.sdk.trace import ReadableSpan + + span = ReadableSpan( + name="t", + start_time=0, + end_time=1, + attributes={ + "tool.name": "t", + "tool.id": "abc", + "input.value": "this is not valid python or json{{{", + }, + ) + calls = extract_tool_calls([span], include_args=False) + assert len(calls) == 1 + assert calls[0].name == "t" + assert calls[0].id == "abc" + assert calls[0].args == {} # short-circuited, not parsed + + def test_extractors_default_id_to_none_when_absent(self) -> None: + """Spans without `tool.id` produce ToolCall/ToolOutput with id=None (back-compat).""" + from opentelemetry.sdk.trace import ReadableSpan + + span = ReadableSpan( + name="Tool call - legacy", + start_time=0, + end_time=1, + attributes={ + "tool.name": "legacy_tool", + "input.value": "{}", + "output.value": '{"content": "ok"}', + }, + ) + calls = extract_tool_calls([span]) + outputs = extract_tool_calls_outputs([span]) + assert calls[0].id is None + assert outputs[0].id is None + + +class TestIdAwareMatching: + """Verify id-aware matching across all four tool-call scoring functions. + + For each function: an Expected criterion authored against the tool's id + matches the actual call when the actual carries the same id, even if the + `name` differs (the common case after a tool rename or the + 'Web Search' → 'Web_Search' display-vs-runtime divergence). + """ + + def test_args_score_matches_by_id_when_names_differ(self) -> None: + """Expected keyed by id matches actual with same id but different name.""" + from uipath.eval._helpers.evaluators_helpers import tool_calls_args_score + + actual = [ToolCall(name="Web_Search", id="uuid-1", args={"q": "x"})] + expected = [ToolCall(name="Web Search", id="uuid-1", args={"q": "x"})] + score, _ = tool_calls_args_score(actual, expected) + assert score == 1.0 + + def test_args_score_strict_kind_no_id_fallback(self) -> None: + """Strict kind: actual has id → id-only mode, no name fallback even when actual.name matches expected.name.""" + from uipath.eval._helpers.evaluators_helpers import tool_calls_args_score + + actual = [ToolCall(name="Web_Search", id="uuid-1", args={"q": "x"})] + expected = [ToolCall(name="Web_Search", args={"q": "x"})] + score, _ = tool_calls_args_score(actual, expected) + assert score == 0.0 # actual.id="uuid-1" != expected.name="Web_Search" + + def test_args_score_name_only_when_actual_has_no_id(self) -> None: + """When actual has no id, sanitised-name comparison is the only path.""" + from uipath.eval._helpers.evaluators_helpers import tool_calls_args_score + + actual = [ToolCall(name="Web_Search", args={"q": "x"})] + expected = [ToolCall(name="Web Search", args={"q": "x"})] + score, _ = tool_calls_args_score(actual, expected) + assert score == 1.0 + + def test_args_score_no_match_when_ids_differ(self) -> None: + """Different ids → no match even with same name.""" + from uipath.eval._helpers.evaluators_helpers import tool_calls_args_score + + actual = [ToolCall(name="Web_Search", id="uuid-A", args={"q": "x"})] + expected = [ToolCall(name="Web_Search", id="uuid-B", args={"q": "x"})] + score, _ = tool_calls_args_score(actual, expected) + assert score == 0.0 + + def test_output_score_matches_by_id(self) -> None: + from uipath.eval._helpers.evaluators_helpers import tool_calls_output_score + + actual = [ToolOutput(name="Web_Search", id="uuid-1", output="ok")] + expected = [ToolOutput(name="Web Search", id="uuid-1", output="ok")] + score, _ = tool_calls_output_score(actual, expected) + assert score == 1.0 + + def test_count_by_name_and_id_helper(self) -> None: + """Strict per-call kind: id-keyed when call has id, name-keyed otherwise — never both.""" + from uipath.eval._helpers.evaluators_helpers import ( + count_tool_calls_by_name_and_id, + ) + + calls = [ + ToolCall(name="Web_Search", id="uuid-1", args={}), + ToolCall(name="Web_Search", id="uuid-1", args={}), + ToolCall(name="get_temp", args={}), # no id + ] + counts = count_tool_calls_by_name_and_id(calls) + assert counts == {"uuid-1": 2, "get_temp": 1} + # Name key is NOT populated when id is present — kind separation. + assert "Web_Search" not in counts + + def test_order_score_with_ids_matches_id_keyed_expected(self) -> None: + """Strict kind: actual has id → only id-keyed expected matches; legacy name-keyed against id-bearing actual is a miss.""" + from uipath.eval._helpers.evaluators_helpers import ( + tool_calls_order_score_with_ids, + ) + + actual = [ + ToolCall(name="Web_Search", id="uuid-1", args={}), + ToolCall(name="Web_Search", id="uuid-1", args={}), + ] + # Expected authored by id matches. + score, _ = tool_calls_order_score_with_ids(actual, ["uuid-1", "uuid-1"]) + assert score == 1.0 + # Expected authored by name against id-bearing actual is a miss (no cross-kind). + score, _ = tool_calls_order_score_with_ids(actual, ["Web_Search", "Web_Search"]) + assert score == 0.0 + # Mixed expected: only the id-keyed element matches. + score, _ = tool_calls_order_score_with_ids(actual, ["uuid-1", "Web_Search"]) + assert 0.0 < score < 1.0 + + def test_order_score_with_ids_back_compat_when_id_absent(self) -> None: + """When actual has no ids (legacy traces), comparison is name-only.""" + from uipath.eval._helpers.evaluators_helpers import ( + tool_calls_order_score_with_ids, + ) + + actual = [ + ToolCall(name="get_temp", args={}), + ToolCall(name="get_humidity", args={}), + ] + score, _ = tool_calls_order_score_with_ids(actual, ["get_temp", "get_humidity"]) + assert score == 1.0 + + +class TestSanitizedNameMatch: + """Sanitised-name fallback in ``_match_key`` / ``_calls_match`` — id-equality wins first.""" + + @staticmethod + def _reference_sanitize(name: str) -> str: + """Pinned copy of ``uipath_langchain.agent.tools.utils.sanitize_tool_name``.""" + import re + + trim_whitespaces = "_".join(name.split()) + sanitized = re.sub(r"[^a-zA-Z0-9_-]", "", trim_whitespaces) + return sanitized[:64] + + @pytest.mark.parametrize( + "raw", + [ + "Web Search", + "Google Sheets / Read", + "Add Numbers", + "tool with spaces and (parens)", + "snake_case_tool", + "kebab-case-tool", + "alreadySanitised", + " multiple whitespace ", + "very-long-name-" + "x" * 100, + "", + ], + ) + def test_normalize_matches_langchain_reference(self, raw: str) -> None: + assert _sanitize_tool_name(raw) == self._reference_sanitize(raw) + + def test_normalize_handles_none(self) -> None: + assert _sanitize_tool_name(None) == "" + + def test_match_key_display_vs_sanitised(self) -> None: + assert _match_key("Web_Search", None, "Web Search") is True + + def test_match_key_id_wins_when_present(self) -> None: + assert _match_key("Web_Search", "webSearch1", "webSearch1") is True + # Strict kind: actual has id → display-name expected is rejected (no cross-kind). + assert _match_key("Web_Search", "webSearch1", "Web Search") is False + + def test_match_key_mismatch_after_sanitising(self) -> None: + assert _match_key("Web_Search", None, "Image_Search") is False + + def test_calls_match_display_vs_sanitised(self) -> None: + actual = ToolCall(name="Web_Search", args={}) + expected = ToolCall(name="Web Search", args={}) + assert _calls_match(actual, expected) is True + + def test_calls_match_id_equality_unchanged(self) -> None: + actual = ToolCall(name="Web_Search", id="webSearch1", args={}) + expected = ToolCall(name="totally different", id="webSearch1", args={}) + assert _calls_match(actual, expected) is True + + def test_calls_match_output_display_vs_sanitised(self) -> None: + actual = ToolOutput(name="Web_Search", output="x") + expected = ToolOutput(name="Web Search", output="x") + assert _calls_match(actual, expected) is True + + def test_count_score_display_name_matches_sanitised_actual(self) -> None: + actual = {"Web_Search": 2} + expected = {"Web Search": ("==", 2)} + score, _ = tool_calls_count_score(actual, expected) + assert score == 1.0 + + def test_count_score_id_keyed_expected_still_wins(self) -> None: + actual = {"Web_Search": 1, "webSearch1": 1} + expected = {"webSearch1": (">=", 1)} + score, _ = tool_calls_count_score(actual, expected) + assert score == 1.0 diff --git a/packages/uipath/tests/evaluators/test_evaluator_methods.py b/packages/uipath/tests/evaluators/test_evaluator_methods.py index 22cfc980e..ec795499d 100644 --- a/packages/uipath/tests/evaluators/test_evaluator_methods.py +++ b/packages/uipath/tests/evaluators/test_evaluator_methods.py @@ -447,6 +447,448 @@ async def test_exact_match_line_by_line_has_individual_results(self) -> None: assert line3_result.score == 1.0 +class TestListTargetOutputKey: + """Test target_output_key as a list of keys.""" + + @pytest.mark.asyncio + async def test_exact_match_list_keys_all_match(self) -> None: + """All listed keys match → score 1.0.""" + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok", "total": 42, "extra": "ignored"}, + agent_trace=[], + ) + config = { + "name": "ExactMatchListKeys", + "target_output_key": ["status", "total"], + } + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"status": "ok", "total": 42} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 + + @pytest.mark.asyncio + async def test_exact_match_list_keys_value_mismatch(self) -> None: + """One key's value differs → score 0.0.""" + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok", "total": 99}, + agent_trace=[], + ) + config = { + "name": "ExactMatchListKeys", + "target_output_key": ["status", "total"], + } + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"status": "ok", "total": 42} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_exact_match_list_keys_dot_notation(self) -> None: + """Nested dot-notation paths inside a list of keys.""" + execution = AgentExecution( + agent_input={}, + agent_output={"order": {"status": "shipped"}, "qty": 3}, + agent_trace=[], + ) + config = { + "name": "ExactMatchListDotKeys", + "target_output_key": ["order.status", "qty"], + } + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"order": {"status": "shipped"}, "qty": 3} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 + + @pytest.mark.asyncio + async def test_list_keys_missing_key_in_actual_raises(self) -> None: + """Missing key in actual output returns an ErrorEvaluationResult.""" + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok"}, # 'total' is missing + agent_trace=[], + ) + config = { + "name": "ExactMatchListKeys", + "target_output_key": ["status", "total"], + } + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"status": "ok", "total": 42} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_list_keys_missing_key_in_expected_raises(self) -> None: + """Missing key in expected output returns an ErrorEvaluationResult.""" + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok", "total": 42}, + agent_trace=[], + ) + config = { + "name": "ExactMatchListKeys", + "target_output_key": ["status", "total"], + } + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={ + "status": "ok" + } # 'total' missing # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_list_keys_expected_as_json_string(self) -> None: + """Expected output as a JSON string is parsed when key is a list.""" + import json + + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok", "total": 5}, + agent_trace=[], + ) + config = { + "name": "ExactMatchListKeys", + "target_output_key": ["status", "total"], + } + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output=json.dumps({"status": "ok", "total": 5}) # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 + + @pytest.mark.asyncio + async def test_list_keys_invalid_json_string_expected_raises(self) -> None: + """Invalid JSON string for expected output returns an ErrorEvaluationResult.""" + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok"}, + agent_trace=[], + ) + config = {"name": "ExactMatchListKeys", "target_output_key": ["status"]} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output="not valid json" # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_list_keys_disables_line_by_line(self) -> None: + """line_by_line_evaluator=True is ignored when target_output_key is a list.""" + execution = AgentExecution( + agent_input={}, + agent_output={"a": "x", "b": "y"}, + agent_trace=[], + ) + config = { + "name": "ExactMatchListLbl", + "target_output_key": ["a", "b"], + "line_by_line_evaluator": True, + } + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + raw_criteria = {"expected_output": {"a": "x", "b": "y"}} + # Should not raise or split into lines; returns a plain NumericEvaluationResult + result = await evaluator.validate_and_evaluate_criteria(execution, raw_criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 + # No line-by-line details attached + assert ( + not hasattr(result, "_line_by_line_results") + or result._line_by_line_results is None + ) + + @pytest.mark.asyncio + async def test_json_similarity_list_keys_perfect_match(self) -> None: + """JsonSimilarityEvaluator with list keys scores 1.0 on exact match.""" + from uipath.eval.evaluators.json_similarity_evaluator import ( + JsonSimilarityEvaluator, + ) + + execution = AgentExecution( + agent_input={}, + agent_output={"name": "Alice", "score": 100, "extra": "ignored"}, + agent_trace=[], + ) + config = { + "name": "JsonSimListKeys", + "target_output_key": ["name", "score"], + } + evaluator = JsonSimilarityEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"name": "Alice", "score": 100} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 + + @pytest.mark.asyncio + async def test_list_keys_non_dict_actual_raises(self) -> None: + """Non-dict agent_output with list key returns ErrorEvaluationResult.""" + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output="just a string", # pyright: ignore[reportArgumentType] + agent_trace=[], + ) + config = {"name": "ExactMatchListKeys", "target_output_key": ["status"]} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"status": "ok"} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_list_keys_non_dict_json_expected_raises(self) -> None: + """Valid JSON that parses to a non-dict returns ErrorEvaluationResult.""" + import json + + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok"}, + agent_trace=[], + ) + config = {"name": "ExactMatchListKeys", "target_output_key": ["status"]} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + # Valid JSON but not an object — triggers the isinstance(expected_output, dict) guard + criteria = OutputEvaluationCriteria( + expected_output=json.dumps("just a string") # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_list_keys_attachment_uri_downloaded( + self, mocker: MockerFixture + ) -> None: + """Attachment URIs within list-key actual output values are downloaded.""" + att_uri = ( + "urn:uipath:cas:file:orchestrator:00000000-0000-0000-0000-000000000001" + ) + mocker.patch( + "uipath.eval.evaluators.output_evaluator.download_attachment_as_string", + return_value="downloaded_content", + ) + execution = AgentExecution( + agent_input={}, + agent_output={"file": att_uri, "status": "ok"}, + agent_trace=[], + ) + config = {"name": "ExactMatchListKeys", "target_output_key": ["file", "status"]} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"file": "downloaded_content", "status": "ok"} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 + + @pytest.mark.asyncio + async def test_scalar_key_attachment_uri_downloaded( + self, mocker: MockerFixture + ) -> None: + """Attachment URI in scalar-key actual output is downloaded.""" + att_uri = ( + "urn:uipath:cas:file:orchestrator:00000000-0000-0000-0000-000000000002" + ) + mocker.patch( + "uipath.eval.evaluators.output_evaluator.download_attachment_as_string", + return_value="file_content", + ) + execution = AgentExecution( + agent_input={}, + agent_output={"report": att_uri}, + agent_trace=[], + ) + config = {"name": "ExactMatchScalarAtt", "target_output_key": "report"} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"report": "file_content"} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, NumericEvaluationResult) + assert result.score == 1.0 + + @pytest.mark.asyncio + async def test_scalar_key_missing_in_actual_raises(self) -> None: + """Missing scalar target_output_key in actual output returns ErrorEvaluationResult.""" + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output={"other": "value"}, + agent_trace=[], + ) + config = {"name": "ExactMatchMissingActual", "target_output_key": "missing_key"} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output={"missing_key": "val"} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_scalar_key_missing_in_expected_raises(self) -> None: + """Missing scalar target_output_key in expected output returns ErrorEvaluationResult.""" + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok"}, + agent_trace=[], + ) + config = {"name": "ExactMatchMissingExpected", "target_output_key": "status"} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + # expected output dict doesn't contain the target key + criteria = OutputEvaluationCriteria( + expected_output={"other_key": "ok"} # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_scalar_key_invalid_json_expected_raises(self) -> None: + """Invalid JSON string for expected output with scalar key returns ErrorEvaluationResult.""" + from uipath.eval.models.models import ErrorEvaluationResult + + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok"}, + agent_trace=[], + ) + config = {"name": "ExactMatchInvalidJson", "target_output_key": "status"} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + criteria = OutputEvaluationCriteria( + expected_output="not valid json" # pyright: ignore[reportCallIssue] + ) + result = await evaluator.evaluate(execution, criteria) + assert isinstance(result, ErrorEvaluationResult) + assert result.score == 0.0 + + @pytest.mark.asyncio + async def test_validate_and_evaluate_criteria_none_raises(self) -> None: + """None criteria with no default configured raises UiPathEvaluationError.""" + execution = AgentExecution( + agent_input={}, + agent_output={"status": "ok"}, + agent_trace=[], + ) + config = {"name": "ExactMatchNoCriteria"} + evaluator = ExactMatchEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) + with pytest.raises(UiPathEvaluationError) as exc_info: + await evaluator.validate_and_evaluate_criteria(execution, None) + assert "MISSING_EVALUATION_CRITERIA" in exc_info.value.error_info.code + + def test_base_output_evaluator_get_full_expected_output_raises(self) -> None: + """BaseOutputEvaluator._get_full_expected_output raises NOT_IMPLEMENTED.""" + from uipath.eval.evaluators.base_evaluator import BaseEvaluatorJustification + from uipath.eval.evaluators.output_evaluator import ( + BaseOutputEvaluator, + OutputEvaluationCriteria, + OutputEvaluatorConfig, + ) + from uipath.eval.models import EvaluationResult + + class _MinimalEvaluator( + BaseOutputEvaluator[ + OutputEvaluationCriteria, + OutputEvaluatorConfig[OutputEvaluationCriteria], + BaseEvaluatorJustification, + ] + ): + @classmethod + def get_evaluator_id(cls) -> str: + return "uipath-minimal-test" + + async def evaluate( + self, agent_execution: Any, evaluation_criteria: Any + ) -> EvaluationResult: + return None # type: ignore[return-value] + + def validate_evaluation_criteria( + self, raw: Any + ) -> OutputEvaluationCriteria: + return OutputEvaluationCriteria.model_validate(raw) + + evaluator = _MinimalEvaluator.model_validate( + { + "evaluatorConfig": {"name": "minimal"}, + "id": str(uuid.uuid4()), + } + ) + with pytest.raises(UiPathEvaluationError) as exc_info: + evaluator._get_full_expected_output( # pyright: ignore[reportArgumentType] + OutputEvaluationCriteria(expected_output={}) # pyright: ignore[reportCallIssue] + ) + assert "NOT_IMPLEMENTED" in exc_info.value.error_info.code + + class TestContainsEvaluator: """Test ContainsEvaluator.evaluate() method.""" diff --git a/packages/uipath/tests/evaluators/test_legacy_trajectory_evaluator.py b/packages/uipath/tests/evaluators/test_legacy_trajectory_evaluator.py new file mode 100644 index 000000000..687fce08d --- /dev/null +++ b/packages/uipath/tests/evaluators/test_legacy_trajectory_evaluator.py @@ -0,0 +1,64 @@ +import uuid + +from opentelemetry.sdk.trace import ReadableSpan + +from uipath.eval.evaluators import LegacyTrajectoryEvaluator +from uipath.eval.evaluators.base_legacy_evaluator import LegacyEvaluationCriteria +from uipath.eval.evaluators.legacy_trajectory_evaluator import ( + LegacyTrajectoryEvaluatorConfig, +) +from uipath.eval.models.models import LegacyEvaluatorCategory, LegacyEvaluatorType + + +def _legacy_trajectory_evaluator() -> LegacyTrajectoryEvaluator: + return LegacyTrajectoryEvaluator( + id=str(uuid.uuid4()), + name="Legacy trajectory", + config_type=LegacyTrajectoryEvaluatorConfig, + evaluation_criteria_type=LegacyEvaluationCriteria, + justification_type=str, + category=LegacyEvaluatorCategory.Trajectory, + type=LegacyEvaluatorType.Trajectory, + prompt="History:\n{{AgentRunHistory}}\nExpected:\n{{ExpectedAgentBehavior}}", + createdAt="2026-05-14T00:00:00Z", + updatedAt="2026-05-14T00:00:00Z", + ) + + +def test_legacy_trajectory_prompt_uses_compact_tool_history() -> None: + long_prompt = "SYSTEM_PROMPT_" + ("x" * 10_000) + spans = [ + ReadableSpan( + name="agent_llm_call", + start_time=0, + end_time=1, + attributes={ + "openinference.span.kind": "LLM", + "input.value": f'{{"messages": [{{"role": "system", "content": "{long_prompt}"}}]}}', + "output.value": '{"generations": []}', + }, + ), + ReadableSpan( + name="search_profiles", + start_time=1, + end_time=2, + attributes={ + "openinference.span.kind": "TOOL", + "tool.name": "search_profiles", + "input.value": '{"query": "mentor"}', + "output.value": '{"content": "found mentor profile"}', + "metadata": f'{{"agent_prompt": "{long_prompt}"}}', + }, + ), + ] + + prompt = _legacy_trajectory_evaluator()._create_evaluation_prompt( + expected_agent_behavior="The agent should search matching profiles.", + agent_run_history=spans, + ) + + assert "SYSTEM_PROMPT_" not in prompt + assert "Tool: search_profiles" in prompt + assert '{"query": "mentor"}' in prompt + assert "found mentor profile" in prompt + assert "agent_llm_call" not in prompt diff --git a/packages/uipath/tests/resource_overrides/overwrites.json b/packages/uipath/tests/resource_overrides/overwrites.json index c58744a69..e0bca84ba 100644 --- a/packages/uipath/tests/resource_overrides/overwrites.json +++ b/packages/uipath/tests/resource_overrides/overwrites.json @@ -28,5 +28,9 @@ "mcpServer.mcp_server_name": { "name": "Overwritten MCP Server Name", "folderPath": "Overwritten/MCPServer/Folder" + }, + "entity.entity_name": { + "name": "Overwritten Entity Name", + "folderId": "overwritten-entity-folder-id-123" } } \ No newline at end of file diff --git a/packages/uipath/tests/resource_overrides/test_overwrites_logging.py b/packages/uipath/tests/resource_overrides/test_overwrites_logging.py new file mode 100644 index 000000000..e05a7daed --- /dev/null +++ b/packages/uipath/tests/resource_overrides/test_overwrites_logging.py @@ -0,0 +1,308 @@ +# type: ignore +"""Tests for INFO-level diagnostic logging on the resource-overwrites read paths. + +Covers the recent change that surfaces bindings.json content and raw resource +overwrites (from both uipath.json and the Studio API) at INFO so binding/ +overwrite mismatches can be diagnosed from logs alone. +""" + +import json +import logging +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from uipath._cli._utils._common import read_resource_overwrites_from_file +from uipath._cli._utils._studio_project import StudioClient +from uipath.platform.common import GenericResourceOverwrite + +_VALID_OVERWRITES = { + "asset.asset_name": { + "name": "Overwritten Asset Name", + "folderPath": "Overwritten/Asset/Folder", + }, + "bucket.bucket_name": { + "name": "Overwritten Bucket Name", + "folderPath": "Overwritten/Bucket/Folder", + }, +} + + +_TARGET_LOGGERS = ( + "uipath._cli._utils._common", + "uipath._cli._utils._studio_project", +) + + +@pytest.fixture(autouse=True) +def _capture_uipath_loggers( + caplog: pytest.LogCaptureFixture, +) -> None: + """Attach caplog's handler directly to the target module loggers. + + Earlier tests in the suite — chiefly anything that invokes the Click CLI + — call ``setup_logging`` and leave the ``uipath`` logger with + ``propagate = False``. That breaks the usual caplog flow (handler on + root, records reach it via propagation). Some intermediate loggers can + also end up with ``propagate = False`` from other test setups. Attaching + the handler directly to each module logger we assert against, and + forcing the level to DEBUG for the duration of the test, side-steps the + propagation question entirely. + """ + snapshots: list[tuple[logging.Logger, int, bool]] = [] + for name in _TARGET_LOGGERS: + logger = logging.getLogger(name) + snapshots.append((logger, logger.level, logger.propagate)) + logger.setLevel(logging.DEBUG) + logger.propagate = True + logger.addHandler(caplog.handler) + try: + yield + finally: + for logger, level, propagate in snapshots: + logger.removeHandler(caplog.handler) + logger.setLevel(level) + logger.propagate = propagate + + +def _write_uipath_json(directory: Path, overwrites: dict) -> Path: + config_path = directory / "uipath.json" + config_path.write_text( + json.dumps( + { + "runtime": {"internalArguments": {"resourceOverwrites": overwrites}}, + } + ) + ) + return config_path + + +class TestReadResourceOverwritesFromFileLogging: + """Behavior: read_resource_overwrites_from_file logs diagnostic info at INFO.""" + + async def test_logs_raw_overwrites_at_info_when_file_present( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + config_path = _write_uipath_json(tmp_path, _VALID_OVERWRITES) + + with caplog.at_level(logging.INFO, logger="uipath._cli._utils._common"): + result = await read_resource_overwrites_from_file(str(tmp_path)) + + assert set(result.keys()) == set(_VALID_OVERWRITES.keys()) + + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + assert any( + "Resource overwrites read from" in r.getMessage() + and str(config_path) in r.getMessage() + and f"({len(_VALID_OVERWRITES)} entries)" in r.getMessage() + for r in info_records + ), f"expected INFO log with file path and entry count, got: {caplog.text}" + + # The raw JSON payload should be present in the log so a developer can + # diff it against what Studio later returns. + assert "Overwritten Asset Name" in caplog.text + assert "Overwritten Bucket Name" in caplog.text + + async def test_logs_info_when_config_file_missing( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + # tmp_path is empty — no uipath.json present. + missing_dir = tmp_path / "does-not-exist" + missing_dir.mkdir() + + with caplog.at_level(logging.INFO, logger="uipath._cli._utils._common"): + result = await read_resource_overwrites_from_file(str(missing_dir)) + + assert result == {} + info_messages = [ + r.getMessage() for r in caplog.records if r.levelno == logging.INFO + ] + assert any( + "Resource overwrites config file not found" in msg for msg in info_messages + ), f"expected INFO log for missing config, got: {info_messages}" + + async def test_logs_warning_when_json_is_malformed( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + (tmp_path / "uipath.json").write_text("{not valid json") + + with caplog.at_level(logging.WARNING, logger="uipath._cli._utils._common"): + result = await read_resource_overwrites_from_file(str(tmp_path)) + + assert result == {} + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any( + "Failed to parse resource overwrites" in r.getMessage() for r in warnings + ) + + async def test_unrecognized_overwrite_key_is_skipped_with_warning( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + overwrites = { + **_VALID_OVERWRITES, + "totallyUnknownKind.foo": {"name": "x", "folderPath": "y"}, + } + _write_uipath_json(tmp_path, overwrites) + + with caplog.at_level(logging.WARNING, logger="uipath._cli._utils._common"): + result = await read_resource_overwrites_from_file(str(tmp_path)) + + # Valid entries still parsed; unknown key dropped. + assert set(result.keys()) == set(_VALID_OVERWRITES.keys()) + assert any( + "Skipping unrecognized resource overwrite" in r.getMessage() + and "totallyUnknownKind.foo" in r.getMessage() + for r in caplog.records + if r.levelno == logging.WARNING + ) + + +class TestStudioClientGetResourceOverwritesLogging: + """Behavior: StudioClient.get_resource_overwrites logs bindings + raw payload.""" + + @pytest.fixture + def studio_client(self) -> StudioClient: + # Inject a mock UiPath so no real HTTP setup is required. + mock_uipath = MagicMock() + mock_uipath.api_client.request_async = AsyncMock() + client = StudioClient(project_id="test-project-id", uipath=mock_uipath) + # Avoid the network call that resolves the solution id. + client._get_solution_id = AsyncMock(return_value="test-solution-id") # type: ignore[method-assign] + return client + + async def test_warns_and_returns_empty_when_bindings_file_missing( + self, + studio_client: StudioClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + from uipath.platform.common._config import ConfigurationManager + + missing_path = tmp_path / "bindings.json" + monkeypatch.setattr( + ConfigurationManager, + "bindings_file_path", + property(lambda self: missing_path), + ) + + with caplog.at_level(logging.WARNING): + result = await studio_client.get_resource_overwrites() + + assert result == {} + assert any( + "Bindings file not found" in r.getMessage() + for r in caplog.records + if r.levelno == logging.WARNING + ) + # No request should have been made when there is nothing to upload. + studio_client.uipath.api_client.request_async.assert_not_called() + + async def test_logs_bindings_content_and_received_overwrites_at_info( + self, + studio_client: StudioClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + from uipath.platform.common._config import ConfigurationManager + + bindings_path = tmp_path / "bindings.json" + bindings_content = json.dumps( + {"version": "2", "resources": [{"name": "my_bucket", "kind": "bucket"}]} + ) + bindings_path.write_text(bindings_content) + monkeypatch.setattr( + ConfigurationManager, + "bindings_file_path", + property(lambda self: bindings_path), + ) + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) + + response = MagicMock() + response.json.return_value = { + "bucket.my_bucket": { + "name": "prod_bucket", + "folderPath": "Prod/Folder", + } + } + studio_client.uipath.api_client.request_async = AsyncMock(return_value=response) + + with caplog.at_level(logging.INFO, logger="uipath._cli._utils._studio_project"): + result = await studio_client.get_resource_overwrites() + + # Returned dict is parsed via ResourceOverwriteParser. + assert set(result.keys()) == {"bucket.my_bucket"} + + info_text = "\n".join( + r.getMessage() for r in caplog.records if r.levelno == logging.INFO + ) + # Bindings content is logged so we can compare what was sent to Studio. + assert "Resource bindings" in info_text + assert "my_bucket" in info_text + # Received overwrites payload is logged with the solution id and count. + assert "Resource overwrites received for solution test-solution-id" in info_text + assert "(1 entries)" in info_text + assert "prod_bucket" in info_text + + async def test_parses_received_overwrites_into_resource_overwrite_objects( + self, + studio_client: StudioClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + from uipath.platform.common._config import ConfigurationManager + + bindings_path = tmp_path / "bindings.json" + bindings_path.write_text("{}") + monkeypatch.setattr( + ConfigurationManager, + "bindings_file_path", + property(lambda self: bindings_path), + ) + + response = MagicMock() + response.json.return_value = { + "bucket.my_bucket": { + "name": "prod_bucket", + "folderPath": "Prod/Folder", + } + } + studio_client.uipath.api_client.request_async = AsyncMock(return_value=response) + + result = await studio_client.get_resource_overwrites() + + parsed = result["bucket.my_bucket"] + assert isinstance(parsed, GenericResourceOverwrite) + assert parsed.resource_identifier == "prod_bucket" + assert parsed.folder_identifier == "Prod/Folder" + + async def test_passes_tenant_id_header_from_environment( + self, + studio_client: StudioClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + from uipath.platform.common._config import ConfigurationManager + + bindings_path = tmp_path / "bindings.json" + bindings_path.write_text("{}") + monkeypatch.setattr( + ConfigurationManager, + "bindings_file_path", + property(lambda self: bindings_path), + ) + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-from-env") + + response = MagicMock() + response.json.return_value = {} + request_mock = AsyncMock(return_value=response) + studio_client.uipath.api_client.request_async = request_mock + + await studio_client.get_resource_overwrites() + + # The header carrying the tenant id should reflect the env var value. + call_kwargs = request_mock.await_args.kwargs + headers = call_kwargs["headers"] + assert any(value == "tenant-from-env" for value in headers.values()), headers diff --git a/packages/uipath/tests/resource_overrides/test_resource_overrides.py b/packages/uipath/tests/resource_overrides/test_resource_overrides.py index c15bc113b..8d39a762d 100644 --- a/packages/uipath/tests/resource_overrides/test_resource_overrides.py +++ b/packages/uipath/tests/resource_overrides/test_resource_overrides.py @@ -310,6 +310,11 @@ def test_parse_overwrites_with_type_adapter(self, overwrites_data): assert mcp_server.resource_identifier == "Overwritten MCP Server Name" assert mcp_server.folder_identifier == "Overwritten/MCPServer/Folder" + # Verify entity overwrite + entity = parsed_overwrites["entity.entity_name"] + assert entity.resource_identifier == "Overwritten Entity Name" + assert entity.folder_identifier == "overwritten-entity-folder-id-123" + def test_overrides_decorator_should_pop_kwargs_dict_when_present(self): from uipath.platform.common import resource_override diff --git a/packages/uipath/tests/sdk/test_bindings.py b/packages/uipath/tests/sdk/test_bindings.py index d9afd8235..1d51d7413 100644 --- a/packages/uipath/tests/sdk/test_bindings.py +++ b/packages/uipath/tests/sdk/test_bindings.py @@ -391,3 +391,28 @@ def test_parse_connection_with_capitalized_alias(self): assert isinstance(overwrite, ConnectionResourceOverwrite) assert overwrite.connection_id == "conn-456" assert overwrite.folder_key == "folder2" + + +class TestRemoteA2aAgentResourceOverwrite: + """Test that Remote A2A agent resources parse as GenericResourceOverwrite.""" + + def test_remote_a2a_agent_resource_overwrite(self): + overwrite = GenericResourceOverwrite( + resource_type="remoteA2aAgent", + name="basica2a", + folder_path="Customers/ProjectA", + ) + assert overwrite.resource_type == "remoteA2aAgent" + assert overwrite.resource_identifier == "basica2a" + assert overwrite.folder_identifier == "Customers/ProjectA" + + def test_parse_remote_a2a_agent(self): + """Parser accepts a remoteA2aAgent-keyed overwrite without discriminator error.""" + overwrite = ResourceOverwriteParser.parse( + key="remoteA2aAgent.basica2a.solution_folder", + value={"name": "basica2a", "folderPath": "Customers/ProjectA"}, + ) + assert isinstance(overwrite, GenericResourceOverwrite) + assert overwrite.resource_type == "remoteA2aAgent" + assert overwrite.resource_identifier == "basica2a" + assert overwrite.folder_identifier == "Customers/ProjectA" diff --git a/packages/uipath/tests/telemetry/test_track.py b/packages/uipath/tests/telemetry/test_track.py index fe72130d5..827b3878d 100644 --- a/packages/uipath/tests/telemetry/test_track.py +++ b/packages/uipath/tests/telemetry/test_track.py @@ -1,11 +1,20 @@ """Tests for telemetry tracking functionality.""" +import json import os from unittest.mock import MagicMock, patch +import pytest + +from uipath.platform.common.constants import ( + ENV_PROJECT_KEY, + ENV_UIPATH_AGENT_ID, + ENV_UIPATH_PROJECT_ID, +) from uipath.telemetry._track import ( _AppInsightsEventClient, _DiagnosticSender, + _get_project_key, _parse_connection_string, _TelemetryClient, flush_events, @@ -17,6 +26,43 @@ ) +class TestGetProjectKey: + """`_get_project_key` resolution: uipath.json#id, then legacy telemetry file.""" + + @pytest.fixture(autouse=True) + def _clear_cache(self, monkeypatch): + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + for var in (ENV_UIPATH_AGENT_ID, ENV_UIPATH_PROJECT_ID, ENV_PROJECT_KEY): + monkeypatch.delenv(var, raising=False) + yield + _read_config_id.cache_clear() + + def test_prefers_uipath_json_id(self, monkeypatch, tmp_path): + config_id = "00000000-0000-0000-0000-000000000001" + (tmp_path / "uipath.json").write_text(json.dumps({"id": config_id})) + os.makedirs(tmp_path / ".uipath", exist_ok=True) + (tmp_path / ".uipath" / ".telemetry.json").write_text( + json.dumps({"ProjectKey": "from-telemetry"}) + ) + monkeypatch.chdir(tmp_path) + assert _get_project_key() == config_id + + def test_falls_back_to_legacy_telemetry_file(self, monkeypatch, tmp_path): + # No uipath.json#id and no env var; honor an existing .telemetry.json. + os.makedirs(tmp_path / ".uipath", exist_ok=True) + (tmp_path / ".uipath" / ".telemetry.json").write_text( + json.dumps({"ProjectKey": "from-telemetry"}) + ) + monkeypatch.chdir(tmp_path) + assert _get_project_key() == "from-telemetry" + + def test_unknown_when_no_source(self, monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + assert _get_project_key() == "" + + class TestParseConnectionString: """Test connection string parsing functionality.""" diff --git a/packages/uipath/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py index a55fa5d60..fc5a370c0 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -54,7 +54,7 @@ def exporter(mock_env_vars): exporter = LlmOpsHttpExporter() # Mock _build_url to include query parameters as in the actual implementation exporter._build_url = MagicMock( # type: ignore - return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=Robots" + return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=CodedAgents" ) yield exporter @@ -107,7 +107,7 @@ def test_export_success(exporter, mock_span): [{"span": "data", "TraceId": "test-trace-id"}] ) exporter.http_client.post.assert_called_once_with( - "https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=Robots", + "https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=CodedAgents", json=[{"span": "data", "TraceId": "test-trace-id"}], ) @@ -685,7 +685,7 @@ def exporter_with_mocks(self, mock_env_vars): with patch("uipath.tracing._otel_exporters.httpx.Client"): exporter = LlmOpsHttpExporter() exporter._build_url = MagicMock( # type: ignore - return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=Robots" + return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=CodedAgents" ) yield exporter @@ -810,5 +810,18 @@ def test_none_stays_none(self, mock_env_vars, mock_span): assert payload["ProcessKey"] is None +class TestVerbosityLevelReexport: + """VerbosityLevel from uipath-platform is re-exported via uipath.tracing.""" + + def test_uipath_tracing_reexports_verbosity_level(self) -> None: + from uipath.platform.common._span_utils import ( + VerbosityLevel as _CommonVerbosity, + ) + from uipath.tracing import VerbosityLevel as _TracingVerbosity + + assert _TracingVerbosity is _CommonVerbosity + assert _TracingVerbosity.OFF == 6 + + if __name__ == "__main__": unittest.main() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 77434aaa8..989e4b5ea 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" +[options] +exclude-newer = "2026-06-22T13:56:19.8527915Z" +exclude-newer-span = "P2D" + +[options.exclude-newer-package] +uipath-runtime = false +uipath-platform = false +uipath-core = false + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -2543,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.40" +version = "2.11.12" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2616,7 +2625,7 @@ requires-dist = [ { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.10.0,<0.11.0" }, + { name = "uipath-runtime", specifier = ">=0.11.0,<0.12.0" }, ] [package.metadata.requires-dev] @@ -2650,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.10" +version = "0.5.22" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -2682,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.18" +version = "0.1.76" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, @@ -2720,14 +2729,14 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.10.0" +version = "0.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/64/69462ee01a5607ce36b1fa152c52ac72fb28abe0aa049394406fc0b31525/uipath_runtime-0.10.0.tar.gz", hash = "sha256:d27d58e2252f506c8c0e00f814b37c3863150e8ffcde8e4c6ab14bd98febd3df", size = 139626, upload-time = "2026-03-24T19:42:43.738Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/87/fed3a5bd3479b9e7dc6cba769b054f5f1c00e93762356a70010e32f1f03c/uipath_runtime-0.11.2.tar.gz", hash = "sha256:8b3cc986644d6c9f2365345c231577f97d3bad8fb105fe8a6c7e16508d00d9ef", size = 145770, upload-time = "2026-06-22T16:31:40.786Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/ed/9c0e97a078b96e4d3742ea3515cb30886b08579cd08077cd42a159adf70d/uipath_runtime-0.10.0-py3-none-any.whl", hash = "sha256:4f52df0b56f54e70fcf34fbf74e223d02b97b5a6fd6d8f64bc06782bb5484b07", size = 42097, upload-time = "2026-03-24T19:42:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/da/62/c649c18ac39f53e5603abbcfa6917f6e880ac08047b1ce69d4c3ee937de8/uipath_runtime-0.11.2-py3-none-any.whl", hash = "sha256:a3a14dc2378bc934437bbd7523cf884d0cee0eeef26c736a9d6ce504d8a9fea0", size = 43874, upload-time = "2026-06-22T16:31:39.328Z" }, ] [[package]] diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..939a3a112 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,13 @@ +sonar.projectKey=UiPath_uipath-python +sonar.organization=ui +sonar.host.url=https://sonarcloud.io + +sonar.sources=packages/uipath/src,packages/uipath-core/src,packages/uipath-platform/src +sonar.tests=packages/uipath/tests,packages/uipath-core/tests,packages/uipath-platform/tests + +sonar.python.version=3.11,3.12,3.13 +sonar.python.coverage.reportPaths=packages/uipath/coverage.xml,packages/uipath-core/coverage.xml,packages/uipath-platform/coverage.xml + +sonar.exclusions=**/samples/**,**/testcases/**,**/template/**,**/_resources/** + +sonar.sourceEncoding=UTF-8