diff --git a/.github/scripts/force-runtime-override.py b/.github/scripts/force-runtime-override.py
new file mode 100644
index 00000000..49f88205
--- /dev/null
+++ b/.github/scripts/force-runtime-override.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+"""Force cross-repo uv resolves to use the runtime wheel under test."""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+
+def _quoted(value: str) -> str:
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
+
+
+def _add_override(pyproject_path: Path, override: str) -> None:
+ text = pyproject_path.read_text()
+ quoted_override = _quoted(override)
+
+ if override in text:
+ return
+
+ tool_uv_match = re.search(
+ r"(?ms)^\[tool\.uv\]\n(?P
.*?)(?=^\[|\Z)",
+ text,
+ )
+ if not tool_uv_match:
+ text = (
+ text.rstrip()
+ + f"\n\n[tool.uv]\noverride-dependencies = [{quoted_override}]\n"
+ )
+ pyproject_path.write_text(text)
+ return
+
+ body = tool_uv_match.group("body")
+ override_match = re.search(
+ r"(?ms)^override-dependencies\s*=\s*\[(?P.*?)\]\s*",
+ body,
+ )
+ if override_match:
+ # A trailing comma must not survive the join below, or it yields `[..,, ..]`
+ items = override_match.group("items").strip().rstrip(",").strip()
+ if quoted_override in items:
+ return
+
+ if items:
+ replacement = f"override-dependencies = [{items}, {quoted_override}]\n"
+ else:
+ replacement = f"override-dependencies = [{quoted_override}]\n"
+
+ body = (
+ body[: override_match.start()] + replacement + body[override_match.end() :]
+ )
+ else:
+ body = f"override-dependencies = [{quoted_override}]\n" + body
+
+ text = (
+ text[: tool_uv_match.start("body")] + body + text[tool_uv_match.end("body") :]
+ )
+ pyproject_path.write_text(text)
+
+
+def main() -> None:
+ """Add a local runtime wheel override to a pyproject and print the wheel path."""
+ if len(sys.argv) != 3:
+ print(
+ "usage: force-runtime-override.py ",
+ file=sys.stderr,
+ )
+ raise SystemExit(2)
+
+ pyproject_path = Path(sys.argv[1])
+ wheel_dir = Path(sys.argv[2])
+ wheels = sorted(wheel_dir.glob("uipath_runtime-*.whl"))
+ if not wheels:
+ print(f"no uipath-runtime wheel found in {wheel_dir}", file=sys.stderr)
+ raise SystemExit(1)
+ if len(wheels) > 1:
+ wheel_names = ", ".join(str(wheel) for wheel in wheels)
+ print(
+ f"expected one uipath-runtime wheel in {wheel_dir}, found: {wheel_names}",
+ file=sys.stderr,
+ )
+ raise SystemExit(1)
+
+ wheel = wheels[0].resolve()
+
+ _add_override(pyproject_path, f"uipath-runtime @ {wheel.as_uri()}")
+ print(wheel)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml
index a5f67bcc..4bfccae4 100644
--- a/.github/workflows/auto-label.yml
+++ b/.github/workflows/auto-label.yml
@@ -10,10 +10,10 @@ permissions:
jobs:
label:
- runs-on: ubuntu-latest
+ runs-on: uipath-ubuntu-latest
steps:
- name: Label PR
- uses: actions/labeler@v5
+ uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
with:
configuration-path: '.github/labeler.yml'
sync-labels: true
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
index c3b34bb2..d531023b 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -19,7 +19,7 @@ jobs:
build:
name: Build
- runs-on: ubuntu-latest
+ runs-on: uipath-ubuntu-latest
needs:
- lint
@@ -32,15 +32,15 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Setup uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
with:
enable-cache: true
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version-file: ".python-version"
@@ -51,14 +51,14 @@ jobs:
run: uv build
- name: Upload artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-dists
path: dist/
pypi-publish:
name: Upload release to PyPI
- runs-on: ubuntu-latest
+ runs-on: uipath-ubuntu-latest
environment: pypi
needs:
@@ -69,10 +69,10 @@ jobs:
steps:
- name: Retrieve release distributions
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release-dists
path: dist/
- name: Publish package distributions to PyPI
- uses: pypa/gh-action-pypi-publish@release/v1
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml
index 8562e455..d88c4910 100644
--- a/.github/workflows/commitlint.yml
+++ b/.github/workflows/commitlint.yml
@@ -6,18 +6,18 @@ on:
jobs:
commitlint:
name: Commit Lint
- runs-on: ubuntu-latest
+ runs-on: uipath-ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- name: Setup Node
- uses: actions/setup-node@v3
+ uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1
with:
node-version: 22
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index d6813d86..ce8ecd63 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -6,21 +6,21 @@ on:
jobs:
lint:
name: Lint
- runs-on: ubuntu-latest
+ runs-on: uipath-ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Setup uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
with:
enable-cache: true
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version-file: ".python-version"
diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml
index ae0907d8..4d712df3 100644
--- a/.github/workflows/publish-dev.yml
+++ b/.github/workflows/publish-dev.yml
@@ -6,7 +6,7 @@ on:
jobs:
publish-dev:
- runs-on: ubuntu-latest
+ runs-on: uipath-ubuntu-latest
permissions:
contents: read
pull-requests: write
@@ -16,15 +16,15 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Setup uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
with:
enable-cache: true
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version-file: ".python-version"
diff --git a/.github/workflows/test-uipath-langchain.yml b/.github/workflows/test-uipath-langchain.yml
index d30d20e8..057ca7e6 100644
--- a/.github/workflows/test-uipath-langchain.yml
+++ b/.github/workflows/test-uipath-langchain.yml
@@ -10,7 +10,7 @@ jobs:
strategy:
matrix:
python-version: [ "3.11", "3.12", "3.13" ]
- os: [ ubuntu-latest, windows-latest ]
+ os: [ uipath-ubuntu-latest, uipath-windows-latest ]
permissions:
contents: read
@@ -20,15 +20,15 @@ jobs:
steps:
- name: Checkout uipath-runtime-python
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
path: 'uipath-runtime-python'
- name: Setup uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ matrix.python-version }}
@@ -37,7 +37,7 @@ jobs:
run: uv build
- name: Checkout uipath-langchain-python
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: 'UiPath/uipath-langchain-python'
path: 'uipath-langchain-python'
@@ -45,7 +45,9 @@ jobs:
- name: Update uipath-runtime version
shell: bash
working-directory: uipath-langchain-python
- run: uv add ../uipath-runtime-python/dist/*.whl --dev
+ run: |
+ runtime_wheel="$(python ../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../uipath-runtime-python/dist)"
+ uv add "$runtime_wheel" --dev
- name: Run uipath-langchain tests
working-directory: uipath-langchain-python
@@ -54,7 +56,7 @@ jobs:
uv run pytest
typecheck-uipath-langchain:
- runs-on: ubuntu-latest
+ runs-on: uipath-ubuntu-latest
permissions:
contents: read
@@ -64,15 +66,15 @@ jobs:
steps:
- name: Checkout uipath-runtime-python
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
path: 'uipath-runtime-python'
- name: Setup uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.12'
@@ -81,7 +83,7 @@ jobs:
run: uv build
- name: Checkout uipath-langchain-python
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: 'UiPath/uipath-langchain-python'
path: 'uipath-langchain-python'
@@ -89,7 +91,9 @@ jobs:
- name: Update uipath-runtime version
shell: bash
working-directory: uipath-langchain-python
- run: uv add ../uipath-runtime-python/dist/*.whl --dev
+ run: |
+ runtime_wheel="$(python ../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../uipath-runtime-python/dist)"
+ uv add "$runtime_wheel" --dev
- name: Run typecheck
working-directory: uipath-langchain-python
@@ -98,7 +102,7 @@ jobs:
uv run mypy --config-file pyproject.toml .
discover-testcases:
- runs-on: ubuntu-latest
+ runs-on: uipath-ubuntu-latest
permissions:
contents: read
needs: [test-uipath-langchain]
@@ -106,7 +110,7 @@ jobs:
testcases: ${{ steps.discover.outputs.testcases }}
steps:
- name: Checkout uipath-langchain-python
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: 'UiPath/uipath-langchain-python'
path: 'uipath-langchain-python'
@@ -126,7 +130,7 @@ jobs:
echo "testcases=$testcases_json" >> $GITHUB_OUTPUT
run-uipath-langchain-integration-tests:
- runs-on: ubuntu-latest
+ runs-on: uipath-ubuntu-latest
needs: [discover-testcases]
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm
@@ -146,7 +150,7 @@ jobs:
steps:
- name: Checkout uipath-runtime-python
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
path: 'uipath-runtime-python'
@@ -155,7 +159,7 @@ jobs:
run: uv build
- name: Checkout uipath-langchain-python
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: 'UiPath/uipath-langchain-python'
path: 'uipath-langchain-python'
@@ -163,7 +167,9 @@ jobs:
- name: Update uipath-runtime version
shell: bash
working-directory: uipath-langchain-python
- run: uv add ../uipath-runtime-python/dist/*.whl --dev
+ run: |
+ runtime_wheel="$(python ../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../uipath-runtime-python/dist)"
+ uv add "$runtime_wheel" --dev
- name: Install dependencies
working-directory: uipath-langchain-python
@@ -182,8 +188,8 @@ jobs:
echo "Environment: ${{ matrix.environment }}"
echo "LLM: ${{ matrix.use_azure_chat && 'UiPathAzureChatOpenAI' || 'UiPathChat' }}"
echo "USE_AZURE_CHAT: ${{ matrix.use_azure_chat }}"
+ python ../../../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../../../uipath-runtime-python/dist
# Execute the testcase run script directly
bash run.sh
bash ../common/validate_output.sh
-
diff --git a/.github/workflows/test-uipath.yml b/.github/workflows/test-uipath.yml
index ddcc5e1d..1673e1bb 100644
--- a/.github/workflows/test-uipath.yml
+++ b/.github/workflows/test-uipath.yml
@@ -10,7 +10,7 @@ jobs:
strategy:
matrix:
python-version: [ "3.11", "3.12", "3.13" ]
- os: [ ubuntu-latest, windows-latest ]
+ os: [ uipath-ubuntu-latest, uipath-windows-latest ]
permissions:
contents: read
@@ -20,15 +20,15 @@ jobs:
steps:
- name: Checkout uipath-runtime-python
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
path: 'uipath-runtime-python'
- name: Setup uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ matrix.python-version }}
@@ -37,7 +37,7 @@ jobs:
run: uv build
- name: Checkout uipath-python
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: 'UiPath/uipath-python'
path: 'uipath-python'
@@ -45,7 +45,9 @@ jobs:
- name: Update uipath-runtime version
shell: bash
working-directory: uipath-python/packages/uipath
- run: uv add ../../../uipath-runtime-python/dist/*.whl --dev
+ run: |
+ runtime_wheel="$(python ../../../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../../../uipath-runtime-python/dist)"
+ uv add "$runtime_wheel" --dev
- name: Run uipath tests
working-directory: uipath-python/packages/uipath
@@ -54,7 +56,7 @@ jobs:
uv run pytest
typecheck-uipath:
- runs-on: ubuntu-latest
+ runs-on: uipath-ubuntu-latest
permissions:
contents: read
@@ -64,15 +66,15 @@ jobs:
steps:
- name: Checkout uipath-runtime-python
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
path: 'uipath-runtime-python'
- name: Setup uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.12'
@@ -81,7 +83,7 @@ jobs:
run: uv build
- name: Checkout uipath-python
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: 'UiPath/uipath-python'
path: 'uipath-python'
@@ -89,7 +91,9 @@ jobs:
- name: Update uipath-runtime version
shell: bash
working-directory: uipath-python/packages/uipath
- run: uv add ../../../uipath-runtime-python/dist/*.whl --dev
+ run: |
+ runtime_wheel="$(python ../../../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../../../uipath-runtime-python/dist)"
+ uv add "$runtime_wheel" --dev
- name: Run typecheck
working-directory: uipath-python/packages/uipath
@@ -98,7 +102,7 @@ jobs:
uv run mypy --config-file pyproject.toml .
discover-testcases:
- runs-on: ubuntu-latest
+ runs-on: uipath-ubuntu-latest
permissions:
contents: read
needs: [test-uipath]
@@ -106,7 +110,7 @@ jobs:
testcases: ${{ steps.discover.outputs.testcases }}
steps:
- name: Checkout uipath-python
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: 'UiPath/uipath-python'
path: 'uipath-python'
@@ -126,7 +130,7 @@ jobs:
echo "testcases=$testcases_json" >> $GITHUB_OUTPUT
run-uipath-integration-tests:
- runs-on: ubuntu-latest
+ runs-on: uipath-ubuntu-latest
needs: [discover-testcases]
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm
@@ -145,7 +149,7 @@ jobs:
steps:
- name: Checkout uipath-runtime-python
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
path: 'uipath-runtime-python'
@@ -154,7 +158,7 @@ jobs:
run: uv build
- name: Checkout uipath-python
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: 'UiPath/uipath-python'
path: 'uipath-python'
@@ -162,7 +166,9 @@ jobs:
- name: Update uipath-runtime version
shell: bash
working-directory: uipath-python/packages/uipath
- run: uv add ../../../uipath-runtime-python/dist/*.whl --dev
+ run: |
+ runtime_wheel="$(python ../../../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../../../uipath-runtime-python/dist)"
+ uv add "$runtime_wheel" --dev
- name: Install dependencies
working-directory: uipath-python/packages/uipath
@@ -179,8 +185,8 @@ jobs:
run: |
echo "Running testcase: ${{ matrix.testcase }}"
echo "Environment: ${{ matrix.environment }}"
+ python ../../../../../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../../../../../uipath-runtime-python/dist
# Execute the testcase run script directly
bash run.sh
bash ../common/validate_output.sh
-
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 1a126aae..cb2d06e9 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -13,20 +13,20 @@ jobs:
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
- os: [ubuntu-latest, windows-latest]
+ os: [uipath-ubuntu-latest, uipath-windows-latest]
permissions:
contents: read
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Setup uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ matrix.python-version }}
@@ -34,24 +34,24 @@ jobs:
run: uv sync --all-extras
- name: Run tests
- if: "!(matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13')"
+ if: "!(matrix.os == 'uipath-ubuntu-latest' && matrix.python-version == '3.13')"
run: uv run pytest
- name: Run tests with coverage
- if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13'
+ if: matrix.os == 'uipath-ubuntu-latest' && matrix.python-version == '3.13'
run: uv run pytest --cov-report=xml --cov-report=html --tb=short
- name: Upload coverage HTML report
- if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always()
- uses: actions/upload-artifact@v4
+ if: matrix.os == 'uipath-ubuntu-latest' && matrix.python-version == '3.13' && always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: coverage-html-report
path: htmlcov/
retention-days: 30
- name: Upload coverage XML report
- if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always()
- uses: actions/upload-artifact@v4
+ if: matrix.os == 'uipath-ubuntu-latest' && matrix.python-version == '3.13' && always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: coverage-xml-report
path: coverage.xml
@@ -62,23 +62,23 @@ jobs:
sonarcloud:
name: SonarCloud
needs: test
- runs-on: ubuntu-latest
+ runs-on: uipath-ubuntu-latest
if: always() && needs.test.result != 'failure'
permissions:
contents: read
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- name: Download coverage
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
continue-on-error: true
with:
name: coverage-xml-report
- name: SonarCloud Scan
- uses: SonarSource/sonarqube-scan-action@2f77a1ec69fb1d595b06f35ab27e97605bdef703 # v5
+ uses: SonarSource/sonarqube-scan-action@2f77a1ec69fb1d595b06f35ab27e97605bdef703 # v5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
diff --git a/CODEOWNERS b/CODEOWNERS
index 03c07761..8410903c 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -1 +1 @@
-* @UiPath/team-coded-agents
+* @UiPath/agents @cristipufu
diff --git a/pyproject.toml b/pyproject.toml
index 1762d366..84af1782 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,11 +1,13 @@
[project]
name = "uipath-runtime"
-version = "0.11.0"
+version = "0.13.1"
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
dependencies = [
- "uipath-core>=0.5.17, <0.6.0"
+ "uipath-core>=0.5.31, <0.6.0",
+ "vaderSentiment>=3.3.2, <4.0",
+ "chardet>=5.2.0, <8.0",
]
classifiers = [
"Intended Audience :: Developers",
diff --git a/src/uipath/runtime/__init__.py b/src/uipath/runtime/__init__.py
index 1eed011f..8807ae09 100644
--- a/src/uipath/runtime/__init__.py
+++ b/src/uipath/runtime/__init__.py
@@ -15,7 +15,7 @@
UiPathStreamNotSupportedError,
UiPathStreamOptions,
)
-from uipath.runtime.chat.protocol import UiPathChatProtocol
+from uipath.runtime.chat.protocol import UiPathChatMetaEventProtocol, UiPathChatProtocol
from uipath.runtime.chat.runtime import UiPathChatRuntime
from uipath.runtime.context import UiPathRuntimeContext
from uipath.runtime.debug.breakpoint import UiPathBreakpointResult
@@ -43,6 +43,16 @@
)
from uipath.runtime.schema import UiPathRuntimeSchema
from uipath.runtime.storage import UiPathRuntimeStorageProtocol
+from uipath.runtime.workspace import (
+ AttachmentRegistryEntry,
+ ConversationalWorkspaceRuntime,
+ HydrationPolicy,
+ HydrationRuntime,
+ Workspace,
+ WorkspaceHydrator,
+ WorkspaceRegistryStore,
+ get_workspace_path,
+)
__all__ = [
"UiPathExecuteOptions",
@@ -71,6 +81,15 @@
"UiPathBreakpointResult",
"UiPathStreamNotSupportedError",
"UiPathResumeTriggerName",
+ "UiPathChatMetaEventProtocol",
"UiPathChatProtocol",
"UiPathChatRuntime",
+ "AttachmentRegistryEntry",
+ "ConversationalWorkspaceRuntime",
+ "HydrationPolicy",
+ "HydrationRuntime",
+ "get_workspace_path",
+ "Workspace",
+ "WorkspaceHydrator",
+ "WorkspaceRegistryStore",
]
diff --git a/src/uipath/runtime/chat/__init__.py b/src/uipath/runtime/chat/__init__.py
index 8261465b..429fbf26 100644
--- a/src/uipath/runtime/chat/__init__.py
+++ b/src/uipath/runtime/chat/__init__.py
@@ -1,6 +1,6 @@
"""Chat bridge protocol and runtime for conversational agents."""
-from uipath.runtime.chat.protocol import UiPathChatProtocol
+from uipath.runtime.chat.protocol import UiPathChatMetaEventProtocol, UiPathChatProtocol
from uipath.runtime.chat.runtime import UiPathChatRuntime
-__all__ = ["UiPathChatProtocol", "UiPathChatRuntime"]
+__all__ = ["UiPathChatMetaEventProtocol", "UiPathChatProtocol", "UiPathChatRuntime"]
diff --git a/src/uipath/runtime/chat/protocol.py b/src/uipath/runtime/chat/protocol.py
index aa7930c5..343e03e1 100644
--- a/src/uipath/runtime/chat/protocol.py
+++ b/src/uipath/runtime/chat/protocol.py
@@ -1,6 +1,6 @@
"""Abstract conversation bridge interface."""
-from typing import Any, Protocol
+from typing import Any, Protocol, runtime_checkable
from uipath.core.chat import (
UiPathConversationMessageEvent,
@@ -70,3 +70,12 @@ async def emit_exchange_error_event(self, error: Exception) -> None:
async def wait_for_resume(self) -> dict[str, Any]:
"""Wait for the interrupt_end event to be received."""
...
+
+
+@runtime_checkable
+class UiPathChatMetaEventProtocol(Protocol):
+ """Optional chat-bridge capability for conversation metadata events."""
+
+ async def emit_meta_event(self, meta_event: dict[str, Any]) -> None:
+ """Send an exchange-scoped conversation metadata event."""
+ ...
diff --git a/src/uipath/runtime/chat/runtime.py b/src/uipath/runtime/chat/runtime.py
index 040040be..9eb31a7f 100644
--- a/src/uipath/runtime/chat/runtime.py
+++ b/src/uipath/runtime/chat/runtime.py
@@ -12,10 +12,11 @@
UiPathRuntimeProtocol,
UiPathStreamOptions,
)
-from uipath.runtime.chat.protocol import UiPathChatProtocol
+from uipath.runtime.chat.protocol import UiPathChatMetaEventProtocol, UiPathChatProtocol
from uipath.runtime.errors import UiPathBaseRuntimeError
from uipath.runtime.errors.contract import UiPathErrorCategory
from uipath.runtime.events import (
+ UiPathRuntimeConversationMetaEvent,
UiPathRuntimeEvent,
UiPathRuntimeMessageEvent,
)
@@ -86,6 +87,13 @@ async def stream(
if isinstance(event, UiPathRuntimeMessageEvent):
if event.payload:
await self.chat_bridge.emit_message_event(event.payload)
+ elif isinstance(event, UiPathRuntimeConversationMetaEvent):
+ if isinstance(self.chat_bridge, UiPathChatMetaEventProtocol):
+ await self.chat_bridge.emit_meta_event(event.payload)
+ else:
+ logger.warning(
+ "Chat bridge does not support conversation metadata events"
+ )
if isinstance(event, UiPathRuntimeResult):
runtime_result = event
diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py
index 2fe81a8a..575db03a 100644
--- a/src/uipath/runtime/context.py
+++ b/src/uipath/runtime/context.py
@@ -7,7 +7,7 @@
from pathlib import Path
from typing import Any, Literal
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import BaseModel, ConfigDict, Field, model_validator
from uipath.core.errors import UiPathFaultedTriggerError
from uipath.core.tracing import UiPathTraceManager
@@ -23,6 +23,15 @@
logger = logging.getLogger(__name__)
+OUTPUT_ARGUMENTS_SUFFIX = ".args"
+
+_EXECUTION_SOURCE_BY_COMMAND: dict[str, str] = {
+ "run": "runtime",
+ "debug": "playground",
+ "dev": "playground",
+ "eval": "eval",
+}
+
class UiPathRuntimeContext(BaseModel):
"""Context information passed throughout the runtime execution."""
@@ -31,13 +40,31 @@ class UiPathRuntimeContext(BaseModel):
input: str | None = None
resume: bool = False
command: str | None = None
+ execution_source: str | None = Field(
+ None, description="Execution source derived from the command."
+ )
job_id: str | None = None
conversation_id: str | None = Field(
None, description="Conversation identifier for CAS"
)
exchange_id: str | None = Field(None, description="Exchange identifier for CAS")
message_id: str | None = Field(None, description="Message identifier for CAS")
- voice_mode: Literal["session"] | None = Field(
+ end_exchange: bool = Field(
+ True,
+ description="Whether to emit the exchange end event for CAS",
+ )
+ conversational_outputs_enabled: bool = Field(
+ False, description="Whether conversational agent output-generation is enabled."
+ )
+ conversational_user_id: str | None = Field(
+ None,
+ description="Conversation owner id for CAS (a real cloud user id or a synthetic user id)",
+ )
+ conversational_run_as_me: bool = Field(
+ False,
+ description="Whether the conversational agent job is running with RunAsMe: true.",
+ )
+ voice_mode: Literal["session", "maestro_flow"] | None = Field(
None, description="Voice job type for CAS"
)
mcp_server_id: str | None = None
@@ -62,6 +89,14 @@ class UiPathRuntimeContext(BaseModel):
"If not specified, path is constructed from runtime_dir and result_file."
),
)
+ split_output_arguments: bool = Field(
+ False,
+ description=(
+ "Write the output arguments to their own file alongside the result file, "
+ "and carry an 'outputArgumentsFilePath' pointer in the result file "
+ "instead of the inline 'output' value."
+ ),
+ )
state_file: str = Field("state.db", description="Filename for the state database")
state_file_path: str | None = Field(
None,
@@ -88,6 +123,28 @@ class UiPathRuntimeContext(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow")
+ def _apply_execution_source(self) -> None:
+ """Derive execution_source from the command, if not already set.
+
+ Only assigns a mapped value, so the field stays unset (absent under
+ ``model_dump(exclude_unset=True)``) for unmapped commands, and an
+ explicitly-provided value is never overwritten.
+ """
+ if self.execution_source is None and self.command is not None:
+ source = _EXECUTION_SOURCE_BY_COMMAND.get(self.command)
+ if source is not None:
+ self.execution_source = source
+
+ @model_validator(mode="after")
+ def _derive_execution_source(self) -> "UiPathRuntimeContext":
+ """Derive execution_source on the constructor path (e.g. dev/init).
+
+ ``with_defaults`` mutates ``command`` via ``setattr`` after construction,
+ so it re-applies the derivation itself.
+ """
+ self._apply_execution_source()
+ return self
+
def get_input(self) -> dict[str, Any] | None:
"""Get parsed input data.
@@ -228,6 +285,21 @@ def __exit__(self, exc_type, exc_val, exc_tb):
content = self.result.to_dict()
+ # Read, not popped: output_file needs the arguments even when the split
+ # does not run, and popping to re-insert would move "output" after
+ # "status" in the envelope
+ output_payload = content.get("output", {})
+
+ # Gated on job_id like the envelope write below: the pointer only has a
+ # reader when there is a job, so without one there is nothing to point at it
+ if self.split_output_arguments and self.job_id:
+ output_arguments_path = self.resolved_output_arguments_file_path
+ os.makedirs(os.path.dirname(output_arguments_path), exist_ok=True)
+ with open(output_arguments_path, "w") as f:
+ json.dump(output_payload, f, default=str)
+ content.pop("output", None)
+ content["outputArgumentsFilePath"] = output_arguments_path
+
# Always write output file at runtime, except for inner runtimes
# Inner runtimes have execution_id
if self.job_id:
@@ -236,7 +308,6 @@ def __exit__(self, exc_type, exc_val, exc_tb):
# Write the execution output to file if requested
if self.output_file:
- output_payload = content.get("output", {})
with open(self.output_file, "w") as f:
json.dump(output_payload, f, default=str)
@@ -296,6 +367,21 @@ def resolved_result_file_path(self) -> str:
return os.path.join(self.runtime_dir, self.result_file)
return os.path.join("__uipath", "output.json")
+ @cached_property
+ def resolved_output_arguments_file_path(self) -> str:
+ """Get the full path to the output arguments file.
+
+ Derived from the result file, not configured: the host cannot put the two
+ in different places, and inserting the suffix before the extension keeps
+ them distinct whatever the result file is called.
+ """
+ result_path = Path(os.path.abspath(self.resolved_result_file_path))
+ return str(
+ result_path.with_name(
+ f"{result_path.stem}{OUTPUT_ARGUMENTS_SUFFIX}{result_path.suffix}"
+ )
+ )
+
@cached_property
def resolved_state_file_path(self) -> str:
"""Get the full path to the state file."""
@@ -337,6 +423,9 @@ def with_defaults(
for k, v in kwargs.items():
setattr(base, k, v)
+ # setattr does not re-run the validator, so derive explicitly.
+ base._apply_execution_source()
+
return base
@classmethod
@@ -356,6 +445,7 @@ def from_config(
mapping = {
"dir": "runtime_dir",
"outputFile": "result_file", # we need this to maintain back-compat with serverless runtime
+ "splitOutputArguments": "split_output_arguments",
"stateFile": "state_file",
"logsFile": "logs_file",
}
@@ -364,6 +454,10 @@ def from_config(
"conversationalService.conversationId": "conversation_id",
"conversationalService.exchangeId": "exchange_id",
"conversationalService.messageId": "message_id",
+ "conversationalService.endExchange": "end_exchange",
+ "conversationalService.enableOutputs": "conversational_outputs_enabled",
+ "conversationalService.conversationalUserId": "conversational_user_id",
+ "conversationalService.runAsMe": "conversational_run_as_me",
"mcpServer.id": "mcp_server_id",
"mcpServer.slug": "mcp_server_slug",
"voice.mode": "voice_mode",
diff --git a/src/uipath/runtime/debug/runtime.py b/src/uipath/runtime/debug/runtime.py
index 6bd56e2c..a64606a4 100644
--- a/src/uipath/runtime/debug/runtime.py
+++ b/src/uipath/runtime/debug/runtime.py
@@ -194,9 +194,10 @@ async def _stream_and_debug(
resume_data: dict[str, Any] | None = None
try:
trigger_data: dict[str, Any] | None = None
- if (
- final_result.trigger.trigger_type
- == UiPathResumeTriggerType.API
+ if final_result.trigger.trigger_type in (
+ UiPathResumeTriggerType.API,
+ UiPathResumeTriggerType.INBOX,
+ UiPathResumeTriggerType.TIMER,
):
trigger_data = (
await self.debug_bridge.wait_for_resume()
diff --git a/src/uipath/runtime/errors/codes.py b/src/uipath/runtime/errors/codes.py
index f198fdc1..460d0fa1 100644
--- a/src/uipath/runtime/errors/codes.py
+++ b/src/uipath/runtime/errors/codes.py
@@ -16,6 +16,7 @@ class UiPathErrorCode(str, Enum):
MODULE_EXECUTION_ERROR = "MODULE_EXECUTION_ERROR"
FUNCTION_EXECUTION_ERROR = "FUNCTION_EXECUTION_ERROR"
EXECUTION_ERROR = "EXECUTION_ERROR"
+ MANAGED_WORKSPACE_UNAVAILABLE = "MANAGED_WORKSPACE_UNAVAILABLE"
# Input validation errors
INVALID_INPUT_FILE_EXTENSION = "INVALID_INPUT_FILE_EXTENSION"
diff --git a/src/uipath/runtime/events/__init__.py b/src/uipath/runtime/events/__init__.py
index a7aace53..22d13f98 100644
--- a/src/uipath/runtime/events/__init__.py
+++ b/src/uipath/runtime/events/__init__.py
@@ -14,6 +14,7 @@
UiPathRuntimeStatePhase,
)
from uipath.runtime.events.state import (
+ UiPathRuntimeConversationMetaEvent,
UiPathRuntimeMessageEvent,
UiPathRuntimeStateEvent,
)
@@ -26,4 +27,5 @@
# Runtime events
"UiPathRuntimeStateEvent",
"UiPathRuntimeMessageEvent",
+ "UiPathRuntimeConversationMetaEvent",
]
diff --git a/src/uipath/runtime/events/base.py b/src/uipath/runtime/events/base.py
index d305e1eb..08176ba2 100644
--- a/src/uipath/runtime/events/base.py
+++ b/src/uipath/runtime/events/base.py
@@ -10,6 +10,7 @@ class UiPathRuntimeEventType(str, Enum):
"""Types of events that can be emitted during execution."""
RUNTIME_MESSAGE = "runtime_message"
+ CONVERSATION_META = "conversation_meta"
RUNTIME_STATE = "runtime_state"
RUNTIME_ERROR = "runtime_error"
RUNTIME_RESULT = "runtime_result"
diff --git a/src/uipath/runtime/events/state.py b/src/uipath/runtime/events/state.py
index 735fbb6e..33f2c4fd 100644
--- a/src/uipath/runtime/events/state.py
+++ b/src/uipath/runtime/events/state.py
@@ -40,6 +40,15 @@ class UiPathRuntimeMessageEvent(UiPathRuntimeEvent):
)
+class UiPathRuntimeConversationMetaEvent(UiPathRuntimeEvent):
+ """Conversation metadata emitted by a runtime."""
+
+ payload: dict[str, Any]
+ event_type: UiPathRuntimeEventType = Field(
+ default=UiPathRuntimeEventType.CONVERSATION_META, frozen=True
+ )
+
+
class UiPathRuntimeStateEvent(UiPathRuntimeEvent):
"""Event emitted when agent state is updated.
@@ -82,4 +91,8 @@ class UiPathRuntimeStateEvent(UiPathRuntimeEvent):
)
-__all__ = ["UiPathRuntimeMessageEvent", "UiPathRuntimeStateEvent"]
+__all__ = [
+ "UiPathRuntimeConversationMetaEvent",
+ "UiPathRuntimeMessageEvent",
+ "UiPathRuntimeStateEvent",
+]
diff --git a/src/uipath/runtime/factory.py b/src/uipath/runtime/factory.py
index c9481bd3..cdfbf128 100644
--- a/src/uipath/runtime/factory.py
+++ b/src/uipath/runtime/factory.py
@@ -19,6 +19,13 @@ class UiPathRuntimeFactorySettings(BaseModel):
trace_settings: UiPathTraceSettings | None = None
+ agent_type: str | None = None
+
+ agent_framework: str | None = None
+
+ managed_workspace: bool = False
+ """Whether this runtime can use host-managed persistent workspace storage."""
+
class UiPathRuntimeFactoryProtocol(
UiPathDisposableProtocol,
diff --git a/src/uipath/runtime/governance/_audit/__init__.py b/src/uipath/runtime/governance/_audit/__init__.py
new file mode 100644
index 00000000..ce109ef8
--- /dev/null
+++ b/src/uipath/runtime/governance/_audit/__init__.py
@@ -0,0 +1,12 @@
+"""Audit sink framework for governance events.
+
+Internal module. Provides a pluggable audit system that emits
+governance events to one or more sinks. The built-in
+:class:`TracesAuditSink` emits OpenTelemetry spans and is always
+registered by every :class:`AuditManager` — it carries the governance
+audit trail and cannot be disabled by application code.
+
+Callers import from the submodules directly (``_audit.base``,
+``_audit.traces``, ``_audit.factory``). This package exposes no
+aggregated symbols.
+"""
diff --git a/src/uipath/runtime/governance/_audit/base.py b/src/uipath/runtime/governance/_audit/base.py
new file mode 100644
index 00000000..7f002712
--- /dev/null
+++ b/src/uipath/runtime/governance/_audit/base.py
@@ -0,0 +1,639 @@
+"""Base classes and models for the audit sink framework.
+
+This module provides the core abstractions for the governance audit system:
+- AuditEvent: The data model for audit events
+- EventType: Constants for common event types
+- AuditSink: Abstract base class for sink implementations
+- AuditManager: Central hub for routing events to sinks
+
+Sink dispatch is synchronous on the caller's thread. Sinks that need
+async export (HTTP, batched I/O) own that concern internally — the
+OTel traces sink rides on opentelemetry-sdk's BatchSpanProcessor,
+which handles export off the caller's thread.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import threading
+from abc import ABC, abstractmethod
+from dataclasses import asdict, dataclass, field
+from datetime import datetime, timezone
+from typing import Any, Callable
+
+from uipath.core.governance import EnforcementMode
+
+from .metadata import GovernanceRuntimeMetadata
+
+logger = logging.getLogger(__name__)
+
+
+# =============================================================================
+# Audit Event Model
+# =============================================================================
+
+
+@dataclass
+class AuditEvent:
+ """Generic audit event that can be sent to any sink.
+
+ Trace correlation is intentionally absent from this dataclass.
+ Sinks that need a trace id resolve one at their own boundary:
+ OTel-backed sinks read the live span from the caller's
+ ``contextvars`` directly (sink dispatch runs synchronously on the
+ caller's thread, so ``trace.get_current_span()`` resolves to the
+ agent's live span), and HTTP sinks defer to their injected
+ provider, which resolves at HTTP-call time.
+
+ Attributes:
+ event_type: Type of event (e.g., "rule_evaluation", "hook_summary")
+ timestamp: When the event occurred (auto-set if not provided)
+ agent_name: Name of the agent being governed
+ hook: Lifecycle hook where event occurred (optional)
+ data: Event-specific data dictionary
+ metadata: Additional metadata for filtering/routing
+ """
+
+ event_type: str
+ agent_name: str = "unknown"
+ hook: str = ""
+ data: dict[str, Any] = field(default_factory=dict)
+ metadata: dict[str, Any] = field(default_factory=dict)
+ timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+ def to_dict(self) -> dict[str, Any]:
+ """Convert to dictionary for serialization."""
+ result = asdict(self)
+ result["timestamp"] = self.timestamp.isoformat()
+ return result
+
+ def to_json(self) -> str:
+ """Convert to JSON string."""
+ return json.dumps(self.to_dict())
+
+
+class EventType:
+ """Constants for common event types."""
+
+ RULE_EVALUATION = "rule_evaluation"
+ HOOK_START = "hook_start"
+ HOOK_END = "hook_end"
+ SESSION_START = "session_start"
+ SESSION_END = "session_end"
+ POLICY_VIOLATION = "policy_violation"
+ POLICY_ALLOW = "policy_allow"
+ PACKS_LOADED = "packs_loaded"
+
+
+# =============================================================================
+# Audit Sink Base Class
+# =============================================================================
+
+
+class AuditSink(ABC):
+ """Abstract base class for audit output destinations.
+
+ Subclass this to create custom audit sinks. Each sink receives
+ all audit events and decides how to handle them.
+
+ Sinks that perform network I/O should batch internally — :meth:`emit`
+ runs on the caller's thread (typically an agent hook), so a slow
+ synchronous sink blocks the agent. The standard pattern is the one
+ opentelemetry-sdk uses for its trace exporter: enqueue in-process,
+ drain on a sink-owned background thread.
+
+ Example:
+ A Slack sink that posts on rule denials. ``emit`` enqueues onto
+ an in-process queue; a daemon thread the sink owns drains the
+ queue and runs the HTTP POST off the caller's thread.
+
+ class SlackAuditSink(AuditSink):
+ def __init__(self, webhook_url: str):
+ self.webhook_url = webhook_url
+ self._name = "slack"
+ self._queue: queue.Queue[AuditEvent | None] = queue.Queue()
+ self._worker = threading.Thread(
+ target=self._drain, name="slack-audit", daemon=True
+ )
+ self._worker.start()
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ def accepts(self, event: AuditEvent) -> bool:
+ # Only ship denials — drops irrelevant events at the
+ # boundary instead of forwarding them to the queue.
+ return (
+ event.data.get("matched")
+ and event.data.get("action") == "deny"
+ )
+
+ def emit(self, event: AuditEvent) -> None:
+ # Non-blocking — runs on the caller's hook thread.
+ self._queue.put_nowait(event)
+
+ def _drain(self) -> None:
+ while True:
+ event = self._queue.get()
+ if event is None:
+ return # close() sentinel
+ try:
+ requests.post(self.webhook_url, json=event.to_dict())
+ except Exception:
+ pass # log/retry per sink's own policy
+ finally:
+ self._queue.task_done()
+
+ def flush(self) -> None:
+ self._queue.join()
+
+ def close(self) -> None:
+ self._queue.put_nowait(None)
+ self._worker.join(timeout=2.0)
+ """
+
+ @property
+ @abstractmethod
+ def name(self) -> str:
+ """Unique name for this sink."""
+ pass
+
+ @abstractmethod
+ def emit(self, event: AuditEvent) -> None:
+ """Emit an audit event to this sink.
+
+ Args:
+ event: The audit event to emit
+
+ Note:
+ Implementations should handle errors gracefully and not
+ raise exceptions that would disrupt governance evaluation.
+ """
+ pass
+
+ def flush(self) -> None: # noqa: B027 — optional hook; subclasses override
+ """Flush any buffered events.
+
+ Override if sink buffers events before writing.
+ """
+
+ def close(self) -> None: # noqa: B027 — optional hook; subclasses override
+ """Clean up resources.
+
+ Override if sink holds resources that need cleanup.
+ """
+
+ def accepts(self, event: AuditEvent) -> bool:
+ """Check if this sink should receive the event.
+
+ Override to filter events. Default accepts all events.
+
+ Args:
+ event: The audit event to check
+
+ Returns:
+ True if sink should receive event, False to skip
+ """
+ return True
+
+
+# =============================================================================
+# Audit Manager
+# =============================================================================
+
+
+class AuditManager:
+ """Manages multiple audit sinks and routes events to them.
+
+ Instance-scoped: each :class:`GovernanceRuntime` owns its own
+ manager. Parallel runtimes (``uipath eval``) don't share sinks or
+ per-sink failure state.
+
+ Constructor automatically registers the always-on ``traces`` sink,
+ which carries the governance audit trail and cannot be disabled by
+ application code. Additional sinks can be added via
+ :meth:`register_sink`.
+
+ Thread Safety:
+ :meth:`emit` dispatches synchronously on the caller's thread.
+ Sinks that need to avoid blocking the caller (HTTP exporters)
+ own their own batching — the OTel traces sink, for example,
+ rides on opentelemetry-sdk's BatchSpanProcessor.
+ """
+
+ # Trip a sink after this many consecutive emit failures (circuit-breaker).
+ _SINK_FAILURE_THRESHOLD = 10
+
+ def __init__(
+ self,
+ register_default_sinks: bool = True,
+ *,
+ track_event: Callable[..., None] | None = None,
+ runtime_metadata: GovernanceRuntimeMetadata | None = None,
+ ) -> None:
+ """Initialize the audit manager.
+
+ Args:
+ register_default_sinks: If True (default), register the
+ always-on ``traces`` and platform-mandated
+ ``track_events`` sinks. Tests that want a bare manager
+ can pass ``False`` and register sinks explicitly.
+ track_event: Platform-supplied telemetry callable wired by
+ the host. When ``None`` (or any sink-construction
+ error), the ``track_events`` sink is skipped and a
+ warning is logged — the runtime continues so a wiring
+ bug never breaks the agent run.
+ runtime_metadata: Constants stamped on every telemetry
+ event (execution engine, agent type, agent framework,
+ runtime version). Defaults to
+ :class:`GovernanceRuntimeMetadata` () — auto-resolved
+ version + ``unknown`` agent type / framework. The host
+ overrides with concrete values.
+ """
+ self._sinks: list[AuditSink] = []
+ # Guards _sinks, _sink_failures, _tripped_sinks — all read +
+ # mutated by emit() across threads when concurrent agent hooks
+ # share one manager.
+ self._sinks_lock = threading.Lock()
+ # Per-sink consecutive-failure counter, keyed by sink name.
+ self._sink_failures: dict[str, int] = {}
+ self._tripped_sinks: set[str] = set()
+
+ if register_default_sinks:
+ self._register_traces_sink()
+ self._register_track_event_sink(track_event, runtime_metadata)
+
+ def _register_traces_sink(self) -> None:
+ """Register the always-on ``traces`` sink.
+
+ Registered for every manager and cannot be disabled by
+ application code — it carries the governance audit trail. The
+ factory import is deferred to avoid a module-load cycle
+ (``factory`` imports back into this module).
+ """
+ from .factory import create_sink
+
+ sink = create_sink("traces")
+ if sink is not None:
+ self.register_sink(sink)
+ logger.info("Governance audit sink registered: traces")
+
+ def _register_track_event_sink(
+ self,
+ track_event: Callable[..., None] | None,
+ runtime_metadata: GovernanceRuntimeMetadata | None,
+ ) -> None:
+ """Register the platform-mandated ``track_events`` sink.
+
+ Mirrors :meth:`_register_traces_sink`: deferred import,
+ construct, register, log. The sink is expected to be wired by
+ the host's platform layer.
+
+ Wrapped in a broad ``except`` so a misconfigured wiring layer
+ (missing callable, sink construction error) never crashes the
+ agent — the runtime logs and proceeds without the sink. The
+ sink-level circuit breaker handles per-emit failures
+ separately.
+ """
+ try:
+ from .track_events import TrackEventAuditSink
+
+ if track_event is None:
+ raise ValueError(
+ "Platform-mandated track_event callable was not supplied; "
+ "the host wiring layer must pass it to AuditManager(...)."
+ )
+ meta = (
+ runtime_metadata
+ if runtime_metadata is not None
+ else GovernanceRuntimeMetadata()
+ )
+ sink = TrackEventAuditSink(track_event, meta)
+ self.register_sink(sink)
+ logger.info("Governance audit sink registered: track_events")
+ except Exception as exc: # noqa: BLE001 - registration must not crash the agent
+ # ``str(exc)`` instead of passing ``exc`` directly: the
+ # logging LogRecord retains its ``args`` tuple until the
+ # handler formats the record, and a raw exception there
+ # carries its ``__traceback__`` → frame chain → ``self``,
+ # which would keep the AuditManager alive in any
+ # log-capturing test. Stringifying breaks that ref.
+ logger.warning("Failed to register track_events sink: %s", str(exc))
+
+ def register_sink(self, sink: AuditSink) -> None:
+ """Register an audit sink.
+
+ Args:
+ sink: The sink to register
+
+ Note:
+ Duplicate sinks (same name) are ignored.
+ The circuit-breaker failure counter is cleared so a freshly
+ registered sink doesn't inherit a previous instance's tripped
+ state. ``unregister_sink`` already clears these, but the
+ defensive reset here guards against external manipulation
+ of the internal counters (tests, future callers).
+ """
+ with self._sinks_lock:
+ if any(s.name == sink.name for s in self._sinks):
+ logger.debug("Sink '%s' already registered, skipping", sink.name)
+ return
+ self._sinks.append(sink)
+ self._sink_failures.pop(sink.name, None)
+ self._tripped_sinks.discard(sink.name)
+ logger.info("Registered audit sink: %s", sink.name)
+
+ def unregister_sink(self, name: str) -> bool:
+ """Unregister an audit sink by name.
+
+ Args:
+ name: Name of the sink to remove
+
+ Returns:
+ True if sink was removed, False if not found
+ """
+ sink_to_close: AuditSink | None = None
+ with self._sinks_lock:
+ for i, sink in enumerate(self._sinks):
+ if sink.name == name:
+ sink_to_close = sink
+ del self._sinks[i]
+ self._sink_failures.pop(name, None)
+ self._tripped_sinks.discard(name)
+ break
+ if sink_to_close is not None:
+ try:
+ sink_to_close.close()
+ except Exception as e:
+ logger.warning("Audit sink '%s' failed to close: %s", name, e)
+ logger.info("Unregistered audit sink: %s", name)
+ return True
+ return False
+
+ def get_sink(self, name: str) -> AuditSink | None:
+ """Get a registered sink by name."""
+ with self._sinks_lock:
+ for sink in self._sinks:
+ if sink.name == name:
+ return sink
+ return None
+
+ def list_sinks(self) -> list[str]:
+ """Get names of all registered sinks."""
+ with self._sinks_lock:
+ return [s.name for s in self._sinks]
+
+ def emit(self, event: AuditEvent) -> None:
+ """Dispatch ``event`` synchronously to every live sink.
+
+ Per-sink errors are caught and folded into the circuit breaker
+ — a sink that fails too many times in a row is skipped for the
+ rest of the manager's lifetime. The caller never sees a sink
+ exception.
+
+ Args:
+ event: The audit event to emit
+ """
+ with self._sinks_lock:
+ sinks = list(self._sinks)
+ tripped = set(self._tripped_sinks)
+ for sink in sinks:
+ if sink.name in tripped:
+ continue
+ try:
+ if sink.accepts(event):
+ sink.emit(event)
+ # Success — reset failure counter for this sink.
+ with self._sinks_lock:
+ if self._sink_failures.get(sink.name):
+ self._sink_failures[sink.name] = 0
+ except Exception as exc: # noqa: BLE001 — swallow to guard the agent
+ self._record_sink_failure(sink.name, exc)
+
+ def _record_sink_failure(self, sink_name: str, exc: Exception) -> None:
+ """Bump the circuit-breaker counter for ``sink_name`` and log.
+
+ Extracted from :meth:`emit` to keep that method's cognitive
+ complexity below the linter's threshold. The lock-guarded
+ counter mutation and the branch between "still under threshold"
+ vs "just tripped" logging live here as a single responsibility.
+ """
+ with self._sinks_lock:
+ fails = self._sink_failures.get(sink_name, 0) + 1
+ self._sink_failures[sink_name] = fails
+ tripped_now = fails >= self._SINK_FAILURE_THRESHOLD
+ if tripped_now:
+ self._tripped_sinks.add(sink_name)
+ if tripped_now:
+ # ``logger.exception`` captures the traceback via
+ # ``exc_info`` — no need to interpolate ``exc`` into
+ # the message (Sonar S8572).
+ logger.exception(
+ "Audit sink '%s' tripped after %d consecutive failures; "
+ "will be skipped for the rest of this process.",
+ sink_name,
+ fails,
+ )
+ else:
+ logger.warning(
+ "Audit sink '%s' failed to emit event (%d/%d): %s",
+ sink_name,
+ fails,
+ self._SINK_FAILURE_THRESHOLD,
+ exc,
+ )
+
+ def emit_rule_evaluation(
+ self,
+ policy_id: str,
+ rule_name: str,
+ pack_name: str,
+ hook: str,
+ matched: bool,
+ action: str,
+ enforcement_mode: EnforcementMode,
+ detail: str = "",
+ agent_name: str = "agent",
+ description: str = "",
+ duration_ms: float = 0.0,
+ mapped_to_uipath: bool = False,
+ ) -> None:
+ """Convenience method to emit a rule evaluation event.
+
+ ``enforcement_mode`` travels on the event so sinks don't have to
+ read a process-global. Each emitter (instance-scoped) supplies
+ its own mode — parallel runtimes can run in different modes
+ simultaneously, and a process-global wouldn't be authoritative
+ for any of them.
+
+ ``duration_ms`` and ``mapped_to_uipath`` are stamped on the
+ event's data dict for telemetry sinks (e.g.
+ :class:`TrackEventAuditSink`); the OTel traces sink ignores them.
+ """
+ self.emit(
+ AuditEvent(
+ event_type=EventType.RULE_EVALUATION,
+ agent_name=agent_name,
+ hook=hook,
+ data={
+ "policy_id": policy_id,
+ "rule_name": rule_name,
+ "pack_name": pack_name,
+ "matched": matched,
+ "action": action,
+ "enforcement_mode": enforcement_mode,
+ "detail": detail,
+ "description": description,
+ "status": "MATCHED" if matched else "PASS",
+ "duration_ms": duration_ms,
+ "mapped_to_uipath": mapped_to_uipath,
+ },
+ )
+ )
+
+ def emit_hook_summary(
+ self,
+ hook: str,
+ agent_name: str,
+ total_rules: int,
+ matched_rules: int,
+ final_action: str,
+ enforcement_mode: EnforcementMode,
+ duration_ms: float = 0.0,
+ skipped_policy_names: list[str] | None = None,
+ guardrail_dispatched_count: int = 0,
+ denied_count: int | None = None,
+ ) -> None:
+ """Convenience method to emit a hook summary event.
+
+ ``matched_rules`` keeps its historical meaning — any rule whose
+ checks matched, regardless of the configured action — for
+ backward compatibility with existing sinks. The newer
+ ``denied_count`` captures only the rules the evaluator actually
+ wanted to act on (matched **and** configured action ≠
+ ``allow``). A matched rule whose action is ``allow`` is a
+ positive informational match and is folded into
+ ``passed_count``, not ``denied_count``.
+
+ Args:
+ duration_ms: Total wall time spent evaluating this hook.
+ skipped_policy_names: Rules in the pack that were not
+ evaluated (currently disabled). The summary carries
+ their ids so operators can spot which policies a tenant
+ turned off.
+ guardrail_dispatched_count: How many UiPath-mapped
+ guardrail-fallback rules fired for this hook and were
+ handed to the compensating path. Lets dashboards
+ compute the native-vs-dispatched ratio.
+ denied_count: Rules that matched **and** would have acted
+ (action ∈ {``deny``, ``escalate``, ``audit``}). When
+ ``None`` (legacy callers), falls back to
+ ``matched_rules`` so the old "matched == denial"
+ semantic is preserved.
+ """
+ skipped = list(skipped_policy_names or [])
+ actual_denied = denied_count if denied_count is not None else matched_rules
+ passed_count = max(total_rules - actual_denied, 0)
+ self.emit(
+ AuditEvent(
+ event_type=EventType.HOOK_END,
+ agent_name=agent_name,
+ hook=hook,
+ data={
+ "total_rules": total_rules,
+ "matched_rules": matched_rules,
+ "final_action": final_action,
+ "enforcement_mode": enforcement_mode,
+ "duration_ms": duration_ms,
+ "passed_count": passed_count,
+ "denied_count": actual_denied,
+ "skipped_count": len(skipped),
+ "skipped_policy_names": skipped,
+ "guardrail_dispatched_count": guardrail_dispatched_count,
+ },
+ )
+ )
+
+ def emit_session_start(
+ self,
+ session_id: str,
+ agent_name: str,
+ packs: list[str],
+ enforcement_mode: EnforcementMode,
+ ) -> None:
+ """Convenience method to emit a session start event.
+
+ Same ``enforcement_mode: EnforcementMode`` contract as
+ :meth:`emit_rule_evaluation` and :meth:`emit_hook_summary`
+ — every governance event carries the emitter's per-instance
+ mode so sinks don't depend on a process-global.
+ """
+ self.emit(
+ AuditEvent(
+ event_type=EventType.SESSION_START,
+ agent_name=agent_name,
+ data={
+ "session_id": session_id,
+ "packs": packs,
+ "enforcement_mode": enforcement_mode,
+ },
+ )
+ )
+
+ def emit_session_end(
+ self,
+ session_id: str,
+ agent_name: str,
+ total_evaluations: int,
+ rules_matched: int,
+ rules_denied: int,
+ enforcement_mode: EnforcementMode,
+ ) -> None:
+ """Convenience method to emit a session end event."""
+ self.emit(
+ AuditEvent(
+ event_type=EventType.SESSION_END,
+ agent_name=agent_name,
+ data={
+ "session_id": session_id,
+ "total_evaluations": total_evaluations,
+ "rules_matched": rules_matched,
+ "rules_denied": rules_denied,
+ "enforcement_mode": enforcement_mode,
+ },
+ )
+ )
+
+ def flush(self) -> None:
+ """Flush every registered sink.
+
+ Per-sink — a sink that maintains its own buffer (OTel batched
+ export, HTTP batcher, etc.) gets a chance to drain. The
+ manager itself holds no queue.
+ """
+ with self._sinks_lock:
+ sinks = list(self._sinks)
+ for sink in sinks:
+ try:
+ sink.flush()
+ except Exception as e:
+ logger.warning("Audit sink '%s' failed to flush: %s", sink.name, e)
+
+ def close(self) -> None:
+ """Close all sinks and release resources.
+
+ Idempotent — a manager that has already been closed has an
+ empty sink list, so a repeat call is a no-op.
+ """
+ with self._sinks_lock:
+ sinks = list(self._sinks)
+ self._sinks.clear()
+ self._sink_failures.clear()
+ self._tripped_sinks.clear()
+ for sink in sinks:
+ try:
+ sink.close()
+ except Exception as e:
+ logger.warning("Audit sink '%s' failed to close: %s", sink.name, e)
diff --git a/src/uipath/runtime/governance/_audit/factory.py b/src/uipath/runtime/governance/_audit/factory.py
new file mode 100644
index 00000000..334f8678
--- /dev/null
+++ b/src/uipath/runtime/governance/_audit/factory.py
@@ -0,0 +1,33 @@
+"""Factory function for creating audit sinks by name.
+
+Used by :class:`AuditManager` to construct the always-on ``traces``
+sink at initialization.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from .base import AuditSink
+
+logger = logging.getLogger(__name__)
+
+
+def create_sink(name: str) -> AuditSink | None:
+ """Create an audit sink by name.
+
+ Args:
+ name: Name of the sink to create (currently only ``traces``).
+
+ Returns:
+ The created sink, or ``None`` if the name is unknown.
+ """
+ name = name.lower()
+
+ if name == "traces":
+ from .traces import TracesAuditSink
+
+ return TracesAuditSink()
+
+ logger.warning("Unknown audit sink: %s", name)
+ return None
diff --git a/src/uipath/runtime/governance/_audit/metadata.py b/src/uipath/runtime/governance/_audit/metadata.py
new file mode 100644
index 00000000..068a8a67
--- /dev/null
+++ b/src/uipath/runtime/governance/_audit/metadata.py
@@ -0,0 +1,69 @@
+"""Per-runtime metadata stamped on every governance telemetry event.
+
+The host constructs a :class:`GovernanceRuntimeMetadata` once per
+agent run and passes it to the telemetry sink. Every event produced
+by :class:`TrackEventAuditSink` carries these fields so downstream
+consumers can pivot on engine / agent type / framework / runtime
+version.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from importlib.metadata import PackageNotFoundError, version
+
+NATIVE_EXECUTION_ENGINE = "uipath_native_governance_checker"
+
+
+def _resolve_runtime_version() -> str:
+ """Read the ``uipath-runtime`` package version, or ``"unknown"``.
+
+ ``importlib.metadata.version`` fails when the package is imported
+ from a source checkout that was never installed (CI fixtures,
+ editable installs with stripped metadata). Telemetry must keep
+ flowing in those cases, so the fallback is a sentinel rather than
+ a raise.
+ """
+ try:
+ return version("uipath-runtime")
+ except PackageNotFoundError:
+ return "unknown"
+
+
+@dataclass(frozen=True)
+class GovernanceRuntimeMetadata:
+ """Constants stamped on every governance telemetry event.
+
+ Attributes:
+ execution_engine: Implementation behind the evaluator. Default
+ ``"uipath_native_governance_checker"``. When a future engine
+ (e.g. AGT) replaces the native checker, the host supplies
+ its own identifier here so the emitted event records which
+ engine produced the verdict.
+ agent_type: Category of agent under governance — e.g.
+ ``"uipath_coded"``, ``"uipath_lowcode"``, ``"servicenow"``,
+ or any other identifier the host wants to attach. External
+ agents (ServiceNow, etc.) join this taxonomy when they
+ land. ``"unknown"`` keeps telemetry flowing if the host
+ forgets to set it.
+ agent_framework: Framework that drives the agent — e.g.
+ ``"langchain"``, ``"openai_agents"``, ``"llamaindex"``,
+ ``"google_adk"``, ``"agent_framework"``, ``"mcp"``.
+ runtime_version: ``uipath-runtime`` package version. Resolved
+ from installed package metadata at construction; falls
+ back to ``"unknown"`` for source checkouts.
+ """
+
+ execution_engine: str = NATIVE_EXECUTION_ENGINE
+ agent_type: str = "unknown"
+ agent_framework: str = "unknown"
+ runtime_version: str = field(default_factory=_resolve_runtime_version)
+
+ def as_payload(self) -> dict[str, str]:
+ """Return the metadata as a dict ready to merge into an event payload."""
+ return {
+ "execution_engine": self.execution_engine,
+ "agent_type": self.agent_type,
+ "agent_framework": self.agent_framework,
+ "runtime_version": self.runtime_version,
+ }
diff --git a/src/uipath/runtime/governance/_audit/traces.py b/src/uipath/runtime/governance/_audit/traces.py
new file mode 100644
index 00000000..5cb14603
--- /dev/null
+++ b/src/uipath/runtime/governance/_audit/traces.py
@@ -0,0 +1,246 @@
+"""OpenTelemetry traces audit sink for governance events.
+
+Emits an OpenTelemetry span per rule evaluation.
+This sink emits spans only — it does not resolve or stamp
+job-execution metadata (organization, tenant, folder, job, trace id)
+onto them. That resolution is owned by the platform-side OTel
+exporter that ships spans downstream, so the runtime governance
+contract stays scoped to span emission.
+"""
+
+from __future__ import annotations
+
+import importlib.metadata
+import logging
+from typing import Any
+
+from uipath.core.governance import EnforcementMode
+
+from .base import AuditEvent, AuditSink, EventType
+
+logger = logging.getLogger(__name__)
+
+
+def _package_version() -> str:
+ """Return the installed ``uipath-runtime`` version (``unknown`` if absent)."""
+ try:
+ return importlib.metadata.version("uipath-runtime")
+ except importlib.metadata.PackageNotFoundError:
+ return "unknown"
+
+
+# Stamped on every governance span as ``uipath_governance.version`` so
+# consumers can correlate the trace payload shape with the runtime
+# release that produced it. Resolved once at import time — the installed
+# package version doesn't change for the life of the process.
+SCHEMA_VERSION = _package_version()
+
+# Value of the ``type`` / ``span_type`` span attributes on every
+# governance span. Local to the runtime trace contract — kept as a
+# string literal (not a cross-package import) so the runtime stays
+# self-contained.
+SPAN_TYPE_GOVERNANCE = "governance"
+
+# Set as the ``source`` attribute on every governance span. Lets
+# consumers identify which producer emitted a given span when more
+# than one governance producer feeds the same trace backend.
+GOVERNANCE_SOURCE = "governance-checker-python"
+
+# Value of the root ``uipath.source`` span attribute.
+UIPATH_SOURCE = "Governance"
+
+# Shared attribute namespace for every governance span attribute.
+# Concatenated into each ``span.set_attribute`` call so the prefix
+# appears in one place and a future rename is a one-line change.
+NS = "uipath_governance"
+
+# Governance verdict / action vocabulary (UPPER_SNAKE).
+EVALUATOR_ALLOW = "ALLOW"
+EVALUATOR_DENY = "DENY"
+EVALUATOR_HITL = "HITL"
+
+ACTION_ALLOW = "ALLOW"
+ACTION_DENY = "DENY"
+ACTION_HITL = "HITL"
+ACTION_AUDIT = "AUDIT"
+ACTION_NONE = "NONE"
+
+
+def _resolve_mode(event: AuditEvent) -> EnforcementMode:
+ """Read the enforcement mode the evaluator stamped on the event.
+
+ Mode travels with the event (set by the emitter when it calls
+ :meth:`AuditManager.emit_rule_evaluation` /
+ :meth:`emit_hook_summary` and passes its own per-instance mode) so
+ the sink doesn't read a process-global that wouldn't be
+ authoritative in a parallel-runtime setup.
+
+ Falls back to ``AUDIT`` only when the field is missing — that's a
+ contract violation by the emitter (every governance event must carry
+ the mode), but defaulting to the safe option avoids a sink crash.
+ """
+ mode = event.data.get("enforcement_mode")
+ if isinstance(mode, EnforcementMode):
+ return mode
+ if isinstance(mode, str):
+ try:
+ return EnforcementMode(mode.lower())
+ except ValueError:
+ pass
+ return EnforcementMode.AUDIT
+
+
+def _derive_results(
+ matched: bool, configured_action: str, mode: EnforcementMode
+) -> tuple[str, str]:
+ """Return ``(evaluator_result, action_applied)`` in spec vocabulary.
+
+ ``evaluator_result`` is mode-independent — what the rule decided. The
+ rule's configured ``audit`` action collapses into a DENY decision
+ here; whether that DENY is actually applied is reflected in
+ ``action_applied``.
+
+ ``action_applied`` is mode-driven. Currently only AUDIT mode is wired
+ in the runtime, so every non-allow result lands on ``AUDIT``; the
+ ENFORCE branch is kept so the contract is already correct when
+ ENFORCE arrives in a later phase.
+
+ The configured ``audit`` rule-level action acts as a per-rule audit
+ override: even when global mode is ENFORCE, such a rule only ever
+ produces ``action_applied = AUDIT``. This preserves today's "audit
+ never blocks" behavior.
+ """
+ action = configured_action.lower()
+
+ if not matched or action == "allow":
+ return EVALUATOR_ALLOW, ACTION_NONE
+
+ if action == "escalate":
+ evaluator = EVALUATOR_HITL
+ else:
+ evaluator = EVALUATOR_DENY
+
+ # Per-rule audit override: emit AUDIT regardless of global mode.
+ if action == "audit":
+ return evaluator, ACTION_AUDIT
+
+ if mode == EnforcementMode.ENFORCE:
+ return evaluator, ACTION_DENY if evaluator == EVALUATOR_DENY else ACTION_HITL
+ return evaluator, ACTION_AUDIT
+
+
+class TracesAuditSink(AuditSink):
+ """Audit sink that emits an OpenTelemetry span per governance event."""
+
+ def __init__(self) -> None:
+ """Initialize the sink with a deferred tracer and zero span count."""
+ self._tracer: Any = None # Can be None, Tracer, or False
+ self._spans_created = 0
+
+ @property
+ def name(self) -> str:
+ """Constant sink identifier."""
+ return "traces"
+
+ def _get_tracer(self) -> Any:
+ """Get or create the OpenTelemetry tracer."""
+ if self._tracer is None:
+ try:
+ from opentelemetry import trace
+
+ self._tracer = trace.get_tracer("uipath.governance")
+ logger.info("OpenTelemetry tracer initialized for governance traces")
+ except ImportError:
+ logger.warning(
+ "OpenTelemetry not available — governance traces disabled."
+ )
+ self._tracer = False
+ return self._tracer if self._tracer else None
+
+ def emit(self, event: AuditEvent) -> None:
+ """Create a span for RULE_EVALUATION events; drop others."""
+ if event.event_type == EventType.RULE_EVALUATION:
+ self._emit_rule_span(event)
+
+ def _emit_rule_span(self, event: AuditEvent) -> None:
+ """Create a span for a rule evaluation."""
+ tracer = self._get_tracer()
+ if tracer is None:
+ return
+
+ try:
+ data = event.data
+ policy_id = data.get("policy_id", "unknown")
+ span_name = f"{NS}.rule.{policy_id}"
+
+ # Sink dispatch runs on the caller's thread (see
+ # :meth:`AuditManager.emit`), so the current OTel context
+ # is the agent's live span — this rule span attaches as
+ # its child without any cross-thread plumbing.
+ with tracer.start_as_current_span(span_name) as span:
+ span.set_attribute("type", SPAN_TYPE_GOVERNANCE)
+ span.set_attribute("span_type", SPAN_TYPE_GOVERNANCE)
+ span.set_attribute("uipath.custom_instrumentation", True)
+ span.set_attribute("uipath.source", UIPATH_SOURCE)
+ span.set_attribute(f"{NS}.source", GOVERNANCE_SOURCE)
+
+ # Single source of truth for the emitted attributes
+ # below AND the verbosityLevel / Status decision further
+ # down. Mode comes from the event (per-instance) so
+ # parallel runtimes don't cross-contaminate.
+ mode = _resolve_mode(event)
+ configured_action = data.get("action", "allow")
+ matched = bool(data.get("matched", False))
+ evaluator_result, action_applied = _derive_results(
+ matched=matched,
+ configured_action=configured_action,
+ mode=mode,
+ )
+
+ span.set_attribute(f"{NS}.policy_id", policy_id)
+ span.set_attribute(f"{NS}.rule_name", data.get("rule_name", ""))
+ span.set_attribute(f"{NS}.pack_name", data.get("pack_name", ""))
+ span.set_attribute(f"{NS}.hook", event.hook)
+ span.set_attribute(f"{NS}.evaluator_result", evaluator_result)
+ span.set_attribute(f"{NS}.action_applied", action_applied)
+ span.set_attribute(f"{NS}.mode", mode.value.upper())
+ span.set_attribute(f"{NS}.version", SCHEMA_VERSION)
+
+ detail = data.get("detail", "")
+ if detail:
+ span.set_attribute(f"{NS}.evidence", detail[:500])
+
+ # Severity is driven off the derived ``action_applied``:
+ # - DENY — runtime blocked → verbosityLevel=4 +
+ # Status.ERROR (agent span genuinely failed).
+ # - AUDIT / HITL — advisory, runtime did not block →
+ # verbosityLevel=3, Status stays UNSET. Marking the
+ # agent span failed for an advisory rule would mislead.
+ # - ALLOW / NONE — no verbosityLevel attribute set.
+ if action_applied == ACTION_DENY:
+ span.set_attribute("verbosityLevel", 4)
+ try:
+ from opentelemetry.trace import Status, StatusCode
+
+ span.set_status(
+ Status(
+ StatusCode.ERROR,
+ f"Policy violation: "
+ f"{data.get('rule_name', policy_id)} "
+ f"(action={configured_action.lower()})",
+ )
+ )
+ except ImportError:
+ pass
+ elif action_applied in (ACTION_AUDIT, ACTION_HITL):
+ span.set_attribute("verbosityLevel", 3)
+
+ self._spans_created += 1
+
+ except Exception as e:
+ logger.warning("Failed to create governance span: %s", e)
+
+ @property
+ def spans_created(self) -> int:
+ """Number of spans created."""
+ return self._spans_created
diff --git a/src/uipath/runtime/governance/_audit/track_events.py b/src/uipath/runtime/governance/_audit/track_events.py
new file mode 100644
index 00000000..8a2b745a
--- /dev/null
+++ b/src/uipath/runtime/governance/_audit/track_events.py
@@ -0,0 +1,329 @@
+"""Telemetry sink for governance evaluation events.
+
+Submits custom telemetry events through a caller-supplied **non-blocking**
+``track_event`` callable. The runtime layer does not own dispatch — the
+host is responsible for supplying a callable that returns immediately
+(typically a thread-pool-backed adapter around the real telemetry
+client). Tests pass a mock that captures payloads.
+
+Event-rate policy (volume control):
+
+- DENIED rules (``matched=True``) → one ``governance.rule.denied`` event
+ per matched rule.
+- PASSED + SKIPPED rules → one ``governance.hook.summary`` event per
+ hook with counts + the names of skipped rules + the count of
+ UiPath-mapped guardrails dispatched through the compensating path.
+
+Why: a 50-rule pack evaluated on every hook of every agent step would
+multiply per-step telemetry calls by 50. Bundling the "nothing
+happened" cases into a single hook summary cuts that to 1.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Callable
+
+from uipath.core.governance import EnforcementMode
+
+from .base import AuditEvent, AuditSink, EventType
+from .metadata import GovernanceRuntimeMetadata
+
+logger = logging.getLogger(__name__)
+
+
+TrackEventCallable = Callable[..., None]
+"""Host-supplied telemetry callable.
+
+Expected kwargs: ``event_name: str``, ``data: dict | None``,
+``operation_id: str | None``. The sink calls this synchronously from
+the caller's hook thread, so the callable **MUST** be non-blocking —
+the agent run shares that thread, and any wait propagates straight
+into latency the agent observes. Hosts that need to do network I/O
+are expected to wrap the underlying call in a thread-pool-backed
+adapter that submits the work and returns immediately.
+"""
+
+
+EVENT_RULE_DENIED = "governance.rule.denied"
+EVENT_HOOK_SUMMARY = "governance.hook.summary"
+
+
+def _resolve_operation_id() -> str | None:
+ """Read the current OTel span's trace id for downstream correlation.
+
+ Sink dispatch runs on the caller's thread (see
+ :meth:`AuditManager.emit`), so the live agent span is visible via
+ ``trace.get_current_span()`` directly. Rendered as a 32-char
+ lowercase hex string for use as a correlation id by the consumer.
+
+ Returns ``None`` when:
+
+ - OpenTelemetry isn't installed (sink no-ops gracefully).
+ - No span is recording (no trace context to correlate against).
+
+ The live span is the single source of truth for trace correlation;
+ the sink does not consult environment variables.
+ """
+ try:
+ from opentelemetry import trace
+ except ImportError:
+ return None
+
+ span = trace.get_current_span()
+ span_ctx = span.get_span_context()
+ if not span_ctx.is_valid:
+ return None
+ return f"{span_ctx.trace_id:032x}"
+
+
+def _mode_str(mode: Any) -> str:
+ """Coerce an enforcement-mode field to its canonical uppercase string."""
+ if isinstance(mode, EnforcementMode):
+ return mode.value.upper()
+ if isinstance(mode, str):
+ return mode.upper()
+ return "AUDIT"
+
+
+def _evaluator_result(action: str) -> str:
+ """Map a rule's configured action to the spec-vocabulary verdict.
+
+ Mirrors :func:`uipath.runtime.governance._audit.traces._derive_results`
+ so both sinks agree on the (matched, action) → verdict mapping.
+ """
+ action_lc = action.lower()
+ if action_lc == "deny":
+ return "DENY"
+ if action_lc == "escalate":
+ return "HITL"
+ if action_lc == "audit":
+ # The rule wanted to deny but was tagged audit-only at the
+ # check level — the evaluator's intent is still "deny".
+ return "DENY"
+ return "ALLOW"
+
+
+def _action_applied(evaluator_result: str, configured_action: str, mode: str) -> str:
+ """Mode-adjusted action: AUDIT mode collapses DENY/HITL into AUDIT."""
+ if mode == "AUDIT":
+ if evaluator_result in ("DENY", "HITL"):
+ return "AUDIT"
+ return "NONE"
+ # ENFORCE mode: per-rule ``audit`` override stays AUDIT.
+ if configured_action.lower() == "audit":
+ return "AUDIT"
+ return evaluator_result if evaluator_result != "ALLOW" else "NONE"
+
+
+class TrackEventAuditSink(AuditSink):
+ """Sink that emits governance telemetry via the injected ``track_event``.
+
+ Volume-controlled per the module docstring: individual events only
+ for denials, one aggregated event per hook for passed / skipped /
+ dispatched. Other event types are dropped at :meth:`accepts`.
+
+ The ``track_event`` callable is invoked synchronously on the
+ caller's hook thread, so the callable must be non-blocking — see
+ the :class:`TrackEventCallable` type alias for the contract the
+ host is expected to satisfy.
+ """
+
+ SINK_NAME = "track_events"
+
+ def __init__(
+ self,
+ track_event: TrackEventCallable,
+ runtime_metadata: GovernanceRuntimeMetadata,
+ *,
+ name: str = SINK_NAME,
+ ) -> None:
+ """Initialize the sink.
+
+ Args:
+ track_event: Host-supplied **non-blocking** telemetry
+ callable. Receives ``event_name``, ``data``,
+ ``operation_id`` kwargs. The sink invokes it
+ synchronously from the agent's hook thread, so any
+ network or otherwise blocking work in the underlying
+ implementation must be offloaded by the host (e.g.,
+ via a thread-pool-backed adapter). Tests pass a
+ capture callable. **Required** — ``None`` is a wiring
+ bug and is rejected here so the manager's registration
+ try/except surfaces it as a clear warning rather than
+ letting a ``TypeError`` fire on the first event.
+ runtime_metadata: Per-run constants stamped on every event
+ (execution engine, agent type, agent framework, runtime
+ version).
+ name: Sink name used for circuit-breaker accounting in the
+ manager. Override only if more than one telemetry sink
+ of this kind is registered against the same manager.
+ """
+ if track_event is None:
+ raise ValueError(
+ "TrackEventAuditSink requires a non-None track_event callable. "
+ "The host is expected to supply a non-blocking adapter — "
+ "wiring a blocking client (e.g., raw HTTP) on the agent's "
+ "hook thread will block the agent on every governance event."
+ )
+ self._track_event = track_event
+ self._meta = runtime_metadata
+ self._name = name
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ def accepts(self, event: AuditEvent) -> bool:
+ """Filter the audit stream down to denials + non-empty hook summaries.
+
+ Drops:
+
+ - **Any event whose mode is** :attr:`EnforcementMode.DISABLED`
+ — governance is off; emit nothing. The evaluator short-circuits
+ before ``_emit_audit`` in this case, so in practice the sink
+ shouldn't see these, but the guard here is belt-and-suspenders
+ against any future emitter that bypasses the short-circuit.
+ - Rules that didn't match (``matched=False``) — rolled into the
+ hook summary's ``passed_count``.
+ - Rules that matched but whose configured ``action`` is
+ ``allow`` — a positive informational match isn't a denial;
+ rolled into the hook summary too.
+ - **Empty hook summaries** — ``total_rules=0`` AND
+ ``skipped_count=0`` means nothing was evaluated and nothing
+ was deliberately skipped. Zero operator value; suppressed.
+ (A summary with ``skipped_count>0`` still fires because the
+ ``skipped_policy_names`` payload is operator-relevant — it
+ shows which policies a tenant turned off.)
+ - Other event types (``hook_start``, ``session_*``, generic
+ violations) — out of scope for this sink's telemetry surface.
+
+ The result is bounded volume: at most one ``rule.denied`` event
+ per matched-and-restrictive rule + at most one
+ ``hook.summary`` per hook end (and only when it carries data).
+ """
+ if _mode_str(event.data.get("enforcement_mode")) == "DISABLED":
+ return False
+
+ if event.event_type == EventType.RULE_EVALUATION:
+ if not event.data.get("matched"):
+ return False
+ action = str(event.data.get("action") or "allow").lower()
+ return action != "allow"
+ if event.event_type == EventType.HOOK_END:
+ data = event.data
+ total = int(data.get("total_rules") or 0)
+ skipped = int(data.get("skipped_count") or 0)
+ return not (total == 0 and skipped == 0)
+ return False
+
+ def emit(self, event: AuditEvent) -> None:
+ """Render the event and dispatch via the injected callable.
+
+ Re-checks ``matched`` for rule evaluations as a defense-in-depth
+ guard. :meth:`accepts` already drops passed rules at the
+ :class:`AuditManager` dispatch boundary, but a direct
+ ``sink.emit(event)`` call (tests, future alternate dispatch
+ paths) must not route a passed rule into the
+ ``governance.rule.denied`` telemetry stream.
+
+ Errors from ``track_event`` propagate to the audit manager,
+ which has its own circuit breaker (10 consecutive failures →
+ sink tripped for the rest of the process). The sink itself
+ doesn't catch — letting the manager track failure rate is the
+ whole point of the sink-failure protocol.
+ """
+ # DISABLED governance = no telemetry, full stop. Same
+ # defense-in-depth rationale as the matched/allow check below:
+ # ``accepts`` drops these at the manager boundary, but a
+ # direct ``sink.emit`` call must not leak a DISABLED-mode
+ # event downstream either.
+ if _mode_str(event.data.get("enforcement_mode")) == "DISABLED":
+ return
+
+ if event.event_type == EventType.RULE_EVALUATION:
+ if not event.data.get("matched"):
+ return
+ action = str(event.data.get("action") or "allow").lower()
+ if action == "allow":
+ # Positive informational match — not a denial. Same
+ # defense-in-depth rationale as the ``matched`` check
+ # above: ``accepts`` drops these at the manager
+ # boundary, but a direct ``sink.emit`` call must not
+ # leak them into the ``rule.denied`` stream.
+ return
+ self._emit_rule_denied(event)
+ elif event.event_type == EventType.HOOK_END:
+ data = event.data
+ total = int(data.get("total_rules") or 0)
+ skipped = int(data.get("skipped_count") or 0)
+ if total == 0 and skipped == 0:
+ # Empty hook — same defense-in-depth as the disabled
+ # mode and matched-allow filters.
+ return
+ self._emit_hook_summary(event)
+ # Other event types are filtered out at accepts(); reaching
+ # here for anything else would be a contract violation by the
+ # manager, not the sink.
+
+ def _common_payload(self, event: AuditEvent) -> dict[str, Any]:
+ """Per-runtime constants + event identifiers stamped on every payload."""
+ payload: dict[str, Any] = dict(self._meta.as_payload())
+ payload["agent_name"] = event.agent_name
+ payload["hook"] = event.hook
+ payload["timestamp"] = event.timestamp.isoformat()
+ return payload
+
+ def _emit_rule_denied(self, event: AuditEvent) -> None:
+ """Emit one ``governance.rule.denied`` per matched rule."""
+ data = event.data
+ mode = _mode_str(data.get("enforcement_mode"))
+ configured_action = str(data.get("action") or "allow")
+ evaluator_result = _evaluator_result(configured_action)
+ action_applied = _action_applied(evaluator_result, configured_action, mode)
+
+ payload = self._common_payload(event)
+ payload.update(
+ {
+ "pack": str(data.get("pack_name") or ""),
+ "clause": str(data.get("policy_id") or ""),
+ "rule_name": str(data.get("rule_name") or ""),
+ "mode": mode,
+ "evaluator_result": evaluator_result,
+ "action_applied": action_applied,
+ "duration_ms": float(data.get("duration_ms") or 0.0),
+ "mapped_to_uipath": bool(data.get("mapped_to_uipath", False)),
+ "detail": str(data.get("detail") or ""),
+ }
+ )
+ self._track_event(
+ event_name=EVENT_RULE_DENIED,
+ data=payload,
+ operation_id=_resolve_operation_id(),
+ )
+
+ def _emit_hook_summary(self, event: AuditEvent) -> None:
+ """Emit one ``governance.hook.summary`` per hook end."""
+ data = event.data
+ mode = _mode_str(data.get("enforcement_mode"))
+
+ payload = self._common_payload(event)
+ payload.update(
+ {
+ "mode": mode,
+ "passed_count": int(data.get("passed_count") or 0),
+ "skipped_count": int(data.get("skipped_count") or 0),
+ "skipped_policy_names": list(data.get("skipped_policy_names") or []),
+ "denied_count": int(data.get("denied_count") or 0),
+ "guardrail_dispatched_count": int(
+ data.get("guardrail_dispatched_count") or 0
+ ),
+ "duration_ms": float(data.get("duration_ms") or 0.0),
+ "final_action": str(data.get("final_action") or "allow").upper(),
+ }
+ )
+ self._track_event(
+ event_name=EVENT_HOOK_SUMMARY,
+ data=payload,
+ operation_id=_resolve_operation_id(),
+ )
diff --git a/src/uipath/runtime/governance/native/__init__.py b/src/uipath/runtime/governance/native/__init__.py
new file mode 100644
index 00000000..e3bca078
--- /dev/null
+++ b/src/uipath/runtime/governance/native/__init__.py
@@ -0,0 +1,40 @@
+"""Native UiPath governance policy evaluator.
+
+Rules evaluated in-process at each agent lifecycle hook. The caller
+fetches the policy pack and compiles it into a :class:`PolicyIndex`
+outside this package — the runtime never sees the wire format. Any
+provider, any code path, any format is fair game; this package only
+consumes the compiled index.
+
+This subpackage owns:
+
+- :class:`GovernanceEvaluator` – the evaluator implementation.
+- The native policy model: :class:`Rule`, :class:`Check`,
+ :class:`Condition`, :class:`PolicyIndex`, :class:`PolicyPack`.
+
+Shared output types (``Action``, ``AuditRecord``, …) live in
+:mod:`uipath.core.governance`.
+"""
+
+from .evaluator import GovernanceEvaluator
+from .models import (
+ Check,
+ CheckContext,
+ Condition,
+ PolicyIndex,
+ PolicyPack,
+ Rule,
+ Severity,
+)
+
+__all__ = [
+ "GovernanceEvaluator",
+ # Native policy model
+ "Check",
+ "CheckContext",
+ "Condition",
+ "PolicyIndex",
+ "PolicyPack",
+ "Rule",
+ "Severity",
+]
diff --git a/src/uipath/runtime/governance/native/evaluator.py b/src/uipath/runtime/governance/native/evaluator.py
new file mode 100644
index 00000000..a9c60a60
--- /dev/null
+++ b/src/uipath/runtime/governance/native/evaluator.py
@@ -0,0 +1,1188 @@
+"""Governance rule evaluator.
+
+Instance-scoped — every :class:`GovernanceRuntime` constructs its own
+evaluator with explicit dependencies (audit manager, compensator,
+enforcement mode). The evaluator does not reach across the runtime
+layer through process-globals; the wiring layer composes the runtime
+graph and the evaluator consumes what it's given.
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+import re
+import time
+from collections import Counter
+from datetime import datetime, timezone
+from functools import cache, lru_cache
+from typing import Any
+
+from uipath.core.governance import EnforcementMode
+from uipath.core.governance.exceptions import GovernanceBlockException
+from uipath.core.governance.models import (
+ Action,
+ AuditRecord,
+ LifecycleHook,
+ RuleEvaluation,
+)
+
+from uipath.runtime.governance._audit.base import AuditManager
+from uipath.runtime.governance.native.guardrail_compensation import (
+ GuardrailCompensator,
+ disabled_guardrails,
+)
+from uipath.runtime.governance.native.models import (
+ Check,
+ CheckContext,
+ Condition,
+ PolicyIndex,
+ Rule,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _compensation_data_for_hook(context: CheckContext) -> dict[str, Any]:
+ """Build the ``data`` payload for the compensating-governance call.
+
+ Forwards whichever :class:`CheckContext` field is populated for the
+ active hook so the compensating call evaluates the same content
+ the evaluator was looking at. Fields not relevant to the hook are
+ omitted to keep the payload tight.
+ """
+ if context.hook in (LifecycleHook.BEFORE_AGENT,):
+ return {"content": context.agent_input}
+ if context.hook in (LifecycleHook.AFTER_AGENT,):
+ return {"content": context.agent_output}
+ if context.hook in (LifecycleHook.BEFORE_MODEL,):
+ payload: dict[str, Any] = {"content": context.model_input}
+ if context.messages:
+ payload["messages"] = context.messages
+ return payload
+ if context.hook in (LifecycleHook.AFTER_MODEL,):
+ return {"content": context.model_output}
+ if context.hook in (LifecycleHook.TOOL_CALL,):
+ return {"tool_name": context.tool_name, "tool_args": context.tool_args}
+ if context.hook in (LifecycleHook.AFTER_TOOL,):
+ return {"tool_name": context.tool_name, "tool_result": context.tool_result}
+ # Memory-write and unknown hooks: pass an empty content so the
+ # server still receives a structurally-valid payload.
+ return {"content": ""}
+
+
+@lru_cache(maxsize=256)
+def _compile_regex(pattern: str) -> re.Pattern[str] | None:
+ """Compile and cache a regex pattern.
+
+ Args:
+ pattern: The regex pattern string
+
+ Returns:
+ Compiled pattern or None if invalid
+ """
+ try:
+ return re.compile(pattern)
+ except re.error as e:
+ logger.warning("Invalid regex pattern '%s': %s", pattern, e)
+ return None
+
+
+# --- vaderSentiment: lazy-imported singleton ---
+# Hard dependency, but lazy-loaded to keep import-time cost off the
+# critical path. The except branch is defence against a corrupted
+# install (file present in METADATA but module unimportable) — the
+# operator no-ops rather than crashing the agent.
+@cache
+def _get_vader_analyzer() -> Any:
+ """Return a cached SentimentIntensityAnalyzer, or None if unavailable."""
+ try:
+ from vaderSentiment.vaderSentiment import ( # type: ignore[import-untyped]
+ SentimentIntensityAnalyzer,
+ )
+
+ return SentimentIntensityAnalyzer()
+ except ImportError:
+ logger.error(
+ "vaderSentiment failed to import despite being a hard dependency; "
+ "sentiment_concern checks will not fire."
+ )
+ return None
+
+
+# --- chardet: lazy-imported module for encoding integrity (A.7.4) ---
+# Hard dependency, lazy-loaded for symmetry with the other library
+# wrappers. The except branch covers corrupted installs only.
+@cache
+def _get_chardet() -> Any:
+ """Return the chardet module, or None if unavailable."""
+ try:
+ import chardet
+
+ return chardet
+ except ImportError:
+ logger.error(
+ "chardet failed to import despite being a hard dependency; "
+ "encoding_concern confidence check will not fire (stdlib "
+ "signals still apply)."
+ )
+ return None
+
+
+# --- Static patterns for encoding_concern (A.7.4) ---
+# Latin-1-as-UTF-8 mojibake bigrams — the visible artefacts when
+# UTF-8-encoded text is re-decoded as Latin-1 / Windows-1252.
+_MOJIBAKE_BIGRAMS: tuple[str, ...] = (
+ "é",
+ "è",
+ "â",
+ "Ã ",
+ "ù",
+ "î",
+ "ô",
+ "ç", # accented vowels
+ "Ä",
+ "Ö",
+ "Ü",
+ "ß", # German umlauts / eszett
+ "’",
+ "“",
+ "â€\x9d",
+ "–",
+ "—",
+ "•", # smart quotes / dashes
+ "£",
+ "°",
+ "§",
+ "¶",
+ "©",
+ "®", # NBSP-leading symbols
+ "ï¿",
+ "¿½", # mojibake'd U+FFFD (0xEF 0xBF 0xBD as Latin-1)
+ "ï»",
+ "»¿", # mojibake'd BOM (0xEF 0xBB 0xBF as Latin-1)
+)
+
+# Literal hex escape sequences ("\x80" as 4 source chars) indicate raw
+# bytes leaked through a string layer rather than being decoded.
+_HEX_ESCAPE_PATTERN = re.compile(r"\\x[0-9a-fA-F]{2}")
+
+
+# --- Static patterns for incident_concern (A.8.4) ---
+# Stdlib-only categorical taxonomy. Mirrors sentry-sdk's incident shape
+# (categorical types over stack/status), but for string payloads from
+# model output / tool result rather than exception objects.
+_INCIDENT_PATTERNS: dict[str, list[re.Pattern[str]]] = {
+ "safety_refusal": [
+ re.compile(
+ r"(?i)\b(i\s+(?:cannot|can'?t|am\s+unable\s+to|won'?t\s+be\s+able\s+to)"
+ r"\s+(?:help|assist|provide|answer|do\s+that))\b"
+ ),
+ re.compile(r"(?i)\b(i'?m\s+sorry,?\s+but\s+i\s+(?:cannot|can'?t))\b"),
+ re.compile(r"(?i)\b(against\s+my\s+(?:guidelines|policies|programming))\b"),
+ ],
+ "tool_failure": [
+ re.compile(
+ r"\b(5\d{2})\b\s*(?:internal\s+server\s+error|service\s+unavailable)"
+ ),
+ re.compile(r"(?i)\b(ERR_[A-Z_]+|connection\s+refused|ECONNREFUSED)\b"),
+ re.compile(r"(?i)\b(timed?\s*out)\b"),
+ ],
+ "auth_failure": [
+ re.compile(r"\b(401|403)\b\s*(?:unauthori[sz]ed|forbidden)"),
+ re.compile(
+ r"(?i)\b(authentication\s+failed|invalid\s+(?:token|credentials))\b"
+ ),
+ ],
+ "quota_exceeded": [
+ re.compile(r"\b(429)\b"),
+ re.compile(
+ r"(?i)\b(rate\s+limit\s+exceeded|quota\s+exceeded|too\s+many\s+requests)\b"
+ ),
+ ],
+ "hallucination": [
+ re.compile(r"(?i)\b(i\s+(?:made\s+(?:that|this)\s+up|am\s+just\s+guessing))\b"),
+ re.compile(r"(?i)\b(i\s+don'?t\s+actually\s+know|i\s+fabricat(?:ed|ing))\b"),
+ ],
+}
+
+# --- Static patterns for commitment_concern (A.10.4) ---
+# Commitment-language signals. The verb pattern covers both first-person
+# promise verbs ("we will refund") and formal-business commitment markers
+# common in proposal / SOW outputs ("Cost: $X", "fixed scope",
+# "Deliverables", "Timeline: N days", "I propose"). Verb, amount, and
+# deadline signals combine via OR semantics — see
+# :meth:`_check_commitment_concern`.
+_COMMITMENT_VERB_PATTERN = re.compile(
+ r"(?i)("
+ # First-person promise / liability verbs
+ r"\brefund\b|\breimburse\b|"
+ r"\bwarranty\b|\bwarrant(?:y|ed|ies)\b|\bguarante[ed]+\b|"
+ r"\bsla\b|"
+ r"\bwaive[d]?\b|"
+ r"\b(?:we|i)\s+(?:will|shall|promise|commit|guarantee)\b|"
+ r"\b(?:we|i|i'?ll)\s+(?:deliver|provide|complete|finish|"
+ r"handover|hand\s+over|ship)\b|"
+ # Proposal / SOW commitment markers
+ r"\bfixed\s+(?:price|cost|fee|scope|bid|rate)\b|"
+ r"\bcost\s*:\s*\$?\d|"
+ r"\bquote\s*:\s*\$?\d|"
+ r"\bdeliverables?\b|"
+ r"\btimeline\s*:\s*\d+\s*(?:second|minute|hour|day|week|month|year)s?\b|"
+ r"\bI\s+propose\b"
+ r")"
+)
+# Currency-anchored amount detection. Requires a currency marker adjacent
+# to the number so URL fragments (e.g. ``/667851``) don't false-positive.
+# Covers symbol-then-number ($780) and number-then-code (780 USD).
+#
+# Bare percentages (``75%``, ``99.9%``) are deliberately NOT matched
+# here — they fire on benign status / progress text ("75% complete",
+# "99.9% uptime") under OR semantics. Real percentage-bearing
+# commitments ("we'll give you a 20% discount", "refund 100%") still
+# fire via the verb pattern.
+_COMMITMENT_AMOUNT_FALLBACK = re.compile(
+ r"(?:\$|€|£|¥|₹|USD|EUR|GBP|JPY|INR)\s*\d[\d,]*(?:\.\d+)?"
+ r"|\b\d[\d,]*(?:\.\d+)?\s*(?:USD|EUR|GBP|JPY|INR|"
+ r"dollars?|euros?|pounds?|yen|rupees?)\b"
+)
+_COMMITMENT_DEADLINE_PATTERN = re.compile(
+ r"(?i)\bwithin\s+\d+\s*(?:second|minute|hour|day|week|month|year)s?\b"
+ r"|\bby\s+(?:tomorrow|next\s+\w+|\d+/\d+(?:/\d+)?)\b"
+)
+
+
+class GovernanceEvaluator:
+ """Evaluates governance rules against check contexts.
+
+ Supports two enforcement modes:
+
+ - ``AUDIT``: log all violations but never block (DENY collapses to
+ AUDIT in the final action).
+ - ``ENFORCE``: actually block on DENY rules — raises
+ :class:`GovernanceBlockException` and the agent stops.
+
+ All dependencies (mode, audit manager, compensator) are injected
+ via the constructor. The evaluator does not consult any
+ process-global state — parallel runtimes (``uipath eval``) get
+ their own evaluator with their own audit + compensation pipelines.
+ """
+
+ def __init__(
+ self,
+ policy_index: PolicyIndex,
+ *,
+ enforcement_mode: EnforcementMode = EnforcementMode.AUDIT,
+ audit_manager: AuditManager | None = None,
+ compensator: GuardrailCompensator | None = None,
+ ) -> None:
+ """Initialize with a compiled policy index and runtime-scoped deps.
+
+ Args:
+ policy_index: The compiled :class:`PolicyIndex` to evaluate.
+ Typically read from :attr:`GovernanceRuntime.policy_index`
+ — built by the caller from whatever policy source they
+ own; the evaluator only consumes the compiled index.
+ enforcement_mode: Mode the evaluator applies. Defaults to
+ ``AUDIT`` — the safe default for callers that don't
+ explicitly opt in to ENFORCE. The wiring layer should
+ pass ``runtime.enforcement_mode`` here so the evaluator
+ and the wrapping :class:`GovernanceRuntime` agree on a
+ single source of truth.
+ audit_manager: Per-runtime :class:`AuditManager`. When
+ ``None`` the evaluator runs silently (no audit events
+ emitted). Tests that don't care about emission can
+ leave this out.
+ compensator: Per-runtime :class:`GuardrailCompensator`
+ used to dispatch compensating-governance calls for
+ guardrail-fallback rules. When ``None`` such dispatch
+ is skipped — the evaluator still records the matched
+ rules in the :class:`AuditRecord`.
+ """
+ self._policy_index = policy_index
+ self._enforcement_mode = enforcement_mode
+ self._audit_manager = audit_manager
+ self._compensator = compensator
+
+ @property
+ def policy_index(self) -> PolicyIndex:
+ """Return the compiled policy index this evaluator runs against."""
+ return self._policy_index
+
+ @property
+ def mode(self) -> EnforcementMode:
+ """The enforcement mode this evaluator applies."""
+ return self._enforcement_mode
+
+ def is_audit_mode(self) -> bool:
+ """Check if running in audit-only mode."""
+ return self._enforcement_mode == EnforcementMode.AUDIT
+
+ def evaluate(self, context: CheckContext) -> AuditRecord:
+ """Evaluate rules registered for ``context.hook`` against the context.
+
+ Only rules whose ``hook`` field matches the current lifecycle hook
+ are evaluated — a ``tool_call`` rule does not fire on
+ ``before_model``, and vice versa. This avoids running checks
+ against fields the context cannot provide and keeps the audit
+ stream scoped to the active phase.
+
+ The final action depends on the enforcement mode:
+ - DISABLED mode: Short-circuit; no rules evaluated, no audit emitted.
+ - AUDIT mode: Even DENY rules result in AUDIT action (log only, don't block)
+ - ENFORCE mode: DENY rules result in DENY action AND a
+ :class:`GovernanceBlockException` is raised.
+
+ Audit events (per-rule + hook summary) are emitted via the
+ :class:`AuditManager` injected at construction (skipped when
+ none was supplied).
+
+ Args:
+ context: The check context with hook and content
+
+ Returns:
+ AuditRecord with all evaluations and final action.
+
+ Raises:
+ GovernanceBlockException: In ENFORCE mode when a DENY rule matches.
+ """
+ mode = self._enforcement_mode
+ if mode == EnforcementMode.DISABLED:
+ return AuditRecord(
+ timestamp=datetime.now(timezone.utc),
+ agent_name=context.agent_name,
+ runtime_id=context.runtime_id,
+ hook=context.hook,
+ evaluations=[],
+ final_action=Action.ALLOW,
+ metadata={**context.metadata, "enforcement_mode": mode.value},
+ )
+
+ rules = self._policy_index.get_rules_for_hook(context.hook)
+
+ evaluations: list[RuleEvaluation] = []
+ # Per-rule wall time (ms), keyed by rule_id. Forwarded to the
+ # telemetry sink via emit_rule_evaluation(duration_ms=...) so
+ # operators can see which rules are slow.
+ rule_durations: dict[str, float] = {}
+ # Disabled rules — never evaluated, surfaced in the hook
+ # summary's skipped_policy_names so dashboards can spot which
+ # policies a tenant turned off.
+ skipped_policy_ids: list[str] = []
+ raw_action = Action.ALLOW # The action before mode adjustment
+ deny_would_fire = False # Track if DENY would have fired
+
+ hook_start = time.monotonic()
+
+ for rule in rules:
+ if not rule.enabled:
+ skipped_policy_ids.append(rule.rule_id)
+ continue
+
+ rule_start = time.monotonic()
+ evaluation = self._evaluate_rule(rule, context)
+ rule_durations[rule.rule_id] = (time.monotonic() - rule_start) * 1000.0
+ evaluations.append(evaluation)
+
+ if evaluation.matched:
+ # Take the most restrictive action. Use evaluation.action
+ # (which already folds in per-check overrides), not
+ # rule.action, so check-level overrides are honored here too.
+ eval_action = evaluation.action
+ if eval_action == Action.DENY:
+ raw_action = Action.DENY
+ deny_would_fire = True
+ elif eval_action == Action.ESCALATE and raw_action != Action.DENY:
+ raw_action = Action.ESCALATE
+ elif eval_action == Action.AUDIT and raw_action == Action.ALLOW:
+ raw_action = Action.AUDIT
+
+ hook_duration_ms = (time.monotonic() - hook_start) * 1000.0
+
+ # Apply enforcement mode
+ final_action = self._apply_enforcement_mode(raw_action)
+
+ # Build metadata with mode info
+ record_metadata = dict(context.metadata)
+ record_metadata["enforcement_mode"] = mode.value
+ if deny_would_fire and self.is_audit_mode():
+ record_metadata["audit_mode_would_deny"] = True
+
+ audit = AuditRecord(
+ timestamp=datetime.now(timezone.utc),
+ agent_name=context.agent_name,
+ runtime_id=context.runtime_id,
+ hook=context.hook,
+ evaluations=evaluations,
+ final_action=final_action,
+ metadata=record_metadata,
+ )
+
+ self._emit_audit(
+ audit,
+ mode,
+ rule_durations=rule_durations,
+ skipped_policy_ids=skipped_policy_ids,
+ hook_duration_ms=hook_duration_ms,
+ )
+
+ # For any guardrail mapped to UiPath but currently disabled,
+ # dispatch a compensating-governance call via the injected
+ # compensator. The compensating call (provider-owned) runs the
+ # real guardrail check and writes its own audit trace — the
+ # evaluator does NOT emit a Python-side trace for these rules,
+ # to avoid double-writing. The provider is expected to be
+ # non-blocking (batched / async / fire-and-forget internally);
+ # the runtime layer no longer owns that scheduling.
+ self._dispatch_compensation(audit, context)
+
+ if final_action == Action.DENY:
+ raise GovernanceBlockException.from_audit_record(audit)
+
+ return audit
+
+ def _dispatch_compensation(self, audit: AuditRecord, context: CheckContext) -> None:
+ """Dispatch compensating governance for any matched fallback rules.
+
+ Delegates to the injected :class:`GuardrailCompensator`, which
+ builds the wire payload and hands it to the provider. This
+ method picks the fallback rules out of the audit, logs the
+ summary, and submits.
+
+ No-op when no compensator was supplied at construction (e.g.
+ unit tests that don't care about the dispatch path).
+ """
+ if self._compensator is None:
+ return
+
+ try:
+ disabled = disabled_guardrails(audit, self._policy_index)
+ if not disabled:
+ return
+
+ # Distinct validator names for the operator-facing log line.
+ validators = [rule.validator for rule in disabled]
+
+ # Surface the disabled-guardrail fire-up: how many rules
+ # triggered the compensating call, and which validators
+ # they map to (e.g. pii_detection / prompt_injection /
+ # harmful_content). One line per dispatch so an operator
+ # can see the volume + breakdown at a glance.
+ logger.info(
+ "Compensating governance triggered: hook=%s, count=%d, validators=[%s]",
+ audit.hook.value,
+ len(disabled),
+ ", ".join(validators),
+ )
+
+ self._compensator.submit(
+ rules=disabled,
+ data=_compensation_data_for_hook(context),
+ hook=audit.hook.value,
+ src_timestamp=audit.timestamp.isoformat(),
+ agent_name=audit.agent_name,
+ runtime_id=audit.runtime_id,
+ )
+ except Exception as exc: # noqa: BLE001 - fail-open
+ logger.warning("Failed to dispatch compensating governance call: %s", exc)
+
+ def _emit_audit(
+ self,
+ audit: AuditRecord,
+ mode: EnforcementMode,
+ *,
+ rule_durations: dict[str, float] | None = None,
+ skipped_policy_ids: list[str] | None = None,
+ hook_duration_ms: float = 0.0,
+ ) -> None:
+ """Emit per-rule and hook-summary events to the injected audit manager.
+
+ No-op when no audit manager was supplied at construction. The
+ per-runtime :class:`AuditManager` handles sink-level circuit
+ breaking; emission errors stay there and never break evaluation.
+
+ Args:
+ audit: Resolved :class:`AuditRecord` produced by
+ :meth:`evaluate`.
+ mode: Active :class:`EnforcementMode`.
+ rule_durations: Per-rule wall time in ms, keyed by
+ ``rule_id``. Looked up when emitting individual
+ rule-evaluation events; unknown ids default to 0.
+ skipped_policy_ids: Ids of rules that were disabled and
+ therefore not evaluated. Folded into the hook
+ summary's ``skipped_policy_names``.
+ hook_duration_ms: Total wall time spent evaluating this
+ hook. Folded into the hook summary's ``duration_ms``.
+ """
+ manager = self._audit_manager
+ if manager is None:
+ return
+
+ hook_name = audit.hook.name
+ durations = rule_durations or {}
+
+ # ``guardrail_fallback`` rules are traced by the compensating
+ # path (see :meth:`_dispatch_compensation`), which carries the
+ # actual validator verdict. Emitting a Python-side
+ # ``rule_evaluation`` event here would produce a duplicate
+ # trace carrying no verdict, so filter these rules out of every
+ # per-rule event the evaluator emits AND out of the hook
+ # summary's passed/denied counts. The summary keeps a separate
+ # ``guardrail_dispatched_count`` so the mapped-vs-unmapped
+ # dimension is still queryable.
+ emittable = [
+ ev
+ for ev in audit.evaluations
+ if not self._is_guardrail_fallback_rule(ev.rule_id)
+ ]
+ guardrail_dispatched_count = sum(
+ 1
+ for ev in audit.evaluations
+ if ev.matched and self._is_guardrail_fallback_rule(ev.rule_id)
+ )
+
+ # No emittable rules means every match this hook was a
+ # guardrail-fallback already traced by the compensation path.
+ # Skip the hook summary entirely — emitting it with
+ # total_rules=0 + the audit's overall final_action would
+ # double-count the compensation-owned verdict.
+ if not emittable:
+ return
+
+ for evaluation in emittable:
+ manager.emit_rule_evaluation(
+ policy_id=evaluation.rule_id,
+ rule_name=evaluation.rule_name,
+ pack_name=evaluation.pack_name,
+ hook=hook_name,
+ matched=evaluation.matched,
+ action=evaluation.action.value if evaluation.matched else "allow",
+ enforcement_mode=mode,
+ detail=evaluation.detail,
+ agent_name=audit.agent_name,
+ description=evaluation.description,
+ duration_ms=durations.get(evaluation.rule_id, 0.0),
+ # Per-rule events the evaluator emits are never for
+ # UiPath-mapped guardrails (those are filtered out of
+ # ``emittable`` above and traced by the compensating
+ # path). Stamping ``False`` keeps the schema stable
+ # for sinks.
+ mapped_to_uipath=False,
+ )
+
+ # All summary metrics derive from ``emittable`` only —
+ # ``audit.final_action`` folded in fallback rules whose
+ # verdict travels on the compensation path, so using it here
+ # would mix the two trace paths' verdicts.
+ #
+ # ``matched_rules`` keeps the historical "any check matched"
+ # semantic (used by the legacy traces sink). ``denied_count``
+ # is the precise spec verdict — only rules whose configured
+ # action would actually act (not ``allow``). A matched rule
+ # configured as ``allow`` is an explicit positive match and
+ # rolls into ``passed_count`` via the manager's arithmetic.
+ matched_rules = sum(1 for ev in emittable if ev.matched)
+ denied_count = sum(
+ 1 for ev in emittable if ev.matched and ev.action != Action.ALLOW
+ )
+ summary_final_action = self._apply_enforcement_mode(
+ self._most_restrictive_matched_action(emittable)
+ )
+
+ manager.emit_hook_summary(
+ hook=hook_name,
+ agent_name=audit.agent_name,
+ total_rules=len(emittable),
+ matched_rules=matched_rules,
+ final_action=summary_final_action.value,
+ enforcement_mode=mode,
+ duration_ms=hook_duration_ms,
+ skipped_policy_names=list(skipped_policy_ids or []),
+ guardrail_dispatched_count=guardrail_dispatched_count,
+ denied_count=denied_count,
+ )
+
+ @staticmethod
+ def _most_restrictive_matched_action(
+ evals: list[RuleEvaluation],
+ ) -> Action:
+ """Return the most-restrictive matched action across ``evals``.
+
+ Mirrors the cross-rule aggregation in :meth:`evaluate`:
+ DENY > ESCALATE > AUDIT > ALLOW. Unmatched rules contribute
+ nothing.
+ """
+ result = Action.ALLOW
+ for ev in evals:
+ if not ev.matched:
+ continue
+ a = ev.action
+ if a == Action.DENY:
+ return Action.DENY
+ if a == Action.ESCALATE and result != Action.DENY:
+ result = Action.ESCALATE
+ elif a == Action.AUDIT and result == Action.ALLOW:
+ result = Action.AUDIT
+ return result
+
+ def _is_guardrail_fallback_rule(self, rule_id: str) -> bool:
+ """Return True if the rule carries a ``guardrail_fallback`` condition.
+
+ Such rules are traced by the compensating path, so the
+ evaluator must not emit a duplicate Python-side trace for them.
+ """
+ rule = self._policy_index.get_rule(rule_id)
+ if rule is None:
+ return False
+ for check in rule.checks:
+ for cond in check.conditions:
+ if cond.operator == "guardrail_fallback":
+ return True
+ return False
+
+ def _apply_enforcement_mode(self, raw_action: Action) -> Action:
+ """Apply enforcement mode to the raw action.
+
+ In AUDIT mode:
+ - DENY becomes AUDIT (log but don't block)
+ - ESCALATE becomes AUDIT (log but don't escalate)
+ - AUDIT stays AUDIT
+ - ALLOW stays ALLOW
+
+ In ENFORCE mode:
+ - All actions pass through unchanged
+ """
+ if self._enforcement_mode == EnforcementMode.AUDIT:
+ if raw_action in (Action.DENY, Action.ESCALATE):
+ return Action.AUDIT
+ return raw_action
+
+ def evaluate_before_agent(
+ self,
+ agent_input: str,
+ agent_name: str,
+ runtime_id: str,
+ model_name: str = "",
+ **kwargs: Any,
+ ) -> AuditRecord:
+ """Evaluate BEFORE_AGENT rules."""
+ context = CheckContext(
+ hook=LifecycleHook.BEFORE_AGENT,
+ agent_name=agent_name,
+ runtime_id=runtime_id,
+ agent_input=agent_input,
+ model_name=model_name,
+ metadata=kwargs.get("metadata", {}),
+ )
+ return self.evaluate(context)
+
+ def evaluate_after_agent(
+ self,
+ agent_output: str,
+ agent_name: str,
+ runtime_id: str,
+ **kwargs: Any,
+ ) -> AuditRecord:
+ """Evaluate AFTER_AGENT rules."""
+ context = CheckContext(
+ hook=LifecycleHook.AFTER_AGENT,
+ agent_name=agent_name,
+ runtime_id=runtime_id,
+ agent_output=agent_output,
+ metadata=kwargs.get("metadata", {}),
+ )
+ return self.evaluate(context)
+
+ def evaluate_before_model(
+ self,
+ model_input: str,
+ agent_name: str,
+ runtime_id: str,
+ messages: list[dict[str, Any]] | None = None,
+ model_name: str = "",
+ **kwargs: Any,
+ ) -> AuditRecord:
+ """Evaluate BEFORE_MODEL rules."""
+ context = CheckContext(
+ hook=LifecycleHook.BEFORE_MODEL,
+ agent_name=agent_name,
+ runtime_id=runtime_id,
+ model_input=model_input,
+ model_name=model_name,
+ messages=messages or [],
+ metadata=kwargs.get("metadata", {}),
+ )
+ return self.evaluate(context)
+
+ def evaluate_after_model(
+ self,
+ model_output: str,
+ agent_name: str,
+ runtime_id: str,
+ **kwargs: Any,
+ ) -> AuditRecord:
+ """Evaluate AFTER_MODEL rules."""
+ context = CheckContext(
+ hook=LifecycleHook.AFTER_MODEL,
+ agent_name=agent_name,
+ runtime_id=runtime_id,
+ model_output=model_output,
+ metadata=kwargs.get("metadata", {}),
+ )
+ return self.evaluate(context)
+
+ def evaluate_tool_call(
+ self,
+ tool_name: str,
+ tool_args: dict[str, Any],
+ agent_name: str,
+ runtime_id: str,
+ session_state: dict[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> AuditRecord:
+ """Evaluate TOOL_CALL rules."""
+ context = CheckContext(
+ hook=LifecycleHook.TOOL_CALL,
+ agent_name=agent_name,
+ runtime_id=runtime_id,
+ tool_name=tool_name,
+ tool_args=tool_args,
+ session_state=session_state or {},
+ metadata=kwargs.get("metadata", {}),
+ )
+ return self.evaluate(context)
+
+ def evaluate_after_tool(
+ self,
+ tool_name: str,
+ tool_result: str,
+ agent_name: str,
+ runtime_id: str,
+ **kwargs: Any,
+ ) -> AuditRecord:
+ """Evaluate AFTER_TOOL rules."""
+ context = CheckContext(
+ hook=LifecycleHook.AFTER_TOOL,
+ agent_name=agent_name,
+ runtime_id=runtime_id,
+ tool_name=tool_name,
+ tool_result=tool_result,
+ metadata=kwargs.get("metadata", {}),
+ )
+ return self.evaluate(context)
+
+ def _evaluate_rule(self, rule: Rule, context: CheckContext) -> RuleEvaluation:
+ """Evaluate a single rule against the context."""
+ if not rule.checks:
+ # No checks = always matches (for audit-only rules)
+ return RuleEvaluation(
+ rule_id=rule.rule_id,
+ rule_name=rule.name,
+ matched=True,
+ detail="Rule has no conditions (always matches)",
+ pack_name=rule.pack_name,
+ action=rule.action,
+ description=rule.description,
+ )
+
+ check_results: list[dict[str, Any]] = []
+ any_check_matched = False
+ # Resolve the rule's action from the MATCHED checks so per-check
+ # `action` overrides take effect. ``Check.action`` defaults to the
+ # rule's action (see _yaml_to_index), so for rules without an
+ # override this equals ``rule.action`` exactly. Take the most
+ # restrictive matched action (DENY > ESCALATE > AUDIT > ALLOW),
+ # mirroring evaluate()'s cross-rule aggregation.
+ matched_action = Action.ALLOW
+
+ for check in rule.checks:
+ matched, detail = self._evaluate_check(check, context)
+ check_results.append(
+ {
+ "matched": matched,
+ "detail": detail,
+ "action": check.action.value,
+ }
+ )
+ if matched:
+ any_check_matched = True
+ if check.action == Action.DENY:
+ matched_action = Action.DENY
+ elif check.action == Action.ESCALATE and matched_action != Action.DENY:
+ matched_action = Action.ESCALATE
+ elif check.action == Action.AUDIT and matched_action == Action.ALLOW:
+ matched_action = Action.AUDIT
+
+ # Surface the FIRST matched check's message; falls back to the
+ # first check's detail (empty string when none matched) for
+ # backward compatibility with rules that have a single check.
+ first_matched_detail = next(
+ (cr["detail"] for cr in check_results if cr["matched"]),
+ check_results[0]["detail"] if check_results else "",
+ )
+
+ return RuleEvaluation(
+ rule_id=rule.rule_id,
+ rule_name=rule.name,
+ matched=any_check_matched,
+ detail=first_matched_detail,
+ pack_name=rule.pack_name,
+ action=matched_action if any_check_matched else Action.ALLOW,
+ description=rule.description,
+ check_results=check_results,
+ )
+
+ def _evaluate_check(self, check: Check, context: CheckContext) -> tuple[bool, str]:
+ """Evaluate a single check against the context."""
+ if not check.conditions:
+ return True, "No conditions (always matches)"
+
+ results = []
+ for condition in check.conditions:
+ matched = self._evaluate_condition(condition, context)
+ results.append(matched)
+
+ if check.logic == "any":
+ final_match = any(results)
+ else: # "all" is default
+ final_match = all(results)
+
+ detail = check.message if final_match else ""
+ return final_match, detail
+
+ def _evaluate_condition(self, condition: Condition, context: CheckContext) -> bool:
+ """Evaluate a single condition against the context."""
+ field_value = self._get_field_value(condition.field, context)
+ result = self._apply_operator(condition.operator, field_value, condition.value)
+
+ if condition.negate:
+ result = not result
+
+ return result
+
+ def _get_field_value(self, field: str, context: CheckContext) -> Any:
+ """Get a field value from the context."""
+ parts = field.split(".")
+
+ # Start with context
+ value: Any = context
+
+ for part in parts:
+ if hasattr(value, part):
+ value = getattr(value, part)
+ elif isinstance(value, dict) and part in value:
+ value = value[part]
+ else:
+ return None
+
+ return value
+
+ def _apply_operator(
+ self, operator: str, field_value: Any, check_value: Any
+ ) -> bool:
+ """Apply an operator to compare field value against check value."""
+ # Handle existence checks before the None check
+ if operator == "exists":
+ return field_value is not None
+ if operator == "not_exists":
+ return field_value is None
+
+ # guardrail_fallback fires only when the guardrail is mapped to
+ # UiPath but its policy is disabled. Config travels in
+ # ``check_value``; the rule's ``field`` is unused (so
+ # ``field_value`` is ``None`` here, which is expected — we must
+ # special-case this before the generic ``None`` short-circuit
+ # below).
+ if operator == "guardrail_fallback":
+ cfg = check_value if isinstance(check_value, dict) else {}
+ return bool(cfg.get("mapped_to_uipath", False)) and not bool(
+ cfg.get("policy_enabled", True)
+ )
+
+ if field_value is None:
+ return False
+
+ # Numeric operators don't need stringification — short-circuit
+ # before `str(field_value)` (expensive for dict / large payloads).
+ if operator in ("gt", "gte", "lt", "lte"):
+ try:
+ lhs = float(field_value)
+ rhs = float(check_value)
+ except (ValueError, TypeError):
+ return False
+ if operator == "gt":
+ return lhs > rhs
+ if operator == "gte":
+ return lhs >= rhs
+ if operator == "lt":
+ return lhs < rhs
+ return lhs <= rhs
+
+ field_str = str(field_value)
+
+ match operator:
+ case "equals" | "eq":
+ return field_str == str(check_value)
+
+ case "not_equals" | "ne":
+ return field_str != str(check_value)
+
+ case "contains":
+ return str(check_value).lower() in field_str.lower()
+
+ case "not_contains":
+ return str(check_value).lower() not in field_str.lower()
+
+ case "regex" | "matches":
+ compiled = _compile_regex(str(check_value))
+ if compiled is None:
+ return False
+ return bool(compiled.search(field_str))
+
+ case "in_list":
+ if isinstance(check_value, list):
+ return field_str in check_value
+ return False
+
+ case "not_in_list":
+ if isinstance(check_value, list):
+ return field_str not in check_value
+ return True
+
+ case "vader_concern":
+ # VADER compound score <= threshold.
+ # check_value: dict like {"threshold": -0.3} (default -0.3)
+ return self._check_vader_concern(field_str, check_value)
+
+ case "encoding_concern":
+ # chardet-backed encoding integrity check (A.7.4).
+ # check_value: dict with optional `min_confidence` (default 0.5)
+ # and `max_replacement_ratio` (default 0.05).
+ return self._check_encoding_concern(field_str, check_value)
+
+ case "entropy_concern":
+ # Shannon entropy outside expected range (A.7.4).
+ # check_value: dict with optional `min` (default 1.5) and
+ # `max` (default 7.5) bits/byte. Stdlib only.
+ return self._check_entropy_concern(field_str, check_value)
+
+ case "incident_concern":
+ # Categorical incident detection (A.8.4).
+ # check_value: dict with optional `categories` list
+ # (subset of safety_refusal/tool_failure/auth_failure/
+ # quota_exceeded/hallucination). Default: all categories.
+ return self._check_incident_concern(field_str, check_value)
+
+ case "commitment_concern":
+ # Customer commitment language detection (A.10.4).
+ # check_value: dict with optional `require_amount` (default
+ # True) and `require_deadline` (default False). Fires when
+ # a commitment verb co-occurs with the configured signals.
+ return self._check_commitment_concern(field_str, check_value)
+
+ case _:
+ logger.debug("Unknown operator: %s", operator)
+ return False
+
+ @staticmethod
+ def _check_vader_concern(text: str, params: Any) -> bool:
+ """Return True if VADER compound score on `text` is <= threshold.
+
+ Args:
+ text: Text to analyse.
+ params: Either a dict with `threshold` key, or a numeric threshold
+ directly. Default threshold is -0.3 (clearly-negative).
+
+ Returns:
+ True iff vaderSentiment is available AND compound score <= threshold.
+ Returns False on empty input or if the library is not installed —
+ sentiment checks no-op rather than crash.
+ """
+ if not text or not text.strip():
+ return False
+
+ analyzer = _get_vader_analyzer()
+ if analyzer is None:
+ return False
+
+ if isinstance(params, dict):
+ threshold = float(params.get("threshold", -0.3))
+ else:
+ try:
+ threshold = float(params)
+ except (TypeError, ValueError):
+ threshold = -0.3
+
+ try:
+ compound = float(analyzer.polarity_scores(text)["compound"])
+ except Exception as exc: # pragma: no cover - defensive
+ logger.debug("VADER analysis failed: %s", exc)
+ return False
+
+ return compound <= threshold
+
+ @staticmethod
+ def _check_encoding_concern(text: str, params: Any) -> bool:
+ r"""Return True if `text` shows encoding integrity issues.
+
+ Sums multiple deterministic corruption signals against text length:
+ - U+FFFD replacement characters (already-decoded lossy text)
+ - Literal ``�`` escape sequences carried through a JSON
+ / repr layer rather than being decoded
+ - Literal ``\xHH`` hex escapes (raw bytes leaked into a string)
+ - Latin-1-as-UTF-8 mojibake bigrams (e.g. ``é``, ``’``)
+ If the corruption ratio exceeds ``max_replacement_ratio`` the
+ check fires. chardet (when installed) is consulted as a
+ secondary low-confidence signal.
+ """
+ if not text or not text.strip():
+ return False
+
+ if not isinstance(params, dict):
+ params = {}
+ min_confidence = float(params.get("min_confidence", 0.5))
+ max_replacement_ratio = float(params.get("max_replacement_ratio", 0.05))
+ min_corruption_events = int(params.get("min_corruption_events", 2))
+
+ length = max(len(text), 1)
+
+ replacement_chars = text.count("�")
+ literal_ufffd_escapes = text.count("\\ufffd")
+ hex_escapes = len(_HEX_ESCAPE_PATTERN.findall(text))
+ mojibake_bigrams = sum(text.count(bigram) for bigram in _MOJIBAKE_BIGRAMS)
+
+ # Absolute count of distinct corruption *events* (one per
+ # U+FFFD, one per literal escape sequence, one per mojibake
+ # bigram). Even diluted by a lot of clean text, a few of these
+ # in production output is a strong signal.
+ corruption_events = (
+ replacement_chars + literal_ufffd_escapes + hex_escapes + mojibake_bigrams
+ )
+ if corruption_events >= min_corruption_events:
+ return True
+
+ # Ratio-based fallback for cases below the absolute floor: still
+ # catches very short payloads where a single corruption char is
+ # disproportionate.
+ # Weight each event by its source-char span so denser corruption
+ # in shorter text trips the ratio sooner:
+ # U+FFFD = 1 char, "�" = 6 chars, "\xHH" = 4 chars,
+ # mojibake bigram = 2 chars.
+ corruption_chars = (
+ replacement_chars
+ + 6 * literal_ufffd_escapes
+ + 4 * hex_escapes
+ + 2 * mojibake_bigrams
+ )
+ if corruption_chars / length > max_replacement_ratio:
+ return True
+
+ # Secondary: chardet on the encoded bytes. For pure str input
+ # this almost always reports high UTF-8/ASCII confidence (the
+ # branch is intentionally permissive), but it does catch bytes
+ # routed through `repr()` or `__str__` of a `bytes` object that
+ # chardet recognises as a non-UTF8 encoding with low confidence.
+ chardet = _get_chardet()
+ if chardet is None:
+ return False
+ try:
+ detection = chardet.detect(text.encode("utf-8", errors="replace"))
+ confidence = float(detection.get("confidence") or 0.0)
+ except Exception as exc: # pragma: no cover - defensive
+ logger.debug("chardet detection failed: %s", exc)
+ return False
+
+ return confidence < min_confidence
+
+ @staticmethod
+ def _check_entropy_concern(text: str, params: Any) -> bool:
+ """Return True if Shannon entropy of `text` is outside an expected range.
+
+ Stdlib-only. Entropy is computed in bits per symbol over byte
+ frequencies. English prose typically lands ~3.5–4.5 bits/byte;
+ binary noise approaches 8 bits/byte; constant/repetitive text
+ approaches 0.
+ """
+ if not text or not text.strip():
+ return False
+
+ if not isinstance(params, dict):
+ params = {}
+ lo = float(params.get("min", 1.5))
+ hi = float(params.get("max", 7.5))
+
+ data = text.encode("utf-8", errors="replace")
+ total = len(data)
+ if total == 0:
+ return False
+
+ counts = Counter(data)
+ entropy = 0.0
+ for c in counts.values():
+ p = c / total
+ entropy -= p * math.log2(p)
+
+ return entropy < lo or entropy > hi
+
+ @staticmethod
+ def _check_incident_concern(text: str, params: Any) -> bool:
+ """Return True if `text` matches any configured incident pattern (A.8.4).
+
+ Categories: safety_refusal, tool_failure, auth_failure,
+ quota_exceeded, hallucination. Pass ``{"categories": [...]}`` to
+ restrict; default scans all categories.
+ """
+ if not text or not text.strip():
+ return False
+
+ if isinstance(params, dict):
+ requested = params.get("categories")
+ else:
+ requested = None
+
+ if not requested:
+ categories = list(_INCIDENT_PATTERNS.keys())
+ else:
+ categories = [c for c in requested if c in _INCIDENT_PATTERNS]
+
+ for category in categories:
+ for pattern in _INCIDENT_PATTERNS[category]:
+ if pattern.search(text):
+ return True
+ return False
+
+ @staticmethod
+ def _check_commitment_concern(text: str, params: Any) -> bool:
+ """Return True if `text` carries customer-commitment language (A.10.4).
+
+ OR semantics: a commitment-verb match always fires; when
+ ``require_amount`` is true, a currency-anchored amount alone also
+ fires; when ``require_deadline`` is true, a deadline phrase alone
+ also fires. With both flags false the rule matches on verb only
+ (verb-only mode).
+
+ The verb pattern covers first-person promise verbs *and* proposal
+ / SOW commitment markers ("Cost: $X", "fixed scope",
+ "Deliverables", "Timeline: N days", "I propose"). The amount
+ pattern requires a currency marker adjacent to the number so URL
+ fragments don't false-positive.
+ """
+ if not text or not text.strip():
+ return False
+
+ if not isinstance(params, dict):
+ params = {}
+ require_amount = bool(params.get("require_amount", True))
+ require_deadline = bool(params.get("require_deadline", False))
+
+ verb_match = bool(_COMMITMENT_VERB_PATTERN.search(text))
+
+ # Verb-only mode: neither supporting signal is enabled.
+ if not require_amount and not require_deadline:
+ return verb_match
+
+ amount_match = require_amount and bool(_COMMITMENT_AMOUNT_FALLBACK.search(text))
+ deadline_match = require_deadline and bool(
+ _COMMITMENT_DEADLINE_PATTERN.search(text)
+ )
+ return verb_match or amount_match or deadline_match
diff --git a/src/uipath/runtime/governance/native/guardrail_compensation.py b/src/uipath/runtime/governance/native/guardrail_compensation.py
new file mode 100644
index 00000000..53fd688e
--- /dev/null
+++ b/src/uipath/runtime/governance/native/guardrail_compensation.py
@@ -0,0 +1,183 @@
+"""Compensating governance for disabled centralized guardrails.
+
+When a ``guardrail_fallback`` rule fires, this module builds the
+:class:`GovernRequest` wire payload and calls the injected
+:class:`uipath.core.governance.GovernanceCompensationProvider`. The
+provider runs the actual guardrail check and writes its own audit
+trace; the runtime layer here only assembles the request.
+
+Synchronous dispatch on the caller's thread. The provider owns
+non-blocking semantics (internal batching, async fire-and-forget, or
+whatever scheduling the host wires) — the runtime layer no longer
+holds a worker pool, semaphore, or process-exit cleanup for this
+path. Same architectural shape as :class:`AuditManager`: runtime is
+synchronous and pure; async export is the sink/provider's concern.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from uipath.core.governance import (
+ FiredRule,
+ GovernanceCompensationProvider,
+ GovernRequest,
+)
+
+from .._audit.metadata import GovernanceRuntimeMetadata
+
+logger = logging.getLogger(__name__)
+
+
+# ----------------------------------------------------------------------------
+# Stateless helpers
+# ----------------------------------------------------------------------------
+
+
+def disabled_guardrails(audit: Any, policy_index: Any) -> list[FiredRule]:
+ """Return per-rule metadata for each fired guardrail-fallback rule.
+
+ A guardrail rule fires only when it is mapped to UiPath
+ (``mapped_to_uipath`` true) but disabled (``policy_enabled`` false) —
+ see the ``guardrail_fallback`` operator. The validator name (e.g.
+ ``pii_detection``) is read from the rule's ``guardrail_fallback``
+ check config and used as the validator on the compensating call.
+
+ One :class:`FiredRule` entry is emitted per matching
+ ``guardrail_fallback`` condition. Rules in this codebase declare a
+ single fallback condition each, so the returned list has one entry
+ per fired rule in practice; multi-condition rules would emit more
+ than one entry sharing the same ``rule_id``.
+ """
+ out: list[FiredRule] = []
+ for ev in audit.evaluations:
+ if not ev.matched:
+ continue
+ rule = policy_index.get_rule(ev.rule_id)
+ if rule is None:
+ continue
+ for check in rule.checks:
+ for cond in check.conditions:
+ if cond.operator != "guardrail_fallback":
+ continue
+ if not isinstance(cond.value, dict):
+ continue
+ # The ``guardrail_fallback`` operator at evaluation time
+ # only matches when ``mapped_to_uipath=True`` AND
+ # ``policy_enabled=False``. We re-check here defensively
+ # so a future code path that bypasses the evaluator (or
+ # a multi-condition rule that fired on a sibling check)
+ # can't trigger a compensation call for a guardrail
+ # that isn't actually disabled.
+ if not bool(cond.value.get("mapped_to_uipath", False)):
+ continue
+ if bool(cond.value.get("policy_enabled", True)):
+ continue
+ validator = str(cond.value.get("validator", ""))
+ if validator:
+ out.append(
+ FiredRule(
+ rule_id=ev.rule_id,
+ rule_name=ev.rule_name,
+ pack_name=getattr(rule, "pack_name", "") or "",
+ validator=validator,
+ )
+ )
+ return out
+
+
+def _validators(rules: list[FiredRule]) -> list[str]:
+ """Distinct validator names from the fired rules, preserving order."""
+ return list(dict.fromkeys(r.validator for r in rules if r.validator))
+
+
+# ----------------------------------------------------------------------------
+# GuardrailCompensator
+# ----------------------------------------------------------------------------
+
+
+class GuardrailCompensator:
+ """Synchronous dispatcher for compensating-governance calls.
+
+ Builds the :class:`GovernRequest` payload and invokes the injected
+ provider's ``compensate`` method on the caller's thread. The
+ provider is expected to be non-blocking (batched internally, async
+ fire-and-forget, or otherwise scheduled off the agent's hook
+ thread) — the runtime layer owns no worker pool, semaphore, or
+ process-exit cleanup for this path.
+
+ Per-call exceptions are caught and logged so a provider failure
+ never breaks the agent hook.
+ """
+
+ def __init__(
+ self,
+ provider: GovernanceCompensationProvider,
+ *,
+ runtime_metadata: GovernanceRuntimeMetadata | None = None,
+ ) -> None:
+ """Construct a compensator bound to one provider.
+
+ Args:
+ provider: Host-supplied
+ :class:`GovernanceCompensationProvider`. ``compensate``
+ is invoked synchronously on the agent's hook thread.
+ runtime_metadata: Per-run identity (agent framework / type /
+ runtime version) stamped onto the ``/runtime/govern`` call
+ so the server can attach it to the rule-denied telemetry it
+ emits. ``None`` leaves the fields unset (server defaults
+ them to ``unknown``).
+ """
+ self._provider = provider
+ self._runtime_metadata = runtime_metadata
+
+ def submit(
+ self,
+ rules: list[FiredRule],
+ data: dict[str, Any],
+ hook: str,
+ src_timestamp: str,
+ agent_name: str,
+ runtime_id: str,
+ ) -> None:
+ """Build the wire payload and hand it to the provider.
+
+ Short-circuits on empty rules / empty validators. Per-call
+ provider exceptions are caught and logged.
+ """
+ if not rules:
+ return
+ validators = _validators(rules)
+ if not validators:
+ return
+
+ meta = self._runtime_metadata
+ request = GovernRequest(
+ validators=validators,
+ rules=rules,
+ data=data,
+ hook=hook,
+ src_timestamp=src_timestamp,
+ agent_name=agent_name,
+ runtime_id=runtime_id,
+ agent_framework=meta.agent_framework if meta else None,
+ agent_type=meta.agent_type if meta else None,
+ runtime_version=meta.runtime_version if meta else None,
+ )
+
+ try:
+ self._provider.compensate(request)
+ except Exception as exc: # noqa: BLE001 - fail-open by contract
+ logger.warning(
+ "Compensation provider call failed (validators=[%s]): %s",
+ ", ".join(validators),
+ exc,
+ )
+
+ def close(self) -> None:
+ """No-op — the compensator holds no resources.
+
+ Kept on the API so callers that wire ``close()`` (e.g. shared
+ teardown patterns) don't need a branch for this class.
+ """
diff --git a/src/uipath/runtime/governance/native/models.py b/src/uipath/runtime/governance/native/models.py
new file mode 100644
index 00000000..eb874a76
--- /dev/null
+++ b/src/uipath/runtime/governance/native/models.py
@@ -0,0 +1,162 @@
+"""Native policy model.
+
+Rules, checks, conditions and pack indexes consumed by the native
+governance evaluator.
+
+These are the inputs of the native evaluator. The evaluator-agnostic
+*output* types (``Action``, ``AuditRecord``, …) live in
+:mod:`uipath.core.governance.models`.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Any
+
+from uipath.core.governance.models import Action, LifecycleHook
+
+
+class Severity(Enum):
+ """Rule severity levels."""
+
+ LOW = "low"
+ MEDIUM = "medium"
+ HIGH = "high"
+ CRITICAL = "critical"
+
+
+class Logic(str, Enum):
+ """How a check combines its conditions."""
+
+ ALL = "all" # AND — every condition must hold.
+ ANY = "any" # OR — any matching condition is a hit.
+
+
+@dataclass
+class Condition:
+ """A single condition within a rule check."""
+
+ operator: str
+ field: str
+ value: Any
+ negate: bool = False
+
+
+@dataclass
+class Check:
+ """A check within a rule - contains conditions and action."""
+
+ conditions: list[Condition]
+ action: Action = Action.DENY
+ message: str = ""
+ logic: Logic = Logic.ALL
+
+
+@dataclass
+class Rule:
+ """A compliance rule with checks evaluated at a specific lifecycle hook."""
+
+ rule_id: str
+ name: str
+ clause: str
+ hook: LifecycleHook
+ action: Action
+ severity: Severity = Severity.HIGH
+ checks: list[Check] = field(default_factory=list)
+ enabled: bool = True
+ description: str = ""
+ pack_name: str = ""
+
+ # Approval configuration (for ESCALATE action)
+ approval_config: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class CheckContext:
+ """Context passed to rule evaluation.
+
+ Scoped to evaluator input data only. Trace correlation is
+ intentionally not carried here — that concern is owned by the
+ provider / platform layer, not by the evaluator input model.
+ """
+
+ hook: LifecycleHook
+ agent_name: str
+ runtime_id: str
+
+ # Content fields (populated based on hook)
+ agent_input: str = ""
+ agent_output: str = ""
+ model_input: str = ""
+ model_output: str = ""
+ model_name: str = ""
+ tool_name: str = ""
+ tool_args: dict[str, Any] = field(default_factory=dict)
+ tool_result: str = ""
+ messages: list[dict[str, Any]] = field(default_factory=list)
+
+ # Session state
+ session_state: dict[str, Any] = field(default_factory=dict)
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ # Ring level (privilege level: 0=system, 1=admin, 2=user, 3=untrusted)
+ ring: int = 2
+
+
+@dataclass
+class PolicyPack:
+ """A collection of rules for a compliance standard."""
+
+ name: str
+ version: str
+ description: str
+ rules: list[Rule]
+ enabled: bool = True
+
+
+@dataclass
+class PolicyIndex:
+ """Index of all loaded policy packs and rules."""
+
+ packs: dict[str, PolicyPack] = field(default_factory=dict)
+ _rules_by_id: dict[str, Rule] = field(default_factory=dict)
+ _rules_by_hook: dict[LifecycleHook, list[Rule]] = field(default_factory=dict)
+
+ def add_pack(self, pack: PolicyPack) -> None:
+ """Add a policy pack to the index."""
+ self.packs[pack.name] = pack
+ for rule in pack.rules:
+ rule.pack_name = pack.name
+ self._rules_by_id[rule.rule_id] = rule
+ if rule.hook not in self._rules_by_hook:
+ self._rules_by_hook[rule.hook] = []
+ self._rules_by_hook[rule.hook].append(rule)
+
+ def get_rule(self, rule_id: str) -> Rule | None:
+ """Get a rule by ID."""
+ return self._rules_by_id.get(rule_id)
+
+ def get_rules_for_hook(self, hook: LifecycleHook) -> list[Rule]:
+ """Get all rules for a lifecycle hook."""
+ return self._rules_by_hook.get(hook, [])
+
+ def get_rules_for_pack(self, pack_name: str) -> list[Rule]:
+ """Get all rules for a pack."""
+ pack = self.packs.get(pack_name)
+ return pack.rules if pack else []
+
+ @property
+ def pack_names(self) -> list[str]:
+ """Get all pack names."""
+ return list(self.packs.keys())
+
+ @property
+ def total_rules(self) -> int:
+ """Get total number of rules."""
+ return len(self._rules_by_id)
+
+ @property
+ def all_rules(self) -> list[Rule]:
+ """Get all rules."""
+ return list(self._rules_by_id.values())
diff --git a/src/uipath/runtime/governance/runtime.py b/src/uipath/runtime/governance/runtime.py
new file mode 100644
index 00000000..c9cf6996
--- /dev/null
+++ b/src/uipath/runtime/governance/runtime.py
@@ -0,0 +1,301 @@
+"""Governance runtime wrapper.
+
+Wraps a :class:`UiPathRuntimeProtocol` delegate and carries a resolved
+policy snapshot — a :class:`PolicyIndex` and :class:`EnforcementMode`
+supplied by the caller. The wrapper performs no I/O at construction,
+holds no background thread, retains no policy provider, and reads no
+host environment variables.
+
+The caller is expected to:
+
+- resolve a policy pack for the current run — any provider, any code
+ path, any wire format,
+- produce a :class:`~uipath.runtime.governance.native.PolicyIndex`
+ from that policy; the runtime is format-agnostic and only accepts a
+ compiled index,
+- skip wrapping entirely when the resolved enforcement mode is
+ :attr:`EnforcementMode.DISABLED`,
+- pass the resolved ``PolicyIndex`` and ``EnforcementMode`` into the
+ constructor.
+
+The wrapper owns the BEFORE_AGENT / AFTER_AGENT lifecycle boundary
+when an evaluator is supplied at construction. Framework adapters
+intentionally skip chain-level events so nested chain runs don't fire
+duplicate boundary evaluations; the runtime layer is the unambiguous
+"one invocation = one boundary" point, so it owns those hooks. Per-step
+hooks (BEFORE_MODEL, AFTER_MODEL, TOOL_CALL, AFTER_TOOL) are fired by
+adapters that observe per-step events.
+
+Trace correlation: :meth:`UiPathGovernedRuntime.execute` and
+:meth:`UiPathGovernedRuntime.stream` open a single OTel span that
+wraps BEFORE_AGENT, the delegate's run (including any framework
+adapter spans), and AFTER_AGENT. The span inherits the host's
+ambient context when one is set, or opens a new root trace
+otherwise. Every governance event emitted under that span shares
+its ``trace_id``, so runtime-layer telemetry and framework-layer
+OTel spans correlate end-to-end. Downstream consumers map that
+``trace_id`` to whatever correlation field they use.
+
+The governance compensator dispatches synchronously on the caller's
+thread; the injected compensation provider resolves the canonical
+trace id at call time. The runtime layer remains env-free for that
+path.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from contextlib import contextmanager
+from typing import Any, AsyncGenerator, Iterator
+
+from uipath.core.governance import EnforcementMode
+from uipath.core.governance.exceptions import GovernanceBlockException
+from uipath.core.serialization import serialize_object
+
+from uipath.runtime.base import (
+ UiPathExecuteOptions,
+ UiPathRuntimeProtocol,
+ UiPathStreamOptions,
+)
+from uipath.runtime.events import UiPathRuntimeEvent
+from uipath.runtime.governance.native.evaluator import GovernanceEvaluator
+from uipath.runtime.governance.native.models import PolicyIndex
+from uipath.runtime.result import UiPathRuntimeResult
+from uipath.runtime.schema import UiPathRuntimeSchema
+
+logger = logging.getLogger(__name__)
+
+
+@contextmanager
+def _governance_root_span(agent_name: str, runtime_id: str) -> Iterator[None]:
+ """Open the governance run's root OTel span if OTel is available.
+
+ Every governance event and every framework OTel span emitted
+ inside this block inherits the span's ``trace_id``. The result
+ is one trace per agent run that correlates BEFORE_AGENT,
+ BEFORE_MODEL / AFTER_MODEL, AFTER_AGENT, and any per-rule
+ governance events.
+
+ Behavior matrix:
+
+ - **OTel installed + host opened a parent span**: this becomes a
+ child of the host's span and inherits its ``trace_id`` — the
+ host's outer correlation context is preserved end-to-end.
+ - **OTel installed + no parent span**: this becomes the root
+ span of a fresh trace; everything below it shares the new
+ ``trace_id``.
+ - **OTel not installed**: no-op context manager — the wrapper
+ degrades gracefully (no spans, no correlation, but the
+ runtime still functions).
+
+ Lazy import mirrors
+ :func:`uipath.runtime.governance._audit.track_events._resolve_operation_id`:
+ OTel is a transitive runtime dependency, but the wrapper must
+ not crash if a downstream host strips it.
+ """
+ try:
+ from opentelemetry import trace
+ except ImportError:
+ yield
+ return
+
+ tracer = trace.get_tracer("uipath.runtime.governance")
+ # No explicit ``context=`` → OTel picks up the ambient context.
+ # If the host wrapped this call in its own span, we become its
+ # child (same trace_id). Otherwise we open a root span (new
+ # trace_id).
+ with tracer.start_as_current_span("uipath.governance.run") as span:
+ # Span attributes for downstream consumers. ``agent_name``
+ # and ``runtime_id`` are the primary keys an operator
+ # filters on; ``source`` identifies which producer emitted
+ # the span (mirrors the constant used by
+ # :class:`TracesAuditSink` so consumers stay consistent).
+ if agent_name:
+ span.set_attribute("uipath_governance.agent_name", agent_name)
+ if runtime_id:
+ span.set_attribute("uipath_governance.runtime_id", runtime_id)
+ span.set_attribute("uipath_governance.source", "governance-runtime-python")
+ yield
+
+
+def _serialize_payload(payload: Any) -> str:
+ """Serialize an agent input / output to a string for evaluator checks.
+
+ The native evaluator's BEFORE_AGENT / AFTER_AGENT checks scan a
+ flat string. ``None`` becomes ``""``, ``str`` passes through (so
+ regex / sentiment checks don't see JSON quotes around the bare
+ text), and everything else is normalized via
+ :func:`uipath.core.serialization.serialize_object` (handles
+ Pydantic / dataclass / datetime / nested structures) and then
+ JSON-encoded.
+ """
+ if payload is None:
+ return ""
+ if isinstance(payload, str):
+ return payload
+ try:
+ return json.dumps(serialize_object(payload))
+ except Exception: # noqa: BLE001 — last-resort string fallback
+ return str(payload)
+
+
+class UiPathGovernedRuntime:
+ """Governance wrapper over a :class:`UiPathRuntimeProtocol` delegate.
+
+ Holds a caller-resolved :class:`PolicyIndex` and
+ :class:`EnforcementMode` for the lifetime of the instance.
+ ``execute`` / ``stream`` / ``get_schema`` forward to the delegate.
+ ``dispose`` is a no-op — the caller owns the delegate (and every
+ other host-managed resource) and is responsible for tearing them
+ down. The method is kept only to satisfy
+ :class:`UiPathDisposableProtocol` so the wrapper stays
+ structurally substitutable for any :class:`UiPathRuntimeProtocol`.
+
+ When ``evaluator`` is supplied, :meth:`execute` and :meth:`stream`
+ fire ``BEFORE_AGENT`` before delegating and ``AFTER_AGENT`` after a
+ successful return. Without an evaluator the wrapper is a pure
+ pass-through.
+ """
+
+ def __init__(
+ self,
+ delegate: UiPathRuntimeProtocol,
+ policy_index: PolicyIndex,
+ enforcement_mode: EnforcementMode,
+ *,
+ evaluator: GovernanceEvaluator | None = None,
+ agent_name: str = "",
+ runtime_id: str = "",
+ ):
+ """Initialize the governance runtime with a resolved policy snapshot.
+
+ Args:
+ delegate: The wrapped runtime to forward execution to.
+ policy_index: Resolved :class:`PolicyIndex` built from the
+ provider's :class:`PolicyResponse`. Pass an empty
+ ``PolicyIndex()`` to attach the wrapper without any
+ rules (useful when the wrapper exists for audit
+ emission only).
+ enforcement_mode: Resolved :class:`EnforcementMode` from
+ the provider's :class:`PolicyResponse`. The caller is
+ expected to skip wrapping entirely when the response
+ mode is :attr:`EnforcementMode.DISABLED`; this
+ constructor does not check.
+ evaluator: Optional :class:`GovernanceEvaluator` that
+ drives BEFORE_AGENT / AFTER_AGENT inside
+ :meth:`execute` / :meth:`stream`. When ``None`` the
+ wrapper is a pure passthrough — the caller is expected
+ to fire those evaluations itself.
+ agent_name: Name of the agent (the runtime's entrypoint).
+ Passed through to the evaluator's hook methods.
+ runtime_id: Runtime-instance id (conversation id, job id,
+ or a synthetic per-run id). Passed through so
+ per-runtime state routes cleanly.
+ """
+ self._delegate = delegate
+ self._policy_index = policy_index
+ self._enforcement_mode = enforcement_mode
+ self._evaluator = evaluator
+ self._agent_name = agent_name
+ self._runtime_id = runtime_id
+
+ def _fire_before_agent(self, input: Any) -> None:
+ """Fire BEFORE_AGENT when an evaluator is wired; otherwise no-op.
+
+ ``GovernanceBlockException`` propagates — that's how
+ ENFORCE-mode DENY rules halt a run. Anything else is logged
+ and swallowed so a governance bug never breaks the agent.
+ """
+ if self._evaluator is None:
+ return
+ try:
+ self._evaluator.evaluate_before_agent(
+ agent_input=_serialize_payload(input),
+ agent_name=self._agent_name,
+ runtime_id=self._runtime_id,
+ )
+ except GovernanceBlockException:
+ raise
+ except Exception as exc: # noqa: BLE001 — never break a run on audit failure
+ logger.warning("BEFORE_AGENT governance evaluation failed: %s", exc)
+
+ def _fire_after_agent(self, result: UiPathRuntimeResult) -> None:
+ """Fire AFTER_AGENT against ``result.output``.
+
+ Same exception policy as :meth:`_fire_before_agent`.
+ """
+ if self._evaluator is None:
+ return
+ try:
+ self._evaluator.evaluate_after_agent(
+ agent_output=_serialize_payload(result.output),
+ agent_name=self._agent_name,
+ runtime_id=self._runtime_id,
+ )
+ except GovernanceBlockException:
+ raise
+ except Exception as exc: # noqa: BLE001
+ logger.warning("AFTER_AGENT governance evaluation failed: %s", exc)
+
+ async def execute(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathExecuteOptions | None = None,
+ ) -> UiPathRuntimeResult:
+ """Execute the delegate, firing BEFORE_AGENT / AFTER_AGENT around it.
+
+ Wraps the entire invocation — BEFORE_AGENT, delegate execution
+ (which may itself open framework spans), and AFTER_AGENT — in
+ a single OTel span. The shared ``trace_id`` unifies the
+ runtime-side governance events with any framework-side OTel
+ spans the delegate emits.
+
+ AFTER_AGENT fires only on successful return — if the delegate
+ raises, there's no output to evaluate.
+ """
+ with _governance_root_span(self._agent_name, self._runtime_id):
+ self._fire_before_agent(input)
+ result = await self._delegate.execute(input, options=options)
+ self._fire_after_agent(result)
+ return result
+
+ async def stream(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathStreamOptions | None = None,
+ ) -> AsyncGenerator[UiPathRuntimeEvent, None]:
+ """Stream events from the delegate, firing BEFORE_AGENT first.
+
+ Same root-span wrap as :meth:`execute`. Holding the span
+ across ``async for`` is safe — Python asyncio propagates
+ contextvars (including the OTel current-span pointer) across
+ ``await`` suspensions.
+
+ AFTER_AGENT fires once a :class:`UiPathRuntimeResult` event is
+ observed in the stream — that's the runtime's contract for
+ signalling a completed invocation. Intermediate state events
+ pass through untouched.
+ """
+ with _governance_root_span(self._agent_name, self._runtime_id):
+ self._fire_before_agent(input)
+ async for event in self._delegate.stream(input, options=options):
+ if isinstance(event, UiPathRuntimeResult):
+ self._fire_after_agent(event)
+ yield event
+
+ async def get_schema(self) -> UiPathRuntimeSchema:
+ """Forward schema lookup to the delegate."""
+ return await self._delegate.get_schema()
+
+ async def dispose(self) -> None:
+ """No-op — the caller owns delegate + host-resource lifecycle.
+
+ The wrapper deliberately does not forward to
+ ``self._delegate.dispose()`` (the caller instantiated the
+ delegate and is responsible for disposing it) and does not
+ invoke any host-supplied cleanup hook (the host runs its own
+ teardown on the same code path). Kept as a method purely to
+ satisfy :class:`UiPathDisposableProtocol` — the wrapper stays
+ structurally substitutable for any ``UiPathRuntimeProtocol``.
+ """
diff --git a/src/uipath/runtime/resumable/protocols.py b/src/uipath/runtime/resumable/protocols.py
index 7ce77c80..53855c67 100644
--- a/src/uipath/runtime/resumable/protocols.py
+++ b/src/uipath/runtime/resumable/protocols.py
@@ -4,6 +4,8 @@
from uipath.core.triggers import UiPathResumeTrigger
+from uipath.runtime.errors import UiPathRuntimeError
+from uipath.runtime.errors.codes import UiPathErrorCode
from uipath.runtime.storage import UiPathRuntimeStorageProtocol
@@ -34,6 +36,20 @@ async def get_triggers(self, runtime_id: str) -> list[UiPathResumeTrigger] | Non
"""
...
+ async def delete_triggers(
+ self, runtime_id: str, triggers: list[UiPathResumeTrigger]
+ ) -> None:
+ """Delete resume triggers from storage.
+
+ Args:
+ runtime_id: The runtime ID
+ triggers: The resume triggers to delete
+
+ Raises:
+ Exception: If deletion operation fails
+ """
+ ...
+
async def delete_trigger(
self, runtime_id: str, trigger: UiPathResumeTrigger
) -> None:
@@ -46,7 +62,8 @@ async def delete_trigger(
Raises:
Exception: If deletion operation fails
"""
- ...
+ # TODO: Remove this compatibility alias in the next minor version.
+ await self.delete_triggers(runtime_id, [trigger])
class UiPathResumeTriggerCreatorProtocol(Protocol):
@@ -63,6 +80,34 @@ async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger:
Returns:
UiPathResumeTrigger ready to be persisted
+ Raises:
+ UiPathRuntimeError: If trigger creation fails
+ """
+ # TODO: Remove this compatibility alias in the next minor version.
+ triggers = await self.create_triggers(suspend_value)
+ if len(triggers) != 1:
+ raise UiPathRuntimeError(
+ code=UiPathErrorCode.CREATE_RESUME_TRIGGER_ERROR,
+ title="Unexpected resume trigger count",
+ detail=(
+ "create_trigger() cannot return multiple resume triggers. "
+ "Use create_triggers() instead."
+ ),
+ )
+ return triggers[0]
+
+ async def create_triggers(self, suspend_value: Any) -> list[UiPathResumeTrigger]:
+ """Create resume triggers from a suspend value.
+
+ Most suspend values produce one trigger. Composite values may produce
+ multiple sibling triggers for the same interrupt.
+
+ Args:
+ suspend_value: The value that caused the suspension.
+
+ Returns:
+ UiPathResumeTrigger objects ready to be persisted.
+
Raises:
UiPathRuntimeError: If trigger creation fails
"""
diff --git a/src/uipath/runtime/resumable/runtime.py b/src/uipath/runtime/resumable/runtime.py
index 93bc2e51..6bdb6f86 100644
--- a/src/uipath/runtime/resumable/runtime.py
+++ b/src/uipath/runtime/resumable/runtime.py
@@ -1,10 +1,16 @@
"""Resumable runtime protocol and implementation."""
import logging
+from collections import Counter
+from enum import Enum
from typing import Any, AsyncGenerator
from uipath.core.errors import UiPathPendingTriggerError
-from uipath.core.triggers import UiPathResumeTrigger, UiPathResumeTriggerType
+from uipath.core.triggers import (
+ UIPATH_METADATA_KEY,
+ UiPathResumeTrigger,
+ UiPathResumeTriggerType,
+)
from uipath.runtime.base import (
UiPathExecuteOptions,
@@ -147,8 +153,9 @@ async def stream(
async def _get_fired_triggers(self) -> dict[str, Any] | None:
"""Check stored triggers for any that have already fired.
- Skips async-external triggers (API, Inbox) whose payloads only arrive
- asynchronously and cannot be polled at suspend time.
+ Skips external triggers (API, Inbox, Timer) whose payloads only arrive
+ asynchronously or through Orchestrator resume and cannot be polled at
+ suspend time.
Returns:
A resume map of {interrupt_id: resume_data} for fired triggers, or None.
@@ -161,9 +168,13 @@ async def _get_fired_triggers(self) -> dict[str, Any] | None:
t
for t in triggers
if t.trigger_type
- not in (UiPathResumeTriggerType.API, UiPathResumeTriggerType.INBOX)
+ not in (
+ UiPathResumeTriggerType.API,
+ UiPathResumeTriggerType.INBOX,
+ UiPathResumeTriggerType.TIMER,
+ )
]
- return await self._build_resume_map(pollable_triggers)
+ return await self._build_resume_map(pollable_triggers, all_triggers=triggers)
async def _restore_resume_input(
self,
@@ -185,15 +196,17 @@ async def _restore_resume_input(
if triggers:
if len(triggers) == 1:
# Single trigger - just delete it
- await self.storage.delete_trigger(self.runtime_id, triggers[0])
+ await self.storage.delete_triggers(self.runtime_id, triggers)
else:
# Multiple triggers - match by interrupt_id
- found = False
- for trigger in triggers:
- if trigger.interrupt_id in input:
- await self.storage.delete_trigger(self.runtime_id, trigger)
- found = True
- if not found:
+ matched_triggers = [
+ trigger for trigger in triggers if trigger.interrupt_id in input
+ ]
+ if matched_triggers:
+ await self.storage.delete_triggers(
+ self.runtime_id, matched_triggers
+ )
+ else:
logger.warning(
f"Multiple triggers detected but none match the provided input. "
f"Please specify which trigger to resume by {{interrupt_id: value}}. "
@@ -208,31 +221,75 @@ async def _restore_resume_input(
async def _build_resume_map(
self,
- triggers: list[UiPathResumeTrigger],
+ pollable_triggers: list[UiPathResumeTrigger],
+ all_triggers: list[UiPathResumeTrigger] | None = None,
) -> dict[str, Any]:
"""Build resume map from triggers: {interrupt_id: resume_data}.
Args:
- triggers: List of triggers to read and map
+ pollable_triggers: List of triggers to read and map.
+ all_triggers: Optional full trigger set for the same suspension.
+ When provided, it is used to detect and delete sibling triggers
+ that are not pollable.
Returns:
A dict mapping interrupt_id to the trigger's resume data.
"""
resume_map: dict[str, Any] = {}
- for trigger in triggers:
+ triggers_to_match = (
+ all_triggers if all_triggers is not None else pollable_triggers
+ )
+ trigger_count_by_interrupt_id = Counter(
+ trigger.interrupt_id for trigger in triggers_to_match
+ )
+ for trigger in pollable_triggers:
+ assert trigger.interrupt_id is not None, (
+ "Trigger interrupt_id cannot be None"
+ )
+ # one fired sibling is enough to resume an interrupt; skip the rest.
+ if trigger.interrupt_id in resume_map:
+ continue
+
try:
data = await self.trigger_manager.read_trigger(trigger)
- assert trigger.interrupt_id is not None, (
- "Trigger interrupt_id cannot be None"
- )
+ if trigger_count_by_interrupt_id[trigger.interrupt_id] > 1:
+ data = self._with_trigger_metadata(trigger, data)
resume_map[trigger.interrupt_id] = data
- await self.storage.delete_trigger(self.runtime_id, trigger)
+ sibling_triggers = [
+ sibling
+ for sibling in triggers_to_match
+ if sibling.interrupt_id == trigger.interrupt_id
+ ]
+ await self.storage.delete_triggers(self.runtime_id, sibling_triggers)
except UiPathPendingTriggerError:
# Trigger still pending, skip it
pass
return resume_map
+ def _with_trigger_metadata(
+ self, trigger: UiPathResumeTrigger, data: Any
+ ) -> dict[str, Any]:
+ def normalize_enum_values(value: Any) -> Any:
+ if isinstance(value, Enum):
+ return value.value
+
+ return value
+
+ metadata = {
+ "triggerType": normalize_enum_values(trigger.trigger_type),
+ "triggerName": normalize_enum_values(trigger.trigger_name),
+ }
+
+ if isinstance(data, dict):
+ existing_metadata = data.get(UIPATH_METADATA_KEY)
+ if isinstance(existing_metadata, dict):
+ metadata = {**existing_metadata, **metadata}
+
+ return {**data, UIPATH_METADATA_KEY: metadata}
+
+ return {UIPATH_METADATA_KEY: metadata, "value": data}
+
async def _handle_suspension(
self, result: UiPathRuntimeResult
) -> UiPathRuntimeResult:
@@ -268,11 +325,12 @@ async def _handle_suspension(
# Create triggers only for new interrupts
for interrupt_id in new_ids:
- trigger = await self.trigger_manager.create_trigger(
+ triggers = await self.trigger_manager.create_triggers(
current_interrupts[interrupt_id]
)
- trigger.interrupt_id = interrupt_id
- suspended_result.triggers.append(trigger)
+ for trigger in triggers:
+ trigger.interrupt_id = interrupt_id
+ suspended_result.triggers.append(trigger)
if suspended_result.triggers:
await self.storage.save_triggers(self.runtime_id, suspended_result.triggers)
diff --git a/src/uipath/runtime/schema.py b/src/uipath/runtime/schema.py
index f9a25a37..cf4b3118 100644
--- a/src/uipath/runtime/schema.py
+++ b/src/uipath/runtime/schema.py
@@ -59,6 +59,12 @@ class UiPathRuntimeSchema(BaseModel):
type: str = Field(..., alias="type")
input: dict[str, Any] = Field(..., alias="input")
output: dict[str, Any] = Field(..., alias="output")
+ is_transaction_root: bool | None = Field(
+ None,
+ alias="isTransactionRoot",
+ description="Whether this entrypoint is a transaction root "
+ "(set for vertical-solution projects)",
+ )
graph: UiPathRuntimeGraph | None = Field(
None, description="Runtime graph structure for debugging"
)
diff --git a/src/uipath/runtime/workspace/__init__.py b/src/uipath/runtime/workspace/__init__.py
new file mode 100644
index 00000000..03e8e97e
--- /dev/null
+++ b/src/uipath/runtime/workspace/__init__.py
@@ -0,0 +1,25 @@
+"""Workspace persistence primitives for runtime implementations."""
+
+from uipath.runtime.workspace.context import get_workspace_path
+from uipath.runtime.workspace.conversational import ConversationalWorkspaceRuntime
+from uipath.runtime.workspace.hydration import (
+ HydrationPolicy,
+ HydrationRuntime,
+)
+from uipath.runtime.workspace.hydrator import (
+ AttachmentRegistryEntry,
+ WorkspaceHydrator,
+)
+from uipath.runtime.workspace.registry_store import WorkspaceRegistryStore
+from uipath.runtime.workspace.workspace import Workspace
+
+__all__ = [
+ "AttachmentRegistryEntry",
+ "ConversationalWorkspaceRuntime",
+ "HydrationPolicy",
+ "HydrationRuntime",
+ "get_workspace_path",
+ "Workspace",
+ "WorkspaceHydrator",
+ "WorkspaceRegistryStore",
+]
diff --git a/src/uipath/runtime/workspace/context.py b/src/uipath/runtime/workspace/context.py
new file mode 100644
index 00000000..6e554095
--- /dev/null
+++ b/src/uipath/runtime/workspace/context.py
@@ -0,0 +1,85 @@
+"""Execution-scoped access to a managed workspace."""
+
+from collections.abc import Iterator
+from contextlib import contextmanager
+from contextvars import ContextVar
+from dataclasses import dataclass
+from pathlib import Path
+
+from uipath.runtime.errors import (
+ UiPathErrorCategory,
+ UiPathErrorCode,
+ UiPathRuntimeError,
+)
+
+
+@dataclass
+class _WorkspaceExecution:
+ """Revocable workspace state shared with tasks created during execution."""
+
+ path: Path
+ active: bool = True
+
+
+_workspace_execution_state: ContextVar[_WorkspaceExecution | None] = ContextVar(
+ "uipath_workspace_execution", default=None
+)
+
+
+def get_workspace_path() -> Path:
+ """Return the workspace available to the current runtime execution.
+
+ A workspace is available only while a runtime managed by
+ :class:`HydrationRuntime` is executing. Files created beneath this path are
+ restored before a resumed execution and persisted when the runtime
+ suspends, according to its hydration policy.
+
+ Raises:
+ UiPathRuntimeError: If called outside a managed runtime execution.
+ """
+ execution = _workspace_execution_state.get()
+ if execution is None or not execution.active:
+ raise UiPathRuntimeError(
+ code=UiPathErrorCode.MANAGED_WORKSPACE_UNAVAILABLE,
+ title="Managed Workspace Unavailable",
+ detail=(
+ "No managed workspace is available in the current code path. "
+ "Call get_workspace_path() only from code invoked by a managed runtime, "
+ "not from module-level initialization or from the caller that invokes "
+ "or consumes the execution."
+ ),
+ category=UiPathErrorCategory.USER,
+ include_traceback=False,
+ )
+ return execution.path
+
+
+@contextmanager
+def _bind_workspace_execution(execution: _WorkspaceExecution) -> Iterator[None]:
+ """Make an active execution available in the current context."""
+ token = _workspace_execution_state.set(execution)
+ try:
+ yield
+ finally:
+ _workspace_execution_state.reset(token)
+
+
+def _create_workspace_execution(path: Path) -> _WorkspaceExecution:
+ """Create state for a workspace execution."""
+ return _WorkspaceExecution(path)
+
+
+def _revoke_workspace_execution(execution: _WorkspaceExecution) -> None:
+ """Prevent all contexts that inherited ``execution`` from accessing it."""
+ execution.active = False
+
+
+@contextmanager
+def _workspace_execution(path: Path) -> Iterator[None]:
+ """Make ``path`` available for one non-streaming execution."""
+ execution = _create_workspace_execution(path)
+ try:
+ with _bind_workspace_execution(execution):
+ yield
+ finally:
+ _revoke_workspace_execution(execution)
diff --git a/src/uipath/runtime/workspace/conversational.py b/src/uipath/runtime/workspace/conversational.py
new file mode 100644
index 00000000..635b95c2
--- /dev/null
+++ b/src/uipath/runtime/workspace/conversational.py
@@ -0,0 +1,211 @@
+"""Attachment-backed workspace persistence for conversational agents."""
+
+import logging
+from collections.abc import Mapping
+from typing import Any, AsyncGenerator
+
+from uipath.runtime.base import (
+ UiPathExecuteOptions,
+ UiPathRuntimeProtocol,
+ UiPathStreamOptions,
+)
+from uipath.runtime.events import (
+ UiPathRuntimeConversationMetaEvent,
+ UiPathRuntimeEvent,
+)
+from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus
+from uipath.runtime.schema import UiPathRuntimeSchema
+from uipath.runtime.workspace.hydrator import WorkspaceHydrator
+from uipath.runtime.workspace.registry_store import WorkspaceRegistryStore
+
+logger = logging.getLogger(__name__)
+
+CONVERSATION_META_EVENTS_INPUT_KEY = "uipath__conversation_meta_events"
+WORKSPACE_FILES_META_KEY = "workspaceFiles"
+WORKSPACE_FILE_PATH_KEY = "path"
+WORKSPACE_FILE_ATTACHMENT_KEY = "attachmentKey"
+
+
+class _InvalidWorkspaceSnapshot(ValueError):
+ pass
+
+
+def _meta_event_payload(
+ event: Mapping[object, object],
+) -> Mapping[object, object] | None:
+ exchange = event.get("exchange")
+ if isinstance(exchange, Mapping):
+ exchange_meta_event = exchange.get("metaEvent")
+ if isinstance(exchange_meta_event, Mapping):
+ return exchange_meta_event
+
+ meta_event = event.get("metaEvent")
+ return meta_event if isinstance(meta_event, Mapping) else None
+
+
+def _attachment_keys_from_meta_events(
+ input: Mapping[str, object] | None,
+) -> dict[str, str] | None:
+ events = (input or {}).get(CONVERSATION_META_EVENTS_INPUT_KEY)
+ if not isinstance(events, list):
+ return None
+
+ for event in reversed(events):
+ if not isinstance(event, Mapping):
+ continue
+ meta_event = _meta_event_payload(event)
+ if meta_event is None or WORKSPACE_FILES_META_KEY not in meta_event:
+ continue
+
+ workspace_files = meta_event[WORKSPACE_FILES_META_KEY]
+ if not isinstance(workspace_files, list):
+ raise _InvalidWorkspaceSnapshot
+
+ attachment_keys_by_path: dict[str, str] = {}
+ for workspace_file in workspace_files:
+ if not isinstance(workspace_file, Mapping):
+ raise _InvalidWorkspaceSnapshot
+ path = workspace_file.get(WORKSPACE_FILE_PATH_KEY)
+ attachment_key = workspace_file.get(WORKSPACE_FILE_ATTACHMENT_KEY)
+ if not isinstance(path, str) or not isinstance(attachment_key, str):
+ raise _InvalidWorkspaceSnapshot
+ attachment_keys_by_path[path] = attachment_key
+ return attachment_keys_by_path
+
+ return None
+
+
+def _without_conversation_meta_events(
+ input: dict[str, Any] | None,
+) -> dict[str, Any] | None:
+ if input is None or CONVERSATION_META_EVENTS_INPUT_KEY not in input:
+ return input
+ return {
+ key: value
+ for key, value in input.items()
+ if key != CONVERSATION_META_EVENTS_INPUT_KEY
+ }
+
+
+class ConversationalWorkspaceRuntime:
+ """Persists workspace attachments between conversational jobs."""
+
+ def __init__(
+ self,
+ delegate: UiPathRuntimeProtocol,
+ *,
+ hydrator: WorkspaceHydrator,
+ registry_store: WorkspaceRegistryStore | None = None,
+ ):
+ """Initialize the wrapper with its delegate and hydrator."""
+ self.delegate = delegate
+ self.hydrator = hydrator
+ self.registry_store = registry_store
+ self._registry: dict[str, dict[str, Any]] = {}
+ self._hydrated = False
+
+ async def execute(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathExecuteOptions | None = None,
+ ) -> UiPathRuntimeResult:
+ """Execute by draining the stream."""
+ result: UiPathRuntimeResult | None = None
+ stream_options = (
+ UiPathStreamOptions.model_validate(options.model_dump())
+ if options is not None
+ else None
+ )
+ async for event in self.stream(input, options=stream_options):
+ if isinstance(event, UiPathRuntimeResult):
+ result = event
+ if result is None:
+ raise RuntimeError("Delegate stream completed without a runtime result")
+ return result
+
+ async def stream(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathStreamOptions | None = None,
+ ) -> AsyncGenerator[UiPathRuntimeEvent, None]:
+ """Hydrate, stream the delegate, then emit the workspace snapshot."""
+ await self._hydrate(input)
+ delegate_input = _without_conversation_meta_events(input)
+ final_result: UiPathRuntimeResult | None = None
+
+ async for event in self.delegate.stream(delegate_input, options=options):
+ if isinstance(event, UiPathRuntimeResult):
+ final_result = event
+ else:
+ yield event
+
+ if final_result is None:
+ return
+ if final_result.status == UiPathRuntimeStatus.SUCCESSFUL:
+ yield await self._dehydrate()
+ yield final_result
+
+ async def get_schema(self) -> UiPathRuntimeSchema:
+ """Passthrough schema from delegate runtime."""
+ return await self.delegate.get_schema()
+
+ async def dispose(self) -> None:
+ """Release resources owned by this wrapper."""
+
+ async def _hydrate(self, input: Mapping[str, object] | None) -> None:
+ if self._hydrated:
+ return
+
+ persisted_registry = (
+ await self.registry_store.try_load() if self.registry_store else None
+ )
+ if persisted_registry is not None:
+ self._registry = persisted_registry
+ source = "suspended job state"
+ else:
+ try:
+ attachment_keys_by_path = _attachment_keys_from_meta_events(input)
+ except _InvalidWorkspaceSnapshot:
+ logger.warning("Ignoring malformed conversational workspace snapshot")
+ source = "existing workspace"
+ else:
+ if attachment_keys_by_path is None:
+ source = "existing workspace"
+ else:
+ source = "conversation metadata"
+ self._registry = await self.hydrator.hydrate_from_attachments(
+ attachment_keys_by_path
+ )
+
+ logger.info(
+ "Conversational workspace initialized: %d file(s) from %s",
+ len(self._registry),
+ source,
+ )
+ self._hydrated = True
+
+ async def _dehydrate(self) -> UiPathRuntimeConversationMetaEvent:
+ registry = self._registry
+ if self.registry_store is not None:
+ persisted_registry = await self.registry_store.try_load()
+ if persisted_registry is not None:
+ registry = persisted_registry
+
+ self._registry = await self.hydrator.dehydrate(registry)
+ if self.registry_store is not None:
+ await self.registry_store.save(self._registry)
+
+ workspace_files = [
+ {
+ WORKSPACE_FILE_PATH_KEY: virtual_path,
+ WORKSPACE_FILE_ATTACHMENT_KEY: entry["attachment_key"],
+ }
+ for virtual_path, entry in sorted(self._registry.items())
+ ]
+ logger.info(
+ "Conversational workspace dehydrate: emitting %d file(s)",
+ len(workspace_files),
+ )
+ return UiPathRuntimeConversationMetaEvent(
+ payload={WORKSPACE_FILES_META_KEY: workspace_files}
+ )
diff --git a/src/uipath/runtime/workspace/hydration.py b/src/uipath/runtime/workspace/hydration.py
new file mode 100644
index 00000000..d1c04809
--- /dev/null
+++ b/src/uipath/runtime/workspace/hydration.py
@@ -0,0 +1,184 @@
+"""Runtime wrapper for workspace hydration."""
+
+from enum import Enum
+from typing import Any, AsyncGenerator, Callable, overload
+
+from uipath.runtime.base import (
+ UiPathExecuteOptions,
+ UiPathRuntimeProtocol,
+ UiPathStreamOptions,
+)
+from uipath.runtime.events import UiPathRuntimeEvent
+from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus
+from uipath.runtime.schema import UiPathRuntimeSchema
+from uipath.runtime.workspace.context import (
+ _bind_workspace_execution,
+ _create_workspace_execution,
+ _revoke_workspace_execution,
+ _workspace_execution,
+)
+from uipath.runtime.workspace.hydrator import WorkspaceHydrator
+from uipath.runtime.workspace.registry_store import WorkspaceRegistryStore
+from uipath.runtime.workspace.workspace import Workspace
+
+
+class HydrationPolicy(str, Enum):
+ """Controls when workspace changes are persisted."""
+
+ SUSPEND_ONLY = "suspend_only"
+ SUSPEND_OR_SUCCESS = "suspend_or_success"
+ ALWAYS = "always"
+
+
+class HydrationRuntime:
+ """Wraps a runtime with hydrate-before and dehydrate-after behavior."""
+
+ @overload
+ def __init__(
+ self,
+ delegate: UiPathRuntimeProtocol,
+ *,
+ workspace: Workspace,
+ hydrator: WorkspaceHydrator,
+ registry_store: WorkspaceRegistryStore,
+ hydrator_factory: None = None,
+ policy: HydrationPolicy = HydrationPolicy.SUSPEND_ONLY,
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self,
+ delegate: UiPathRuntimeProtocol,
+ *,
+ workspace: Workspace,
+ hydrator: None = None,
+ registry_store: WorkspaceRegistryStore,
+ hydrator_factory: Callable[[], WorkspaceHydrator],
+ policy: HydrationPolicy = HydrationPolicy.SUSPEND_ONLY,
+ ) -> None: ...
+
+ def __init__(
+ self,
+ delegate: UiPathRuntimeProtocol,
+ *,
+ workspace: Workspace,
+ hydrator: WorkspaceHydrator | None = None,
+ registry_store: WorkspaceRegistryStore,
+ hydrator_factory: Callable[[], WorkspaceHydrator] | None = None,
+ policy: HydrationPolicy = HydrationPolicy.SUSPEND_ONLY,
+ ) -> None:
+ """Initialize the hydration wrapper."""
+ if (hydrator is None) == (hydrator_factory is None):
+ raise ValueError("Provide exactly one of hydrator or hydrator_factory")
+
+ self.delegate = delegate
+ self.workspace = workspace
+ self.hydrator = hydrator
+ self._hydrator_factory = hydrator_factory
+ self.registry_store = registry_store
+ self.policy = policy
+
+ def _get_hydrator(self) -> WorkspaceHydrator:
+ if self.hydrator is None:
+ if self._hydrator_factory is None: # pragma: no cover - constructor guard
+ raise RuntimeError("Hydrator is not configured")
+ self.hydrator = self._hydrator_factory()
+ return self.hydrator
+
+ async def execute(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathExecuteOptions | None = None,
+ ) -> UiPathRuntimeResult:
+ """Hydrate, execute, then persist files according to policy."""
+ with _workspace_execution(self.workspace.path):
+ await self._hydrate()
+ try:
+ result = await self.delegate.execute(input, options=options)
+ except Exception:
+ if self.policy == HydrationPolicy.ALWAYS:
+ await self._persist()
+ raise
+ await self._dehydrate(result)
+ return result
+
+ async def stream(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathStreamOptions | None = None,
+ ) -> AsyncGenerator[UiPathRuntimeEvent, None]:
+ """Hydrate, stream delegate events, then persist files according to policy."""
+ execution = _create_workspace_execution(self.workspace.path)
+ delegate_stream: AsyncGenerator[UiPathRuntimeEvent, None] | None = None
+ final_result: UiPathRuntimeResult | None = None
+ hydrated = False
+
+ try:
+ with _bind_workspace_execution(execution):
+ await self._hydrate()
+ hydrated = True
+ delegate_stream = self.delegate.stream(input, options=options)
+
+ event = await anext(delegate_stream, None)
+ while event is not None:
+ if isinstance(event, UiPathRuntimeResult):
+ final_result = event
+ else:
+ yield event
+ with _bind_workspace_execution(execution):
+ event = await anext(delegate_stream, None)
+ except Exception:
+ if self.policy == HydrationPolicy.ALWAYS and hydrated:
+ with _bind_workspace_execution(execution):
+ await self._persist()
+ raise
+ else:
+ if final_result is not None:
+ with _bind_workspace_execution(execution):
+ await self._dehydrate(final_result)
+ finally:
+ try:
+ if delegate_stream is not None:
+ with _bind_workspace_execution(execution):
+ await delegate_stream.aclose()
+ finally:
+ _revoke_workspace_execution(execution)
+
+ if final_result is not None:
+ yield final_result
+
+ async def get_schema(self) -> UiPathRuntimeSchema:
+ """Passthrough schema from delegate runtime."""
+ return await self.delegate.get_schema()
+
+ async def dispose(self) -> None:
+ """Dispose delegate and workspace."""
+ try:
+ await self.delegate.dispose()
+ finally:
+ await self.workspace.dispose()
+
+ async def _hydrate(self) -> None:
+ registry = await self.registry_store.load()
+ hydrated = await self._get_hydrator().hydrate_from_registry(registry)
+ if hydrated != registry:
+ await self.registry_store.save(hydrated)
+
+ async def _dehydrate(self, result: UiPathRuntimeResult) -> None:
+ if self._should_dehydrate(result):
+ await self._persist()
+
+ async def _persist(self) -> None:
+ registry = await self.registry_store.load()
+ dehydrated = await self._get_hydrator().dehydrate(registry)
+ await self.registry_store.save(dehydrated)
+
+ def _should_dehydrate(self, result: UiPathRuntimeResult) -> bool:
+ if self.policy == HydrationPolicy.ALWAYS:
+ return True
+ if result.status == UiPathRuntimeStatus.SUSPENDED:
+ return True
+ return (
+ self.policy == HydrationPolicy.SUSPEND_OR_SUCCESS
+ and result.status == UiPathRuntimeStatus.SUCCESSFUL
+ )
diff --git a/src/uipath/runtime/workspace/hydrator.py b/src/uipath/runtime/workspace/hydrator.py
new file mode 100644
index 00000000..26111be8
--- /dev/null
+++ b/src/uipath/runtime/workspace/hydrator.py
@@ -0,0 +1,233 @@
+"""Attachment-backed workspace hydration."""
+
+import hashlib
+import os
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+from uuid import UUID
+
+from pydantic import BaseModel
+from uipath.core.workspace import AttachmentsProtocol, JobsProtocol
+
+
+class AttachmentRegistryEntry(BaseModel):
+ """Registry entry for one workspace file attachment."""
+
+ attachment_key: str
+ sha256: str
+ size: int
+ uploaded_at: str
+ attachment_name: str | None = None
+
+
+class WorkspaceHydrator:
+ """Hydrates/dehydrates a workspace directory through job attachments."""
+
+ DEFAULT_ATTACHMENT_PREFIX = ".uipath-workspace/"
+
+ def __init__(
+ self,
+ *,
+ workspace_path: str | Path,
+ attachments: AttachmentsProtocol,
+ jobs: JobsProtocol | None = None,
+ current_job_key: str | None = None,
+ folder_key: str | None = None,
+ folder_path: str | None = None,
+ attachment_prefix: str = DEFAULT_ATTACHMENT_PREFIX,
+ ):
+ """Initialize the hydrator."""
+ self.workspace_path = Path(workspace_path)
+ self.attachments = attachments
+ self.jobs = jobs
+ self.current_job_key = current_job_key
+ self.folder_key = folder_key
+ self.folder_path = folder_path
+ self.attachment_prefix = attachment_prefix.strip("/")
+
+ async def hydrate(
+ self,
+ registry: dict[str, dict[str, Any]],
+ ) -> dict[str, dict[str, Any]]:
+ """Restore a registry using the original public API."""
+ return await self.hydrate_from_registry(registry)
+
+ async def hydrate_from_registry(
+ self,
+ registry: dict[str, dict[str, Any]],
+ ) -> dict[str, dict[str, Any]]:
+ """Restore registry attachments into the workspace.
+
+ Files with matching SHA-256 are left untouched.
+ """
+ normalized = self._normalize_registry(registry)
+ for virtual_path, entry in normalized.items():
+ target = self._resolve_workspace_path(virtual_path)
+ if target.exists() and self._sha256(target) == entry.sha256:
+ continue
+ target.parent.mkdir(parents=True, exist_ok=True)
+ await self.attachments.download_async(
+ key=UUID(entry.attachment_key),
+ destination_path=str(target),
+ folder_key=self.folder_key,
+ folder_path=self.folder_path,
+ )
+ return self._dump_registry(normalized)
+
+ async def hydrate_from_attachments(
+ self,
+ attachment_keys_by_path: dict[str, str],
+ ) -> dict[str, dict[str, Any]]:
+ """Replace the workspace with an attachment snapshot."""
+ attachments_by_path: dict[str, UUID] = {}
+ for virtual_path, raw_attachment_key in attachment_keys_by_path.items():
+ normalized_path = self._virtual_path(
+ self._resolve_workspace_path(virtual_path)
+ )
+ attachments_by_path[normalized_path] = UUID(raw_attachment_key)
+
+ registry: dict[str, AttachmentRegistryEntry] = {}
+ for virtual_path, parsed_attachment_key in attachments_by_path.items():
+ target = self._resolve_workspace_path(virtual_path)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ await self.attachments.download_async(
+ key=parsed_attachment_key,
+ destination_path=str(target),
+ folder_key=self.folder_key,
+ folder_path=self.folder_path,
+ )
+ registry[virtual_path] = AttachmentRegistryEntry(
+ attachment_key=str(parsed_attachment_key),
+ sha256=self._sha256(target),
+ size=target.stat().st_size,
+ uploaded_at=datetime.now(timezone.utc).isoformat(),
+ attachment_name=self._attachment_name_for_virtual_path(virtual_path),
+ )
+ self._remove_files_not_in(set(registry))
+ return self._dump_registry(registry)
+
+ async def dehydrate(
+ self,
+ registry: dict[str, dict[str, Any]],
+ ) -> dict[str, dict[str, Any]]:
+ """Upload new/changed workspace files and return the merged registry.
+
+ The result is rebuilt from the files currently present, so entries for
+ files deleted locally are dropped and not restored on the next hydrate.
+ """
+ previous = self._normalize_registry(registry)
+ current: dict[str, AttachmentRegistryEntry] = {}
+ for file_path in self._iter_files():
+ virtual_path = self._virtual_path(file_path)
+ digest = self._sha256(file_path)
+ size = file_path.stat().st_size
+ existing = previous.get(virtual_path)
+
+ if existing and existing.sha256 == digest and existing.size == size:
+ current[virtual_path] = existing
+ if self.current_job_key:
+ await self.link_attachment(existing.attachment_key)
+ continue
+
+ attachment_name = self._attachment_name_for_virtual_path(virtual_path)
+ attachment_key = await self.attachments.upload_async(
+ name=attachment_name,
+ source_path=str(file_path),
+ folder_key=self.folder_key,
+ folder_path=self.folder_path,
+ )
+ entry = AttachmentRegistryEntry(
+ attachment_key=str(attachment_key),
+ sha256=digest,
+ size=size,
+ uploaded_at=datetime.now(timezone.utc).isoformat(),
+ attachment_name=attachment_name,
+ )
+ current[virtual_path] = entry
+
+ if self.current_job_key:
+ await self.link_attachment(entry.attachment_key)
+
+ return self._dump_registry(current)
+
+ async def link_attachment(self, attachment_key: str) -> None:
+ """Link an already uploaded attachment to the current job."""
+ if self.jobs is None or not self.current_job_key:
+ return
+
+ await self.jobs.link_attachment_async(
+ job_key=UUID(self.current_job_key),
+ attachment_key=UUID(attachment_key),
+ folder_key=self.folder_key,
+ folder_path=self.folder_path,
+ )
+
+ def _iter_files(self) -> list[Path]:
+ if not self.workspace_path.exists():
+ return []
+
+ files: list[Path] = []
+ for root, _, names in os.walk(self.workspace_path):
+ for name in names:
+ path = Path(root) / name
+ if path.is_file():
+ files.append(path)
+ return sorted(files)
+
+ def _resolve_workspace_path(self, virtual_path: str) -> Path:
+ path = (self.workspace_path / virtual_path).resolve()
+ workspace = self.workspace_path.resolve()
+ if workspace != path and workspace not in path.parents:
+ raise ValueError(f"Workspace path escapes root: {virtual_path}")
+ return path
+
+ def _virtual_path(self, path: Path) -> str:
+ return path.relative_to(self.workspace_path).as_posix()
+
+ def _remove_files_not_in(self, virtual_paths: set[str]) -> None:
+ for path in self._iter_files():
+ if self._virtual_path(path) not in virtual_paths:
+ path.unlink()
+
+ @staticmethod
+ def _escape(value: str) -> str:
+ # json-pointer escaping; escape ~ before / so they can't collide
+ return value.replace("~", "~0").replace("/", "~1")
+
+ @staticmethod
+ def _unescape(value: str) -> str:
+ return value.replace("~1", "/").replace("~0", "~")
+
+ def _attachment_name_for_virtual_path(self, virtual_path: str) -> str:
+ # the platform mishandles "/" in attachment names, so keep it slash-free
+ return self._escape(f"{self.attachment_prefix}/{virtual_path}")
+
+ def _virtual_path_from_attachment_name(self, name: str) -> str | None:
+ decoded = self._unescape(name)
+ prefix = f"{self.attachment_prefix}/"
+ if not decoded.startswith(prefix):
+ return None
+ virtual_path = decoded[len(prefix) :]
+ self._resolve_workspace_path(virtual_path)
+ return virtual_path
+
+ def _normalize_registry(
+ self, registry: dict[str, dict[str, Any]]
+ ) -> dict[str, AttachmentRegistryEntry]:
+ normalized: dict[str, AttachmentRegistryEntry] = {}
+ for virtual_path, entry in registry.items():
+ normalized[virtual_path] = AttachmentRegistryEntry.model_validate(entry)
+ return normalized
+
+ def _dump_registry(
+ self, registry: dict[str, AttachmentRegistryEntry]
+ ) -> dict[str, dict[str, Any]]:
+ return {path: entry.model_dump() for path, entry in registry.items()}
+
+ def _sha256(self, path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as file:
+ for chunk in iter(lambda: file.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
diff --git a/src/uipath/runtime/workspace/registry_store.py b/src/uipath/runtime/workspace/registry_store.py
new file mode 100644
index 00000000..a494dba2
--- /dev/null
+++ b/src/uipath/runtime/workspace/registry_store.py
@@ -0,0 +1,54 @@
+"""Storage-backed workspace attachment registry."""
+
+from typing import Any
+
+from uipath.runtime.storage import UiPathRuntimeStorageProtocol
+
+
+class WorkspaceRegistryStore:
+ """Stores workspace file attachment metadata in runtime storage."""
+
+ DEFAULT_NAMESPACE = "workspace"
+ DEFAULT_KEY = "attachments"
+
+ def __init__(
+ self,
+ storage: UiPathRuntimeStorageProtocol,
+ runtime_id: str,
+ *,
+ namespace: str = DEFAULT_NAMESPACE,
+ key: str = DEFAULT_KEY,
+ ):
+ """Initialize the registry store."""
+ self.storage = storage
+ self.runtime_id = runtime_id
+ self.namespace = namespace
+ self.key = key
+
+ async def try_load(self) -> dict[str, dict[str, Any]] | None:
+ """Load the registry, or return ``None`` when it has not been saved."""
+ value = await self.storage.get_value(self.runtime_id, self.namespace, self.key)
+ if value is None:
+ return None
+ if not isinstance(value, dict):
+ raise TypeError("Workspace registry payload must be a dictionary.")
+
+ registry: dict[str, dict[str, Any]] = {}
+ for path, entry in value.items():
+ if isinstance(path, str) and isinstance(entry, dict):
+ registry[path] = entry
+ return registry
+
+ async def load(self) -> dict[str, dict[str, Any]]:
+ """Load registry entries, defaulting to an empty registry."""
+ registry = await self.try_load()
+ return registry if registry is not None else {}
+
+ async def save(self, registry: dict[str, dict[str, Any]]) -> None:
+ """Persist registry entries."""
+ await self.storage.set_value(
+ self.runtime_id,
+ self.namespace,
+ self.key,
+ registry,
+ )
diff --git a/src/uipath/runtime/workspace/workspace.py b/src/uipath/runtime/workspace/workspace.py
new file mode 100644
index 00000000..ffd12a3e
--- /dev/null
+++ b/src/uipath/runtime/workspace/workspace.py
@@ -0,0 +1,50 @@
+"""Disk workspace lifecycle helpers."""
+
+import asyncio
+import shutil
+import tempfile
+from pathlib import Path
+
+
+class Workspace:
+ """Owns a directory used by a runtime to persist files between executions."""
+
+ def __init__(self, path: Path, *, cleanup: bool = True):
+ """Initialize the workspace.
+
+ Args:
+ path: Workspace directory.
+ cleanup: Whether dispose should remove the directory.
+ """
+ self.path = path
+ self.cleanup = cleanup
+
+ @classmethod
+ def create(
+ cls,
+ path: str | Path | None = None,
+ *,
+ prefix: str = "uipath-workspace-",
+ cleanup: bool | None = None,
+ ) -> "Workspace":
+ """Create a workspace directory.
+
+ ``cleanup`` defaults to True for an owned temp dir and False for a
+ supplied path; pass ``cleanup=True`` for a temp path you want disposed.
+ """
+ if path is None:
+ workspace_path = Path(tempfile.mkdtemp(prefix=prefix))
+ should_cleanup = True if cleanup is None else cleanup
+ else:
+ workspace_path = Path(path)
+ workspace_path.mkdir(parents=True, exist_ok=True)
+ should_cleanup = False if cleanup is None else cleanup
+
+ return cls(workspace_path, cleanup=should_cleanup)
+
+ async def dispose(self) -> None:
+ """Remove the workspace directory when configured to do so."""
+ if not self.cleanup:
+ return
+
+ await asyncio.to_thread(shutil.rmtree, self.path)
diff --git a/tests/conftest.py b/tests/conftest.py
index 2556e753..a6c5cd57 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -17,3 +17,9 @@ def temp_dir() -> Generator[str, None, None]:
"""Provide a temporary directory for test files."""
with tempfile.TemporaryDirectory() as tmp_dir:
yield tmp_dir
+
+
+# Governance state is held inline on the :class:`UiPathGovernedRuntime`
+# instance — the host passes a resolved :class:`PolicyIndex` +
+# :class:`EnforcementMode` into the constructor, no module-level
+# state, no cross-test reset needed.
diff --git a/tests/test_audit_factory.py b/tests/test_audit_factory.py
new file mode 100644
index 00000000..3dc44e17
--- /dev/null
+++ b/tests/test_audit_factory.py
@@ -0,0 +1,50 @@
+"""Tests for :func:`uipath.runtime.governance._audit.factory.create_sink`.
+
+The factory is the entry point :class:`AuditManager` uses to construct
+its always-on ``traces`` sink at init. It's tiny (one name → one sink)
+but the unknown-name path deserves a regression guard so a future
+refactor can't silently drop a sink registration.
+"""
+
+from __future__ import annotations
+
+import logging
+
+import pytest
+
+from uipath.runtime.governance._audit.factory import create_sink
+from uipath.runtime.governance._audit.traces import TracesAuditSink
+
+
+def test_create_sink_traces_returns_traces_audit_sink() -> None:
+ """The single supported name resolves to a real ``TracesAuditSink``."""
+ sink = create_sink("traces")
+ assert isinstance(sink, TracesAuditSink)
+
+
+def test_create_sink_is_name_case_insensitive() -> None:
+ """Callers can pass any casing — the factory normalizes internally.
+
+ Regression guard: dropping the ``.lower()`` would silently break
+ every consumer that hand-typed a name with different casing.
+ """
+ assert isinstance(create_sink("TRACES"), TracesAuditSink)
+ assert isinstance(create_sink("Traces"), TracesAuditSink)
+
+
+def test_create_sink_unknown_name_returns_none_and_warns(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """An unknown name returns ``None`` (no crash) and logs a warning.
+
+ Contract: registration failures must not raise — the audit manager's
+ ``_register_traces_sink`` handles ``None`` by skipping the sink,
+ which is safer than crashing at construction on a bad config.
+ """
+ with caplog.at_level(
+ logging.WARNING, logger="uipath.runtime.governance._audit.factory"
+ ):
+ result = create_sink("unknown-sink")
+
+ assert result is None
+ assert any("Unknown audit sink" in r.message for r in caplog.records)
diff --git a/tests/test_audit_manager_lifecycle.py b/tests/test_audit_manager_lifecycle.py
new file mode 100644
index 00000000..25094f9b
--- /dev/null
+++ b/tests/test_audit_manager_lifecycle.py
@@ -0,0 +1,207 @@
+"""Lifecycle tests for :class:`AuditManager`.
+
+Pins the production-readiness invariants of the audit manager:
+
+- Construction is side-effect-free: no background thread, no atexit
+ registration, no global state mutation.
+- :meth:`close` is idempotent.
+- :meth:`emit` dispatches on the caller's thread, so an OTel-backed
+ sink sees the caller's live span without any cross-thread plumbing.
+"""
+
+from __future__ import annotations
+
+import threading
+from typing import Any
+
+from uipath.runtime.governance._audit.base import (
+ AuditEvent,
+ AuditManager,
+ AuditSink,
+ EventType,
+)
+
+# ---------------------------------------------------------------------------
+# Construction is side-effect-free
+# ---------------------------------------------------------------------------
+
+
+def test_construction_starts_no_background_thread() -> None:
+ """``AuditManager()`` must not spawn a worker thread.
+
+ Regression guard for the design pivot: the audit pipeline used to
+ construct a daemon worker thread eagerly. Construction now only
+ builds in-memory state; any async export lives inside the sink.
+ """
+ before = {t.name for t in threading.enumerate()}
+ m = AuditManager(register_default_sinks=False)
+ after = {t.name for t in threading.enumerate()}
+ try:
+ assert after == before, (
+ f"AuditManager() spawned a thread; new threads: {after - before}"
+ )
+ finally:
+ m.close()
+
+
+def test_default_sink_registered_on_construction() -> None:
+ """With defaults, the traces sink is auto-registered."""
+ m = AuditManager()
+ try:
+ assert "traces" in m.list_sinks()
+ finally:
+ m.close()
+
+
+def test_bare_construction_skips_default_sink() -> None:
+ """``register_default_sinks=False`` produces an empty manager."""
+ m = AuditManager(register_default_sinks=False)
+ try:
+ assert m.list_sinks() == []
+ finally:
+ m.close()
+
+
+# ---------------------------------------------------------------------------
+# close() is idempotent and clears sinks
+# ---------------------------------------------------------------------------
+
+
+def test_close_clears_sinks_and_failure_state() -> None:
+ """``close()`` empties sinks, failure counters, and tripped set."""
+
+ class _Sink(AuditSink):
+ def __init__(self, name: str) -> None:
+ self._name = name
+ self.closed = False
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ def emit(self, event: AuditEvent) -> None:
+ pass
+
+ def close(self) -> None:
+ self.closed = True
+
+ m = AuditManager(register_default_sinks=False)
+ s = _Sink("test")
+ m.register_sink(s)
+ m._sink_failures["test"] = 3
+ m._tripped_sinks.add("test")
+
+ m.close()
+
+ assert m.list_sinks() == []
+ assert m._sink_failures == {}
+ assert m._tripped_sinks == set()
+ assert s.closed
+
+
+def test_close_is_idempotent() -> None:
+ """Calling ``close()`` twice must not raise."""
+ m = AuditManager(register_default_sinks=False)
+ m.close()
+ m.close() # must not raise
+
+
+# ---------------------------------------------------------------------------
+# flush() delegates to every sink
+# ---------------------------------------------------------------------------
+
+
+def test_flush_calls_flush_on_each_sink() -> None:
+ """The manager holds no buffer; ``flush()`` is a fan-out to sinks."""
+
+ class _Sink(AuditSink):
+ def __init__(self, name: str) -> None:
+ self._name = name
+ self.flush_count = 0
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ def emit(self, event: AuditEvent) -> None:
+ pass
+
+ def flush(self) -> None:
+ self.flush_count += 1
+
+ m = AuditManager(register_default_sinks=False)
+ a, b = _Sink("a"), _Sink("b")
+ m.register_sink(a)
+ m.register_sink(b)
+ try:
+ m.flush()
+ assert a.flush_count == 1
+ assert b.flush_count == 1
+ finally:
+ m.close()
+
+
+# ---------------------------------------------------------------------------
+# emit() runs on the caller's thread — OTel context visible directly
+# ---------------------------------------------------------------------------
+
+
+def test_emit_runs_on_caller_thread() -> None:
+ """``emit()`` invokes sinks synchronously on the calling thread.
+
+ Asserts the design contract that lets OTel-backed sinks see the
+ agent's live span via ``trace.get_current_span()`` without any
+ cross-thread context propagation.
+ """
+ captured: dict[str, Any] = {}
+
+ class _Probe(AuditSink):
+ @property
+ def name(self) -> str:
+ return "probe"
+
+ def emit(self, event: AuditEvent) -> None:
+ captured["thread"] = threading.current_thread()
+
+ m = AuditManager(register_default_sinks=False)
+ m.register_sink(_Probe())
+ try:
+ m.emit(AuditEvent(event_type=EventType.RULE_EVALUATION))
+ assert captured["thread"] is threading.current_thread()
+ finally:
+ m.close()
+
+
+def test_emit_propagates_otel_span_via_current_context() -> None:
+ """An OTel-backed sink sees the caller's live span directly.
+
+ With sync dispatch there's no contextvars snapshot/restore — the
+ sink just calls ``trace.get_current_span()`` on the same thread the
+ caller is on, and that's the span the caller has active.
+ """
+ from opentelemetry import trace
+ from opentelemetry.sdk.trace import TracerProvider
+
+ captured: dict[str, Any] = {}
+
+ class _Probe(AuditSink):
+ @property
+ def name(self) -> str:
+ return "probe"
+
+ def emit(self, event: AuditEvent) -> None:
+ sc = trace.get_current_span().get_span_context()
+ captured["trace_id"] = sc.trace_id if sc.is_valid else None
+ captured["span_id"] = sc.span_id if sc.is_valid else None
+
+ tracer = TracerProvider().get_tracer("test")
+ m = AuditManager(register_default_sinks=False)
+ m.register_sink(_Probe())
+ try:
+ with tracer.start_as_current_span("agent-run") as span:
+ expected = span.get_span_context()
+ m.emit(AuditEvent(event_type=EventType.RULE_EVALUATION))
+ assert captured["trace_id"] == expected.trace_id
+ assert captured["span_id"] == expected.span_id
+ finally:
+ m.close()
diff --git a/tests/test_audit_manager_track_event_wiring.py b/tests/test_audit_manager_track_event_wiring.py
new file mode 100644
index 00000000..31c62e9c
--- /dev/null
+++ b/tests/test_audit_manager_track_event_wiring.py
@@ -0,0 +1,178 @@
+"""Tests for ``AuditManager`` auto-registration of the ``track_events`` sink.
+
+The sink is platform-mandated, like ``traces``. The host wires the
+``track_event`` callable + ``GovernanceRuntimeMetadata`` at
+construction. ``_register_track_event_sink`` mirrors the simple shape
+of ``_register_traces_sink`` — deferred import, construct, register,
+log — wrapped in a broad ``except`` so a misconfigured wiring layer
+(missing callable, sink-construction error) is surfaced as a warning
+instead of crashing the agent.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+import pytest
+
+from uipath.runtime.governance._audit.base import AuditManager
+from uipath.runtime.governance._audit.metadata import GovernanceRuntimeMetadata
+from uipath.runtime.governance._audit.track_events import TrackEventAuditSink
+
+
+class _Capture:
+ """Stand-in for ``provider.track_event`` — records calls."""
+
+ def __init__(self) -> None:
+ self.calls: list[dict[str, Any]] = []
+
+ def __call__(self, **kwargs: Any) -> None:
+ self.calls.append(kwargs)
+
+
+# ---------------------------------------------------------------------------
+# Happy path — host wires the callable
+# ---------------------------------------------------------------------------
+
+
+def test_track_event_sink_registered_when_callable_supplied() -> None:
+ mgr = AuditManager(
+ track_event=_Capture(),
+ runtime_metadata=GovernanceRuntimeMetadata(agent_type="uipath_coded"),
+ )
+ try:
+ sinks = mgr.list_sinks()
+ assert TrackEventAuditSink.SINK_NAME in sinks
+ sink = mgr.get_sink(TrackEventAuditSink.SINK_NAME)
+ assert isinstance(sink, TrackEventAuditSink)
+ finally:
+ mgr.close()
+
+
+def test_traces_sink_is_still_registered_alongside() -> None:
+ """track_events doesn't replace traces — both are mandatory."""
+ mgr = AuditManager(
+ track_event=_Capture(),
+ runtime_metadata=GovernanceRuntimeMetadata(),
+ )
+ try:
+ sinks = mgr.list_sinks()
+ assert "traces" in sinks
+ assert TrackEventAuditSink.SINK_NAME in sinks
+ finally:
+ mgr.close()
+
+
+def test_supplied_runtime_metadata_reaches_sink() -> None:
+ mgr = AuditManager(
+ track_event=_Capture(),
+ runtime_metadata=GovernanceRuntimeMetadata(
+ agent_type="uipath_coded", agent_framework="langchain"
+ ),
+ )
+ try:
+ sink = mgr.get_sink(TrackEventAuditSink.SINK_NAME)
+ assert isinstance(sink, TrackEventAuditSink)
+ # Internal: confirm the sink carries the host-supplied metadata.
+ assert sink._meta.agent_type == "uipath_coded"
+ assert sink._meta.agent_framework == "langchain"
+ finally:
+ mgr.close()
+
+
+def test_metadata_defaults_when_not_supplied() -> None:
+ """Helper falls back to ``GovernanceRuntimeMetadata()`` defaults."""
+ mgr = AuditManager(track_event=_Capture())
+ try:
+ sink = mgr.get_sink(TrackEventAuditSink.SINK_NAME)
+ assert isinstance(sink, TrackEventAuditSink)
+ assert sink._meta.agent_type == "unknown"
+ assert sink._meta.agent_framework == "unknown"
+ finally:
+ mgr.close()
+
+
+# ---------------------------------------------------------------------------
+# Missing-callable path — caught by the helper's try/except, logged
+# ---------------------------------------------------------------------------
+
+
+def test_missing_callable_logs_warning_and_skips_sink(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """``track_event=None`` → sink __init__ raises → helper catches + warns."""
+ caplog.set_level(logging.WARNING, logger="uipath.runtime.governance._audit.base")
+ mgr = AuditManager() # no track_event supplied
+ try:
+ # Traces sink still wired; track_events skipped because sink
+ # construction raised ValueError caught by the helper.
+ assert "traces" in mgr.list_sinks()
+ assert TrackEventAuditSink.SINK_NAME not in mgr.list_sinks()
+
+ warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
+ assert any("Failed to register track_events sink" in m for m in warnings), (
+ f"expected registration-failure warning, got: {warnings}"
+ )
+ finally:
+ mgr.close()
+
+
+def test_sink_construction_error_does_not_crash_manager(
+ caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """An exception inside ``TrackEventAuditSink.__init__`` is swallowed.
+
+ Forces a non-``ValueError`` exception (a raise inside ``__init__``
+ triggered via the runtime_metadata path) to confirm the helper's
+ ``except Exception`` covers the broad case the user asked for, not
+ just the validation error.
+ """
+ caplog.set_level(logging.WARNING, logger="uipath.runtime.governance._audit.base")
+
+ def _boom(*args: Any, **kwargs: Any) -> Any:
+ raise RuntimeError("synthetic registration failure")
+
+ from uipath.runtime.governance._audit import track_events as te
+
+ monkeypatch.setattr(te, "TrackEventAuditSink", _boom)
+
+ mgr = AuditManager(track_event=_Capture())
+ try:
+ # Helper swallowed the RuntimeError; manager kept the traces sink
+ # and reached a constructed state without crashing.
+ assert "traces" in mgr.list_sinks()
+ assert TrackEventAuditSink.SINK_NAME not in mgr.list_sinks()
+ warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
+ assert any(
+ "Failed to register track_events sink" in m
+ and "synthetic registration failure" in m
+ for m in warnings
+ ), f"expected wrapped registration failure, got: {warnings}"
+ finally:
+ mgr.close()
+
+
+# ---------------------------------------------------------------------------
+# Opt-out path — register_default_sinks=False
+# ---------------------------------------------------------------------------
+
+
+def test_opt_out_skips_both_sinks() -> None:
+ """register_default_sinks=False keeps the audit pipeline empty for tests."""
+ mgr = AuditManager(register_default_sinks=False)
+ try:
+ assert mgr.list_sinks() == []
+ finally:
+ mgr.close()
+
+
+def test_opt_out_does_not_warn(caplog: pytest.LogCaptureFixture) -> None:
+ """Opting out is an explicit signal — no missing-callable warning."""
+ caplog.set_level(logging.WARNING, logger="uipath.runtime.governance._audit.base")
+ mgr = AuditManager(register_default_sinks=False)
+ try:
+ warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
+ assert not any("track_events" in m for m in warnings)
+ finally:
+ mgr.close()
diff --git a/tests/test_audit_register_sink.py b/tests/test_audit_register_sink.py
new file mode 100644
index 00000000..d0e3590b
--- /dev/null
+++ b/tests/test_audit_register_sink.py
@@ -0,0 +1,108 @@
+"""Tests for ``AuditManager.register_sink`` failure-counter semantics.
+
+A re-registered same-name sink must NOT inherit the previous instance's
+tripped circuit-breaker state. ``unregister_sink`` already clears these
+counters, but ``register_sink`` also clears them on a successful add as
+defense-in-depth (covers tests / external callers that touch the
+internal counter dicts directly).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from uipath.runtime.governance._audit.base import (
+ AuditEvent,
+ AuditManager,
+ AuditSink,
+ EventType,
+)
+
+
+class _NoopSink(AuditSink):
+ """Sink that records emit calls and never raises."""
+
+ def __init__(self, name: str = "test-sink") -> None:
+ self._name = name
+ self.events: list[AuditEvent] = []
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ def emit(self, event: AuditEvent) -> None:
+ self.events.append(event)
+
+
+def _event() -> AuditEvent:
+ return AuditEvent(event_type=EventType.RULE_EVALUATION, agent_name="a")
+
+
+@pytest.fixture
+def manager() -> Any:
+ """Build a fresh AuditManager with no default sinks.
+
+ ``register_default_sinks=False`` keeps the traces sink out of the
+ test, so assertions about registered sinks see only what the test
+ puts there.
+ """
+ return AuditManager(register_default_sinks=False)
+
+
+def test_register_clears_stale_failure_counter(manager: AuditManager) -> None:
+ """A new sink with a name that previously tripped starts fresh."""
+ # Simulate prior instance having tripped the circuit-breaker without
+ # going through unregister (e.g. test code or external code that
+ # mutated the counters directly).
+ manager._sink_failures["test-sink"] = manager._SINK_FAILURE_THRESHOLD
+ manager._tripped_sinks.add("test-sink")
+
+ new_sink = _NoopSink(name="test-sink")
+ manager.register_sink(new_sink)
+
+ # Counter and tripped-set must be cleared.
+ assert manager._sink_failures.get("test-sink", 0) == 0
+ assert "test-sink" not in manager._tripped_sinks
+
+ # And the new sink actually receives events (would be skipped if
+ # still considered tripped).
+ manager.emit(_event())
+ assert len(new_sink.events) == 1
+
+
+def test_register_does_not_clear_for_duplicate(manager: AuditManager) -> None:
+ """Re-registering an already-present sink is a no-op (no counter reset)."""
+ sink = _NoopSink(name="test-sink")
+ manager.register_sink(sink)
+
+ # Simulate the existing sink having accumulated some failures.
+ manager._sink_failures["test-sink"] = 3
+
+ # A second register call with the same name should NOT clear those
+ # failures — the duplicate-check fires before the reset.
+ duplicate = _NoopSink(name="test-sink")
+ manager.register_sink(duplicate)
+
+ assert manager._sink_failures["test-sink"] == 3
+
+
+def test_unregister_then_register_starts_fresh(manager: AuditManager) -> None:
+ """The full lifecycle: register → trip → unregister → register again."""
+ sink = _NoopSink(name="test-sink")
+ manager.register_sink(sink)
+ manager._sink_failures["test-sink"] = manager._SINK_FAILURE_THRESHOLD
+ manager._tripped_sinks.add("test-sink")
+
+ manager.unregister_sink("test-sink")
+ # Unregister already clears.
+ assert "test-sink" not in manager._tripped_sinks
+
+ new_sink = _NoopSink(name="test-sink")
+ manager.register_sink(new_sink)
+ assert manager._sink_failures.get("test-sink", 0) == 0
+ assert "test-sink" not in manager._tripped_sinks
+
+ manager.emit(_event())
+ assert len(new_sink.events) == 1
diff --git a/tests/test_chat.py b/tests/test_chat.py
index c3be06b3..f17bad53 100644
--- a/tests/test_chat.py
+++ b/tests/test_chat.py
@@ -23,23 +23,31 @@
UiPathStreamOptions,
)
from uipath.runtime.chat import (
+ UiPathChatMetaEventProtocol,
UiPathChatProtocol,
UiPathChatRuntime,
)
from uipath.runtime.chat.runtime import _parse_confirmation
-from uipath.runtime.events import UiPathRuntimeEvent, UiPathRuntimeMessageEvent
+from uipath.runtime.events import (
+ UiPathRuntimeConversationMetaEvent,
+ UiPathRuntimeEvent,
+ UiPathRuntimeMessageEvent,
+)
from uipath.runtime.schema import UiPathRuntimeSchema
-def make_chat_bridge_mock() -> UiPathChatProtocol:
+def make_chat_bridge_mock(*, supports_meta_events: bool = True) -> UiPathChatProtocol:
"""Create a chat bridge mock with all methods that UiPathChatRuntime uses."""
bridge_mock: Mock = Mock(spec=UiPathChatProtocol)
bridge_mock.connect = AsyncMock()
bridge_mock.disconnect = AsyncMock()
bridge_mock.emit_message_event = AsyncMock()
+ if supports_meta_events:
+ bridge_mock.emit_meta_event = AsyncMock()
bridge_mock.emit_interrupt_event = AsyncMock()
bridge_mock.emit_executing_tool_call_event = AsyncMock()
+ bridge_mock.emit_exchange_end_event = AsyncMock()
bridge_mock.wait_for_resume = AsyncMock()
return cast(UiPathChatProtocol, bridge_mock)
@@ -52,10 +60,12 @@ def __init__(
self,
messages: Sequence[str],
*,
+ meta_events: Sequence[dict[str, Any]] = (),
error_in_stream: bool = False,
) -> None:
super().__init__()
self.messages: list[str] = list(messages)
+ self.meta_events = list(meta_events)
self.error_in_stream: bool = error_in_stream
self.execute_called: bool = False
@@ -93,6 +103,9 @@ async def stream(
)
yield UiPathRuntimeMessageEvent(payload=message_event)
+ for meta_event in self.meta_events:
+ yield UiPathRuntimeConversationMetaEvent(payload=meta_event)
+
# Final result at the end of streaming
yield UiPathRuntimeResult(
status=UiPathRuntimeStatus.SUCCESSFUL,
@@ -257,6 +270,42 @@ async def test_chat_runtime_stream_yields_all_events():
assert cast(AsyncMock, bridge.emit_message_event).await_count == 2
+@pytest.mark.asyncio
+async def test_chat_runtime_emits_conversation_meta_events():
+ runtime_impl = StreamingMockRuntime(
+ messages=[],
+ meta_events=[{"workspaceFiles": [{"path": "plan.md"}]}],
+ )
+ bridge = make_chat_bridge_mock()
+ chat_runtime = UiPathChatRuntime(runtime_impl, bridge)
+
+ events = [event async for event in chat_runtime.stream({})]
+
+ meta_event_bridge = cast(UiPathChatMetaEventProtocol, bridge)
+ cast(AsyncMock, meta_event_bridge.emit_meta_event).assert_awaited_once_with(
+ {"workspaceFiles": [{"path": "plan.md"}]}
+ )
+ assert isinstance(events[0], UiPathRuntimeConversationMetaEvent)
+ assert isinstance(events[1], UiPathRuntimeResult)
+
+
+@pytest.mark.asyncio
+async def test_chat_runtime_skips_meta_events_for_legacy_bridge(caplog):
+ runtime_impl = StreamingMockRuntime(
+ messages=[],
+ meta_events=[{"workspaceFiles": [{"path": "plan.md"}]}],
+ )
+ bridge = make_chat_bridge_mock(supports_meta_events=False)
+ chat_runtime = UiPathChatRuntime(runtime_impl, bridge)
+
+ with caplog.at_level("WARNING"):
+ events = [event async for event in chat_runtime.stream({})]
+
+ assert "does not support conversation metadata events" in caplog.text
+ assert isinstance(events[0], UiPathRuntimeConversationMetaEvent)
+ assert isinstance(events[1], UiPathRuntimeResult)
+
+
@pytest.mark.asyncio
async def test_chat_runtime_handles_errors():
"""On unexpected errors, UiPathChatRuntime should propagate them."""
diff --git a/tests/test_commitment_concern.py b/tests/test_commitment_concern.py
new file mode 100644
index 00000000..378bb604
--- /dev/null
+++ b/tests/test_commitment_concern.py
@@ -0,0 +1,200 @@
+"""Tests for the commitment_concern check (A.10.4).
+
+The check now uses OR semantics: a verb match, an amount match, or a
+deadline match is each sufficient when its enabling flag is on. With
+both flags false the rule matches verb-only.
+
+The verb pattern also covers proposal / SOW style commitment markers
+("Cost: $X", "fixed scope", "Deliverables", "Timeline", "I propose")
+so formal-business commitments without first-person verbs still fire.
+
+Amount detection requires a currency marker adjacent to the number to
+prevent URL fragments (forum-post IDs, image dimensions, etc.) from
+false-positiving.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from uipath.runtime.governance.native.evaluator import GovernanceEvaluator
+
+# ---------------------------------------------------------------------------
+# The proposal-style sample that originally slipped through the rule.
+# Contains: "Cost: $780 (fixed for the above scope)", "Deliverables",
+# "Timeline: 4 days total", "I propose", a forum URL with a 6-digit ID.
+# Triple-quoted so we keep the line breaks the model produced.
+# ---------------------------------------------------------------------------
+SAMPLE_PROPOSAL = """To address your concerns, I reviewed the official UiPath site you referenced and relevant resources on uipath.com to inform a fast stabilization plan. Notable findings include: a community CI/CD sample for UiPath projects (https://forum.uipath.com/t/announcement-ci-cd-pipeline-sample-implementation-s-for-uipath-projects-alpha/667851).
+
+Here's how I propose we turn your software around quickly:
+
+Plan
+- Triage (logs + reproduce)
+- Quick stabilization
+
+Deliverables
+- Defect triage report
+
+Timeline: 4 days total
+- Day 1: Triage + reproduction
+
+Cost: $780 (fixed for the above scope)
+"""
+
+
+@pytest.mark.parametrize(
+ "text",
+ [
+ "Cost: $780 (fixed for the above scope)",
+ "Deliverables: a, b, c",
+ "Timeline: 4 days total for the whole engagement",
+ "I propose we turn this around in a week",
+ "We will refund the difference",
+ "I'll deliver the report by Friday",
+ "the warranty covers parts only",
+ "fixed price of one hundred dollars",
+ ],
+)
+def test_verb_match_alone_fires(text: str) -> None:
+ """Each verb-style commitment marker fires on its own (verb-only mode)."""
+ assert (
+ GovernanceEvaluator._check_commitment_concern(
+ text, {"require_amount": False, "require_deadline": False}
+ )
+ is True
+ )
+
+
+def test_full_proposal_sample_fires() -> None:
+ """The originally-missed proposal output now fires."""
+ assert (
+ GovernanceEvaluator._check_commitment_concern(
+ SAMPLE_PROPOSAL,
+ {"require_amount": False, "require_deadline": False},
+ )
+ is True
+ )
+
+
+@pytest.mark.parametrize(
+ "text",
+ [
+ "$780",
+ "We charge USD 1,200 per seat",
+ "The fee is 500 EUR",
+ ],
+)
+def test_amount_alone_fires_when_require_amount_true(text: str) -> None:
+ """Currency-anchored amount alone fires under OR semantics."""
+ assert (
+ GovernanceEvaluator._check_commitment_concern(
+ text, {"require_amount": True, "require_deadline": False}
+ )
+ is True
+ )
+
+
+@pytest.mark.parametrize(
+ "text",
+ [
+ "Task is 75% complete.",
+ "We maintain 99.9% uptime.",
+ "Battery at 50%.",
+ "Score: 12%.",
+ ],
+)
+def test_bare_percentage_does_not_fire(text: str) -> None:
+ """Status-only percentages must not trigger commitment_concern.
+
+ Regression for the prior ``\\d{1,3}\\s*%`` branch in the amount
+ regex, which fired on benign status / progress text. Real
+ percentage-bearing commitments ("we'll give a 20% discount")
+ still fire via the verb pattern.
+ """
+ assert (
+ GovernanceEvaluator._check_commitment_concern(
+ text, {"require_amount": True, "require_deadline": False}
+ )
+ is False
+ )
+
+
+def test_percentage_with_verb_still_fires() -> None:
+ """A commitment verb co-occurring with a percentage still fires."""
+ assert (
+ GovernanceEvaluator._check_commitment_concern(
+ "We will refund 100% of the purchase price.",
+ {"require_amount": True, "require_deadline": False},
+ )
+ is True
+ )
+
+
+def test_amount_alone_does_not_fire_when_require_amount_false() -> None:
+ """Amount-only text is silent when require_amount=False and no verb."""
+ assert (
+ GovernanceEvaluator._check_commitment_concern(
+ "The list price is $780.",
+ {"require_amount": False, "require_deadline": False},
+ )
+ is False
+ )
+
+
+def test_deadline_alone_fires_when_require_deadline_true() -> None:
+ """Deadline phrase alone fires under OR semantics."""
+ assert (
+ GovernanceEvaluator._check_commitment_concern(
+ "Will be done within 5 days.",
+ {"require_amount": False, "require_deadline": True},
+ )
+ is True
+ )
+
+
+def test_url_fragment_digits_do_not_false_positive() -> None:
+ """A long URL with embedded digits is not a 'commitment'.
+
+ Catches the prior price-parser misbehaviour where Price.fromstring()
+ picked up forum-post IDs (e.g. ``667851``) and conflated them with
+ unrelated currency symbols elsewhere in the text.
+ """
+ text = (
+ "See https://forum.example.com/t/topic/667851 for details — "
+ "no commitment language here."
+ )
+ assert (
+ GovernanceEvaluator._check_commitment_concern(
+ text, {"require_amount": True, "require_deadline": True}
+ )
+ is False
+ )
+
+
+@pytest.mark.parametrize(
+ "text",
+ [
+ "",
+ " ",
+ "Just chatting about the weather today.",
+ "The product is durable and well-made.",
+ ],
+)
+def test_no_signal_does_not_fire(text: str) -> None:
+ """Text without any commitment signal stays silent regardless of flags."""
+ assert (
+ GovernanceEvaluator._check_commitment_concern(
+ text, {"require_amount": True, "require_deadline": True}
+ )
+ is False
+ )
+
+
+def test_non_dict_params_treated_as_defaults() -> None:
+ """``params`` of the wrong type degrades to defaults rather than crashing."""
+ assert GovernanceEvaluator._check_commitment_concern("we will refund", None) is True
+ assert (
+ GovernanceEvaluator._check_commitment_concern("no verbs here", "garbage")
+ is False
+ )
diff --git a/tests/test_context.py b/tests/test_context.py
index 9231f65a..d04de55e 100644
--- a/tests/test_context.py
+++ b/tests/test_context.py
@@ -1,9 +1,11 @@
import json
+import os
from pathlib import Path
from typing import Any
import pytest
from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError
+from uipath.core.triggers import UiPathResumeTrigger
from uipath.runtime.context import UiPathRuntimeContext
from uipath.runtime.errors import (
@@ -183,6 +185,9 @@ def test_from_config_extracts_fps_properties_without_runtime(tmp_path: Path) ->
"conversationalService.conversationId": "conv-123",
"conversationalService.exchangeId": "ex-456",
"conversationalService.messageId": "msg-789",
+ "conversationalService.enableOutputs": True,
+ "conversationalService.conversationalUserId": "owner-guid",
+ "conversationalService.runAsMe": True,
"mcpServer.id": "server-id-123",
"mcpServer.slug": "my-mcp-server",
}
@@ -195,10 +200,48 @@ def test_from_config_extracts_fps_properties_without_runtime(tmp_path: Path) ->
assert ctx.conversation_id == "conv-123"
assert ctx.exchange_id == "ex-456"
assert ctx.message_id == "msg-789"
+ assert ctx.conversational_outputs_enabled is True
+ assert ctx.conversational_user_id == "owner-guid"
+ assert ctx.conversational_run_as_me is True
assert ctx.mcp_server_id == "server-id-123"
assert ctx.mcp_server_slug == "my-mcp-server"
+def test_conversational_run_as_me_defaults_false_when_fps_property_absent(
+ tmp_path: Path,
+) -> None:
+ """conversational_run_as_me defaults to False when the fps key is missing."""
+ cfg = {
+ "fpsProperties": {
+ "conversationalService.conversationId": "conv-123",
+ }
+ }
+ config_path = tmp_path / "uipath.json"
+ config_path.write_text(json.dumps(cfg))
+
+ ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))
+
+ assert ctx.conversational_run_as_me is False
+
+
+def test_from_config_conversational_outputs_enabled_defaults_false(
+ tmp_path: Path,
+) -> None:
+ """When enableOutputs isn't in fpsProperties, the field defaults to False —
+ legacy safe behavior for pre-migration conversational agents."""
+ cfg = {
+ "fpsProperties": {
+ "conversationalService.conversationId": "conv-legacy",
+ }
+ }
+ config_path = tmp_path / "uipath.json"
+ config_path.write_text(json.dumps(cfg))
+
+ ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))
+
+ assert ctx.conversational_outputs_enabled is False
+
+
def test_from_config_loads_runtime_and_fps_properties(tmp_path: Path) -> None:
"""runtime.* keys and fpsProperties.* keys should both be applied."""
cfg = {
@@ -241,6 +284,38 @@ def test_from_config_loads_runtime_and_fps_properties(tmp_path: Path) -> None:
assert ctx.mcp_server_slug == "test-server-slug"
+def test_from_config_maps_end_exchange_fps_property(tmp_path: Path) -> None:
+ """conversationalService.endExchange should map onto end_exchange."""
+ cfg = {
+ "fpsProperties": {
+ "conversationalService.conversationId": "conv-123",
+ "conversationalService.exchangeId": "ex-456",
+ "conversationalService.endExchange": False,
+ }
+ }
+ config_path = tmp_path / "uipath.json"
+ config_path.write_text(json.dumps(cfg))
+
+ ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))
+
+ assert ctx.end_exchange is False
+
+
+def test_end_exchange_defaults_true_when_fps_property_absent(tmp_path: Path) -> None:
+ """end_exchange defaults to True (legacy behavior) when the fps key is missing."""
+ cfg = {
+ "fpsProperties": {
+ "conversationalService.conversationId": "conv-123",
+ }
+ }
+ config_path = tmp_path / "uipath.json"
+ config_path.write_text(json.dumps(cfg))
+
+ ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))
+
+ assert ctx.end_exchange is True
+
+
def test_result_file_written_on_faulted_trigger_error(tmp_path: Path) -> None:
runtime_dir = tmp_path / "runtime"
ctx = UiPathRuntimeContext(
@@ -284,3 +359,344 @@ def test_string_output_wrapped_in_dict() -> None:
assert result_dict["output"] == {"output": "primitive str"}
assert result_dict["status"] == UiPathRuntimeStatus.SUCCESSFUL
+
+
+@pytest.mark.parametrize(
+ "command,expected",
+ [
+ ("run", "runtime"),
+ ("debug", "playground"),
+ ("dev", "playground"),
+ ("eval", "eval"),
+ ],
+)
+def test_constructor_derives_execution_source(command: str, expected: str) -> None:
+ """execution_source is derived from the command on the plain constructor path."""
+ ctx = UiPathRuntimeContext(command=command)
+
+ assert ctx.execution_source == expected
+
+
+@pytest.mark.parametrize(
+ "command,expected",
+ [
+ ("run", "runtime"),
+ ("eval", "eval"),
+ ],
+)
+def test_with_defaults_derives_execution_source(
+ command: str, expected: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """execution_source is also derived via with_defaults."""
+ monkeypatch.chdir(tmp_path)
+
+ ctx = UiPathRuntimeContext.with_defaults(command=command)
+
+ assert ctx.execution_source == expected
+
+
+def test_execution_source_unset_for_unmapped_command() -> None:
+ """Commands that do not run an agent leave execution_source unset.
+
+ The field must remain unset (not explicitly None) so it is absent from
+ model_dump(exclude_unset=True).
+ """
+ ctx = UiPathRuntimeContext(command="pack")
+
+ assert ctx.execution_source is None
+ assert "execution_source" not in ctx.model_dump(exclude_unset=True)
+
+
+def test_explicit_execution_source_not_overwritten() -> None:
+ """An explicitly provided execution_source takes precedence over the command."""
+ ctx = UiPathRuntimeContext(command="run", execution_source="custom")
+
+ assert ctx.execution_source == "custom"
+
+
+def test_constructor_accepts_maestro_flow_voice_mode() -> None:
+ ctx = UiPathRuntimeContext(voice_mode="maestro_flow")
+
+ assert ctx.voice_mode == "maestro_flow"
+
+
+def test_from_config_accepts_maestro_flow_voice_mode(tmp_path: Path) -> None:
+ config_path = tmp_path / "uipath.json"
+ config_path.write_text(
+ json.dumps({"fpsProperties": {"voice.mode": "maestro_flow"}})
+ )
+
+ ctx = UiPathRuntimeContext.from_config(str(config_path))
+
+ assert ctx.voice_mode == "maestro_flow"
+
+
+def test_from_config_maps_split_output_arguments(tmp_path: Path) -> None:
+ """runtime.splitOutputArguments should map onto the knob."""
+ cfg = {"runtime": {"splitOutputArguments": True}}
+ config_path = tmp_path / "uipath.json"
+ config_path.write_text(json.dumps(cfg))
+
+ ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))
+
+ assert ctx.split_output_arguments is True
+
+
+def test_split_output_arguments_defaults_off_when_config_key_absent(
+ tmp_path: Path,
+) -> None:
+ """The split stays off when the config omits the key."""
+ cfg = {"runtime": {"outputFile": "my_output.json"}}
+ config_path = tmp_path / "uipath.json"
+ config_path.write_text(json.dumps(cfg))
+
+ ctx = UiPathRuntimeContext.from_config(config_path=str(config_path))
+
+ assert ctx.split_output_arguments is False
+
+
+def test_output_arguments_file_is_a_sibling_of_the_result_file(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """The arguments file lands next to the result file, never in the process CWD.
+
+ The host names the directory once, through runtime_dir, and both files follow
+ it. The filename is not configurable, so the knob has exactly one encoding and
+ the two files cannot be pointed at different directories.
+ """
+ cwd = tmp_path / "cwd"
+ cwd.mkdir()
+ monkeypatch.chdir(cwd)
+ runtime_dir = tmp_path / "runtime"
+ ctx = UiPathRuntimeContext(
+ job_id="job-sibling",
+ runtime_dir=str(runtime_dir),
+ result_file="result.json",
+ split_output_arguments=True,
+ )
+
+ arguments_path = Path(ctx.resolved_output_arguments_file_path)
+ assert arguments_path.parent == Path(ctx.resolved_result_file_path).parent
+ assert arguments_path.parent == runtime_dir
+ assert arguments_path.name == "result.args.json"
+ assert arguments_path.is_absolute()
+ assert cwd not in arguments_path.parents
+
+
+def test_output_arguments_file_cannot_collide_with_the_result_file(
+ tmp_path: Path,
+) -> None:
+ """The suffix goes before the extension, so the two names can never converge.
+
+ Naming the result file after the arguments file used to produce one path for
+ both: the envelope overwrote the arguments and then pointed at itself.
+ """
+ ctx = UiPathRuntimeContext(
+ job_id="job-collide",
+ runtime_dir=str(tmp_path / "runtime"),
+ result_file="output.args.json",
+ split_output_arguments=True,
+ )
+
+ assert Path(ctx.resolved_output_arguments_file_path).name == "output.args.args.json"
+ assert ctx.resolved_output_arguments_file_path != os.path.abspath(
+ ctx.resolved_result_file_path
+ )
+
+
+def test_output_arguments_file_not_written_without_a_job(tmp_path: Path) -> None:
+ """No job means no envelope, so the pointer would have no reader and no file.
+
+ A local `uipath run` has no UIPATH_JOB_KEY; writing the payload there would
+ leave a full copy on disk that nothing references.
+ """
+ runtime_dir = tmp_path / "runtime"
+ ctx = UiPathRuntimeContext(
+ runtime_dir=str(runtime_dir),
+ result_file="result.json",
+ split_output_arguments=True,
+ )
+
+ with ctx:
+ ctx.result = UiPathRuntimeResult(
+ status=UiPathRuntimeStatus.SUCCESSFUL,
+ output={"foo": "bar"},
+ )
+
+ assert not Path(ctx.resolved_output_arguments_file_path).exists()
+ assert not Path(ctx.resolved_result_file_path).exists()
+
+
+def test_result_file_keeps_output_inline_when_split_disabled(
+ tmp_path: Path,
+) -> None:
+ """Without the knob, the result file is byte-identical to the legacy envelope."""
+ runtime_dir = tmp_path / "runtime"
+ ctx = UiPathRuntimeContext(
+ job_id="job-inline",
+ runtime_dir=str(runtime_dir),
+ result_file="result.json",
+ )
+
+ with ctx:
+ ctx.result = UiPathRuntimeResult(
+ status=UiPathRuntimeStatus.SUCCESSFUL,
+ output={"foo": "bar"},
+ )
+
+ result_path = Path(ctx.resolved_result_file_path)
+ # The envelope is written in text mode, so json's newline reaches disk as os.linesep
+ expected = json.dumps(
+ {"output": {"foo": "bar"}, "status": "successful"}, indent=2
+ ).replace("\n", os.linesep)
+ assert result_path.read_bytes() == expected.encode()
+
+ content = json.loads(result_path.read_bytes())
+ assert "outputArgumentsFilePath" not in content
+ assert not Path(ctx.resolved_output_arguments_file_path).exists()
+
+
+def test_output_arguments_written_to_separate_file(tmp_path: Path) -> None:
+ """With the knob, the arguments move out and the envelope carries the path."""
+ runtime_dir = tmp_path / "nested" / "runtime"
+ ctx = UiPathRuntimeContext(
+ job_id="job-split",
+ runtime_dir=str(runtime_dir),
+ result_file="result.json",
+ split_output_arguments=True,
+ )
+
+ with ctx:
+ ctx.result = UiPathRuntimeResult(
+ status=UiPathRuntimeStatus.SUCCESSFUL,
+ output={"foo": "bar"},
+ )
+
+ arguments_path = Path(ctx.resolved_output_arguments_file_path)
+ # Parent directory is created on demand
+ assert json.loads(arguments_path.read_text()) == {"foo": "bar"}
+
+ content = json.loads(Path(ctx.resolved_result_file_path).read_text())
+ assert "output" not in content
+ assert content["status"] == UiPathRuntimeStatus.SUCCESSFUL.value
+ assert content["outputArgumentsFilePath"] == str(arguments_path)
+ assert Path(content["outputArgumentsFilePath"]).is_absolute()
+
+
+def test_output_file_receives_bare_arguments_when_split_enabled(
+ tmp_path: Path,
+) -> None:
+ """--output-file keeps receiving the bare arguments when both are set."""
+ runtime_dir = tmp_path / "runtime"
+ output_path = tmp_path / "output.json"
+ ctx = UiPathRuntimeContext(
+ job_id="job-both",
+ runtime_dir=str(runtime_dir),
+ result_file="result.json",
+ output_file=str(output_path),
+ split_output_arguments=True,
+ )
+
+ with ctx:
+ ctx.result = UiPathRuntimeResult(
+ status=UiPathRuntimeStatus.SUCCESSFUL,
+ output={"foo": "bar"},
+ )
+
+ assert json.loads(output_path.read_text()) == {"foo": "bar"}
+ arguments_path = Path(ctx.resolved_output_arguments_file_path)
+ assert json.loads(arguments_path.read_text()) == {"foo": "bar"}
+
+
+def test_faulted_run_keeps_status_and_error_inline_when_split_enabled(
+ tmp_path: Path,
+) -> None:
+ """status and error stay in the envelope when the arguments are split out."""
+ runtime_dir = tmp_path / "runtime"
+ ctx = UiPathRuntimeContext(
+ job_id="job-faulted-split",
+ runtime_dir=str(runtime_dir),
+ result_file="result.json",
+ split_output_arguments=True,
+ )
+
+ with pytest.raises(RuntimeError, match="Stream blew up"):
+ with ctx:
+ raise RuntimeError("Stream blew up")
+
+ content = json.loads(Path(ctx.resolved_result_file_path).read_text())
+ assert content["status"] == UiPathRuntimeStatus.FAULTED.value
+ assert content["error"]["code"] == "ERROR_RuntimeError"
+ assert "Stream blew up" in content["error"]["detail"]
+ assert "output" not in content
+
+ # The pointer must never advertise a file that was not actually written
+ arguments_path = Path(content["outputArgumentsFilePath"])
+ assert arguments_path.exists()
+ assert json.loads(arguments_path.read_text()) == {}
+
+
+def test_resume_triggers_stay_inline_when_split_enabled(tmp_path: Path) -> None:
+ """resume and resumeTriggers must never be moved out of the envelope.
+
+ They are what makes a suspended job resumable, so a split that swept them
+ into the arguments file would strand the job.
+ """
+ runtime_dir = tmp_path / "runtime"
+ ctx = UiPathRuntimeContext(
+ job_id="job-suspended-split",
+ runtime_dir=str(runtime_dir),
+ result_file="result.json",
+ split_output_arguments=True,
+ )
+
+ trigger = UiPathResumeTrigger(item_key="k")
+ with ctx:
+ ctx.result = UiPathRuntimeResult(
+ status=UiPathRuntimeStatus.SUSPENDED,
+ output={"foo": "bar"},
+ trigger=trigger,
+ triggers=[trigger],
+ )
+
+ content = json.loads(Path(ctx.resolved_result_file_path).read_text())
+ assert content["status"] == UiPathRuntimeStatus.SUSPENDED.value
+ assert content["resume"]["itemKey"] == "k"
+ assert len(content["resumeTriggers"]) == 1
+ assert content["resumeTriggers"][0]["itemKey"] == "k"
+ # Only the output moved out
+ assert "output" not in content
+ arguments_path = Path(content["outputArgumentsFilePath"])
+ assert json.loads(arguments_path.read_text()) == {"foo": "bar"}
+
+
+def test_failed_arguments_write_faults_the_run(tmp_path: Path) -> None:
+ """A failing arguments write faults the run, like every other write in __exit__.
+
+ Falling back to the inline value would write the same bytes to the same volume,
+ so it cannot rescue the failure that actually matters, and it would hand the
+ consumer the payload the split exists to keep out of its heap.
+ """
+ runtime_dir = tmp_path / "runtime"
+ runtime_dir.mkdir()
+ # A directory cannot be opened for writing, so the split write fails
+ (runtime_dir / "result.args.json").mkdir()
+ ctx = UiPathRuntimeContext(
+ job_id="job-failed-write",
+ runtime_dir=str(runtime_dir),
+ result_file="result.json",
+ split_output_arguments=True,
+ )
+
+ with pytest.raises(RuntimeError) as excinfo:
+ with ctx:
+ ctx.result = UiPathRuntimeResult(
+ status=UiPathRuntimeStatus.SUCCESSFUL,
+ output={"foo": "bar"},
+ )
+
+ assert "RUNTIME_SHUTDOWN_ERROR" in str(excinfo.value)
+
+ content = json.loads(Path(ctx.resolved_result_file_path).read_text())
+ assert content["status"] == UiPathRuntimeStatus.FAULTED.value
+ assert content["error"]["code"] == "RUNTIME_SHUTDOWN_ERROR"
diff --git a/tests/test_debugger.py b/tests/test_debugger.py
index 739ccbd7..cd82d2e7 100644
--- a/tests/test_debugger.py
+++ b/tests/test_debugger.py
@@ -6,6 +6,7 @@
from unittest.mock import AsyncMock, Mock
import pytest
+from uipath.core.triggers import UiPathResumeTrigger, UiPathResumeTriggerType
from uipath.runtime import (
UiPathBreakpointResult,
@@ -131,6 +132,48 @@ async def get_schema(self) -> UiPathRuntimeSchema:
raise NotImplementedError()
+class SuspendedThenSuccessfulRuntime:
+ """Mock runtime that suspends once and completes after resume."""
+
+ def __init__(self, trigger: UiPathResumeTrigger) -> None:
+ self.trigger = trigger
+ self.inputs: list[dict[str, Any] | None] = []
+ self.options: list[UiPathStreamOptions | None] = []
+
+ async def dispose(self) -> None:
+ pass
+
+ async def execute(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathExecuteOptions | None = None,
+ ) -> UiPathRuntimeResult:
+ raise NotImplementedError()
+
+ async def stream(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathStreamOptions | None = None,
+ ) -> AsyncGenerator[UiPathRuntimeEvent, None]:
+ self.inputs.append(input)
+ self.options.append(options)
+
+ if options and options.resume:
+ yield UiPathRuntimeResult(
+ status=UiPathRuntimeStatus.SUCCESSFUL,
+ output={"resumed_with": input},
+ )
+ return
+
+ yield UiPathRuntimeResult(
+ status=UiPathRuntimeStatus.SUSPENDED,
+ trigger=self.trigger,
+ )
+
+ async def get_schema(self) -> UiPathRuntimeSchema:
+ raise NotImplementedError()
+
+
@pytest.mark.asyncio
async def test_debug_runtime_streams_and_handles_breakpoints_and_state():
"""UiPathDebugRuntime should stream events, handle breakpoints and state updates."""
@@ -169,6 +212,51 @@ async def test_debug_runtime_streams_and_handles_breakpoints_and_state():
) # initial + after breakpoint
+@pytest.mark.asyncio
+async def test_debug_runtime_waits_for_timer_resume_without_polling():
+ """Timer triggers should wait for external resume in debug mode."""
+ trigger = UiPathResumeTrigger(
+ interrupt_id="timer-interrupt",
+ trigger_type=UiPathResumeTriggerType.TIMER,
+ payload={"kind": "timeout"},
+ )
+ runtime_impl = SuspendedThenSuccessfulRuntime(trigger)
+ bridge = make_debug_bridge_mock()
+ cast(AsyncMock, bridge.wait_for_resume).side_effect = [
+ None,
+ {"__uipath": {"kind": "timeout"}},
+ ]
+ cast(Mock, bridge.get_breakpoints).return_value = []
+
+ trigger_manager = Mock()
+ trigger_manager.read_trigger = AsyncMock(
+ side_effect=AssertionError("Timer triggers must not be polled")
+ )
+
+ debug_runtime = UiPathDebugRuntime(
+ delegate=runtime_impl,
+ debug_bridge=bridge,
+ )
+ debug_runtime.get_resumable_runtime = Mock( # type: ignore[method-assign]
+ return_value=Mock(trigger_manager=trigger_manager)
+ )
+
+ result = await debug_runtime.execute({})
+
+ assert result.status == UiPathRuntimeStatus.SUCCESSFUL
+ assert result.output == {
+ "resumed_with": {
+ "timer-interrupt": {"__uipath": {"kind": "timeout"}},
+ },
+ }
+ assert cast(AsyncMock, bridge.wait_for_resume).await_count == 2
+ cast(AsyncMock, bridge.emit_execution_suspended).assert_awaited_once()
+ cast(AsyncMock, bridge.emit_execution_resumed).assert_awaited_once_with(
+ {"timer-interrupt": {"__uipath": {"kind": "timeout"}}}
+ )
+ trigger_manager.read_trigger.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_debug_runtime_falls_back_when_stream_not_supported():
"""If runtime raises UiPathStreamNotSupportedError, we fall back to execute()."""
diff --git a/tests/test_evaluator.py b/tests/test_evaluator.py
new file mode 100644
index 00000000..96750a29
--- /dev/null
+++ b/tests/test_evaluator.py
@@ -0,0 +1,407 @@
+"""Tests for the audit + enforcement behavior of GovernanceEvaluator.
+
+The evaluator's three load-bearing responsibilities:
+
+1. DISABLED enforcement mode short-circuits — no rules evaluated, no
+ audit events emitted, no exceptions raised.
+2. AUDIT mode evaluates rules and emits audit events, but transforms
+ matched DENY actions into AUDIT so execution continues.
+3. ENFORCE mode evaluates, emits audit, and raises
+ :class:`GovernanceBlockException` when a DENY rule matches.
+
+Plus a fail-safe contract: a misbehaving audit sink must not stop
+evaluation from completing or propagate as an exception. The
+evaluator is constructed with explicit dependencies (audit manager,
+enforcement mode); no process-globals are involved.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+from uipath.core.governance import EnforcementMode
+from uipath.core.governance.exceptions import GovernanceBlockException
+from uipath.core.governance.models import Action, LifecycleHook
+
+from uipath.runtime.governance._audit.base import (
+ AuditEvent,
+ AuditManager,
+ AuditSink,
+ EventType,
+)
+from uipath.runtime.governance.native.evaluator import GovernanceEvaluator
+from uipath.runtime.governance.native.models import (
+ Check,
+ CheckContext,
+ Condition,
+ PolicyIndex,
+ PolicyPack,
+ Rule,
+)
+
+# ---------------------------------------------------------------------------
+# Test helpers
+# ---------------------------------------------------------------------------
+
+
+class _CapturingSink(AuditSink):
+ """Audit sink that records every event for assertions."""
+
+ def __init__(self) -> None:
+ self.events: list[AuditEvent] = []
+
+ @property
+ def name(self) -> str:
+ return "capturing"
+
+ def emit(self, event: AuditEvent) -> None:
+ self.events.append(event)
+
+
+def _deny_rule_on_input_contains(needle: str) -> Rule:
+ """Build a rule that DENIES when agent_input contains ``needle``."""
+ return Rule(
+ rule_id="TEST-01",
+ name="Test deny on input",
+ clause="A.1.1",
+ hook=LifecycleHook.BEFORE_AGENT,
+ action=Action.DENY,
+ checks=[
+ Check(
+ conditions=[
+ Condition(
+ operator="contains",
+ field="agent_input",
+ value=needle,
+ )
+ ],
+ action=Action.DENY,
+ message=f"Input must not contain {needle!r}",
+ )
+ ],
+ )
+
+
+def _build_index_with(rule: Rule) -> PolicyIndex:
+ """Wrap a single rule in a one-pack PolicyIndex."""
+ idx = PolicyIndex()
+ idx.add_pack(
+ PolicyPack(
+ name="test_pack",
+ version="1.0",
+ description="test",
+ rules=[rule],
+ )
+ )
+ return idx
+
+
+def _ctx(agent_input: str) -> CheckContext:
+ return CheckContext(
+ hook=LifecycleHook.BEFORE_AGENT,
+ agent_name="test-agent",
+ runtime_id="run-1",
+ agent_input=agent_input,
+ )
+
+
+def _build_evaluator(
+ rule: Rule,
+ mode: EnforcementMode,
+ audit_manager: AuditManager | None = None,
+) -> GovernanceEvaluator:
+ """Construct an evaluator with explicit deps — no process-globals involved."""
+ return GovernanceEvaluator(
+ _build_index_with(rule),
+ enforcement_mode=mode,
+ audit_manager=audit_manager,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def audit_setup() -> Any:
+ """Per-test :class:`AuditManager` + capturing sink — no default sinks.
+
+ Returns ``(manager, sink)`` so a test can build evaluators with the
+ manager and inspect emitted events through the sink.
+ """
+ manager = AuditManager(register_default_sinks=False)
+ sink = _CapturingSink()
+ manager.register_sink(sink)
+ yield manager, sink
+ manager.close()
+
+
+# ---------------------------------------------------------------------------
+# DISABLED mode
+# ---------------------------------------------------------------------------
+
+
+def test_disabled_mode_short_circuits_with_empty_record(audit_setup: Any) -> None:
+ """DISABLED returns an empty AuditRecord and emits nothing."""
+ manager, sink = audit_setup
+ evaluator = _build_evaluator(
+ _deny_rule_on_input_contains("secret"),
+ EnforcementMode.DISABLED,
+ audit_manager=manager,
+ )
+
+ audit = evaluator.evaluate(_ctx("definitely contains secret"))
+
+ assert audit.evaluations == []
+ assert audit.final_action == Action.ALLOW
+ assert audit.metadata["enforcement_mode"] == "disabled"
+ assert sink.events == []
+
+
+def test_disabled_mode_does_not_raise_on_deny_match(audit_setup: Any) -> None:
+ """Even when a DENY rule WOULD match, DISABLED never raises."""
+ manager, _ = audit_setup
+ evaluator = _build_evaluator(
+ _deny_rule_on_input_contains("blocked"),
+ EnforcementMode.DISABLED,
+ audit_manager=manager,
+ )
+
+ # Must not raise.
+ evaluator.evaluate(_ctx("this is blocked"))
+
+
+# ---------------------------------------------------------------------------
+# AUDIT mode
+# ---------------------------------------------------------------------------
+
+
+def test_audit_mode_transforms_deny_to_audit(audit_setup: Any) -> None:
+ """AUDIT mode evaluates rules but never returns a DENY final_action."""
+ manager, _ = audit_setup
+ evaluator = _build_evaluator(
+ _deny_rule_on_input_contains("secret"),
+ EnforcementMode.AUDIT,
+ audit_manager=manager,
+ )
+
+ audit = evaluator.evaluate(_ctx("contains secret data"))
+
+ assert len(audit.evaluations) == 1
+ assert audit.evaluations[0].matched is True
+ assert audit.evaluations[0].action == Action.DENY # raw rule action preserved
+ assert audit.final_action == Action.AUDIT # mode-adjusted
+ assert audit.metadata["audit_mode_would_deny"] is True
+
+
+def test_audit_mode_does_not_raise_on_deny_match(audit_setup: Any) -> None:
+ """AUDIT mode never raises GovernanceBlockException, even on a DENY hit."""
+ manager, _ = audit_setup
+ evaluator = _build_evaluator(
+ _deny_rule_on_input_contains("blocked"),
+ EnforcementMode.AUDIT,
+ audit_manager=manager,
+ )
+
+ evaluator.evaluate(_ctx("this is blocked")) # must not raise
+
+
+def test_audit_mode_emits_per_rule_and_summary_events(audit_setup: Any) -> None:
+ """One rule_evaluation event per rule + one hook_summary per evaluate()."""
+ manager, sink = audit_setup
+ evaluator = _build_evaluator(
+ _deny_rule_on_input_contains("secret"),
+ EnforcementMode.AUDIT,
+ audit_manager=manager,
+ )
+
+ evaluator.evaluate(_ctx("contains secret"))
+
+ rule_events = [e for e in sink.events if e.event_type == EventType.RULE_EVALUATION]
+ summary_events = [e for e in sink.events if e.event_type == EventType.HOOK_END]
+ assert len(rule_events) == 1
+ assert rule_events[0].hook == "BEFORE_AGENT"
+ assert rule_events[0].data["policy_id"] == "TEST-01"
+ assert rule_events[0].data["matched"] is True
+ assert rule_events[0].data["action"] == "deny"
+ # Mode travels on every event (PR #122 contract).
+ assert rule_events[0].data["enforcement_mode"] == EnforcementMode.AUDIT
+
+ assert len(summary_events) == 1
+ assert summary_events[0].data["matched_rules"] == 1
+ assert summary_events[0].data["final_action"] == "audit"
+ assert summary_events[0].data["enforcement_mode"] == EnforcementMode.AUDIT
+
+
+def test_audit_mode_unmatched_rule_logged_as_allow(audit_setup: Any) -> None:
+ """Unmatched rules still emit a rule_evaluation event with action='allow'."""
+ manager, sink = audit_setup
+ evaluator = _build_evaluator(
+ _deny_rule_on_input_contains("secret"),
+ EnforcementMode.AUDIT,
+ audit_manager=manager,
+ )
+
+ evaluator.evaluate(_ctx("benign user query"))
+
+ rule_events = [e for e in sink.events if e.event_type == EventType.RULE_EVALUATION]
+ assert len(rule_events) == 1
+ assert rule_events[0].data["matched"] is False
+ assert rule_events[0].data["action"] == "allow"
+
+
+# ---------------------------------------------------------------------------
+# ENFORCE mode
+# ---------------------------------------------------------------------------
+
+
+def test_enforce_mode_raises_on_deny_match(audit_setup: Any) -> None:
+ """ENFORCE mode raises GovernanceBlockException when a DENY rule matches."""
+ manager, _ = audit_setup
+ evaluator = _build_evaluator(
+ _deny_rule_on_input_contains("blocked"),
+ EnforcementMode.ENFORCE,
+ audit_manager=manager,
+ )
+
+ with pytest.raises(GovernanceBlockException) as exc_info:
+ evaluator.evaluate(_ctx("input is blocked"))
+
+ exc = exc_info.value
+ assert exc.rule_id == "TEST-01"
+ assert exc.rule_name == "Test deny on input"
+ assert exc.audit_record is not None
+ assert exc.audit_record.final_action == Action.DENY
+
+
+def test_enforce_mode_emits_audit_before_raising(audit_setup: Any) -> None:
+ """The audit trail must be emitted even when the call raises."""
+ manager, sink = audit_setup
+ evaluator = _build_evaluator(
+ _deny_rule_on_input_contains("blocked"),
+ EnforcementMode.ENFORCE,
+ audit_manager=manager,
+ )
+
+ with pytest.raises(GovernanceBlockException):
+ evaluator.evaluate(_ctx("contains blocked"))
+
+ rule_events = [e for e in sink.events if e.event_type == EventType.RULE_EVALUATION]
+ summary_events = [e for e in sink.events if e.event_type == EventType.HOOK_END]
+ assert len(rule_events) == 1
+ assert summary_events[0].data["final_action"] == "deny"
+ assert summary_events[0].data["enforcement_mode"] == EnforcementMode.ENFORCE
+
+
+def test_enforce_mode_returns_record_when_no_rule_matches(audit_setup: Any) -> None:
+ """No DENY hit → no raise; the AuditRecord is returned normally."""
+ manager, _ = audit_setup
+ evaluator = _build_evaluator(
+ _deny_rule_on_input_contains("blocked"),
+ EnforcementMode.ENFORCE,
+ audit_manager=manager,
+ )
+
+ audit = evaluator.evaluate(_ctx("benign query"))
+
+ assert audit.final_action == Action.ALLOW
+ assert audit.evaluations[0].matched is False
+
+
+# ---------------------------------------------------------------------------
+# Sink-failure isolation + no-audit-manager case
+# ---------------------------------------------------------------------------
+
+
+def test_sink_failure_does_not_propagate_or_block_evaluation(
+ audit_setup: Any,
+) -> None:
+ """A broken sink must not make evaluate() raise or lose its return value.
+
+ Contract: AuditManager wraps each sink's emit() in try/except with a
+ per-sink failure counter (circuit-breaker), so a sink exception
+ never propagates back to the evaluator.
+ """
+ manager, capturing_sink = audit_setup
+
+ class _BrokenSink(AuditSink):
+ @property
+ def name(self) -> str:
+ return "broken"
+
+ def emit(self, event: AuditEvent) -> None:
+ raise RuntimeError("sink broke")
+
+ manager.register_sink(_BrokenSink())
+
+ evaluator = _build_evaluator(
+ _deny_rule_on_input_contains("secret"),
+ EnforcementMode.AUDIT,
+ audit_manager=manager,
+ )
+
+ # Must complete without raising even with a broken sink registered.
+ audit = evaluator.evaluate(_ctx("contains secret"))
+
+ assert audit.final_action == Action.AUDIT
+ # The non-broken capturing sink still got its events.
+ assert any(e.event_type == EventType.RULE_EVALUATION for e in capturing_sink.events)
+
+
+def test_no_audit_manager_short_circuits_emission() -> None:
+ """``audit_manager=None`` is a no-op — evaluation still completes.
+
+ Replaces the previous test that mocked ``get_audit_manager`` to
+ raise. With explicit injection, the equivalent "no manager
+ available" state is simply ``audit_manager=None`` at construction.
+ """
+ evaluator = _build_evaluator(
+ _deny_rule_on_input_contains("secret"),
+ EnforcementMode.AUDIT,
+ audit_manager=None,
+ )
+
+ # Must complete, return record, and not raise.
+ audit = evaluator.evaluate(_ctx("contains secret"))
+
+ assert audit.final_action == Action.AUDIT
+ assert audit.evaluations[0].matched is True
+
+
+# ---------------------------------------------------------------------------
+# Protocol conformance smoke test
+# ---------------------------------------------------------------------------
+
+
+def test_governance_evaluator_satisfies_evaluator_protocol() -> None:
+ """GovernanceEvaluator must be usable wherever EvaluatorProtocol is expected.
+
+ Mirrors the pattern from test_detached_bridge_satisfies_debug_protocol —
+ an explicit assignment to the protocol-typed variable documents the
+ structural contract.
+ """
+ from uipath.core.adapters import EvaluatorProtocol
+
+ evaluator: EvaluatorProtocol = GovernanceEvaluator(PolicyIndex())
+ assert isinstance(evaluator, EvaluatorProtocol)
+
+
+def test_evaluator_protocol_methods_resolvable_on_concrete() -> None:
+ """Every method the protocol declares must be callable on the concrete impl."""
+ from uipath.core.adapters import EvaluatorProtocol
+
+ evaluator: Any = GovernanceEvaluator(PolicyIndex())
+ for method_name in (
+ "evaluate_before_agent",
+ "evaluate_after_agent",
+ "evaluate_before_model",
+ "evaluate_after_model",
+ "evaluate_tool_call",
+ "evaluate_after_tool",
+ ):
+ assert callable(getattr(evaluator, method_name))
+ # The variable annotation also asserts type compatibility at runtime
+ # because EvaluatorProtocol is @runtime_checkable.
+ assert isinstance(evaluator, EvaluatorProtocol)
diff --git a/tests/test_evaluator_operators.py b/tests/test_evaluator_operators.py
new file mode 100644
index 00000000..9f050557
--- /dev/null
+++ b/tests/test_evaluator_operators.py
@@ -0,0 +1,688 @@
+"""Tests for ``GovernanceEvaluator`` operators and field resolution.
+
+Covers each operator implemented in :meth:`_apply_operator` plus the
+``_check_*`` helper functions (vader, encoding, entropy, incident,
+commitment) and the ``evaluate_*`` dispatchers.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+from uipath.core.governance import EnforcementMode
+from uipath.core.governance.models import Action, LifecycleHook
+
+from uipath.runtime.governance.native.evaluator import (
+ _INCIDENT_PATTERNS,
+ GovernanceEvaluator,
+)
+from uipath.runtime.governance.native.models import (
+ Check,
+ CheckContext,
+ Condition,
+ Logic,
+ PolicyIndex,
+ PolicyPack,
+ Rule,
+)
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _evaluator() -> GovernanceEvaluator:
+ """Build a GovernanceEvaluator with an empty PolicyIndex (operators only).
+
+ AUDIT is the default mode; operator tests don't care about
+ enforcement and we don't need an audit manager for purely
+ operator-level assertions.
+ """
+ return GovernanceEvaluator(policy_index=PolicyIndex())
+
+
+def _ctx(**fields: Any) -> CheckContext:
+ """Construct a CheckContext with sensible defaults plus overrides."""
+ defaults: dict[str, Any] = dict(
+ hook=LifecycleHook.AFTER_MODEL,
+ agent_name="agent",
+ runtime_id="rt-1",
+ )
+ defaults.update(fields)
+ return CheckContext(**defaults)
+
+
+def _rule_with_condition(
+ operator: str, field: str, value, *, negate: bool = False
+) -> Rule:
+ return Rule(
+ rule_id="r1",
+ name="r1",
+ clause="",
+ hook=LifecycleHook.AFTER_MODEL,
+ action=Action.AUDIT,
+ checks=[
+ Check(
+ conditions=[
+ Condition(
+ operator=operator, field=field, value=value, negate=negate
+ )
+ ],
+ )
+ ],
+ )
+
+
+# Mode is per-instance now — tests construct evaluators with the mode
+# they need via the ``enforcement_mode`` kwarg. No process-globals to
+# reset.
+
+
+# ---------------------------------------------------------------------------
+# Field resolution — _get_field_value
+# ---------------------------------------------------------------------------
+
+
+def test_get_field_value_top_level_attr() -> None:
+ ev = _evaluator()
+ ctx = _ctx(model_output="hello")
+ assert ev._get_field_value("model_output", ctx) == "hello"
+
+
+def test_get_field_value_dotted_path_into_dict() -> None:
+ ev = _evaluator()
+ ctx = _ctx(session_state={"tool_calls": 7})
+ assert ev._get_field_value("session_state.tool_calls", ctx) == 7
+
+
+def test_get_field_value_missing_segment_returns_none() -> None:
+ ev = _evaluator()
+ ctx = _ctx()
+ assert ev._get_field_value("nonexistent", ctx) is None
+ assert ev._get_field_value("session_state.absent", ctx) is None
+
+
+# ---------------------------------------------------------------------------
+# Existence / guardrail_fallback (special-cased before the None check)
+# ---------------------------------------------------------------------------
+
+
+def test_exists_true_when_value_present() -> None:
+ ev = _evaluator()
+ ctx = _ctx(model_output="x")
+ assert (
+ ev._apply_operator("exists", ev._get_field_value("model_output", ctx), None)
+ is True
+ )
+
+
+def test_exists_false_when_missing() -> None:
+ ev = _evaluator()
+ assert ev._apply_operator("exists", None, None) is False
+
+
+def test_not_exists_inverse() -> None:
+ ev = _evaluator()
+ assert ev._apply_operator("not_exists", None, None) is True
+ assert ev._apply_operator("not_exists", "x", None) is False
+
+
+def test_guardrail_fallback_mapped_and_disabled_fires() -> None:
+ ev = _evaluator()
+ result = ev._apply_operator(
+ "guardrail_fallback",
+ None,
+ {"mapped_to_uipath": True, "policy_enabled": False, "validator": "pii"},
+ )
+ assert result is True
+
+
+@pytest.mark.parametrize(
+ "cfg",
+ [
+ {"mapped_to_uipath": False, "policy_enabled": False},
+ {"mapped_to_uipath": True, "policy_enabled": True},
+ {"mapped_to_uipath": False, "policy_enabled": True},
+ ],
+)
+def test_guardrail_fallback_silent_when_not_mapped_or_enabled(
+ cfg: dict[str, Any],
+) -> None:
+ ev = _evaluator()
+ assert ev._apply_operator("guardrail_fallback", None, cfg) is False
+
+
+def test_guardrail_fallback_non_dict_value_silent() -> None:
+ ev = _evaluator()
+ assert ev._apply_operator("guardrail_fallback", None, "string") is False
+
+
+# ---------------------------------------------------------------------------
+# None-field short-circuit (everything except exists / guardrail_fallback)
+# ---------------------------------------------------------------------------
+
+
+def test_other_operators_short_circuit_when_field_is_none() -> None:
+ ev = _evaluator()
+ for op in ("contains", "regex", "in_list", "gt"):
+ assert ev._apply_operator(op, None, "anything") is False, op
+
+
+# ---------------------------------------------------------------------------
+# Numeric operators
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "op,lhs,rhs,expected",
+ [
+ ("gt", 5, 3, True),
+ ("gt", 3, 5, False),
+ ("gt", 3, 3, False),
+ ("gte", 3, 3, True),
+ ("gte", 2, 3, False),
+ ("lt", 1, 3, True),
+ ("lt", 3, 3, False),
+ ("lte", 3, 3, True),
+ ("lte", 4, 3, False),
+ ],
+)
+def test_numeric_operators(op: str, lhs: float, rhs: float, expected: bool) -> None:
+ assert _evaluator()._apply_operator(op, lhs, rhs) is expected
+
+
+def test_numeric_operators_handle_string_coercion() -> None:
+ ev = _evaluator()
+ assert ev._apply_operator("gt", "5", "3") is True
+
+
+def test_numeric_operators_return_false_on_uncoercible() -> None:
+ ev = _evaluator()
+ assert ev._apply_operator("gt", "not-a-number", 3) is False
+ assert ev._apply_operator("gt", 3, "not-a-number") is False
+
+
+# ---------------------------------------------------------------------------
+# String operators
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "op,lhs,rhs,expected",
+ [
+ ("equals", "abc", "abc", True),
+ ("equals", "abc", "ABC", False), # equals is case-sensitive
+ ("eq", "x", "x", True),
+ ("not_equals", "abc", "xyz", True),
+ ("ne", "x", "x", False),
+ ("contains", "Hello World", "world", True), # case-insensitive
+ ("contains", "Hello", "xyz", False),
+ ("not_contains", "Hello", "xyz", True),
+ ("not_contains", "Hello", "hello", False),
+ ],
+)
+def test_string_operators(op: str, lhs: str, rhs: str, expected: bool) -> None:
+ assert _evaluator()._apply_operator(op, lhs, rhs) is expected
+
+
+def test_regex_matches_pattern() -> None:
+ ev = _evaluator()
+ assert ev._apply_operator("regex", "Cost: $1,200", r"\$\d+") is True
+
+
+def test_regex_matches_alias() -> None:
+ """``matches`` is documented as a synonym for ``regex``."""
+ ev = _evaluator()
+ assert ev._apply_operator("matches", "abc-123", r"\d+") is True
+
+
+def test_regex_invalid_pattern_returns_false() -> None:
+ """Malformed regex is logged and silently returns False."""
+ ev = _evaluator()
+ assert ev._apply_operator("regex", "anything", "(unclosed") is False
+
+
+# ---------------------------------------------------------------------------
+# List operators
+# ---------------------------------------------------------------------------
+
+
+def test_in_list_membership() -> None:
+ ev = _evaluator()
+ assert (
+ ev._apply_operator("in_list", "delete_file", ["shell", "delete_file"]) is True
+ )
+ assert ev._apply_operator("in_list", "ls", ["shell", "delete_file"]) is False
+
+
+def test_in_list_non_list_value_returns_false() -> None:
+ ev = _evaluator()
+ assert ev._apply_operator("in_list", "x", "not a list") is False
+
+
+def test_not_in_list_inverse() -> None:
+ ev = _evaluator()
+ assert ev._apply_operator("not_in_list", "ls", ["shell"]) is True
+ assert ev._apply_operator("not_in_list", "shell", ["shell"]) is False
+
+
+def test_not_in_list_non_list_value_returns_true() -> None:
+ """``not_in_list`` against a non-list value safely returns True
+ (nothing is in a non-list)."""
+ ev = _evaluator()
+ assert ev._apply_operator("not_in_list", "x", "not a list") is True
+
+
+# ---------------------------------------------------------------------------
+# Unknown operator
+# ---------------------------------------------------------------------------
+
+
+def test_unknown_operator_returns_false() -> None:
+ """Unknown operator strings log a debug message and return False."""
+ ev = _evaluator()
+ assert ev._apply_operator("never_heard_of_this", "x", "y") is False
+
+
+# ---------------------------------------------------------------------------
+# Negate flag — flips the result
+# ---------------------------------------------------------------------------
+
+
+def test_condition_negate_flips_result() -> None:
+ ev = _evaluator()
+ ctx = _ctx(model_output="hello")
+ # contains "hello" → matches; negate inverts to False.
+ cond = Condition(
+ operator="contains",
+ field="model_output",
+ value="hello",
+ negate=True,
+ )
+ assert ev._evaluate_condition(cond, ctx) is False
+ cond2 = Condition(
+ operator="contains",
+ field="model_output",
+ value="world",
+ negate=True,
+ )
+ assert ev._evaluate_condition(cond2, ctx) is True
+
+
+# ---------------------------------------------------------------------------
+# Check-level logic: "all" (AND) vs "any" (OR), and empty-conditions
+# ---------------------------------------------------------------------------
+
+
+def test_empty_check_conditions_always_match() -> None:
+ """A check with no conditions trivially matches — surfaces rule shape bugs."""
+ ev = _evaluator()
+ check = Check(conditions=[], logic=Logic.ALL)
+ matched, _ = ev._evaluate_check(check, _ctx())
+ assert matched is True
+
+
+def test_check_logic_all_requires_every_condition() -> None:
+ ev = _evaluator()
+ check = Check(
+ conditions=[
+ Condition(operator="contains", field="model_output", value="a"),
+ Condition(operator="contains", field="model_output", value="missing"),
+ ],
+ logic=Logic.ALL,
+ )
+ matched, _ = ev._evaluate_check(check, _ctx(model_output="a only"))
+ assert matched is False
+
+
+def test_check_logic_any_requires_one_condition() -> None:
+ ev = _evaluator()
+ check = Check(
+ conditions=[
+ Condition(operator="contains", field="model_output", value="present"),
+ Condition(operator="contains", field="model_output", value="absent"),
+ ],
+ logic=Logic.ANY,
+ )
+ matched, detail = ev._evaluate_check(check, _ctx(model_output="present text"))
+ assert matched is True
+ # detail is the check's message on match; empty by default in our builder.
+ assert detail == ""
+
+
+# ---------------------------------------------------------------------------
+# VADER sentiment
+# ---------------------------------------------------------------------------
+
+
+def test_vader_concern_negative_text_fires() -> None:
+ """A clearly-negative sentence trips the default threshold of -0.3."""
+ assert (
+ GovernanceEvaluator._check_vader_concern(
+ "I absolutely hate this terrible, awful product.", {"threshold": -0.3}
+ )
+ is True
+ )
+
+
+def test_vader_concern_positive_text_does_not_fire() -> None:
+ assert (
+ GovernanceEvaluator._check_vader_concern(
+ "This is wonderful and I love it!", {"threshold": -0.3}
+ )
+ is False
+ )
+
+
+def test_vader_concern_empty_text_silent() -> None:
+ assert GovernanceEvaluator._check_vader_concern("", {}) is False
+ assert GovernanceEvaluator._check_vader_concern(" ", {}) is False
+
+
+def test_vader_concern_threshold_as_scalar() -> None:
+ """``params`` may be a bare number; the operator coerces."""
+ assert GovernanceEvaluator._check_vader_concern("I hate everything", -0.3) is True
+
+
+def test_vader_concern_invalid_threshold_falls_back() -> None:
+ """Non-numeric scalar params fall back to the documented default."""
+ # "garbage" -> default -0.3 → should still classify clear negative
+ assert (
+ GovernanceEvaluator._check_vader_concern(
+ "I hate this awful, terrible thing", "garbage"
+ )
+ is True
+ )
+
+
+# ---------------------------------------------------------------------------
+# Encoding integrity
+# ---------------------------------------------------------------------------
+
+
+def test_encoding_concern_clean_text_silent() -> None:
+ assert (
+ GovernanceEvaluator._check_encoding_concern(
+ "Just a normal English sentence with no corruption.", {}
+ )
+ is False
+ )
+
+
+def test_encoding_concern_empty_silent() -> None:
+ assert GovernanceEvaluator._check_encoding_concern("", {}) is False
+
+
+def test_encoding_concern_replacement_chars_fire() -> None:
+ """U+FFFD replacement chars are a strong corruption signal."""
+ text = "Hello � � world"
+ assert (
+ GovernanceEvaluator._check_encoding_concern(text, {"min_corruption_events": 2})
+ is True
+ )
+
+
+def test_encoding_concern_mojibake_bigrams_fire() -> None:
+ """Latin-1-as-UTF-8 mojibake patterns are a known corruption shape."""
+ text = "é é hello é"
+ assert (
+ GovernanceEvaluator._check_encoding_concern(text, {"min_corruption_events": 2})
+ is True
+ )
+
+
+def test_encoding_concern_hex_escape_literals_fire() -> None:
+ """Literal ``\\xHH`` sequences mean raw bytes leaked into a string."""
+ text = r"Hello \x80 \x81 \x82 world"
+ assert (
+ GovernanceEvaluator._check_encoding_concern(text, {"min_corruption_events": 2})
+ is True
+ )
+
+
+# ---------------------------------------------------------------------------
+# Entropy (stdlib only — deterministic)
+# ---------------------------------------------------------------------------
+
+
+def test_entropy_concern_normal_english_does_not_fire() -> None:
+ """English prose entropy lands ~3.5–4.5 bits/byte — inside default range."""
+ text = "The quick brown fox jumps over the lazy dog." * 5
+ assert (
+ GovernanceEvaluator._check_entropy_concern(text, {"min": 1.5, "max": 7.5})
+ is False
+ )
+
+
+def test_entropy_concern_low_entropy_fires() -> None:
+ """Highly repetitive text approaches 0 bits/byte."""
+ text = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ assert (
+ GovernanceEvaluator._check_entropy_concern(text, {"min": 1.5, "max": 7.5})
+ is True
+ )
+
+
+def test_entropy_concern_high_entropy_fires() -> None:
+ """Random-ish bytes approach 8 bits/byte."""
+ # Build text with many distinct chars to push entropy high.
+ text = "".join(chr(c) for c in range(32, 127)) * 5
+ assert (
+ GovernanceEvaluator._check_entropy_concern(text, {"min": 1.5, "max": 6.0})
+ is True
+ )
+
+
+def test_entropy_concern_empty_silent() -> None:
+ assert GovernanceEvaluator._check_entropy_concern("", {}) is False
+
+
+def test_entropy_concern_non_dict_params_uses_defaults() -> None:
+ """Non-dict params don't crash; defaults apply."""
+ # Normal English prose still won't trip the default min=1.5, max=7.5 range.
+ text = "The quick brown fox jumps over the lazy dog."
+ assert GovernanceEvaluator._check_entropy_concern(text, "garbage") is False
+
+
+# ---------------------------------------------------------------------------
+# Incident taxonomy (regex-based, deterministic)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "text,expected_category",
+ [
+ ("I cannot help with that.", "safety_refusal"),
+ ("I'm sorry, but I cannot answer.", "safety_refusal"),
+ ("500 internal server error", "tool_failure"),
+ ("Connection refused", "tool_failure"),
+ ("timed out", "tool_failure"),
+ ("401 unauthorized", "auth_failure"),
+ ("authentication failed", "auth_failure"),
+ ("429", "quota_exceeded"),
+ ("rate limit exceeded", "quota_exceeded"),
+ ("I made that up", "hallucination"),
+ ("I don't actually know", "hallucination"),
+ ],
+)
+def test_incident_concern_categorical_matches(
+ text: str, expected_category: str
+) -> None:
+ """Each category in ``_INCIDENT_PATTERNS`` has at least one matching exemplar."""
+ assert expected_category in _INCIDENT_PATTERNS
+ assert GovernanceEvaluator._check_incident_concern(text, {}) is True
+
+
+def test_incident_concern_unmatched_silent() -> None:
+ assert (
+ GovernanceEvaluator._check_incident_concern(
+ "All systems operating normally.", {}
+ )
+ is False
+ )
+
+
+def test_incident_concern_empty_silent() -> None:
+ assert GovernanceEvaluator._check_incident_concern("", {}) is False
+
+
+def test_incident_concern_category_filter() -> None:
+ """Limit scanning to a subset of categories via ``categories`` param."""
+ # "401 unauthorized" hits auth_failure; with only quota_exceeded enabled,
+ # the scanner should miss it.
+ assert (
+ GovernanceEvaluator._check_incident_concern(
+ "401 unauthorized", {"categories": ["quota_exceeded"]}
+ )
+ is False
+ )
+ # With auth_failure enabled, it fires.
+ assert (
+ GovernanceEvaluator._check_incident_concern(
+ "401 unauthorized", {"categories": ["auth_failure"]}
+ )
+ is True
+ )
+
+
+def test_incident_concern_unknown_category_silently_dropped() -> None:
+ """Categories the system doesn't know about are silently ignored."""
+ # Only the unknown category is requested — falls back to no categories,
+ # so even matching text doesn't fire.
+ result = GovernanceEvaluator._check_incident_concern(
+ "401 unauthorized", {"categories": ["unknown_cat_xyz"]}
+ )
+ assert result is False
+
+
+# ---------------------------------------------------------------------------
+# evaluate_* dispatchers — verify they build the right CheckContext
+# ---------------------------------------------------------------------------
+
+
+def _record_context_evaluator() -> tuple[GovernanceEvaluator, dict[str, Any]]:
+ """Patch evaluate() to capture the context it receives instead of running rules."""
+ captured: dict[str, Any] = {}
+ ev = _evaluator()
+
+ def _fake_evaluate(ctx: CheckContext) -> Any:
+ captured["ctx"] = ctx
+ from datetime import datetime, timezone
+
+ from uipath.core.governance.models import AuditRecord
+
+ return AuditRecord(
+ timestamp=datetime.now(timezone.utc),
+ agent_name=ctx.agent_name,
+ runtime_id=ctx.runtime_id,
+ hook=ctx.hook,
+ evaluations=[],
+ final_action=Action.ALLOW,
+ )
+
+ ev.evaluate = _fake_evaluate # type: ignore[assignment]
+ return ev, captured
+
+
+def test_evaluate_before_agent_builds_context() -> None:
+ ev, captured = _record_context_evaluator()
+ ev.evaluate_before_agent(
+ agent_input="user-text",
+ agent_name="a",
+ runtime_id="r",
+ model_name="gpt-5",
+ )
+ ctx = captured["ctx"]
+ assert ctx.hook == LifecycleHook.BEFORE_AGENT
+ assert ctx.agent_input == "user-text"
+ assert ctx.model_name == "gpt-5"
+
+
+def test_evaluate_after_agent_builds_context() -> None:
+ ev, captured = _record_context_evaluator()
+ ev.evaluate_after_agent(
+ agent_output="reply",
+ agent_name="a",
+ runtime_id="r",
+ )
+ ctx = captured["ctx"]
+ assert ctx.hook == LifecycleHook.AFTER_AGENT
+ assert ctx.agent_output == "reply"
+
+
+def test_evaluate_before_model_carries_messages() -> None:
+ ev, captured = _record_context_evaluator()
+ ev.evaluate_before_model(
+ model_input="prompt",
+ agent_name="a",
+ runtime_id="r",
+ messages=[{"role": "user", "content": "hi"}],
+ model_name="gpt-5",
+ )
+ ctx = captured["ctx"]
+ assert ctx.hook == LifecycleHook.BEFORE_MODEL
+ assert ctx.model_input == "prompt"
+ assert ctx.messages == [{"role": "user", "content": "hi"}]
+
+
+def test_evaluate_after_model_builds_context() -> None:
+ ev, captured = _record_context_evaluator()
+ ev.evaluate_after_model(
+ model_output="resp",
+ agent_name="a",
+ runtime_id="r",
+ )
+ ctx = captured["ctx"]
+ assert ctx.hook == LifecycleHook.AFTER_MODEL
+ assert ctx.model_output == "resp"
+
+
+def test_evaluate_tool_call_carries_args() -> None:
+ ev, captured = _record_context_evaluator()
+ ev.evaluate_tool_call(
+ tool_name="search",
+ tool_args={"q": "x"},
+ agent_name="a",
+ runtime_id="r",
+ session_state={"tool_calls": 1},
+ )
+ ctx = captured["ctx"]
+ assert ctx.hook == LifecycleHook.TOOL_CALL
+ assert ctx.tool_name == "search"
+ assert ctx.tool_args == {"q": "x"}
+ assert ctx.session_state == {"tool_calls": 1}
+
+
+def test_evaluate_after_tool_carries_result() -> None:
+ ev, captured = _record_context_evaluator()
+ ev.evaluate_after_tool(
+ tool_name="search",
+ tool_result="some-data",
+ agent_name="a",
+ runtime_id="r",
+ )
+ ctx = captured["ctx"]
+ assert ctx.hook == LifecycleHook.AFTER_TOOL
+ assert ctx.tool_name == "search"
+ assert ctx.tool_result == "some-data"
+
+
+# ---------------------------------------------------------------------------
+# DISABLED mode — evaluate() short-circuits without emitting audit
+# ---------------------------------------------------------------------------
+
+
+def test_disabled_mode_returns_empty_audit_record() -> None:
+ """DISABLED mode short-circuits the rule loop and audit emission."""
+ rule = _rule_with_condition("contains", "model_output", "anything")
+ pack = PolicyPack(name="p", version="1", description="", rules=[rule])
+ idx = PolicyIndex()
+ idx.add_pack(pack)
+ ev = GovernanceEvaluator(
+ policy_index=idx, enforcement_mode=EnforcementMode.DISABLED
+ )
+
+ audit = ev.evaluate(_ctx(model_output="contains anything"))
+ assert audit.final_action == Action.ALLOW
+ assert audit.evaluations == []
diff --git a/tests/test_evaluator_telemetry.py b/tests/test_evaluator_telemetry.py
new file mode 100644
index 00000000..55b61cd9
--- /dev/null
+++ b/tests/test_evaluator_telemetry.py
@@ -0,0 +1,289 @@
+"""Tests that the evaluator passes the new telemetry fields downstream.
+
+The evaluator must:
+
+- Measure per-rule and per-hook wall-clock duration.
+- Collect disabled rule ids into ``skipped_policy_names``.
+- Count matched UiPath-mapped guardrails (``guardrail_dispatched_count``).
+- Hand all of the above to ``AuditManager.emit_rule_evaluation`` and
+ ``emit_hook_summary`` so the ``TrackEventAuditSink`` payload carries
+ them.
+
+We capture events via a manager registered with no default sinks +
+one ``_CapturingSink`` so the assertions see exactly the per-rule and
+hook-summary events the evaluator emits.
+"""
+
+from __future__ import annotations
+
+import pytest
+from uipath.core.governance import EnforcementMode
+from uipath.core.governance.models import Action, LifecycleHook
+
+from uipath.runtime.governance._audit.base import (
+ AuditEvent,
+ AuditManager,
+ AuditSink,
+ EventType,
+)
+from uipath.runtime.governance.native.evaluator import GovernanceEvaluator
+from uipath.runtime.governance.native.models import (
+ Check,
+ CheckContext,
+ Condition,
+ PolicyIndex,
+ PolicyPack,
+ Rule,
+)
+
+
+class _CapturingSink(AuditSink):
+ def __init__(self) -> None:
+ self.events: list[AuditEvent] = []
+
+ @property
+ def name(self) -> str:
+ return "capturing"
+
+ def emit(self, event: AuditEvent) -> None:
+ self.events.append(event)
+
+
+def _rule(
+ rule_id: str,
+ *,
+ enabled: bool = True,
+ matches: bool = True,
+ action: Action = Action.DENY,
+) -> Rule:
+ """A rule whose ``contains`` check always (or never) matches the input."""
+ return Rule(
+ rule_id=rule_id,
+ name=f"rule-{rule_id}",
+ clause=rule_id,
+ hook=LifecycleHook.BEFORE_AGENT,
+ action=action,
+ enabled=enabled,
+ checks=[
+ Check(
+ conditions=[
+ Condition(
+ operator="contains",
+ field="agent_input",
+ value="needle" if matches else "absent-needle",
+ )
+ ],
+ action=action,
+ message=f"matched {rule_id}",
+ )
+ ],
+ )
+
+
+def _pack(*rules: Rule) -> PolicyIndex:
+ idx = PolicyIndex()
+ idx.add_pack(
+ PolicyPack(name="test_pack", version="1.0", description="t", rules=list(rules))
+ )
+ return idx
+
+
+def _ctx() -> CheckContext:
+ return CheckContext(
+ hook=LifecycleHook.BEFORE_AGENT,
+ agent_name="agent-x",
+ runtime_id="run-1",
+ agent_input="needle",
+ )
+
+
+@pytest.fixture
+def sink_and_manager() -> tuple[_CapturingSink, AuditManager]:
+ """Sink-capturing manager with no default sinks (no traces, no track_events)."""
+ sink = _CapturingSink()
+ mgr = AuditManager(register_default_sinks=False)
+ mgr.register_sink(sink)
+ return sink, mgr
+
+
+def _rule_events(sink: _CapturingSink) -> list[AuditEvent]:
+ return [e for e in sink.events if e.event_type == EventType.RULE_EVALUATION]
+
+
+def _hook_summary(sink: _CapturingSink) -> AuditEvent:
+ summaries = [e for e in sink.events if e.event_type == EventType.HOOK_END]
+ assert len(summaries) == 1, f"expected 1 hook summary, got {len(summaries)}"
+ return summaries[0]
+
+
+# ---------------------------------------------------------------------------
+# Per-rule timing
+# ---------------------------------------------------------------------------
+
+
+def test_rule_evaluation_carries_duration_ms(
+ sink_and_manager: tuple[_CapturingSink, AuditManager],
+) -> None:
+ sink, mgr = sink_and_manager
+ try:
+ evaluator = GovernanceEvaluator(
+ _pack(_rule("A.1.1")),
+ enforcement_mode=EnforcementMode.AUDIT,
+ audit_manager=mgr,
+ )
+ evaluator.evaluate(_ctx())
+ finally:
+ mgr.close()
+
+ rule_evs = _rule_events(sink)
+ assert len(rule_evs) == 1
+ duration = rule_evs[0].data["duration_ms"]
+ assert isinstance(duration, float)
+ assert duration >= 0.0
+
+
+def test_rule_evaluation_mapped_to_uipath_is_false_for_native(
+ sink_and_manager: tuple[_CapturingSink, AuditManager],
+) -> None:
+ """Native rules (no guardrail_fallback) always emit ``mapped_to_uipath=False``."""
+ sink, mgr = sink_and_manager
+ try:
+ evaluator = GovernanceEvaluator(
+ _pack(_rule("A.1.1")),
+ enforcement_mode=EnforcementMode.AUDIT,
+ audit_manager=mgr,
+ )
+ evaluator.evaluate(_ctx())
+ finally:
+ mgr.close()
+
+ rule_evs = _rule_events(sink)
+ assert rule_evs[0].data["mapped_to_uipath"] is False
+
+
+# ---------------------------------------------------------------------------
+# Hook-summary aggregates
+# ---------------------------------------------------------------------------
+
+
+def test_hook_summary_carries_total_duration(
+ sink_and_manager: tuple[_CapturingSink, AuditManager],
+) -> None:
+ sink, mgr = sink_and_manager
+ try:
+ evaluator = GovernanceEvaluator(
+ _pack(_rule("A.1.1"), _rule("A.1.2", matches=False)),
+ enforcement_mode=EnforcementMode.AUDIT,
+ audit_manager=mgr,
+ )
+ evaluator.evaluate(_ctx())
+ finally:
+ mgr.close()
+
+ summary = _hook_summary(sink)
+ duration = summary.data["duration_ms"]
+ assert isinstance(duration, float)
+ assert duration >= 0.0
+
+
+def test_hook_summary_tracks_skipped_disabled_rules(
+ sink_and_manager: tuple[_CapturingSink, AuditManager],
+) -> None:
+ """Disabled rules appear in ``skipped_policy_names`` (and skipped_count)."""
+ sink, mgr = sink_and_manager
+ try:
+ evaluator = GovernanceEvaluator(
+ _pack(
+ _rule("A.1.1"), # enabled, matches
+ _rule("A.1.2", enabled=False), # DISABLED
+ _rule("A.1.3", enabled=False), # DISABLED
+ ),
+ enforcement_mode=EnforcementMode.AUDIT,
+ audit_manager=mgr,
+ )
+ evaluator.evaluate(_ctx())
+ finally:
+ mgr.close()
+
+ summary = _hook_summary(sink)
+ assert summary.data["skipped_count"] == 2
+ assert set(summary.data["skipped_policy_names"]) == {"A.1.2", "A.1.3"}
+
+
+def test_hook_summary_passed_and_denied_counts(
+ sink_and_manager: tuple[_CapturingSink, AuditManager],
+) -> None:
+ """One match + two non-matches → denied=1, passed=2, matched_rules=1."""
+ sink, mgr = sink_and_manager
+ try:
+ evaluator = GovernanceEvaluator(
+ _pack(
+ _rule("A.1.1"), # matches (DENY)
+ _rule("A.1.2", matches=False), # passes
+ _rule("A.1.3", matches=False), # passes
+ ),
+ enforcement_mode=EnforcementMode.AUDIT,
+ audit_manager=mgr,
+ )
+ evaluator.evaluate(_ctx())
+ finally:
+ mgr.close()
+
+ summary = _hook_summary(sink)
+ assert summary.data["denied_count"] == 1
+ assert summary.data["passed_count"] == 2
+ # ``matched_rules`` keeps its historical "any check matched" sense.
+ assert summary.data["matched_rules"] == 1
+
+
+def test_hook_summary_matched_allow_rule_counts_as_passed(
+ sink_and_manager: tuple[_CapturingSink, AuditManager],
+) -> None:
+ """A matched rule with action=ALLOW is a positive informational match.
+
+ It should NOT contribute to ``denied_count`` — it rolls into
+ ``passed_count`` instead. ``matched_rules`` (raw count) still
+ includes it for backward compatibility with the legacy traces sink.
+ """
+ sink, mgr = sink_and_manager
+ try:
+ evaluator = GovernanceEvaluator(
+ _pack(
+ _rule("A.1.1"), # matches (DENY)
+ _rule("A.1.2", action=Action.ALLOW), # matches (ALLOW) — positive
+ _rule("A.1.3", matches=False), # passes (unmatched)
+ ),
+ enforcement_mode=EnforcementMode.AUDIT,
+ audit_manager=mgr,
+ )
+ evaluator.evaluate(_ctx())
+ finally:
+ mgr.close()
+
+ summary = _hook_summary(sink)
+ # 3 total rules; only the explicit DENY counts as a denial.
+ assert summary.data["denied_count"] == 1
+ # ``passed_count`` includes the unmatched rule AND the matched-allow rule.
+ assert summary.data["passed_count"] == 2
+ # Raw ``matched_rules`` still reflects "any check matched" — both
+ # the DENY and the ALLOW match contribute.
+ assert summary.data["matched_rules"] == 2
+
+
+def test_hook_summary_guardrail_dispatched_count_for_native_rules_is_zero(
+ sink_and_manager: tuple[_CapturingSink, AuditManager],
+) -> None:
+ """Without guardrail_fallback conditions, dispatched count is 0."""
+ sink, mgr = sink_and_manager
+ try:
+ evaluator = GovernanceEvaluator(
+ _pack(_rule("A.1.1")),
+ enforcement_mode=EnforcementMode.AUDIT,
+ audit_manager=mgr,
+ )
+ evaluator.evaluate(_ctx())
+ finally:
+ mgr.close()
+
+ summary = _hook_summary(sink)
+ assert summary.data["guardrail_dispatched_count"] == 0
diff --git a/tests/test_governance_metadata.py b/tests/test_governance_metadata.py
new file mode 100644
index 00000000..0feb2640
--- /dev/null
+++ b/tests/test_governance_metadata.py
@@ -0,0 +1,81 @@
+"""Tests for :class:`GovernanceRuntimeMetadata`.
+
+The dataclass carries the per-runtime constants every governance
+telemetry event stamps. Defaults must keep telemetry flowing when the
+host hasn't populated agent type / framework yet, and the runtime
+version must resolve from installed package metadata when available.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from uipath.runtime.governance._audit.metadata import (
+ NATIVE_EXECUTION_ENGINE,
+ GovernanceRuntimeMetadata,
+ _resolve_runtime_version,
+)
+
+
+def test_defaults() -> None:
+ """Default-constructed metadata uses native engine + ``unknown`` slots."""
+ meta = GovernanceRuntimeMetadata()
+ assert meta.execution_engine == NATIVE_EXECUTION_ENGINE
+ assert meta.agent_type == "unknown"
+ assert meta.agent_framework == "unknown"
+ # runtime_version is resolved at construction; either real or "unknown"
+ assert isinstance(meta.runtime_version, str)
+ assert meta.runtime_version != ""
+
+
+def test_explicit_overrides_persist() -> None:
+ """Host-supplied values override the defaults verbatim."""
+ meta = GovernanceRuntimeMetadata(
+ execution_engine="agt",
+ agent_type="uipath_coded",
+ agent_framework="langchain",
+ runtime_version="1.2.3",
+ )
+ assert meta.execution_engine == "agt"
+ assert meta.agent_type == "uipath_coded"
+ assert meta.agent_framework == "langchain"
+ assert meta.runtime_version == "1.2.3"
+
+
+def test_frozen() -> None:
+ """Dataclass is frozen — host can't mutate per-run constants mid-run."""
+ meta = GovernanceRuntimeMetadata()
+ try:
+ meta.agent_type = "other" # type: ignore[misc]
+ except Exception as exc:
+ assert "frozen" in str(exc).lower() or "cannot assign" in str(exc).lower()
+ else:
+ raise AssertionError("frozen dataclass must reject attribute writes")
+
+
+def test_as_payload_contains_all_four_fields() -> None:
+ """``as_payload`` is the canonical merge-into-event-data dict shape."""
+ meta = GovernanceRuntimeMetadata(
+ execution_engine="agt",
+ agent_type="uipath_coded",
+ agent_framework="langchain",
+ runtime_version="1.2.3",
+ )
+ payload = meta.as_payload()
+ assert payload == {
+ "execution_engine": "agt",
+ "agent_type": "uipath_coded",
+ "agent_framework": "langchain",
+ "runtime_version": "1.2.3",
+ }
+
+
+def test_runtime_version_fallback_on_missing_package() -> None:
+ """A source checkout with no installed metadata falls back to ``unknown``."""
+ from importlib.metadata import PackageNotFoundError
+
+ with patch(
+ "uipath.runtime.governance._audit.metadata.version",
+ side_effect=PackageNotFoundError("uipath-runtime"),
+ ):
+ assert _resolve_runtime_version() == "unknown"
diff --git a/tests/test_governance_runtime.py b/tests/test_governance_runtime.py
new file mode 100644
index 00000000..b2ca3e3d
--- /dev/null
+++ b/tests/test_governance_runtime.py
@@ -0,0 +1,630 @@
+"""Tests for :class:`UiPathGovernedRuntime` — pure resolved-policy wrapper.
+
+The runtime takes an already-resolved :class:`PolicyIndex` +
+:class:`EnforcementMode` at construction. The caller resolves and
+compiles the policy on its own side; the runtime is format-agnostic
+and never sees a wire representation. Tests here confirm the wrapper
+holds the snapshot and passes execution straight through to the
+delegate.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+from uipath.core.governance import EnforcementMode
+
+from uipath.runtime.governance.native.models import PolicyIndex
+from uipath.runtime.governance.runtime import UiPathGovernedRuntime
+
+# ---------------------------------------------------------------------------
+# UiPathGovernedRuntime — passthroughs
+# ---------------------------------------------------------------------------
+
+
+class _StubDelegate:
+ """Captures delegate calls so the passthroughs can be asserted."""
+
+ def __init__(self) -> None:
+ self.execute_calls: list[tuple[Any, Any]] = []
+ self.stream_calls: list[tuple[Any, Any]] = []
+ self.disposed = False
+ self.schema_called = False
+
+ async def execute(self, input: Any = None, options: Any = None) -> Any:
+ self.execute_calls.append((input, options))
+ return "result"
+
+ async def stream(self, input: Any = None, options: Any = None) -> Any:
+ self.stream_calls.append((input, options))
+ for event in ("a", "b"):
+ yield event
+
+ async def get_schema(self) -> Any:
+ self.schema_called = True
+ return "schema"
+
+ async def dispose(self) -> None:
+ self.disposed = True
+
+
+def _make_runtime(
+ delegate: _StubDelegate | None = None,
+ *,
+ policy_index: PolicyIndex | None = None,
+ enforcement_mode: EnforcementMode = EnforcementMode.AUDIT,
+) -> UiPathGovernedRuntime:
+ """Build a runtime with sensible test defaults."""
+ return UiPathGovernedRuntime(
+ delegate or _StubDelegate(),
+ policy_index if policy_index is not None else PolicyIndex(),
+ enforcement_mode,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Snapshot stored internally — not exposed as a public property
+# ---------------------------------------------------------------------------
+
+
+def test_resolved_policy_index_is_held_for_evaluator_use() -> None:
+ """The wrapper stores the resolved snapshot; the evaluator reads it.
+
+ The wrapper is oblivious to how the index was built (any wire
+ format, any compilation path, or a hand-constructed test fixture)
+ — it just holds the instance. A plain ``PolicyIndex()`` suffices
+ to prove the reference is preserved.
+ """
+ index = PolicyIndex()
+ runtime = _make_runtime(policy_index=index)
+ # Internal attribute — verify the wrapper kept the exact instance.
+ assert runtime._policy_index is index
+
+
+def test_enforcement_mode_is_held_for_evaluator_use() -> None:
+ """The wrapper stores the mode supplied at construction."""
+ runtime = _make_runtime(enforcement_mode=EnforcementMode.ENFORCE)
+ assert runtime._enforcement_mode is EnforcementMode.ENFORCE
+
+
+def test_empty_policy_index_is_a_valid_construction() -> None:
+ """``PolicyIndex()`` with no packs is acceptable — wrapper attaches without rules."""
+ runtime = _make_runtime(policy_index=PolicyIndex())
+ assert runtime._policy_index.total_rules == 0
+
+
+# ---------------------------------------------------------------------------
+# Passthrough behavior
+# ---------------------------------------------------------------------------
+
+
+async def test_governance_runtime_execute_delegates() -> None:
+ delegate = _StubDelegate()
+ runtime = _make_runtime(delegate)
+
+ result = await runtime.execute({"x": 1})
+
+ assert result == "result"
+ assert delegate.execute_calls == [({"x": 1}, None)]
+
+
+async def test_governance_runtime_stream_delegates() -> None:
+ delegate = _StubDelegate()
+ runtime = _make_runtime(delegate)
+
+ events = [e async for e in runtime.stream({"x": 1})]
+
+ assert events == ["a", "b"]
+ assert delegate.stream_calls == [({"x": 1}, None)]
+
+
+async def test_governance_runtime_schema_delegates() -> None:
+ """``get_schema`` still forwards — only ``dispose`` was cut to a no-op."""
+ delegate = _StubDelegate()
+ runtime = _make_runtime(delegate)
+
+ assert await runtime.get_schema() == "schema"
+ assert delegate.schema_called
+
+
+# ---------------------------------------------------------------------------
+# dispose — no-op by contract; caller owns delegate + host cleanup
+# ---------------------------------------------------------------------------
+
+
+async def test_dispose_does_not_touch_the_delegate() -> None:
+ """The wrapper deliberately does not forward ``dispose`` to the delegate.
+
+ Caller-owned lifecycle: whichever code built the delegate is
+ responsible for disposing it, along with any adjacent resources
+ the caller wired up (telemetry dispatchers, session pools, etc.).
+ The wrapper's ``dispose`` exists only to satisfy
+ :class:`UiPathDisposableProtocol` so the wrapper stays structurally
+ substitutable for any runtime.
+ """
+ delegate = _StubDelegate()
+ runtime = _make_runtime(delegate)
+
+ await runtime.dispose()
+
+ # The delegate was never touched — caller must dispose it themselves.
+ assert delegate.disposed is False
+
+
+async def test_dispose_does_not_raise_when_delegate_would_have_raised() -> None:
+ """Because the wrapper's ``dispose`` never invokes the delegate,
+ a delegate whose ``dispose`` raises has no path to surface through
+ the wrapper.
+ """
+
+ class _RaisingDelegate(_StubDelegate):
+ async def dispose(self) -> None:
+ raise RuntimeError("delegate would boom — but wrapper never calls this")
+
+ runtime = _make_runtime(_RaisingDelegate())
+
+ # Must not raise — the wrapper's dispose is a plain no-op.
+ await runtime.dispose()
+
+
+async def test_dispose_is_idempotent() -> None:
+ """Two calls in a row must not raise — teardown paths sometimes wire
+ ``close``/``dispose`` from multiple layers.
+ """
+ runtime = _make_runtime()
+
+ await runtime.dispose()
+ await runtime.dispose() # second call: no crash, no state to corrupt
+
+
+# ---------------------------------------------------------------------------
+# Root-span trace correlation — one trace_id per agent run
+# ---------------------------------------------------------------------------
+
+
+async def test_execute_opens_a_root_span_that_covers_the_whole_invocation() -> None:
+ """A single OTel span wraps BEFORE_AGENT, delegate, and AFTER_AGENT.
+
+ Verifies the unified-trace contract: every code path under
+ ``execute`` — including any framework adapter span the delegate
+ opens — sees the same ``trace_id`` and the same current span. The
+ ``track_events`` sink reads ``trace.get_current_span()`` at emit
+ time, so this guarantee is what makes its ``operation_id``
+ correlate across BEFORE_AGENT, BEFORE_MODEL, AFTER_MODEL, and
+ AFTER_AGENT events.
+ """
+ from opentelemetry import trace
+ from opentelemetry.sdk.trace import TracerProvider
+
+ # Replace the global tracer provider for this test so our span
+ # actually records (the default NoOp provider returns invalid
+ # contexts).
+ trace.set_tracer_provider(TracerProvider())
+
+ seen_trace_ids: list[int] = []
+
+ class _SpanProbeDelegate:
+ async def execute(self, input: Any = None, options: Any = None) -> Any:
+ seen_trace_ids.append(trace.get_current_span().get_span_context().trace_id)
+ return "result"
+
+ async def stream(self, input: Any = None, options: Any = None) -> Any:
+ if False:
+ yield # pragma: no cover
+
+ async def get_schema(self) -> Any:
+ return "schema"
+
+ async def dispose(self) -> None:
+ return None
+
+ runtime = UiPathGovernedRuntime(
+ _SpanProbeDelegate(),
+ PolicyIndex(),
+ EnforcementMode.AUDIT,
+ agent_name="agent-x",
+ runtime_id="run-1",
+ )
+
+ # Outside execute(): no governance span is active.
+ pre_call_ctx = trace.get_current_span().get_span_context()
+ assert not pre_call_ctx.is_valid, (
+ "no span should be active before execute() opens the wrapper"
+ )
+
+ await runtime.execute({"x": 1})
+
+ # Inside the delegate: a valid span context was current.
+ assert len(seen_trace_ids) == 1
+ assert seen_trace_ids[0] != 0, (
+ "delegate must see a valid OTel trace_id from the wrapping span"
+ )
+
+ # After execute(): the span has closed.
+ post_call_ctx = trace.get_current_span().get_span_context()
+ assert not post_call_ctx.is_valid, "wrapper span must close on return"
+
+
+async def test_execute_span_inherits_host_trace_id_when_one_is_active() -> None:
+ """When the host opens a span first, our wrapper becomes a child.
+
+ Same ``trace_id`` flows through everything — governance events
+ emitted by runtime hooks correlate with the host's outer
+ operation. The host's OTel context is preserved end-to-end.
+ """
+ from opentelemetry import trace
+ from opentelemetry.sdk.trace import TracerProvider
+
+ trace.set_tracer_provider(TracerProvider())
+ tracer = trace.get_tracer("test-host")
+
+ inner_trace_id: list[int] = []
+
+ class _ProbeDelegate:
+ async def execute(self, input: Any = None, options: Any = None) -> Any:
+ inner_trace_id.append(trace.get_current_span().get_span_context().trace_id)
+ return "result"
+
+ async def stream(self, input: Any = None, options: Any = None) -> Any:
+ if False:
+ yield # pragma: no cover
+
+ async def get_schema(self) -> Any:
+ return "schema"
+
+ async def dispose(self) -> None:
+ return None
+
+ runtime = UiPathGovernedRuntime(
+ _ProbeDelegate(), PolicyIndex(), EnforcementMode.AUDIT
+ )
+
+ with tracer.start_as_current_span("host-outer") as host_span:
+ host_trace_id = host_span.get_span_context().trace_id
+ await runtime.execute({"x": 1})
+
+ # The trace_id seen inside the delegate equals the host's
+ # trace_id — the wrapper became a child of the host's span.
+ assert inner_trace_id == [host_trace_id]
+
+
+async def test_stream_opens_a_root_span_that_covers_iteration() -> None:
+ """``stream``'s span stays open across the entire ``async for``.
+
+ Holding the span over ``await`` boundaries is safe — Python's
+ asyncio propagates contextvars (including OTel's current-span
+ pointer) across suspensions. The delegate (which yields events)
+ must see the same wrapper span on every iteration.
+ """
+ from opentelemetry import trace
+ from opentelemetry.sdk.trace import TracerProvider
+
+ trace.set_tracer_provider(TracerProvider())
+
+ per_event_trace_ids: list[int] = []
+
+ class _StreamProbeDelegate:
+ async def execute(self, input: Any = None, options: Any = None) -> Any:
+ return "result"
+
+ async def stream(self, input: Any = None, options: Any = None) -> Any:
+ for label in ("a", "b", "c"):
+ per_event_trace_ids.append(
+ trace.get_current_span().get_span_context().trace_id
+ )
+ yield label
+
+ async def get_schema(self) -> Any:
+ return "schema"
+
+ async def dispose(self) -> None:
+ return None
+
+ runtime = UiPathGovernedRuntime(
+ _StreamProbeDelegate(), PolicyIndex(), EnforcementMode.AUDIT
+ )
+
+ events = [e async for e in runtime.stream({"x": 1})]
+
+ assert events == ["a", "b", "c"]
+ # Same valid trace_id on every yield — one span covers the whole
+ # stream.
+ assert len(set(per_event_trace_ids)) == 1
+ assert per_event_trace_ids[0] != 0
+
+
+# ---------------------------------------------------------------------------
+# _serialize_payload — evaluator input/output flattening
+# ---------------------------------------------------------------------------
+
+
+def test_serialize_payload_none_returns_empty_string() -> None:
+ """``None`` becomes ``""`` — the evaluator's regex/sentiment checks
+ scan a string and would break on a raw ``None``.
+ """
+ from uipath.runtime.governance.runtime import _serialize_payload
+
+ assert _serialize_payload(None) == ""
+
+
+def test_serialize_payload_string_passes_through_unchanged() -> None:
+ """A bare string must not be JSON-quoted — regex/sentiment expect the
+ text as-is, without surrounding double quotes.
+ """
+ from uipath.runtime.governance.runtime import _serialize_payload
+
+ assert _serialize_payload("hello world") == "hello world"
+
+
+def test_serialize_payload_dict_is_json_encoded() -> None:
+ """Structured payloads route through ``serialize_object`` then
+ ``json.dumps`` — the evaluator sees a flat JSON string it can scan.
+ """
+ from uipath.runtime.governance.runtime import _serialize_payload
+
+ result = _serialize_payload({"a": 1, "b": ["x", "y"]})
+ # Order-agnostic content check — json.dumps default ordering can vary
+ # by dict insertion order across Python versions.
+ assert '"a": 1' in result
+ assert '"b": ["x", "y"]' in result
+
+
+def test_serialize_payload_unserializable_object_falls_back_to_str() -> None:
+ """When ``serialize_object`` + ``json.dumps`` can't handle the value
+ (e.g., raw file handles, sockets, generators), the wrapper falls back
+ to ``str(payload)`` so the evaluator still gets *something* rather
+ than crashing the agent hook.
+ """
+ from uipath.runtime.governance.runtime import _serialize_payload
+
+ class _Unpicklable:
+ def __repr__(self) -> str:
+ return ""
+
+ # A recursive dict that can't JSON-encode without hitting cycles or
+ # non-serializable values.
+ obj = _Unpicklable()
+ result = _serialize_payload(obj)
+ assert result == ""
+
+
+# ---------------------------------------------------------------------------
+# _fire_before_agent / _fire_after_agent — evaluator-wired paths
+# ---------------------------------------------------------------------------
+
+
+class _CapturingEvaluator:
+ """Minimal stub for :class:`GovernanceEvaluator` — records calls.
+
+ The runtime only invokes ``evaluate_before_agent`` /
+ ``evaluate_after_agent`` on the evaluator, so those two are the only
+ methods this stub needs to expose.
+ """
+
+ def __init__(self) -> None:
+ self.before_calls: list[dict[str, Any]] = []
+ self.after_calls: list[dict[str, Any]] = []
+ self.before_raises: Exception | None = None
+ self.after_raises: Exception | None = None
+
+ def evaluate_before_agent(self, **kwargs: Any) -> None:
+ self.before_calls.append(kwargs)
+ if self.before_raises is not None:
+ raise self.before_raises
+
+ def evaluate_after_agent(self, **kwargs: Any) -> None:
+ self.after_calls.append(kwargs)
+ if self.after_raises is not None:
+ raise self.after_raises
+
+
+class _ResultDelegate:
+ """Delegate that returns a real ``UiPathRuntimeResult`` so
+ :meth:`_fire_after_agent` can read ``result.output`` without
+ tripping over the ``_StubDelegate``'s bare-string return.
+ """
+
+ def __init__(self) -> None:
+ self.execute_calls: list[tuple[Any, Any]] = []
+
+ async def execute(self, input: Any = None, options: Any = None) -> Any:
+ from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus
+
+ self.execute_calls.append((input, options))
+ return UiPathRuntimeResult(
+ output="agent-output", status=UiPathRuntimeStatus.SUCCESSFUL
+ )
+
+ async def stream(self, input: Any = None, options: Any = None) -> Any:
+ if False:
+ yield # pragma: no cover
+
+ async def get_schema(self) -> Any:
+ return "schema"
+
+ async def dispose(self) -> None:
+ return None
+
+
+async def test_execute_fires_before_and_after_agent_when_evaluator_wired() -> None:
+ """The evaluator's hooks fire on happy-path execute — this is the
+ core reason ``UiPathGovernedRuntime`` exists.
+
+ Regression guard: without wiring the evaluator's calls, the wrapper
+ silently degrades to a pure passthrough (the ``self._evaluator is
+ None`` early-returns in ``_fire_*`` would kick in), and no
+ governance would run.
+ """
+ delegate = _ResultDelegate()
+ evaluator = _CapturingEvaluator()
+ runtime = UiPathGovernedRuntime(
+ delegate,
+ PolicyIndex(),
+ EnforcementMode.AUDIT,
+ evaluator=evaluator, # type: ignore[arg-type]
+ agent_name="agent-x",
+ runtime_id="run-1",
+ )
+
+ result = await runtime.execute({"prompt": "leak?"})
+
+ assert result.output == "agent-output"
+ assert len(evaluator.before_calls) == 1
+ assert len(evaluator.after_calls) == 1
+ # ``_serialize_payload`` turned the dict input into a JSON string.
+ assert "leak" in evaluator.before_calls[0]["agent_input"]
+ assert evaluator.before_calls[0]["agent_name"] == "agent-x"
+ assert evaluator.before_calls[0]["runtime_id"] == "run-1"
+ # And AFTER_AGENT saw the delegate's output.
+ assert evaluator.after_calls[0]["agent_output"] == "agent-output"
+
+
+async def test_execute_propagates_governance_block_exception() -> None:
+ """A DENY in ``ENFORCE`` mode must halt the run — the evaluator
+ raises :class:`GovernanceBlockException` and the wrapper propagates
+ it rather than swallowing.
+ """
+ from uipath.core.governance.exceptions import GovernanceBlockException
+
+ evaluator = _CapturingEvaluator()
+ evaluator.before_raises = GovernanceBlockException("policy denied")
+
+ runtime = UiPathGovernedRuntime(
+ _StubDelegate(),
+ PolicyIndex(),
+ EnforcementMode.ENFORCE,
+ evaluator=evaluator, # type: ignore[arg-type]
+ )
+
+ with pytest.raises(GovernanceBlockException):
+ await runtime.execute({"x": 1})
+
+
+async def test_after_agent_block_exception_also_propagates() -> None:
+ """The re-raise contract holds for AFTER_AGENT too, not just
+ BEFORE_AGENT.
+
+ Even though DENY on output is a rarer configuration (most policies
+ fire pre-model / on tool call), the wrapper must treat both hooks
+ symmetrically — a governance block from either boundary halts the
+ run, not just the input side.
+ """
+ from uipath.core.governance.exceptions import GovernanceBlockException
+
+ evaluator = _CapturingEvaluator()
+ evaluator.after_raises = GovernanceBlockException("output policy denied")
+
+ runtime = UiPathGovernedRuntime(
+ _ResultDelegate(),
+ PolicyIndex(),
+ EnforcementMode.ENFORCE,
+ evaluator=evaluator, # type: ignore[arg-type]
+ )
+
+ with pytest.raises(GovernanceBlockException):
+ await runtime.execute({"x": 1})
+
+
+async def test_execute_swallows_unexpected_evaluator_errors() -> None:
+ """A generic ``Exception`` from the evaluator must NOT break the
+ agent run — governance bugs are logged and the delegate still runs.
+ """
+ delegate = _StubDelegate()
+ evaluator = _CapturingEvaluator()
+ evaluator.before_raises = RuntimeError("evaluator internal error")
+
+ runtime = UiPathGovernedRuntime(
+ delegate,
+ PolicyIndex(),
+ EnforcementMode.AUDIT,
+ evaluator=evaluator, # type: ignore[arg-type]
+ )
+
+ # Must not raise — the wrapper swallows non-block exceptions so a
+ # governance bug never breaks the agent.
+ result = await runtime.execute({"x": 1})
+ assert result == "result"
+ # The delegate still ran (the evaluator failure didn't short-circuit).
+ assert delegate.execute_calls == [({"x": 1}, None)]
+
+
+async def test_stream_fires_after_agent_only_on_result_event() -> None:
+ """AFTER_AGENT fires when a :class:`UiPathRuntimeResult` event is
+ yielded — intermediate events pass through untouched.
+
+ Uses a delegate that yields a mix of plain events + a
+ ``UiPathRuntimeResult`` sentinel so the branch is exercised.
+ """
+ from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus
+
+ class _MixedStreamDelegate:
+ async def execute(self, input: Any = None, options: Any = None) -> Any:
+ return None
+
+ async def stream(self, input: Any = None, options: Any = None) -> Any:
+ yield "intermediate-1"
+ yield UiPathRuntimeResult(
+ output="final-output", status=UiPathRuntimeStatus.SUCCESSFUL
+ )
+ yield "intermediate-2"
+
+ async def get_schema(self) -> Any:
+ return "schema"
+
+ async def dispose(self) -> None:
+ return None
+
+ evaluator = _CapturingEvaluator()
+ runtime = UiPathGovernedRuntime(
+ _MixedStreamDelegate(),
+ PolicyIndex(),
+ EnforcementMode.AUDIT,
+ evaluator=evaluator, # type: ignore[arg-type]
+ )
+
+ events = [e async for e in runtime.stream({"x": 1})]
+
+ # All three events came through in order.
+ assert len(events) == 3
+ assert isinstance(events[1], UiPathRuntimeResult)
+ # AFTER_AGENT fires exactly once — on the UiPathRuntimeResult.
+ assert len(evaluator.after_calls) == 1
+ # BEFORE_AGENT fires once at stream start.
+ assert len(evaluator.before_calls) == 1
+
+
+# ---------------------------------------------------------------------------
+# OTel-not-installed fallback for _governance_root_span
+# ---------------------------------------------------------------------------
+
+
+async def test_execute_still_runs_when_opentelemetry_is_unavailable(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The runtime must not depend on OTel being importable.
+
+ Simulate a stripped host by making ``from opentelemetry import trace``
+ inside ``_governance_root_span`` raise ``ImportError``. The wrapper
+ should yield without opening a span and the agent still runs.
+ """
+ import builtins
+
+ real_import = builtins.__import__
+
+ def _blocked_import(name: str, *args: Any, **kwargs: Any) -> Any:
+ if name == "opentelemetry" or name.startswith("opentelemetry."):
+ raise ImportError(f"simulated missing: {name}")
+ return real_import(name, *args, **kwargs)
+
+ monkeypatch.setattr(builtins, "__import__", _blocked_import)
+
+ delegate = _StubDelegate()
+ runtime = UiPathGovernedRuntime(delegate, PolicyIndex(), EnforcementMode.AUDIT)
+
+ # Must not raise. The context manager takes the ImportError branch,
+ # yields with no span, and execute completes normally.
+ result = await runtime.execute({"x": 1})
+
+ assert result == "result"
+ assert delegate.execute_calls == [({"x": 1}, None)]
diff --git a/tests/test_guardrail_compensation.py b/tests/test_guardrail_compensation.py
new file mode 100644
index 00000000..22b238e1
--- /dev/null
+++ b/tests/test_guardrail_compensation.py
@@ -0,0 +1,298 @@
+"""Tests for the synchronous GuardrailCompensator.
+
+The runtime layer builds the wire payload and hands it to the
+injected provider. The provider owns batching / async / fire-and-
+forget. HTTP/auth/URL/header concerns — including ``trace_id``
+resolution — live behind the
+:class:`uipath.core.governance.GovernanceCompensationProvider`
+protocol and are exercised in the concrete provider's own tests.
+
+These tests cover:
+
+- ``disabled_guardrails`` — distilling fired ``guardrail_fallback`` rules
+ into per-rule wire metadata.
+- ``GuardrailCompensator.submit`` — short-circuits on empty input,
+ wire-model assembly, provider invocation, and fail-open behavior
+ when the provider raises.
+- ``close`` is a no-op (kept for API symmetry).
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+from uipath.core.governance import (
+ FiredRule,
+ GovernanceCompensationProvider,
+ GovernRequest,
+)
+
+from uipath.runtime.governance.native.guardrail_compensation import (
+ GuardrailCompensator,
+ disabled_guardrails,
+)
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _provider() -> MagicMock:
+ """Mock satisfying the GovernanceCompensationProvider protocol."""
+ return MagicMock(spec=GovernanceCompensationProvider)
+
+
+def _rules(
+ *validators: str,
+ rule_id: str = "R1",
+ rule_name: str = "n",
+ pack: str = "p",
+) -> list[FiredRule]:
+ """Build a list of FiredRule wire models — one per validator."""
+ return [
+ FiredRule(
+ rule_id=rule_id,
+ rule_name=rule_name,
+ pack_name=pack,
+ validator=v,
+ )
+ for v in validators
+ ]
+
+
+# ---------------------------------------------------------------------------
+# disabled_guardrails
+# ---------------------------------------------------------------------------
+
+
+def test_disabled_guardrails_returns_fired_rule_for_matched_disabled_guardrail() -> (
+ None
+):
+ cond = SimpleNamespace(
+ operator="guardrail_fallback",
+ value={
+ "validator": "pii_detection",
+ "mapped_to_uipath": True,
+ "policy_enabled": False,
+ },
+ )
+ rule = SimpleNamespace(checks=[SimpleNamespace(conditions=[cond])], pack_name="")
+ audit = SimpleNamespace(
+ evaluations=[
+ SimpleNamespace(matched=True, rule_id="R1", rule_name="PII guardrail")
+ ]
+ )
+ policy_index = SimpleNamespace(get_rule=lambda rid: rule if rid == "R1" else None)
+
+ out = disabled_guardrails(audit, policy_index)
+
+ assert len(out) == 1
+ fr = out[0]
+ assert isinstance(fr, FiredRule)
+ assert fr.rule_id == "R1"
+ assert fr.rule_name == "PII guardrail"
+ assert fr.pack_name == ""
+ assert fr.validator == "pii_detection"
+
+
+def test_disabled_guardrails_skips_unmatched_evaluations() -> None:
+ audit = SimpleNamespace(
+ evaluations=[SimpleNamespace(matched=False, rule_id="R1", rule_name="x")]
+ )
+ policy_index = SimpleNamespace(get_rule=lambda rid: None)
+ assert disabled_guardrails(audit, policy_index) == []
+
+
+def test_disabled_guardrails_skips_non_guardrail_conditions() -> None:
+ cond = SimpleNamespace(operator="regex", value="some-pattern")
+ rule = SimpleNamespace(checks=[SimpleNamespace(conditions=[cond])])
+ audit = SimpleNamespace(
+ evaluations=[SimpleNamespace(matched=True, rule_id="R1", rule_name="x")]
+ )
+ policy_index = SimpleNamespace(get_rule=lambda rid: rule)
+ assert disabled_guardrails(audit, policy_index) == []
+
+
+def test_disabled_guardrails_skips_enabled_guardrails() -> None:
+ """Mapped to UiPath AND enabled → no compensation needed."""
+ cond = SimpleNamespace(
+ operator="guardrail_fallback",
+ value={
+ "validator": "pii_detection",
+ "mapped_to_uipath": True,
+ "policy_enabled": True,
+ },
+ )
+ rule = SimpleNamespace(checks=[SimpleNamespace(conditions=[cond])], pack_name="")
+ audit = SimpleNamespace(
+ evaluations=[SimpleNamespace(matched=True, rule_id="R1", rule_name="x")]
+ )
+ policy_index = SimpleNamespace(get_rule=lambda rid: rule)
+ assert disabled_guardrails(audit, policy_index) == []
+
+
+def test_disabled_guardrails_skips_unmapped_guardrails() -> None:
+ """Not mapped to UiPath → server can't fall back; skip."""
+ cond = SimpleNamespace(
+ operator="guardrail_fallback",
+ value={
+ "validator": "pii_detection",
+ "mapped_to_uipath": False,
+ "policy_enabled": False,
+ },
+ )
+ rule = SimpleNamespace(checks=[SimpleNamespace(conditions=[cond])], pack_name="")
+ audit = SimpleNamespace(
+ evaluations=[SimpleNamespace(matched=True, rule_id="R1", rule_name="x")]
+ )
+ policy_index = SimpleNamespace(get_rule=lambda rid: rule)
+ assert disabled_guardrails(audit, policy_index) == []
+
+
+# ---------------------------------------------------------------------------
+# GuardrailCompensator.submit — short-circuits
+# ---------------------------------------------------------------------------
+
+
+def test_submit_empty_rules_short_circuits() -> None:
+ """No rules → no provider call."""
+ provider = _provider()
+ compensator = GuardrailCompensator(provider)
+ compensator.submit([], {}, "before_model", "ts", "a", "r")
+ provider.compensate.assert_not_called()
+
+
+def test_submit_no_validators_short_circuits() -> None:
+ """Rules with empty validator strings → no call (nothing to dispatch)."""
+ provider = _provider()
+ compensator = GuardrailCompensator(provider)
+ rules = [FiredRule(rule_id="R", rule_name="n", pack_name="p", validator="")]
+ compensator.submit(rules, {}, "before_model", "ts", "a", "r")
+ provider.compensate.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# GuardrailCompensator.submit — wire-model assembly + provider invocation
+# ---------------------------------------------------------------------------
+
+
+def test_submit_invokes_provider_with_govern_request() -> None:
+ """The provider receives a GovernRequest carrying every wire field.
+
+ ``trace_id`` is left empty on the wire — the injected provider
+ resolves it at HTTP-call time.
+ """
+ provider = _provider()
+ compensator = GuardrailCompensator(provider)
+ rules = _rules("pii_detection", "harmful_content")
+
+ compensator.submit(
+ rules,
+ {"content": "x"},
+ "before_model",
+ "2026-06-06T00:00:00Z",
+ "langchain",
+ "patch-langchain",
+ )
+
+ provider.compensate.assert_called_once()
+ (request,) = provider.compensate.call_args.args
+ assert isinstance(request, GovernRequest)
+ # distinct validators drive the guardrail API call
+ assert request.validators == ["pii_detection", "harmful_content"]
+ assert request.rules == rules
+ assert request.data == {"content": "x"}
+ assert request.hook == "before_model"
+ # ``trace_id`` is not carried on the wire — the provider resolves at HTTP time.
+ assert request.trace_id in (None, "")
+ assert request.src_timestamp == "2026-06-06T00:00:00Z"
+ assert request.agent_name == "langchain"
+ assert request.runtime_id == "patch-langchain"
+ # Job-context fields are left for the provider to auto-fill from env.
+ assert request.folder_key is None
+ assert request.job_key is None
+ assert request.process_key is None
+ assert request.reference_id is None
+ assert request.agent_version is None
+ # Runtime-identity fields are unset without metadata (server defaults them).
+ assert request.agent_framework is None
+ assert request.agent_type is None
+ assert request.runtime_version is None
+
+
+def test_submit_stamps_runtime_metadata() -> None:
+ """When runtime_metadata is supplied, its identity fields ride the request."""
+ from uipath.runtime.governance._audit.metadata import GovernanceRuntimeMetadata
+
+ meta = GovernanceRuntimeMetadata(
+ agent_type="uipath_coded",
+ agent_framework="langchain",
+ runtime_version="0.11.4",
+ )
+ provider = _provider()
+ compensator = GuardrailCompensator(provider, runtime_metadata=meta)
+
+ compensator.submit(
+ _rules("pii_detection"),
+ {"content": "x"},
+ "before_model",
+ "2026-06-06T00:00:00Z",
+ "langchain",
+ "patch-langchain",
+ )
+
+ (request,) = provider.compensate.call_args.args
+ assert request.agent_framework == "langchain"
+ assert request.agent_type == "uipath_coded"
+ assert request.runtime_version == "0.11.4"
+
+
+def test_submit_dedupes_validators() -> None:
+ """Multiple rules with the same validator collapse on the wire."""
+ provider = _provider()
+ compensator = GuardrailCompensator(provider)
+ rules = _rules("pii_detection") + _rules("pii_detection", rule_id="R2")
+
+ compensator.submit(rules, {}, "before_model", "ts", "a", "r")
+
+ (request,) = provider.compensate.call_args.args
+ assert request.validators == ["pii_detection"]
+ # Per-rule metadata is preserved (one record per rule even with shared validator).
+ assert len(request.rules) == 2
+
+
+def test_submit_swallows_provider_errors() -> None:
+ """A provider exception must never propagate to the caller / agent."""
+ provider = _provider()
+ provider.compensate.side_effect = RuntimeError("network down")
+ compensator = GuardrailCompensator(provider)
+
+ # Must not raise.
+ compensator.submit(_rules("x"), {}, "before_model", "ts", "a", "r")
+
+ provider.compensate.assert_called_once()
+
+
+def test_submit_recovers_after_provider_error() -> None:
+ """A failed call doesn't poison the compensator — the next call still fires."""
+ provider = _provider()
+ provider.compensate.side_effect = [RuntimeError("transient"), None]
+ compensator = GuardrailCompensator(provider)
+
+ compensator.submit(_rules("x"), {}, "before_model", "ts", "a", "r")
+ compensator.submit(_rules("x"), {}, "before_model", "ts", "a", "r")
+
+ assert provider.compensate.call_count == 2
+
+
+# ---------------------------------------------------------------------------
+# close — no-op for API symmetry
+# ---------------------------------------------------------------------------
+
+
+def test_close_is_a_noop() -> None:
+ """``close()`` holds no resources to release; calling it twice is safe."""
+ c = GuardrailCompensator(_provider())
+ c.close()
+ c.close() # must not raise
diff --git a/tests/test_resumable.py b/tests/test_resumable.py
index 59655f39..8564b575 100644
--- a/tests/test_resumable.py
+++ b/tests/test_resumable.py
@@ -7,7 +7,11 @@
import pytest
from uipath.core.errors import ErrorCategory, UiPathPendingTriggerError
-from uipath.core.triggers import UiPathResumeTrigger, UiPathResumeTriggerType
+from uipath.core.triggers import (
+ UiPathResumeTrigger,
+ UiPathResumeTriggerName,
+ UiPathResumeTriggerType,
+)
from uipath.runtime import (
UiPathExecuteOptions,
@@ -15,15 +19,17 @@
UiPathRuntimeStatus,
UiPathStreamOptions,
)
+from uipath.runtime.errors import UiPathRuntimeError
from uipath.runtime.events import UiPathRuntimeEvent
from uipath.runtime.resumable.protocols import (
+ UiPathResumableStorageProtocol,
UiPathResumeTriggerProtocol,
)
from uipath.runtime.resumable.runtime import UiPathResumableRuntime
from uipath.runtime.schema import UiPathRuntimeSchema
-class MultiTriggerMockRuntime:
+class SiblingTriggerMockRuntime:
"""Mock runtime that simulates parallel branching with multiple interrupts."""
def __init__(self) -> None:
@@ -89,6 +95,56 @@ async def get_schema(self) -> UiPathRuntimeSchema:
raise NotImplementedError()
+class MultiTriggerMockRuntime:
+ """Mock runtime that suspends on a multi-trigger interrupt, then suspends again."""
+
+ def __init__(self) -> None:
+ self.execution_count = 0
+
+ async def dispose(self) -> None:
+ pass
+
+ async def execute(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathExecuteOptions | None = None,
+ ) -> UiPathRuntimeResult:
+ self.execution_count += 1
+ is_resume = options and options.resume
+
+ if self.execution_count == 1:
+ return UiPathRuntimeResult(
+ status=UiPathRuntimeStatus.SUSPENDED,
+ output={"child-1": {"process": "first-child"}},
+ )
+
+ assert is_resume
+ assert input == {
+ "child-1": {
+ "completed": True,
+ "__uipath": {
+ "triggerType": UiPathResumeTriggerType.JOB.value,
+ "triggerName": "Unknown",
+ },
+ }
+ }
+ return UiPathRuntimeResult(
+ status=UiPathRuntimeStatus.SUSPENDED,
+ output={"child-2": {"process": "second-child"}},
+ )
+
+ async def stream(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathStreamOptions | None = None,
+ ) -> AsyncGenerator[UiPathRuntimeEvent, None]:
+ result = await self.execute(input, options)
+ yield result
+
+ async def get_schema(self) -> UiPathRuntimeSchema:
+ raise NotImplementedError()
+
+
class StatefulStorageMock:
"""Stateful storage mock that tracks triggers."""
@@ -106,8 +162,14 @@ async def save_triggers(
async def delete_trigger(
self, runtime_id: str, trigger: UiPathResumeTrigger
) -> None:
+ await self.delete_triggers(runtime_id, [trigger])
+
+ async def delete_triggers(
+ self, runtime_id: str, triggers: list[UiPathResumeTrigger]
+ ) -> None:
+ interrupt_ids = {trigger.interrupt_id for trigger in triggers}
self.triggers = [
- t for t in self.triggers if t.interrupt_id != trigger.interrupt_id
+ t for t in self.triggers if t.interrupt_id not in interrupt_ids
]
async def set_value(
@@ -119,6 +181,56 @@ async def get_value(self, runtime_id: str, namespace: str, key: str) -> Any:
return None
+class DeprecatedDeleteAliasStorage(UiPathResumableStorageProtocol):
+ """Storage that relies on the protocol's deprecated singular delete alias."""
+
+ def __init__(self) -> None:
+ self.deleted_triggers: list[UiPathResumeTrigger] = []
+
+ async def get_triggers(self, runtime_id: str) -> list[UiPathResumeTrigger]:
+ return []
+
+ async def save_triggers(
+ self, runtime_id: str, triggers: list[UiPathResumeTrigger]
+ ) -> None:
+ pass
+
+ async def delete_triggers(
+ self, runtime_id: str, triggers: list[UiPathResumeTrigger]
+ ) -> None:
+ self.deleted_triggers.extend(triggers)
+
+ async def set_value(
+ self, runtime_id: str, namespace: str, key: str, value: Any
+ ) -> None:
+ pass
+
+ async def get_value(self, runtime_id: str, namespace: str, key: str) -> Any:
+ return None
+
+
+class DeprecatedCreateAliasManager(UiPathResumeTriggerProtocol):
+ """Trigger manager that relies on the protocol's singular create alias."""
+
+ def __init__(self) -> None:
+ self.created_values: list[Any] = []
+ self.trigger_count = 1
+
+ async def create_triggers(self, suspend_value: Any) -> list[UiPathResumeTrigger]:
+ self.created_values.append(suspend_value)
+ return [
+ UiPathResumeTrigger(
+ interrupt_id="",
+ trigger_type=UiPathResumeTriggerType.TASK,
+ payload=suspend_value,
+ )
+ for _ in range(self.trigger_count)
+ ]
+
+ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None:
+ return None
+
+
def make_trigger_manager_mock() -> UiPathResumeTriggerProtocol:
"""Create trigger manager mock."""
manager = Mock(spec=UiPathResumeTriggerProtocol)
@@ -134,19 +246,211 @@ async def read_trigger_default(trigger: UiPathResumeTrigger) -> dict[str, Any]:
raise UiPathPendingTriggerError(ErrorCategory.USER, "Trigger not fired yet")
manager.create_trigger = AsyncMock(side_effect=create_trigger_impl)
+
+ async def create_triggers_impl(data: dict[str, Any]) -> list[UiPathResumeTrigger]:
+ return [await manager.create_trigger(data)]
+
+ manager.create_triggers = AsyncMock(side_effect=create_triggers_impl)
manager.read_trigger = AsyncMock(side_effect=read_trigger_default)
return cast(UiPathResumeTriggerProtocol, manager)
class TestResumableRuntime:
+ @pytest.mark.asyncio
+ async def test_delete_trigger_alias_delegates_to_delete_triggers(self) -> None:
+ """Deprecated singular delete API delegates to the plural API."""
+
+ storage = DeprecatedDeleteAliasStorage()
+ trigger = UiPathResumeTrigger(
+ interrupt_id="int-1",
+ trigger_type=UiPathResumeTriggerType.TASK,
+ )
+
+ await storage.delete_trigger("runtime-1", trigger)
+
+ assert storage.deleted_triggers == [trigger]
+
+ @pytest.mark.asyncio
+ async def test_create_trigger_alias_delegates_to_create_triggers(self) -> None:
+ """Deprecated singular create API delegates to the plural API."""
+
+ manager = DeprecatedCreateAliasManager()
+
+ trigger = await manager.create_trigger({"action": "approve"})
+
+ assert trigger.payload == {"action": "approve"}
+ assert manager.created_values == [{"action": "approve"}]
+
+ @pytest.mark.asyncio
+ async def test_create_trigger_alias_rejects_multiple_triggers(self) -> None:
+ """Deprecated singular create API fails if plural API returns siblings."""
+
+ manager = DeprecatedCreateAliasManager()
+ manager.trigger_count = 2
+
+ with pytest.raises(UiPathRuntimeError, match="Use create_triggers"):
+ await manager.create_trigger({"action": "approve"})
+
+ def test_with_trigger_metadata_merges_existing_uipath_metadata(self) -> None:
+ """Existing UiPath metadata is preserved when trigger metadata is added."""
+
+ storage = StatefulStorageMock()
+ trigger_manager = make_trigger_manager_mock()
+ resumable = UiPathResumableRuntime(
+ delegate=SiblingTriggerMockRuntime(),
+ storage=storage,
+ trigger_manager=trigger_manager,
+ runtime_id="runtime-1",
+ )
+ trigger = UiPathResumeTrigger(
+ interrupt_id="int-1",
+ trigger_type=UiPathResumeTriggerType.TIMER,
+ trigger_name=UiPathResumeTriggerName.TIMER,
+ )
+
+ result = resumable._with_trigger_metadata(
+ trigger,
+ {"__uipath": {"kind": "timeout", "timeout": 10}, "value": "done"},
+ )
+
+ assert result == {
+ "__uipath": {
+ "kind": "timeout",
+ "timeout": 10,
+ "triggerType": UiPathResumeTriggerType.TIMER.value,
+ "triggerName": UiPathResumeTriggerName.TIMER.value,
+ },
+ "value": "done",
+ }
+
+ def test_with_trigger_metadata_wraps_non_mapping_data(self) -> None:
+ """Non-mapping resume values are wrapped with trigger metadata."""
+
+ storage = StatefulStorageMock()
+ trigger_manager = make_trigger_manager_mock()
+ resumable = UiPathResumableRuntime(
+ delegate=SiblingTriggerMockRuntime(),
+ storage=storage,
+ trigger_manager=trigger_manager,
+ runtime_id="runtime-1",
+ )
+ trigger = Mock(
+ interrupt_id="int-1",
+ trigger_type="CustomType",
+ trigger_name="CustomName",
+ )
+
+ result = resumable._with_trigger_metadata(
+ cast(UiPathResumeTrigger, trigger), "approved"
+ )
+
+ assert result == {
+ "__uipath": {
+ "triggerType": "CustomType",
+ "triggerName": "CustomName",
+ },
+ "value": "approved",
+ }
+
+ @pytest.mark.asyncio
+ async def test_restore_resume_input_deletes_single_trigger_for_explicit_input(
+ self,
+ ) -> None:
+ """Explicit resume input clears the only stored trigger."""
+
+ storage = StatefulStorageMock()
+ storage.triggers = [
+ UiPathResumeTrigger(
+ interrupt_id="int-1",
+ trigger_type=UiPathResumeTriggerType.TASK,
+ )
+ ]
+ resumable = UiPathResumableRuntime(
+ delegate=SiblingTriggerMockRuntime(),
+ storage=storage,
+ trigger_manager=make_trigger_manager_mock(),
+ runtime_id="runtime-1",
+ )
+
+ result = await resumable._restore_resume_input({"int-1": {"approved": True}})
+
+ assert result == {"int-1": {"approved": True}}
+ assert storage.triggers == []
+
+ @pytest.mark.asyncio
+ async def test_restore_resume_input_deletes_matching_trigger_for_explicit_input(
+ self,
+ ) -> None:
+ """Explicit resume input clears only matching stored triggers."""
+
+ trigger_1 = UiPathResumeTrigger(
+ interrupt_id="int-1",
+ trigger_type=UiPathResumeTriggerType.TASK,
+ )
+ trigger_2 = UiPathResumeTrigger(
+ interrupt_id="int-2",
+ trigger_type=UiPathResumeTriggerType.TASK,
+ )
+ storage = StatefulStorageMock()
+ storage.triggers = [trigger_1, trigger_2]
+ resumable = UiPathResumableRuntime(
+ delegate=SiblingTriggerMockRuntime(),
+ storage=storage,
+ trigger_manager=make_trigger_manager_mock(),
+ runtime_id="runtime-1",
+ )
+
+ result = await resumable._restore_resume_input({"int-2": {"approved": True}})
+
+ assert result == {"int-2": {"approved": True}}
+ assert storage.triggers == [trigger_1]
+
+ @pytest.mark.asyncio
+ async def test_build_resume_map_skips_duplicate_pollable_trigger(self) -> None:
+ """Duplicate pollable triggers for one interrupt are read once."""
+
+ trigger_1 = UiPathResumeTrigger(
+ interrupt_id="int-1",
+ trigger_type=UiPathResumeTriggerType.TASK,
+ )
+ trigger_2 = UiPathResumeTrigger(
+ interrupt_id="int-1",
+ trigger_type=UiPathResumeTriggerType.JOB,
+ )
+ storage = StatefulStorageMock()
+ storage.triggers = [trigger_1, trigger_2]
+ trigger_manager = make_trigger_manager_mock()
+ read_trigger_mock = AsyncMock(return_value="approved")
+ cast(Any, trigger_manager).read_trigger = read_trigger_mock
+ resumable = UiPathResumableRuntime(
+ delegate=SiblingTriggerMockRuntime(),
+ storage=storage,
+ trigger_manager=trigger_manager,
+ runtime_id="runtime-1",
+ )
+
+ result = await resumable._build_resume_map([trigger_1, trigger_2])
+
+ assert result == {
+ "int-1": {
+ "__uipath": {
+ "triggerType": UiPathResumeTriggerType.TASK.value,
+ "triggerName": "Unknown",
+ },
+ "value": "approved",
+ }
+ }
+ read_trigger_mock.assert_awaited_once_with(trigger_1)
+ assert storage.triggers == []
+
@pytest.mark.asyncio
async def test_resumable_creates_multiple_triggers_on_first_suspension(
self,
) -> None:
"""First suspension with parallel branches should create multiple triggers."""
- runtime_impl = MultiTriggerMockRuntime()
+ runtime_impl = SiblingTriggerMockRuntime()
storage = StatefulStorageMock()
trigger_manager = make_trigger_manager_mock()
@@ -179,7 +483,7 @@ async def test_resumable_creates_multiple_triggers_on_first_suspension(
async def test_resumable_adds_only_new_triggers_on_partial_resume(self) -> None:
"""Partial resume should keep pending trigger and add only new ones."""
- runtime_impl = MultiTriggerMockRuntime()
+ runtime_impl = SiblingTriggerMockRuntime()
storage = StatefulStorageMock()
trigger_manager = make_trigger_manager_mock()
@@ -222,7 +526,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
async def test_resumable_completes_after_all_triggers_resolved(self) -> None:
"""After all triggers resolved, execution should complete successfully."""
- runtime_impl = MultiTriggerMockRuntime()
+ runtime_impl = SiblingTriggerMockRuntime()
storage = StatefulStorageMock()
trigger_manager = make_trigger_manager_mock()
@@ -269,7 +573,7 @@ async def read_trigger_impl_3(trigger: UiPathResumeTrigger) -> dict[str, Any]:
async def test_resumable_auto_resumes_when_triggers_already_fired(self) -> None:
"""When triggers are already fired during suspension, runtime should auto-resume."""
- runtime_impl = MultiTriggerMockRuntime()
+ runtime_impl = SiblingTriggerMockRuntime()
storage = StatefulStorageMock()
trigger_manager = make_trigger_manager_mock()
@@ -308,7 +612,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
async def test_resumable_auto_resumes_partial_fired_triggers(self) -> None:
"""When only some triggers are fired during suspension, auto-resume with those."""
- runtime_impl = MultiTriggerMockRuntime()
+ runtime_impl = SiblingTriggerMockRuntime()
storage = StatefulStorageMock()
trigger_manager = make_trigger_manager_mock()
@@ -344,7 +648,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
async def test_resumable_auto_resumes_multiple_times(self) -> None:
"""When triggers keep being fired immediately, keep auto-resuming until complete."""
- runtime_impl = MultiTriggerMockRuntime()
+ runtime_impl = SiblingTriggerMockRuntime()
storage = StatefulStorageMock()
trigger_manager = make_trigger_manager_mock()
@@ -389,7 +693,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
async def test_resumable_stream_auto_resumes_when_triggers_fired(self) -> None:
"""Stream should auto-resume when triggers are already fired."""
- runtime_impl = MultiTriggerMockRuntime()
+ runtime_impl = SiblingTriggerMockRuntime()
storage = StatefulStorageMock()
trigger_manager = make_trigger_manager_mock()
@@ -426,7 +730,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
async def test_resumable_no_auto_resume_when_all_triggers_pending(self) -> None:
"""When all triggers are pending, should NOT auto-resume."""
- runtime_impl = MultiTriggerMockRuntime()
+ runtime_impl = SiblingTriggerMockRuntime()
storage = StatefulStorageMock()
trigger_manager = make_trigger_manager_mock()
@@ -457,7 +761,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
async def test_resumable_skips_api_triggers_on_auto_resume_check(self) -> None:
"""API triggers should be skipped when checking for auto-resume after suspension."""
- runtime_impl = MultiTriggerMockRuntime()
+ runtime_impl = SiblingTriggerMockRuntime()
storage = StatefulStorageMock()
trigger_manager = make_trigger_manager_mock()
@@ -508,7 +812,7 @@ async def test_resumable_skips_inbox_triggers_on_auto_resume_check(self) -> None
404 and fault the run. They should behave like API triggers here.
"""
- runtime_impl = MultiTriggerMockRuntime()
+ runtime_impl = SiblingTriggerMockRuntime()
storage = StatefulStorageMock()
trigger_manager = make_trigger_manager_mock()
@@ -548,13 +852,54 @@ def create_inbox_trigger(data: dict[str, Any]) -> UiPathResumeTrigger:
trigger_manager.read_trigger.assert_not_called()
assert runtime_impl.execution_count == 1
+ @pytest.mark.asyncio
+ async def test_resumable_skips_timer_triggers_on_auto_resume_check(self) -> None:
+ """Timer triggers should be skipped when checking for auto-resume."""
+
+ runtime_impl = SiblingTriggerMockRuntime()
+ storage = StatefulStorageMock()
+ trigger_manager = make_trigger_manager_mock()
+
+ def create_timer_trigger(data: dict[str, Any]) -> UiPathResumeTrigger:
+ return UiPathResumeTrigger(
+ interrupt_id="", # Will be set by resumable runtime
+ trigger_type=UiPathResumeTriggerType.TIMER,
+ payload=data,
+ )
+
+ trigger_manager.create_trigger = AsyncMock(side_effect=create_timer_trigger) # type: ignore[method-assign]
+ read_trigger_guard = AsyncMock(
+ side_effect=AssertionError(
+ "read_trigger must not be called for Timer triggers pre-resume"
+ )
+ )
+ trigger_manager.read_trigger = read_trigger_guard # type: ignore[method-assign]
+
+ resumable = UiPathResumableRuntime(
+ delegate=runtime_impl,
+ storage=storage,
+ trigger_manager=trigger_manager,
+ runtime_id="runtime-1",
+ )
+
+ result = await resumable.execute({})
+
+ assert result.status == UiPathRuntimeStatus.SUSPENDED
+ assert result.triggers is not None
+ assert len(result.triggers) == 2
+ assert all(
+ t.trigger_type == UiPathResumeTriggerType.TIMER for t in result.triggers
+ )
+ trigger_manager.read_trigger.assert_not_called()
+ assert runtime_impl.execution_count == 1
+
@pytest.mark.asyncio
async def test_resumable_auto_resumes_task_triggers_but_not_api_triggers(
self,
) -> None:
"""Mixed triggers: TASK triggers should trigger auto-resume, API triggers should not."""
- runtime_impl = MultiTriggerMockRuntime()
+ runtime_impl = SiblingTriggerMockRuntime()
storage = StatefulStorageMock()
trigger_manager = make_trigger_manager_mock()
@@ -599,3 +944,67 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
# Delegate should have been executed twice (initial + auto-resume for TASK trigger)
assert runtime_impl.execution_count == 2
+
+ @pytest.mark.asyncio
+ async def test_resumable_removes_sibling_triggers_when_one_trigger_fires(
+ self,
+ ) -> None:
+ """When one trigger fires, sibling triggers must not leak forward."""
+
+ runtime_impl = MultiTriggerMockRuntime()
+ storage = StatefulStorageMock()
+ trigger_manager = make_trigger_manager_mock()
+
+ async def create_sibling_triggers(
+ data: dict[str, Any],
+ ) -> list[UiPathResumeTrigger]:
+ return [
+ UiPathResumeTrigger(
+ interrupt_id="", # Will be set by resumable runtime
+ trigger_type=UiPathResumeTriggerType.JOB,
+ payload={"source": data["process"]},
+ ),
+ UiPathResumeTrigger(
+ interrupt_id="", # Will be set by resumable runtime
+ trigger_type=UiPathResumeTriggerType.TIMER,
+ payload={"source": data["process"]},
+ ),
+ ]
+
+ create_triggers_mock = AsyncMock(side_effect=create_sibling_triggers)
+ cast(Any, trigger_manager).create_triggers = create_triggers_mock
+
+ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
+ if (
+ trigger.interrupt_id == "child-1"
+ and trigger.trigger_type == UiPathResumeTriggerType.JOB
+ ):
+ return {"completed": True}
+ raise UiPathPendingTriggerError(ErrorCategory.USER, "still pending")
+
+ read_trigger_mock = AsyncMock(side_effect=read_trigger_impl)
+ cast(Any, trigger_manager).read_trigger = read_trigger_mock
+
+ resumable = UiPathResumableRuntime(
+ delegate=runtime_impl,
+ storage=storage,
+ trigger_manager=trigger_manager,
+ runtime_id="runtime-1",
+ )
+
+ result = await resumable.execute({})
+
+ assert result.status == UiPathRuntimeStatus.SUSPENDED
+ assert result.triggers is not None
+ assert {t.interrupt_id for t in result.triggers} == {"child-2"}
+ assert len(result.triggers) == 2
+ assert {t.trigger_type for t in result.triggers} == {
+ UiPathResumeTriggerType.JOB,
+ UiPathResumeTriggerType.TIMER,
+ }
+ assert runtime_impl.execution_count == 2
+ assert create_triggers_mock.await_count == 2
+ assert read_trigger_mock.await_count == 2
+
+ saved_triggers = await storage.get_triggers("runtime-1")
+ assert {t.interrupt_id for t in saved_triggers} == {"child-2"}
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 3dd737d0..29440be6 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -3,6 +3,7 @@
from pydantic import BaseModel
from uipath.runtime.schema import (
+ UiPathRuntimeSchema,
transform_attachments,
transform_nullable_types,
transform_references,
@@ -295,3 +296,38 @@ class TestModel(BaseModel):
}
assert result == expected
+
+
+class TestUiPathRuntimeSchema:
+ def _make_schema(self, **kwargs: Any) -> UiPathRuntimeSchema:
+ return UiPathRuntimeSchema(
+ filePath="main.py",
+ uniqueId="00000000-0000-0000-0000-000000000001",
+ type="function",
+ input={},
+ output={},
+ **kwargs,
+ )
+
+ def test_is_transaction_root_omitted_when_unset(self):
+ """Unset flag must not appear in the serialized entrypoint (bool? on consumers)."""
+ schema = self._make_schema()
+
+ dumped = schema.model_dump(by_alias=True, exclude_unset=True)
+
+ assert "isTransactionRoot" not in dumped
+
+ def test_is_transaction_root_serializes_by_alias(self):
+ """The flag set via the python name serializes under its camelCase alias."""
+ schema = self._make_schema()
+ schema.is_transaction_root = True
+
+ dumped = schema.model_dump(by_alias=True, exclude_unset=True)
+
+ assert dumped["isTransactionRoot"] is True
+
+ def test_is_transaction_root_validates_from_alias(self):
+ """The flag round-trips when read back from entry-points.json content."""
+ schema = self._make_schema(isTransactionRoot=True)
+
+ assert schema.is_transaction_root is True
diff --git a/tests/test_traces_severity.py b/tests/test_traces_severity.py
new file mode 100644
index 00000000..5b7e11ab
--- /dev/null
+++ b/tests/test_traces_severity.py
@@ -0,0 +1,506 @@
+"""Tests for trace-span verbosity / status semantics.
+
+``TracesAuditSink`` emits an OpenTelemetry span for every rule
+evaluation. HOOK_END events are dropped — no hook-summary span. The
+verdict is split into ``evaluator_result`` (what the rule decided,
+mode-independent) and ``action_applied`` (what actually happened,
+derived from evaluator_result + mode).
+
+Mode travels with the event (set by the emitter from its
+per-instance ``EnforcementMode``) so parallel runtimes running
+different modes don't cross-contaminate the sink's view.
+
+- ``verbosityLevel = 4`` (Error) and ``StatusCode.ERROR`` fire **only**
+ when ``action_applied = DENY`` — i.e. the runtime actually blocked
+ the agent (ENFORCE mode + configured action ``deny``).
+- ``verbosityLevel = 3`` (Warning) and ``Status.UNSET`` for advisory
+ outcomes (``action_applied`` in ``{AUDIT, HITL}``). HITL is its own
+ bucket — escalation pauses for human review, it doesn't fail the
+ run, so it stays Warning even in ENFORCE mode.
+- ``ALLOW`` / ``NONE`` results leave verbosityLevel unset (consumers
+ apply their default) and never call set_status.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+from uipath.core.governance import EnforcementMode
+
+from uipath.runtime.governance._audit.base import AuditEvent, EventType
+from uipath.runtime.governance._audit.traces import (
+ SPAN_TYPE_GOVERNANCE,
+ UIPATH_SOURCE,
+ TracesAuditSink,
+)
+
+
+@pytest.fixture
+def captured_span(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
+ """Wire ``TracesAuditSink`` to a mock tracer and return the span mock."""
+ span = MagicMock(name="span")
+ tracer = MagicMock(name="tracer")
+ tracer.start_as_current_span.return_value.__enter__.return_value = span
+ tracer.start_as_current_span.return_value.__exit__.return_value = False
+ monkeypatch.setattr(TracesAuditSink, "_get_tracer", lambda self: tracer)
+ return span
+
+
+def _hook_event(final_action: str, mode: EnforcementMode) -> AuditEvent:
+ return AuditEvent(
+ event_type=EventType.HOOK_END,
+ agent_name="agent",
+ hook="after_model",
+ data={
+ "total_rules": 1,
+ "matched_rules": 1 if final_action != "allow" else 0,
+ "final_action": final_action,
+ "enforcement_mode": mode,
+ },
+ )
+
+
+def _rule_event(
+ matched: bool, action: str, mode: EnforcementMode = EnforcementMode.AUDIT
+) -> AuditEvent:
+ return AuditEvent(
+ event_type=EventType.RULE_EVALUATION,
+ agent_name="agent",
+ hook="after_model",
+ data={
+ "policy_id": "A.10.4",
+ "rule_name": "commitment-language",
+ "pack_name": "iso42001",
+ "matched": matched,
+ "action": action,
+ "enforcement_mode": mode,
+ "status": "MATCHED" if matched else "PASS",
+ "detail": "Customer-binding commitment detected.",
+ },
+ )
+
+
+def _span_attrs(span: MagicMock) -> dict[str, object]:
+ """Return a mapping of attribute name → value for set_attribute calls."""
+ attrs: dict[str, object] = {}
+ for call in span.set_attribute.call_args_list:
+ key, value = call.args
+ attrs[key] = value
+ return attrs
+
+
+# ---------------------------------------------------------------------------
+# Hook span — dropped; no span is emitted for HOOK_END
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "final_action,mode",
+ [
+ ("deny", EnforcementMode.ENFORCE),
+ ("deny", EnforcementMode.AUDIT),
+ ("audit", EnforcementMode.AUDIT),
+ ("escalate", EnforcementMode.AUDIT),
+ ("allow", EnforcementMode.AUDIT),
+ ],
+)
+def test_hook_end_emits_no_span(
+ captured_span: MagicMock, final_action: str, mode: EnforcementMode
+) -> None:
+ """HOOK_END events are dropped — the sink emits spans per rule only.
+
+ No span is started and the counter stays at zero regardless of the
+ hook's final_action or mode.
+ """
+ sink = TracesAuditSink()
+ sink.emit(_hook_event(final_action=final_action, mode=mode))
+
+ # The tracer is never asked to start a span for HOOK_END — the drop
+ # happens in ``emit`` before any span work.
+ tracer = sink._get_tracer()
+ tracer.start_as_current_span.assert_not_called()
+ assert not captured_span.set_attribute.called
+ assert not captured_span.set_status.called
+ assert sink.spans_created == 0
+
+
+# ---------------------------------------------------------------------------
+# Rule span — OTEL-contract attributes (span type + source)
+# ---------------------------------------------------------------------------
+
+
+def test_rule_span_sets_governance_type_and_source(captured_span: MagicMock) -> None:
+ """Governance rule spans carry the governance span type and source.
+
+ This is the OTEL-contract change the PR exists to make: spans must no
+ longer look like agent runs (``agentRun`` / CodedAgents). Guards
+ against a regression that recreates the span but mis-types/mis-sources
+ it.
+ """
+ sink = TracesAuditSink()
+ sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
+
+ attrs = _span_attrs(captured_span)
+ assert attrs.get("type") == SPAN_TYPE_GOVERNANCE
+ assert attrs.get("span_type") == SPAN_TYPE_GOVERNANCE
+ assert attrs.get("uipath.source") == UIPATH_SOURCE
+
+
+# ---------------------------------------------------------------------------
+# Rule span — enforce-mode DENY is the only Status.ERROR case
+# ---------------------------------------------------------------------------
+
+
+def test_enforce_mode_deny_is_error(captured_span: MagicMock) -> None:
+ """Enforce mode + action=deny = real block → verbosityLevel=4 + Status.ERROR."""
+ sink = TracesAuditSink()
+ sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
+
+ attrs = _span_attrs(captured_span)
+ assert attrs.get("verbosityLevel") == 4
+ assert attrs.get("uipath_governance.evaluator_result") == "DENY"
+ assert attrs.get("uipath_governance.action_applied") == "DENY"
+ assert attrs.get("uipath_governance.mode") == "ENFORCE"
+
+ assert captured_span.set_status.called, (
+ "Status.ERROR must fire for enforce-mode deny violation"
+ )
+ (status_arg,) = captured_span.set_status.call_args.args
+ from opentelemetry.trace import Status, StatusCode
+
+ assert isinstance(status_arg, Status)
+ assert status_arg.status_code is StatusCode.ERROR
+ description = status_arg.description or ""
+ assert "commitment-language" in description
+ assert "deny" in description
+
+
+def test_enforce_mode_escalate_is_hitl_warning(captured_span: MagicMock) -> None:
+ """Enforce mode + action=escalate = HITL pause, not a block.
+
+ HITL is its own spec bucket distinct from DENY — escalation pauses
+ for human review, the run isn't failed. So verbosityLevel stays at
+ Warning and Status is not marked ERROR.
+ """
+ sink = TracesAuditSink()
+ sink.emit(
+ _rule_event(matched=True, action="escalate", mode=EnforcementMode.ENFORCE)
+ )
+
+ attrs = _span_attrs(captured_span)
+ assert attrs.get("verbosityLevel") == 3
+ assert attrs.get("uipath_governance.evaluator_result") == "HITL"
+ assert attrs.get("uipath_governance.action_applied") == "HITL"
+ assert attrs.get("uipath_governance.mode") == "ENFORCE"
+ assert not captured_span.set_status.called
+
+
+# ---------------------------------------------------------------------------
+# Rule span — advisory violations (audit mode, or audit-action rules)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "action,expected_evaluator",
+ [("deny", "DENY"), ("audit", "DENY"), ("escalate", "HITL")],
+)
+def test_audit_mode_violation_is_warning(
+ captured_span: MagicMock, action: str, expected_evaluator: str
+) -> None:
+ """Audit mode never blocks → action_applied=AUDIT, verbosityLevel=3.
+
+ Surfacing Status.ERROR for an audit-mode violation would falsely
+ mark the agent's run as failed when the runtime intentionally
+ let it through. evaluator_result still records the rule's actual
+ decision (DENY/HITL), independent of mode.
+ """
+ sink = TracesAuditSink()
+ sink.emit(_rule_event(matched=True, action=action, mode=EnforcementMode.AUDIT))
+
+ attrs = _span_attrs(captured_span)
+ assert attrs.get("verbosityLevel") == 3
+ assert attrs.get("uipath_governance.evaluator_result") == expected_evaluator
+ assert attrs.get("uipath_governance.action_applied") == "AUDIT"
+ assert attrs.get("uipath_governance.mode") == "AUDIT"
+
+ assert not captured_span.set_status.called, (
+ f"Audit-mode {action} violation must NOT set Status.ERROR"
+ )
+
+
+def test_enforce_mode_audit_action_is_warning(captured_span: MagicMock) -> None:
+ """Enforce mode + action=audit is a per-rule audit override.
+
+ The rule's configured ``audit`` action means "log this match but
+ don't block" even when the global mode is ENFORCE. evaluator_result
+ is DENY (the rule decided to deny), but action_applied is AUDIT
+ (the per-rule override kicks in), so verbosity stays Warning.
+ """
+ sink = TracesAuditSink()
+ sink.emit(_rule_event(matched=True, action="audit", mode=EnforcementMode.ENFORCE))
+
+ attrs = _span_attrs(captured_span)
+ assert attrs.get("verbosityLevel") == 3
+ assert attrs.get("uipath_governance.evaluator_result") == "DENY"
+ assert attrs.get("uipath_governance.action_applied") == "AUDIT"
+ assert attrs.get("uipath_governance.mode") == "ENFORCE"
+ assert not captured_span.set_status.called
+
+
+# ---------------------------------------------------------------------------
+# Rule span — no violation, no verbosityLevel attribute (Orchestrator default = 2)
+# ---------------------------------------------------------------------------
+
+
+def test_unmatched_rule_no_verbosity_no_error(captured_span: MagicMock) -> None:
+ """Unmatched evaluations → evaluator_result=ALLOW, action_applied=NONE, quiet."""
+ sink = TracesAuditSink()
+ sink.emit(_rule_event(matched=False, action="deny", mode=EnforcementMode.ENFORCE))
+
+ attrs = _span_attrs(captured_span)
+ assert "verbosityLevel" not in attrs
+ assert attrs.get("uipath_governance.evaluator_result") == "ALLOW"
+ assert attrs.get("uipath_governance.action_applied") == "NONE"
+ assert not captured_span.set_status.called
+
+
+def test_matched_allow_action_no_verbosity(captured_span: MagicMock) -> None:
+ """A rule whose action is 'allow' is an explicit non-violation."""
+ sink = TracesAuditSink()
+ sink.emit(_rule_event(matched=True, action="allow", mode=EnforcementMode.ENFORCE))
+
+ attrs = _span_attrs(captured_span)
+ assert "verbosityLevel" not in attrs
+ assert attrs.get("uipath_governance.evaluator_result") == "ALLOW"
+ assert attrs.get("uipath_governance.action_applied") == "NONE"
+ assert not captured_span.set_status.called
+
+
+# ---------------------------------------------------------------------------
+# Cross-runtime isolation — the architectural motivation for the refactor
+# ---------------------------------------------------------------------------
+
+
+def test_two_events_carry_independent_modes(captured_span: MagicMock) -> None:
+ """Parallel runtimes (different modes) cannot cross-contaminate the sink.
+
+ Mode travels on each event (set by the emitter from its own
+ per-instance ``EnforcementMode``), so two consecutive emits with
+ different modes each render their own correct
+ ``uipath_governance.mode`` value — no shared state in the sink
+ that one runtime could clobber for another.
+ """
+ sink = TracesAuditSink()
+
+ sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
+ sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.AUDIT))
+
+ # Collect every set_attribute call ordered by emit.
+ calls = [c.args for c in captured_span.set_attribute.call_args_list]
+ modes = [v for k, v in calls if k == "uipath_governance.mode"]
+ actions_applied = [v for k, v in calls if k == "uipath_governance.action_applied"]
+ assert modes == ["ENFORCE", "AUDIT"]
+ assert actions_applied == ["DENY", "AUDIT"]
+
+
+# ---------------------------------------------------------------------------
+# _get_tracer — deferred init + ImportError fallback
+# ---------------------------------------------------------------------------
+
+
+def test_get_tracer_initializes_lazily_and_caches() -> None:
+ """``_get_tracer`` should populate ``self._tracer`` on first call and
+ return the same tracer on subsequent calls. This exercises the
+ real (non-monkeypatched) path — most other tests stub the method.
+ """
+ sink = TracesAuditSink()
+ # Before first call — deferred, no tracer yet.
+ assert sink._tracer is None
+
+ tracer_1 = sink._get_tracer()
+ assert tracer_1 is not None # OTel is installed; a tracer is returned
+
+ tracer_2 = sink._get_tracer()
+ # Second call returns the cached tracer — no re-init log flood.
+ assert tracer_2 is tracer_1
+
+
+def test_get_tracer_returns_none_when_opentelemetry_missing(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """If a downstream host strips OpenTelemetry, ``_get_tracer`` logs a
+ warning once and caches ``False`` so subsequent calls short-circuit
+ instead of retrying the import every event.
+ """
+ import builtins
+
+ real_import = builtins.__import__
+
+ def _blocked_import(name: str, *args, **kwargs):
+ if name == "opentelemetry" or name.startswith("opentelemetry."):
+ raise ImportError(f"simulated missing: {name}")
+ return real_import(name, *args, **kwargs)
+
+ monkeypatch.setattr(builtins, "__import__", _blocked_import)
+
+ sink = TracesAuditSink()
+
+ assert sink._get_tracer() is None
+ # Cached — no retry.
+ assert sink._tracer is False
+ assert sink._get_tracer() is None
+
+
+def test_emit_is_a_noop_when_tracer_unavailable(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """When ``_get_tracer`` returns None, ``emit`` short-circuits inside
+ ``_emit_rule_span`` without touching the tracer. Regression guard
+ for the early-return branch.
+ """
+ sink = TracesAuditSink()
+ monkeypatch.setattr(sink, "_get_tracer", lambda: None)
+
+ # Rule events must not raise, must not create spans; HOOK_END is dropped.
+ sink.emit(_hook_event(final_action="deny", mode=EnforcementMode.ENFORCE))
+ sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
+ assert sink.spans_created == 0
+
+
+# ---------------------------------------------------------------------------
+# spans_created — internal counter
+# ---------------------------------------------------------------------------
+
+
+def test_spans_created_increments_per_successful_emit(
+ captured_span: MagicMock,
+) -> None:
+ """The ``spans_created`` counter must reflect every successful span
+ creation. Consumers use this for smoke-test assertions in
+ integration tests.
+ """
+ sink = TracesAuditSink()
+
+ assert sink.spans_created == 0
+ sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
+ assert sink.spans_created == 1
+ # A second rule increments; HOOK_END events are dropped and never do.
+ sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
+ assert sink.spans_created == 2
+ sink.emit(_hook_event(final_action="deny", mode=EnforcementMode.ENFORCE))
+ assert sink.spans_created == 2
+
+
+# ---------------------------------------------------------------------------
+# _resolve_mode — enforcement-mode field parsing
+# ---------------------------------------------------------------------------
+
+
+def test_resolve_mode_reads_string_field(captured_span: MagicMock) -> None:
+ """Emitters that forward the raw enum value as a lowercase string
+ (rather than the ``EnforcementMode`` object) must still resolve to
+ the correct mode. This exercises the ``isinstance(mode, str)``
+ branch of ``_resolve_mode``.
+ """
+ sink = TracesAuditSink()
+
+ ev = AuditEvent(
+ event_type=EventType.RULE_EVALUATION,
+ agent_name="agent",
+ hook="after_model",
+ data={
+ "policy_id": "A.1.1",
+ "matched": True,
+ "action": "deny",
+ "enforcement_mode": "enforce", # string, not EnforcementMode
+ },
+ )
+ sink.emit(ev)
+
+ modes = [
+ c.args[1]
+ for c in captured_span.set_attribute.call_args_list
+ if c.args[0] == "uipath_governance.mode"
+ ]
+ assert modes == ["ENFORCE"]
+
+
+def test_resolve_mode_falls_back_to_audit_on_unknown_string(
+ captured_span: MagicMock,
+) -> None:
+ """An unparseable mode string (contract violation) must not crash
+ the sink — it falls back to AUDIT so a bad emitter can't kill
+ governance telemetry.
+ """
+ sink = TracesAuditSink()
+
+ ev = AuditEvent(
+ event_type=EventType.RULE_EVALUATION,
+ agent_name="agent",
+ hook="after_model",
+ data={
+ "policy_id": "A.1.1",
+ "matched": True,
+ "action": "deny",
+ "enforcement_mode": "not-a-real-mode",
+ },
+ )
+ sink.emit(ev)
+
+ modes = [
+ c.args[1]
+ for c in captured_span.set_attribute.call_args_list
+ if c.args[0] == "uipath_governance.mode"
+ ]
+ assert modes == ["AUDIT"] # safe default
+
+
+# ---------------------------------------------------------------------------
+# _package_version — install-metadata lookup + fallback
+# ---------------------------------------------------------------------------
+
+
+def test_package_version_returns_unknown_when_package_not_installed(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The version helper must degrade gracefully when the
+ ``uipath-runtime`` package metadata isn't findable (e.g., when
+ running from a source checkout without an install).
+ """
+ import importlib.metadata
+
+ from uipath.runtime.governance._audit import traces
+
+ def _raise_not_found(name: str) -> str:
+ raise importlib.metadata.PackageNotFoundError(name)
+
+ monkeypatch.setattr(importlib.metadata, "version", _raise_not_found)
+
+ assert traces._package_version() == "unknown"
+
+
+# ---------------------------------------------------------------------------
+# Exception isolation — sink swallows internal errors, per contract
+# ---------------------------------------------------------------------------
+
+
+def test_rule_span_exception_is_swallowed(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A sink emit must never propagate exceptions to the caller — the
+ audit manager's circuit breaker tracks failures, but the sink
+ itself catches so a broken tracer can't crash the agent hook.
+ """
+ sink = TracesAuditSink()
+
+ # Tracer whose start_as_current_span raises inside the with-block.
+ tracer = MagicMock()
+ tracer.start_as_current_span.side_effect = RuntimeError("tracer boom")
+ monkeypatch.setattr(sink, "_get_tracer", lambda: tracer)
+
+ # Must not raise.
+ sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
+
+ # And no span was successfully created.
+ assert sink.spans_created == 0
diff --git a/tests/test_track_events_sink.py b/tests/test_track_events_sink.py
new file mode 100644
index 00000000..ee38f311
--- /dev/null
+++ b/tests/test_track_events_sink.py
@@ -0,0 +1,416 @@
+"""Tests for :class:`TrackEventAuditSink`.
+
+Verifies the volume-control filter (``accepts``), event-name vocabulary,
+payload shape (runtime metadata + per-event fields), and OTel-span →
+``operation_id`` correlation (sink resolves the correlation id from
+the live OTel context at emit time, not from the event itself). Uses
+a capture-callable in place of the host-supplied ``track_event`` so
+no I/O fires.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+
+import pytest
+from uipath.core.governance import EnforcementMode
+
+from uipath.runtime.governance._audit.base import AuditEvent, EventType
+from uipath.runtime.governance._audit.metadata import GovernanceRuntimeMetadata
+from uipath.runtime.governance._audit.track_events import (
+ EVENT_HOOK_SUMMARY,
+ EVENT_RULE_DENIED,
+ TrackEventAuditSink,
+)
+
+
+class _Capture:
+ """Stand-in for ``provider.track_event`` — records every call."""
+
+ def __init__(self) -> None:
+ self.calls: list[dict[str, Any]] = []
+
+ def __call__(
+ self,
+ *,
+ event_name: str,
+ data: dict[str, Any] | None = None,
+ operation_id: str | None = None,
+ ) -> None:
+ self.calls.append(
+ {"event_name": event_name, "data": data, "operation_id": operation_id}
+ )
+
+
+@pytest.fixture
+def capture() -> _Capture:
+ return _Capture()
+
+
+@pytest.fixture
+def metadata() -> GovernanceRuntimeMetadata:
+ return GovernanceRuntimeMetadata(
+ execution_engine="uipath_native_governance_checker",
+ agent_type="uipath_coded",
+ agent_framework="langchain",
+ runtime_version="9.9.9-test",
+ )
+
+
+@pytest.fixture
+def sink(capture: _Capture, metadata: GovernanceRuntimeMetadata) -> TrackEventAuditSink:
+ return TrackEventAuditSink(capture, metadata)
+
+
+def _rule_event(
+ *,
+ matched: bool,
+ action: str = "deny",
+ mode: EnforcementMode = EnforcementMode.AUDIT,
+ duration_ms: float = 12.5,
+ mapped_to_uipath: bool = False,
+) -> AuditEvent:
+ return AuditEvent(
+ event_type=EventType.RULE_EVALUATION,
+ agent_name="agent-x",
+ hook="after_model",
+ data={
+ "policy_id": "A.6.1.4",
+ "rule_name": "commitment-language",
+ "pack_name": "iso42001",
+ "matched": matched,
+ "action": action,
+ "enforcement_mode": mode,
+ "detail": "Detected a commitment phrase.",
+ "duration_ms": duration_ms,
+ "mapped_to_uipath": mapped_to_uipath,
+ },
+ timestamp=datetime(2026, 6, 25, 12, 0, 0, tzinfo=timezone.utc),
+ )
+
+
+def _hook_event(
+ *,
+ mode: EnforcementMode = EnforcementMode.AUDIT,
+ duration_ms: float = 45.0,
+ passed_count: int = 4,
+ denied_count: int = 1,
+ skipped_policy_names: list[str] | None = None,
+ guardrail_dispatched_count: int = 0,
+) -> AuditEvent:
+ return AuditEvent(
+ event_type=EventType.HOOK_END,
+ agent_name="agent-x",
+ hook="after_model",
+ data={
+ "total_rules": passed_count + denied_count,
+ "matched_rules": denied_count,
+ "final_action": "audit",
+ "enforcement_mode": mode,
+ "duration_ms": duration_ms,
+ "passed_count": passed_count,
+ "denied_count": denied_count,
+ "skipped_count": len(skipped_policy_names or []),
+ "skipped_policy_names": list(skipped_policy_names or []),
+ "guardrail_dispatched_count": guardrail_dispatched_count,
+ },
+ timestamp=datetime(2026, 6, 25, 12, 0, 0, tzinfo=timezone.utc),
+ )
+
+
+# ---------------------------------------------------------------------------
+# accepts() — volume-control filter
+# ---------------------------------------------------------------------------
+
+
+def test_accepts_matched_rule_evaluation(sink: TrackEventAuditSink) -> None:
+ assert sink.accepts(_rule_event(matched=True)) is True
+
+
+def test_rejects_unmatched_rule_evaluation(sink: TrackEventAuditSink) -> None:
+ assert sink.accepts(_rule_event(matched=False)) is False
+
+
+def test_rejects_matched_allow_rule(sink: TrackEventAuditSink) -> None:
+ """A matched rule with action=allow is a positive informational
+ match — it should NOT trigger the ``rule.denied`` stream. It rolls
+ into the hook summary's ``passed_count`` instead.
+ """
+ assert sink.accepts(_rule_event(matched=True, action="allow")) is False
+
+
+def test_direct_emit_of_unmatched_rule_is_noop(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ """Defense-in-depth: even if a caller bypasses ``accepts``, passed
+ rules must not be routed to ``governance.rule.denied``.
+
+ The AuditManager dispatch path always consults ``accepts`` first,
+ but a direct ``sink.emit(...)`` call (tests, future alternate
+ dispatch) must still produce zero ``track_event`` calls for a
+ ``matched=False`` rule.
+ """
+ sink.emit(_rule_event(matched=False))
+ assert capture.calls == []
+
+
+def test_direct_emit_of_matched_allow_rule_is_noop(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ """Defense-in-depth equivalent of the matched-but-allow filter."""
+ sink.emit(_rule_event(matched=True, action="allow"))
+ assert capture.calls == []
+
+
+# ---------------------------------------------------------------------------
+# DISABLED mode — zero telemetry across every event type
+# ---------------------------------------------------------------------------
+
+
+def test_rejects_disabled_mode_rule_evaluation(sink: TrackEventAuditSink) -> None:
+ """Governance off → ``accepts`` returns False for rule events."""
+ assert (
+ sink.accepts(_rule_event(matched=True, mode=EnforcementMode.DISABLED)) is False
+ )
+
+
+def test_rejects_disabled_mode_hook_summary(sink: TrackEventAuditSink) -> None:
+ """Governance off → ``accepts`` returns False for hook summaries."""
+ assert sink.accepts(_hook_event(mode=EnforcementMode.DISABLED)) is False
+
+
+def test_direct_emit_of_disabled_rule_is_noop(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ """Defense-in-depth: ``emit`` drops DISABLED-mode events too."""
+ sink.emit(_rule_event(matched=True, mode=EnforcementMode.DISABLED))
+ assert capture.calls == []
+
+
+def test_direct_emit_of_disabled_hook_is_noop(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ """Defense-in-depth for hook summary in DISABLED mode."""
+ sink.emit(_hook_event(mode=EnforcementMode.DISABLED))
+ assert capture.calls == []
+
+
+# ---------------------------------------------------------------------------
+# Empty hook summary suppression
+# ---------------------------------------------------------------------------
+
+
+def test_rejects_empty_hook_summary(sink: TrackEventAuditSink) -> None:
+ """``total_rules=0`` AND ``skipped_count=0`` → nothing to report."""
+ ev = _hook_event(passed_count=0, denied_count=0, skipped_policy_names=[])
+ assert sink.accepts(ev) is False
+
+
+def test_accepts_hook_summary_with_only_skipped(sink: TrackEventAuditSink) -> None:
+ """Hook with ``total=0`` but ``skipped_count>0`` is still operator-useful."""
+ ev = _hook_event(
+ passed_count=0,
+ denied_count=0,
+ skipped_policy_names=["A.1.1", "A.1.2"],
+ )
+ assert sink.accepts(ev) is True
+
+
+def test_direct_emit_of_empty_hook_is_noop(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ """Defense-in-depth equivalent of the empty-hook filter."""
+ sink.emit(_hook_event(passed_count=0, denied_count=0, skipped_policy_names=[]))
+ assert capture.calls == []
+
+
+def test_accepts_hook_end(sink: TrackEventAuditSink) -> None:
+ assert sink.accepts(_hook_event()) is True
+
+
+def test_rejects_other_event_types(sink: TrackEventAuditSink) -> None:
+ for et in (
+ EventType.HOOK_START,
+ EventType.SESSION_START,
+ EventType.SESSION_END,
+ EventType.POLICY_VIOLATION,
+ EventType.POLICY_ALLOW,
+ EventType.PACKS_LOADED,
+ ):
+ ev = AuditEvent(event_type=et, agent_name="agent-x")
+ assert sink.accepts(ev) is False, f"sink must drop {et}"
+
+
+# ---------------------------------------------------------------------------
+# emit() — rule-denied event shape
+# ---------------------------------------------------------------------------
+
+
+def test_rule_denied_event_name_and_operation_id(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ """``operation_id`` is the live OTel span's trace id rendered as 32-hex.
+
+ The sink reads from :func:`opentelemetry.trace.get_current_span` at
+ emit time — the audit manager dispatches synchronously on the
+ caller's thread, so any span open on the calling thread is visible
+ to the sink directly.
+ """
+ from opentelemetry.sdk.trace import TracerProvider
+
+ tracer = TracerProvider().get_tracer("test")
+ with tracer.start_as_current_span("agent-run") as span:
+ expected = f"{span.get_span_context().trace_id:032x}"
+ sink.emit(_rule_event(matched=True))
+
+ assert len(capture.calls) == 1
+ call = capture.calls[0]
+ assert call["event_name"] == EVENT_RULE_DENIED
+ assert call["operation_id"] == expected
+
+
+def test_rule_denied_payload_carries_metadata(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ sink.emit(_rule_event(matched=True))
+ data = capture.calls[0]["data"]
+ assert data["execution_engine"] == "uipath_native_governance_checker"
+ assert data["agent_type"] == "uipath_coded"
+ assert data["agent_framework"] == "langchain"
+ assert data["runtime_version"] == "9.9.9-test"
+ assert data["agent_name"] == "agent-x"
+ assert data["hook"] == "after_model"
+
+
+def test_rule_denied_payload_splits_pack_and_clause(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ """``pack`` and ``clause`` are separate fields so consumers can aggregate."""
+ sink.emit(_rule_event(matched=True))
+ data = capture.calls[0]["data"]
+ assert data["pack"] == "iso42001"
+ assert data["clause"] == "A.6.1.4"
+ assert data["rule_name"] == "commitment-language"
+
+
+def test_rule_denied_audit_mode_collapses_to_audit(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ """AUDIT mode never escalates DENY past AUDIT, but evaluator_result is true."""
+ sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.AUDIT))
+ data = capture.calls[0]["data"]
+ assert data["mode"] == "AUDIT"
+ assert data["evaluator_result"] == "DENY"
+ assert data["action_applied"] == "AUDIT"
+
+
+def test_rule_denied_enforce_mode_deny(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
+ data = capture.calls[0]["data"]
+ assert data["mode"] == "ENFORCE"
+ assert data["evaluator_result"] == "DENY"
+ assert data["action_applied"] == "DENY"
+
+
+def test_rule_denied_enforce_mode_escalate_is_hitl(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ sink.emit(
+ _rule_event(matched=True, action="escalate", mode=EnforcementMode.ENFORCE)
+ )
+ data = capture.calls[0]["data"]
+ assert data["evaluator_result"] == "HITL"
+ assert data["action_applied"] == "HITL"
+
+
+def test_rule_denied_carries_duration(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ sink.emit(_rule_event(matched=True, duration_ms=42.5))
+ assert capture.calls[0]["data"]["duration_ms"] == 42.5
+
+
+def test_rule_denied_mapped_to_uipath_passes_through(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ """Field defaults False for native events but the schema is stable."""
+ sink.emit(_rule_event(matched=True, mapped_to_uipath=False))
+ assert capture.calls[0]["data"]["mapped_to_uipath"] is False
+
+
+# ---------------------------------------------------------------------------
+# emit() — hook-summary event shape
+# ---------------------------------------------------------------------------
+
+
+def test_hook_summary_event_name_and_operation_id(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ """Same OTel-derived operation_id contract as the rule-denied event."""
+ from opentelemetry.sdk.trace import TracerProvider
+
+ tracer = TracerProvider().get_tracer("test")
+ with tracer.start_as_current_span("agent-run") as span:
+ expected = f"{span.get_span_context().trace_id:032x}"
+ sink.emit(_hook_event())
+
+ assert len(capture.calls) == 1
+ assert capture.calls[0]["event_name"] == EVENT_HOOK_SUMMARY
+ assert capture.calls[0]["operation_id"] == expected
+
+
+def test_hook_summary_carries_counts(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ sink.emit(
+ _hook_event(
+ passed_count=10,
+ denied_count=2,
+ skipped_policy_names=["A.1.2", "A.3.4"],
+ guardrail_dispatched_count=3,
+ )
+ )
+ data = capture.calls[0]["data"]
+ assert data["passed_count"] == 10
+ assert data["denied_count"] == 2
+ assert data["skipped_count"] == 2
+ assert data["skipped_policy_names"] == ["A.1.2", "A.3.4"]
+ assert data["guardrail_dispatched_count"] == 3
+
+
+def test_hook_summary_duration_ms(sink: TrackEventAuditSink, capture: _Capture) -> None:
+ sink.emit(_hook_event(duration_ms=123.4))
+ assert capture.calls[0]["data"]["duration_ms"] == 123.4
+
+
+def test_hook_summary_carries_metadata(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ """Same per-runtime metadata stamping as rule-denied events."""
+ sink.emit(_hook_event())
+ data = capture.calls[0]["data"]
+ assert data["execution_engine"] == "uipath_native_governance_checker"
+ assert data["agent_type"] == "uipath_coded"
+ assert data["agent_framework"] == "langchain"
+ assert data["runtime_version"] == "9.9.9-test"
+
+
+def test_hook_summary_mode_string(sink: TrackEventAuditSink, capture: _Capture) -> None:
+ sink.emit(_hook_event(mode=EnforcementMode.ENFORCE))
+ assert capture.calls[0]["data"]["mode"] == "ENFORCE"
+
+
+# ---------------------------------------------------------------------------
+# operation_id fallback when no OTel span is active
+# ---------------------------------------------------------------------------
+
+
+def test_operation_id_none_when_no_active_span(
+ sink: TrackEventAuditSink, capture: _Capture
+) -> None:
+ """No live OTel span → ``operation_id=None`` (consumer fills in its own)."""
+ sink.emit(_rule_event(matched=True))
+ assert capture.calls[0]["operation_id"] is None
diff --git a/tests/workspace/test_conversational_workspace.py b/tests/workspace/test_conversational_workspace.py
new file mode 100644
index 00000000..46cafd1f
--- /dev/null
+++ b/tests/workspace/test_conversational_workspace.py
@@ -0,0 +1,555 @@
+from __future__ import annotations
+
+import uuid
+from pathlib import Path
+from typing import Any, AsyncGenerator
+from unittest.mock import AsyncMock
+
+import pytest
+from uipath.core.chat import UiPathConversationMessageEvent
+
+from tests.workspace.test_workspace_hydration import FakeAttachments, MemoryStorage
+from uipath.runtime import (
+ ConversationalWorkspaceRuntime,
+ HydrationRuntime,
+ UiPathExecuteOptions,
+ UiPathRuntimeResult,
+ UiPathRuntimeStatus,
+ UiPathStreamOptions,
+ Workspace,
+ WorkspaceHydrator,
+ WorkspaceRegistryStore,
+)
+from uipath.runtime.events import (
+ UiPathRuntimeConversationMetaEvent,
+ UiPathRuntimeEvent,
+ UiPathRuntimeMessageEvent,
+)
+from uipath.runtime.schema import UiPathRuntimeSchema
+from uipath.runtime.workspace.conversational import (
+ CONVERSATION_META_EVENTS_INPUT_KEY,
+ WORKSPACE_FILES_META_KEY,
+)
+
+
+class ScriptedRuntime:
+ def __init__(
+ self,
+ events: list[UiPathRuntimeEvent],
+ result: UiPathRuntimeResult,
+ workspace_path: Path | None = None,
+ files: dict[str, str] | None = None,
+ ) -> None:
+ self.events = events
+ self.result = result
+ self.workspace_path = workspace_path
+ self.files = files or {}
+ self.disposed = False
+ self.received_input: dict[str, Any] | None = None
+ self.received_options: UiPathStreamOptions | None = None
+
+ async def execute(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathExecuteOptions | None = None,
+ ) -> UiPathRuntimeResult:
+ raise NotImplementedError
+
+ async def stream(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathStreamOptions | None = None,
+ ) -> AsyncGenerator[UiPathRuntimeEvent, None]:
+ self.received_input = input
+ self.received_options = options
+ if self.workspace_path is not None:
+ for virtual_path, content in self.files.items():
+ target = self.workspace_path / virtual_path
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(content, encoding="utf-8")
+ for event in self.events:
+ yield event
+ yield self.result
+
+ async def get_schema(self) -> UiPathRuntimeSchema:
+ raise NotImplementedError
+
+ async def dispose(self) -> None:
+ self.disposed = True
+
+
+def make_runtime(
+ tmp_path: Path,
+ delegate: ScriptedRuntime,
+ attachments: FakeAttachments,
+ registry_store: WorkspaceRegistryStore | None = None,
+) -> tuple[ConversationalWorkspaceRuntime, Workspace]:
+ workspace = Workspace.create(tmp_path / "workspace")
+ return (
+ ConversationalWorkspaceRuntime(
+ delegate,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ ),
+ registry_store=registry_store,
+ ),
+ workspace,
+ )
+
+
+def workspace_meta_event(
+ files: list[tuple[str, uuid.UUID]],
+ *,
+ top_level: bool = False,
+) -> dict[str, Any]:
+ meta_event = {
+ WORKSPACE_FILES_META_KEY: [
+ {"path": path, "attachmentKey": str(attachment_key)}
+ for path, attachment_key in files
+ ]
+ }
+ event: dict[str, Any] = {"conversationId": "conversation-1"}
+ if top_level:
+ event["metaEvent"] = meta_event
+ else:
+ event["exchange"] = {
+ "exchangeId": "exchange-1",
+ "metaEvent": meta_event,
+ }
+ return event
+
+
+def meta_events(
+ events: list[UiPathRuntimeEvent],
+) -> list[UiPathRuntimeConversationMetaEvent]:
+ return [
+ event
+ for event in events
+ if isinstance(event, UiPathRuntimeConversationMetaEvent)
+ ]
+
+
+@pytest.mark.asyncio
+async def test_success_emits_sorted_workspace_snapshot_before_result(
+ tmp_path: Path,
+) -> None:
+ attachments = FakeAttachments()
+ delegate_message = UiPathRuntimeMessageEvent(
+ payload=UiPathConversationMessageEvent(message_id="message-1")
+ )
+ delegate = ScriptedRuntime(
+ events=[delegate_message],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL),
+ workspace_path=tmp_path / "workspace",
+ files={"z.txt": "z", "nested/a.txt": "a"},
+ )
+ runtime, _ = make_runtime(tmp_path, delegate, attachments)
+
+ events = [event async for event in runtime.stream({"messages": []})]
+
+ assert events[0] is delegate_message
+ assert isinstance(events[-2], UiPathRuntimeConversationMetaEvent)
+ assert isinstance(events[-1], UiPathRuntimeResult)
+ assert [file["path"] for file in events[-2].payload[WORKSPACE_FILES_META_KEY]] == [
+ "nested/a.txt",
+ "z.txt",
+ ]
+ assert attachments.uploads == 2
+
+
+@pytest.mark.asyncio
+async def test_empty_workspace_emits_authoritative_snapshot(tmp_path: Path) -> None:
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL),
+ )
+ runtime, _ = make_runtime(tmp_path, delegate, FakeAttachments())
+
+ events = [event async for event in runtime.stream({"messages": []})]
+
+ assert meta_events(events)[0].payload == {WORKSPACE_FILES_META_KEY: []}
+
+
+@pytest.mark.asyncio
+async def test_empty_snapshot_removes_stale_workspace_files(tmp_path: Path) -> None:
+ attachments = FakeAttachments()
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL),
+ )
+ runtime, workspace = make_runtime(tmp_path, delegate, attachments)
+ stale_file = workspace.path / "stale.txt"
+ stale_file.write_text("stale", encoding="utf-8")
+
+ events = [
+ event
+ async for event in runtime.stream(
+ {
+ CONVERSATION_META_EVENTS_INPUT_KEY: [workspace_meta_event([])],
+ }
+ )
+ ]
+
+ assert not stale_file.exists()
+ assert meta_events(events)[0].payload == {WORKSPACE_FILES_META_KEY: []}
+ assert attachments.uploads == 0
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "workspace_files",
+ [
+ "invalid",
+ [{"path": "restored.txt"}],
+ [
+ {"path": "restored.txt", "attachmentKey": str(uuid.uuid4())},
+ {"attachmentKey": str(uuid.uuid4())},
+ ],
+ ],
+)
+async def test_malformed_snapshot_does_not_remove_workspace_files(
+ tmp_path: Path,
+ workspace_files: object,
+) -> None:
+ attachments = FakeAttachments()
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.FAULTED),
+ )
+ runtime, workspace = make_runtime(tmp_path, delegate, attachments)
+ existing_file = workspace.path / "existing.txt"
+ existing_file.write_text("keep", encoding="utf-8")
+
+ input = {
+ CONVERSATION_META_EVENTS_INPUT_KEY: [
+ {"exchange": {"metaEvent": {WORKSPACE_FILES_META_KEY: workspace_files}}}
+ ]
+ }
+ async for _ in runtime.stream(input):
+ pass
+
+ assert existing_file.read_text(encoding="utf-8") == "keep"
+ assert attachments.downloads == 0
+ assert attachments.uploads == 0
+
+
+@pytest.mark.asyncio
+async def test_reserved_meta_events_are_not_forwarded_to_delegate(
+ tmp_path: Path,
+) -> None:
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL),
+ )
+ runtime, _ = make_runtime(tmp_path, delegate, FakeAttachments())
+ input = {
+ "messages": [],
+ "custom": "value",
+ CONVERSATION_META_EVENTS_INPUT_KEY: [],
+ }
+
+ async for _ in runtime.stream(input):
+ pass
+
+ assert delegate.received_input == {"messages": [], "custom": "value"}
+ assert input[CONVERSATION_META_EVENTS_INPUT_KEY] == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("top_level", [False, True])
+async def test_hydrates_single_workspace_snapshot(
+ tmp_path: Path,
+ top_level: bool,
+) -> None:
+ attachments = FakeAttachments()
+ attachment_key = uuid.uuid4()
+ attachments.files[attachment_key] = ("latest.txt", b"latest")
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL),
+ )
+ runtime, workspace = make_runtime(tmp_path, delegate, attachments)
+ input = {
+ CONVERSATION_META_EVENTS_INPUT_KEY: [
+ workspace_meta_event(
+ [("nested/latest.txt", attachment_key)], top_level=top_level
+ )
+ ]
+ }
+
+ async for _ in runtime.stream(input):
+ pass
+
+ assert (workspace.path / "nested/latest.txt").read_bytes() == b"latest"
+ assert attachments.downloads == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("top_level", [False, True])
+async def test_hydrates_latest_workspace_snapshot(
+ tmp_path: Path,
+ top_level: bool,
+) -> None:
+ attachments = FakeAttachments()
+ old_key = uuid.uuid4()
+ latest_key = uuid.uuid4()
+ attachments.files[old_key] = ("old.txt", b"old")
+ attachments.files[latest_key] = ("latest.txt", b"latest")
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL),
+ )
+ runtime, workspace = make_runtime(tmp_path, delegate, attachments)
+ input = {
+ "messages": [],
+ CONVERSATION_META_EVENTS_INPUT_KEY: [
+ workspace_meta_event([("old.txt", old_key)]),
+ workspace_meta_event(
+ [("nested/latest.txt", latest_key)], top_level=top_level
+ ),
+ ],
+ }
+
+ async for _ in runtime.stream(input):
+ pass
+
+ assert not (workspace.path / "old.txt").exists()
+ assert (workspace.path / "nested/latest.txt").read_bytes() == b"latest"
+ assert attachments.downloads == 1
+
+
+@pytest.mark.asyncio
+async def test_latest_empty_snapshot_does_not_restore_older_files(
+ tmp_path: Path,
+) -> None:
+ attachments = FakeAttachments()
+ old_key = uuid.uuid4()
+ attachments.files[old_key] = ("old.txt", b"old")
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL),
+ )
+ runtime, workspace = make_runtime(tmp_path, delegate, attachments)
+ input = {
+ CONVERSATION_META_EVENTS_INPUT_KEY: [
+ workspace_meta_event([("old.txt", old_key)]),
+ workspace_meta_event([]),
+ ]
+ }
+
+ async for _ in runtime.stream(input):
+ pass
+
+ assert not (workspace.path / "old.txt").exists()
+ assert attachments.downloads == 0
+
+
+@pytest.mark.asyncio
+async def test_persisted_empty_registry_wins_over_conversation_metadata(
+ tmp_path: Path,
+) -> None:
+ attachments = FakeAttachments()
+ attachment_key = uuid.uuid4()
+ attachments.files[attachment_key] = ("old.txt", b"old")
+ store = WorkspaceRegistryStore(MemoryStorage(), "runtime-1")
+ await store.save({})
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL),
+ )
+ runtime, workspace = make_runtime(tmp_path, delegate, attachments, store)
+
+ async for _ in runtime.stream(
+ {
+ CONVERSATION_META_EVENTS_INPUT_KEY: [
+ workspace_meta_event([("old.txt", attachment_key)])
+ ]
+ }
+ ):
+ pass
+
+ assert not (workspace.path / "old.txt").exists()
+ assert attachments.downloads == 0
+
+
+@pytest.mark.asyncio
+async def test_hydration_runtime_owns_persisted_registry_hydration(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ attachments = FakeAttachments()
+ store = WorkspaceRegistryStore(MemoryStorage(), "runtime-1")
+ await store.save({})
+ workspace = Workspace.create(tmp_path / "workspace")
+ hydrator = WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ )
+ hydrate_from_registry = AsyncMock(wraps=hydrator.hydrate_from_registry)
+ monkeypatch.setattr(
+ hydrator,
+ "hydrate_from_registry",
+ hydrate_from_registry,
+ )
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL),
+ )
+ hydration_runtime = HydrationRuntime(
+ delegate,
+ workspace=workspace,
+ hydrator=hydrator,
+ registry_store=store,
+ )
+ runtime = ConversationalWorkspaceRuntime(
+ hydration_runtime,
+ hydrator=hydrator,
+ registry_store=store,
+ )
+
+ async for _ in runtime.stream({}):
+ pass
+
+ hydrate_from_registry.assert_awaited_once_with({})
+
+
+@pytest.mark.asyncio
+async def test_faulted_result_does_not_emit_or_upload_snapshot(tmp_path: Path) -> None:
+ attachments = FakeAttachments()
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.FAULTED),
+ workspace_path=tmp_path / "workspace",
+ files={"notes.txt": "hello"},
+ )
+ runtime, _ = make_runtime(tmp_path, delegate, attachments)
+
+ events = [event async for event in runtime.stream({})]
+
+ assert meta_events(events) == []
+ assert attachments.uploads == 0
+ assert isinstance(events[-1], UiPathRuntimeResult)
+
+
+@pytest.mark.asyncio
+async def test_execute_preserves_options(tmp_path: Path) -> None:
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL),
+ )
+ runtime, _ = make_runtime(tmp_path, delegate, FakeAttachments())
+ options = UiPathExecuteOptions(resume=True, breakpoints=["node-1"])
+
+ await runtime.execute({}, options=options)
+
+ assert delegate.received_options is not None
+ assert delegate.received_options.resume is True
+ assert delegate.received_options.breakpoints == ["node-1"]
+
+
+@pytest.mark.asyncio
+async def test_hydration_only_runs_once_across_resume_passes(tmp_path: Path) -> None:
+ attachments = FakeAttachments()
+ attachment_key = uuid.uuid4()
+ attachments.files[attachment_key] = ("todo.md", b"step 1")
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL),
+ )
+ runtime, _ = make_runtime(tmp_path, delegate, attachments)
+ input = {
+ CONVERSATION_META_EVENTS_INPUT_KEY: [
+ workspace_meta_event([("todo.md", attachment_key)])
+ ]
+ }
+
+ async for _ in runtime.stream(input):
+ pass
+ async for _ in runtime.stream({"interrupt-1": {"approved": True}}):
+ pass
+
+ assert attachments.downloads == 1
+
+
+@pytest.mark.asyncio
+async def test_failed_hydration_can_be_retried(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ attachments = FakeAttachments()
+ attachment_key = uuid.uuid4()
+ attachments.files[attachment_key] = ("todo.md", b"step 1")
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL),
+ )
+ runtime, workspace = make_runtime(tmp_path, delegate, attachments)
+ input = {
+ CONVERSATION_META_EVENTS_INPUT_KEY: [
+ workspace_meta_event([("todo.md", attachment_key)])
+ ]
+ }
+ download = attachments.download_async
+
+ async def fail_download(**_: Any) -> str:
+ raise RuntimeError("download failed")
+
+ monkeypatch.setattr(attachments, "download_async", fail_download)
+ with pytest.raises(RuntimeError, match="download failed"):
+ async for _ in runtime.stream(input):
+ pass
+
+ monkeypatch.setattr(attachments, "download_async", download)
+ async for _ in runtime.stream(input):
+ pass
+
+ assert (workspace.path / "todo.md").read_bytes() == b"step 1"
+
+
+@pytest.mark.asyncio
+async def test_attachment_upload_failure_propagates(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ attachments = FakeAttachments()
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL),
+ workspace_path=tmp_path / "workspace",
+ files={"plan.md": "plan"},
+ )
+ runtime, _ = make_runtime(tmp_path, delegate, attachments)
+
+ async def fail_upload(**_: Any) -> uuid.UUID:
+ raise RuntimeError("upload failed")
+
+ monkeypatch.setattr(attachments, "upload_async", fail_upload)
+
+ with pytest.raises(RuntimeError, match="upload failed"):
+ async for _ in runtime.stream({}):
+ pass
+
+
+@pytest.mark.asyncio
+async def test_dispose_does_not_dispose_injected_dependencies(tmp_path: Path) -> None:
+ delegate = ScriptedRuntime(
+ events=[],
+ result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL),
+ )
+ workspace = Workspace.create(tmp_path / "workspace", cleanup=True)
+ runtime = ConversationalWorkspaceRuntime(
+ delegate,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=FakeAttachments(),
+ ),
+ )
+
+ await runtime.dispose()
+
+ assert not delegate.disposed
+ assert workspace.path.exists()
+
+ await delegate.dispose()
+ await workspace.dispose()
diff --git a/tests/workspace/test_workspace_hydration.py b/tests/workspace/test_workspace_hydration.py
new file mode 100644
index 00000000..74e52bbc
--- /dev/null
+++ b/tests/workspace/test_workspace_hydration.py
@@ -0,0 +1,1093 @@
+from __future__ import annotations
+
+import asyncio
+import shutil
+import uuid
+from pathlib import Path
+from typing import Any, AsyncGenerator
+
+import pytest
+from uipath.core.chat import (
+ UiPathConversationMessageEndEvent,
+ UiPathConversationMessageEvent,
+)
+
+from uipath.runtime import (
+ ConversationalWorkspaceRuntime,
+ HydrationPolicy,
+ HydrationRuntime,
+ UiPathExecuteOptions,
+ UiPathRuntimeResult,
+ UiPathRuntimeStatus,
+ UiPathStreamOptions,
+ Workspace,
+ WorkspaceHydrator,
+ WorkspaceRegistryStore,
+ get_workspace_path,
+)
+from uipath.runtime.errors import UiPathErrorCategory, UiPathRuntimeError
+from uipath.runtime.events import (
+ UiPathRuntimeConversationMetaEvent,
+ UiPathRuntimeEvent,
+ UiPathRuntimeMessageEvent,
+ UiPathRuntimeStateEvent,
+)
+from uipath.runtime.schema import UiPathRuntimeSchema
+
+
+class MemoryStorage:
+ def __init__(self) -> None:
+ self.values: dict[tuple[str, str, str], Any] = {}
+
+ async def set_value(
+ self, runtime_id: str, namespace: str, key: str, value: Any
+ ) -> None:
+ self.values[(runtime_id, namespace, key)] = value
+
+ async def get_value(self, runtime_id: str, namespace: str, key: str) -> Any:
+ return self.values.get((runtime_id, namespace, key))
+
+
+class FakeAttachments:
+ def __init__(self) -> None:
+ self.files: dict[uuid.UUID, tuple[str, bytes]] = {}
+ self.uploads = 0
+ self.downloads = 0
+
+ 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.UUID:
+ assert source_path is not None
+ key = uuid.uuid4()
+ self.files[key] = (name, Path(source_path).read_bytes())
+ self.uploads += 1
+ return key
+
+ async def download_async(
+ self,
+ *,
+ key: uuid.UUID,
+ destination_path: str,
+ folder_key: str | None = None,
+ folder_path: str | None = None,
+ ) -> str:
+ name, content = self.files[key]
+ Path(destination_path).write_bytes(content)
+ self.downloads += 1
+ return name
+
+
+class FailingDownloadAttachments(FakeAttachments):
+ async def download_async(
+ self,
+ *,
+ key: uuid.UUID,
+ destination_path: str,
+ folder_key: str | None = None,
+ folder_path: str | None = None,
+ ) -> str:
+ raise RuntimeError("download failed")
+
+
+class FakeJobs:
+ def __init__(self) -> None:
+ self.attachments: dict[str, list[str]] = {}
+ self.links: list[tuple[uuid.UUID, uuid.UUID]] = []
+
+ async def list_attachments_async(
+ self,
+ *,
+ job_key: uuid.UUID,
+ folder_key: str | None = None,
+ folder_path: str | None = None,
+ ) -> list[str]:
+ return self.attachments.get(str(job_key), [])
+
+ async def link_attachment_async(
+ self,
+ *,
+ job_key: uuid.UUID,
+ attachment_key: uuid.UUID,
+ folder_key: str | None = None,
+ folder_path: str | None = None,
+ ) -> None:
+ self.links.append((job_key, attachment_key))
+
+
+@pytest.mark.asyncio
+async def test_registry_store_distinguishes_missing_from_saved_empty() -> None:
+ store = WorkspaceRegistryStore(MemoryStorage(), "runtime-1")
+
+ assert await store.try_load() is None
+
+ await store.save({})
+
+ assert await store.try_load() == {}
+ assert await store.load() == {}
+
+
+class WritingRuntime:
+ def __init__(self, workspace_path: Path, status: UiPathRuntimeStatus) -> None:
+ self.workspace_path = workspace_path
+ self.status = status
+ self.disposed = False
+
+ async def execute(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathExecuteOptions | None = None,
+ ) -> UiPathRuntimeResult:
+ (self.workspace_path / "notes.txt").write_text("hello", encoding="utf-8")
+ return UiPathRuntimeResult(status=self.status, output={"ok": True})
+
+ async def stream(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathStreamOptions | None = None,
+ ) -> AsyncGenerator[UiPathRuntimeEvent, None]:
+ yield UiPathRuntimeStateEvent(payload={"started": True})
+ result = await self.execute(input, options)
+ yield result
+
+ async def get_schema(self) -> UiPathRuntimeSchema:
+ raise NotImplementedError
+
+ async def dispose(self) -> None:
+ self.disposed = True
+
+
+class SchemaRuntime(WritingRuntime):
+ def __init__(self, workspace_path: Path) -> None:
+ super().__init__(workspace_path, UiPathRuntimeStatus.SUCCESSFUL)
+
+ async def get_schema(self) -> UiPathRuntimeSchema:
+ return UiPathRuntimeSchema(
+ filePath="agent.py",
+ uniqueId="agent",
+ type="agent",
+ input={},
+ output={},
+ )
+
+
+class ContextAwareRuntime(WritingRuntime):
+ async def execute(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathExecuteOptions | None = None,
+ ) -> UiPathRuntimeResult:
+ assert get_workspace_path() == self.workspace_path
+ return await super().execute(input, options)
+
+ async def stream(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathStreamOptions | None = None,
+ ) -> AsyncGenerator[UiPathRuntimeEvent, None]:
+ assert get_workspace_path() == self.workspace_path
+ async for event in super().stream(input, options):
+ yield event
+
+
+class ChildTaskRuntime(WritingRuntime):
+ def __init__(self, workspace_path: Path, release: asyncio.Event) -> None:
+ super().__init__(workspace_path, UiPathRuntimeStatus.SUCCESSFUL)
+ self.release = release
+ self.task: asyncio.Task[Path] | None = None
+
+ async def execute(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathExecuteOptions | None = None,
+ ) -> UiPathRuntimeResult:
+ async def access_workspace_after_execution() -> Path:
+ await self.release.wait()
+ return get_workspace_path()
+
+ self.task = asyncio.create_task(access_workspace_after_execution())
+ return await super().execute(input, options)
+
+
+class StreamingChildTaskRuntime(WritingRuntime):
+ def __init__(self, workspace_path: Path, release: asyncio.Event) -> None:
+ super().__init__(workspace_path, UiPathRuntimeStatus.SUCCESSFUL)
+ self.release = release
+ self.task: asyncio.Task[Path] | None = None
+
+ async def stream(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathStreamOptions | None = None,
+ ) -> AsyncGenerator[UiPathRuntimeEvent, None]:
+ async def access_workspace_during_stream() -> Path:
+ await self.release.wait()
+ return get_workspace_path()
+
+ self.task = asyncio.create_task(access_workspace_during_stream())
+ yield UiPathRuntimeStateEvent(payload={"started": True})
+ await self.release.wait()
+ assert self.task is not None
+ assert await self.task == self.workspace_path
+ yield UiPathRuntimeResult(status=self.status, output={"ok": True})
+
+
+class FinalChildTaskRuntime(WritingRuntime):
+ def __init__(self, workspace_path: Path, release: asyncio.Event) -> None:
+ super().__init__(workspace_path, UiPathRuntimeStatus.SUCCESSFUL)
+ self.release = release
+ self.task: asyncio.Task[Path] | None = None
+
+ async def stream(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathStreamOptions | None = None,
+ ) -> AsyncGenerator[UiPathRuntimeEvent, None]:
+ async def access_workspace_after_final_result() -> Path:
+ await self.release.wait()
+ return get_workspace_path()
+
+ self.task = asyncio.create_task(access_workspace_after_final_result())
+ yield UiPathRuntimeStateEvent(payload={"started": True})
+ yield UiPathRuntimeResult(status=self.status, output={"ok": True})
+
+
+class ReadingRuntime(WritingRuntime):
+ async def execute(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathExecuteOptions | None = None,
+ ) -> UiPathRuntimeResult:
+ assert (self.workspace_path / "notes.txt").read_text(
+ encoding="utf-8"
+ ) == "hello"
+ (self.workspace_path / "notes.txt").write_text(
+ "hello after resume", encoding="utf-8"
+ )
+ return UiPathRuntimeResult(status=self.status, output={"ok": True})
+
+ async def stream(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathStreamOptions | None = None,
+ ) -> AsyncGenerator[UiPathRuntimeEvent, None]:
+ message_id = "assistant-1"
+ yield UiPathRuntimeMessageEvent(
+ payload=UiPathConversationMessageEvent(
+ message_id=message_id,
+ end=UiPathConversationMessageEndEvent(),
+ )
+ )
+ yield await self.execute(input, options)
+
+
+@pytest.mark.asyncio
+async def test_dehydrate_uploads_changed_files_and_saves_registry(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ attachments = FakeAttachments()
+ jobs = FakeJobs()
+ current_job = uuid.uuid4()
+ hydrator = WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ jobs=jobs,
+ current_job_key=str(current_job),
+ )
+ storage = MemoryStorage()
+ store = WorkspaceRegistryStore(storage, "runtime-1")
+ runtime = HydrationRuntime(
+ WritingRuntime(workspace.path, UiPathRuntimeStatus.SUSPENDED),
+ workspace=workspace,
+ hydrator=hydrator,
+ registry_store=store,
+ )
+
+ result = await runtime.execute({})
+
+ registry = await store.load()
+ assert result.status == UiPathRuntimeStatus.SUSPENDED
+ assert list(registry) == ["notes.txt"]
+ assert registry["notes.txt"]["attachment_name"] == ".uipath-workspace~1notes.txt"
+ assert attachments.uploads == 1
+ assert len(jobs.links) == 1
+
+
+def test_get_workspace_path_requires_a_managed_execution() -> None:
+ with pytest.raises(UiPathRuntimeError, match="No managed workspace") as error:
+ get_workspace_path()
+
+ assert error.value.error_info.code == "Python.MANAGED_WORKSPACE_UNAVAILABLE"
+ assert "not from module-level initialization" in str(error.value)
+ assert error.value.error_info.category == UiPathErrorCategory.USER
+
+
+@pytest.mark.asyncio
+async def test_workspace_path_is_available_only_during_execution(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ runtime = HydrationRuntime(
+ ContextAwareRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL),
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=FakeAttachments(),
+ ),
+ registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"),
+ )
+
+ await runtime.execute({})
+
+ with pytest.raises(UiPathRuntimeError, match="No managed workspace"):
+ get_workspace_path()
+
+
+@pytest.mark.asyncio
+async def test_workspace_path_is_available_during_streaming(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ runtime = HydrationRuntime(
+ ContextAwareRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL),
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=FakeAttachments(),
+ ),
+ registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"),
+ )
+
+ events = [event async for event in runtime.stream({})]
+
+ assert isinstance(events[-1], UiPathRuntimeResult)
+ with pytest.raises(UiPathRuntimeError, match="No managed workspace"):
+ get_workspace_path()
+
+
+@pytest.mark.asyncio
+async def test_stream_does_not_expose_workspace_to_the_consumer(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ runtime = HydrationRuntime(
+ ContextAwareRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL),
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=FakeAttachments(),
+ ),
+ registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"),
+ )
+
+ stream = runtime.stream({})
+ assert isinstance(await anext(stream), UiPathRuntimeStateEvent)
+ with pytest.raises(UiPathRuntimeError, match="No managed workspace"):
+ get_workspace_path()
+
+ await stream.aclose()
+ with pytest.raises(UiPathRuntimeError, match="No managed workspace"):
+ get_workspace_path()
+
+
+@pytest.mark.asyncio
+async def test_breaking_after_the_final_stream_result_resets_workspace_access(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ runtime = HydrationRuntime(
+ ContextAwareRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL),
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=FakeAttachments(),
+ ),
+ registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"),
+ )
+
+ stream = runtime.stream({})
+ async for event in stream:
+ if isinstance(event, UiPathRuntimeResult):
+ break
+
+ await stream.aclose()
+
+ with pytest.raises(UiPathRuntimeError, match="No managed workspace"):
+ get_workspace_path()
+
+
+@pytest.mark.asyncio
+async def test_stream_supports_task_wrapped_iterator_resumes(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ runtime = HydrationRuntime(
+ ContextAwareRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL),
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=FakeAttachments(),
+ ),
+ registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"),
+ )
+
+ stream = runtime.stream({})
+ first = await asyncio.wait_for(anext(stream), timeout=1)
+ result = await asyncio.wait_for(anext(stream), timeout=1)
+
+ assert isinstance(first, UiPathRuntimeStateEvent)
+ assert isinstance(result, UiPathRuntimeResult)
+ await stream.aclose()
+
+
+@pytest.mark.asyncio
+async def test_stream_child_tasks_keep_workspace_access_between_events(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ release = asyncio.Event()
+ delegate = StreamingChildTaskRuntime(workspace.path, release)
+ runtime = HydrationRuntime(
+ delegate,
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=FakeAttachments(),
+ ),
+ registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"),
+ )
+
+ stream = runtime.stream({})
+ assert isinstance(await anext(stream), UiPathRuntimeStateEvent)
+ release.set()
+ assert isinstance(await anext(stream), UiPathRuntimeResult)
+ await stream.aclose()
+
+
+@pytest.mark.asyncio
+async def test_stream_does_not_retry_failed_final_persistence(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ runtime = HydrationRuntime(
+ WritingRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL),
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=FakeAttachments(),
+ ),
+ registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"),
+ policy=HydrationPolicy.ALWAYS,
+ )
+ persist_calls = 0
+
+ async def fail_persist() -> None:
+ nonlocal persist_calls
+ persist_calls += 1
+ raise RuntimeError("persist failed")
+
+ monkeypatch.setattr(runtime, "_persist", fail_persist)
+
+ with pytest.raises(RuntimeError, match="persist failed"):
+ [event async for event in runtime.stream({})]
+
+ assert persist_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_stream_hydration_failure_preserves_the_workspace_registry(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ storage = MemoryStorage()
+ store = WorkspaceRegistryStore(storage, "runtime-1")
+ registry = {
+ "notes.txt": {
+ "attachment_key": str(uuid.uuid4()),
+ "sha256": "0" * 64,
+ "size": 1,
+ "uploaded_at": "2026-01-01T00:00:00+00:00",
+ }
+ }
+ await store.save(registry)
+ runtime = HydrationRuntime(
+ WritingRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL),
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=FailingDownloadAttachments(),
+ ),
+ registry_store=store,
+ policy=HydrationPolicy.ALWAYS,
+ )
+
+ with pytest.raises(RuntimeError, match="download failed"):
+ [event async for event in runtime.stream({})]
+
+ assert await store.load() == registry
+
+
+@pytest.mark.asyncio
+async def test_stream_revokes_workspace_before_yielding_final_result(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ release = asyncio.Event()
+ delegate = FinalChildTaskRuntime(workspace.path, release)
+ runtime = HydrationRuntime(
+ delegate,
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=FakeAttachments(),
+ ),
+ registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"),
+ )
+
+ stream = runtime.stream({})
+ assert isinstance(await anext(stream), UiPathRuntimeStateEvent)
+ assert isinstance(await anext(stream), UiPathRuntimeResult)
+ release.set()
+
+ assert delegate.task is not None
+ with pytest.raises(UiPathRuntimeError, match="No managed workspace"):
+ await delegate.task
+
+
+@pytest.mark.asyncio
+async def test_child_tasks_cannot_access_workspace_after_execution(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ release = asyncio.Event()
+ delegate = ChildTaskRuntime(workspace.path, release)
+ runtime = HydrationRuntime(
+ delegate,
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=FakeAttachments(),
+ ),
+ registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"),
+ )
+
+ await runtime.execute({})
+ release.set()
+
+ assert delegate.task is not None
+ with pytest.raises(UiPathRuntimeError, match="No managed workspace"):
+ await delegate.task
+
+
+@pytest.mark.asyncio
+async def test_hydrator_factory_is_deferred_and_cached(tmp_path: Path) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ attachments = FakeAttachments()
+ storage = MemoryStorage()
+ factory_calls = 0
+
+ def create_hydrator() -> WorkspaceHydrator:
+ nonlocal factory_calls
+ factory_calls += 1
+ return WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ )
+
+ runtime = HydrationRuntime(
+ SchemaRuntime(workspace.path),
+ workspace=workspace,
+ hydrator_factory=create_hydrator,
+ registry_store=WorkspaceRegistryStore(storage, "runtime-1"),
+ )
+
+ await runtime.get_schema()
+ assert factory_calls == 0
+ assert runtime.hydrator is None
+
+ await runtime.execute({})
+ assert [event async for event in runtime.stream({})]
+
+ assert factory_calls == 1
+ assert runtime.hydrator is not None
+ assert runtime.hydrator.attachments is attachments
+
+
+@pytest.mark.asyncio
+async def test_dispose_does_not_create_hydrator(tmp_path: Path) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ factory_calls = 0
+
+ def create_hydrator() -> WorkspaceHydrator:
+ nonlocal factory_calls
+ factory_calls += 1
+ return WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=FakeAttachments(),
+ )
+
+ delegate = SchemaRuntime(workspace.path)
+ runtime = HydrationRuntime(
+ delegate,
+ workspace=workspace,
+ hydrator_factory=create_hydrator,
+ registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"),
+ )
+
+ await runtime.dispose()
+
+ assert delegate.disposed
+ assert factory_calls == 0
+
+
+def test_hydration_runtime_requires_exactly_one_hydrator_source(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ hydrator = WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=FakeAttachments(),
+ )
+ registry_store = WorkspaceRegistryStore(MemoryStorage(), "runtime-1")
+
+ with pytest.raises(ValueError, match="exactly one"):
+ HydrationRuntime( # type: ignore[call-overload]
+ WritingRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL),
+ workspace=workspace,
+ registry_store=registry_store,
+ )
+
+ with pytest.raises(ValueError, match="exactly one"):
+ HydrationRuntime( # type: ignore[call-overload]
+ WritingRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL),
+ workspace=workspace,
+ hydrator=hydrator,
+ hydrator_factory=lambda: hydrator,
+ registry_store=registry_store,
+ )
+
+
+@pytest.mark.asyncio
+async def test_conversational_resume_reuses_registry_persisted_on_suspend(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ attachments = FakeAttachments()
+ store = WorkspaceRegistryStore(MemoryStorage(), "runtime-1")
+ hydrator = WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ )
+ delegate = WritingRuntime(workspace.path, UiPathRuntimeStatus.SUSPENDED)
+ hydration_runtime = HydrationRuntime(
+ delegate,
+ workspace=workspace,
+ hydrator=hydrator,
+ registry_store=store,
+ )
+ runtime = ConversationalWorkspaceRuntime(
+ hydration_runtime,
+ hydrator=hydrator,
+ registry_store=store,
+ )
+
+ await runtime.execute({})
+ delegate.status = UiPathRuntimeStatus.SUCCESSFUL
+ await runtime.execute({}, options=UiPathExecuteOptions(resume=True))
+
+ assert attachments.uploads == 1
+
+
+@pytest.mark.asyncio
+async def test_successful_completion_persists_when_policy_allows(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ attachments = FakeAttachments()
+ storage = MemoryStorage()
+ runtime = HydrationRuntime(
+ WritingRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL),
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ ),
+ registry_store=WorkspaceRegistryStore(storage, "runtime-1"),
+ policy=HydrationPolicy.SUSPEND_OR_SUCCESS,
+ )
+
+ await runtime.execute({})
+
+ assert attachments.uploads == 1
+ assert "notes.txt" in await runtime.registry_store.load()
+
+
+@pytest.mark.asyncio
+async def test_hydrate_compatibility_api_downloads_registry_files(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ attachments = FakeAttachments()
+ key = uuid.uuid4()
+ attachments.files[key] = (".uipath-workspace/notes.txt", b"from attachment")
+ hydrator = WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ )
+ registry = {
+ "notes.txt": {
+ "attachment_key": str(key),
+ "sha256": "different",
+ "size": 15,
+ "uploaded_at": "2026-01-01T00:00:00+00:00",
+ "attachment_name": ".uipath-workspace/notes.txt",
+ }
+ }
+
+ await hydrator.hydrate(registry)
+
+ assert (workspace.path / "notes.txt").read_text(
+ encoding="utf-8"
+ ) == "from attachment"
+ assert attachments.downloads == 1
+
+
+@pytest.mark.asyncio
+async def test_stream_persists_on_suspend(tmp_path: Path) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ attachments = FakeAttachments()
+ storage = MemoryStorage()
+ runtime = HydrationRuntime(
+ WritingRuntime(workspace.path, UiPathRuntimeStatus.SUSPENDED),
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ ),
+ registry_store=WorkspaceRegistryStore(storage, "runtime-1"),
+ )
+
+ events = [event async for event in runtime.stream({})]
+
+ assert isinstance(events[-1], UiPathRuntimeResult)
+ assert attachments.uploads == 1
+ assert "notes.txt" in await runtime.registry_store.load()
+
+
+@pytest.mark.asyncio
+async def test_conversational_suspend_restores_in_new_workspace(tmp_path: Path) -> None:
+ attachments = FakeAttachments()
+ storage = MemoryStorage()
+ first_workspace = Workspace.create(tmp_path / "first", cleanup=False)
+ first_hydrator = WorkspaceHydrator(
+ workspace_path=first_workspace.path,
+ attachments=attachments,
+ )
+ suspended_runtime = HydrationRuntime(
+ WritingRuntime(first_workspace.path, UiPathRuntimeStatus.SUSPENDED),
+ workspace=first_workspace,
+ hydrator=first_hydrator,
+ registry_store=WorkspaceRegistryStore(storage, "runtime-1"),
+ )
+ conversation_runtime = ConversationalWorkspaceRuntime(
+ suspended_runtime,
+ hydrator=first_hydrator,
+ )
+
+ events = [event async for event in conversation_runtime.stream({"messages": []})]
+
+ assert isinstance(events[-1], UiPathRuntimeResult)
+ assert attachments.uploads == 1
+
+ second_workspace = Workspace.create(tmp_path / "second", cleanup=False)
+ second_hydrator = WorkspaceHydrator(
+ workspace_path=second_workspace.path,
+ attachments=attachments,
+ )
+ second_registry_store = WorkspaceRegistryStore(storage, "runtime-1")
+ resumed_runtime = HydrationRuntime(
+ ReadingRuntime(second_workspace.path, UiPathRuntimeStatus.SUCCESSFUL),
+ workspace=second_workspace,
+ hydrator=second_hydrator,
+ registry_store=second_registry_store,
+ )
+ resumed_conversation_runtime = ConversationalWorkspaceRuntime(
+ resumed_runtime,
+ hydrator=second_hydrator,
+ registry_store=second_registry_store,
+ )
+
+ resumed_events = [
+ event async for event in resumed_conversation_runtime.stream({"messages": []})
+ ]
+
+ assert isinstance(resumed_events[-1], UiPathRuntimeResult)
+ assert attachments.downloads == 1
+ assert attachments.uploads == 2
+ workspace_snapshots = [
+ event.payload
+ for event in resumed_events
+ if isinstance(event, UiPathRuntimeConversationMetaEvent)
+ ]
+ assert len(workspace_snapshots) == 1
+ assert workspace_snapshots[0]["workspaceFiles"][0]["path"] == "notes.txt"
+
+
+@pytest.mark.asyncio
+async def test_hydrate_from_registry_skips_unchanged_local_file(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ attachments = FakeAttachments()
+ (workspace.path / "notes.txt").write_text("same", encoding="utf-8")
+ hydrator = WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ )
+ digest = hydrator._sha256(workspace.path / "notes.txt")
+ registry = {
+ "notes.txt": {
+ "attachment_key": str(uuid.uuid4()),
+ "sha256": digest,
+ "size": 4,
+ "uploaded_at": "",
+ "attachment_name": ".uipath-workspace~1notes.txt",
+ }
+ }
+
+ await hydrator.hydrate_from_registry(registry)
+
+ assert attachments.downloads == 0
+
+
+@pytest.mark.asyncio
+async def test_workspace_dispose_removes_temp_dir(tmp_path: Path) -> None:
+ workspace = Workspace.create(tmp_path / "workspace", cleanup=True)
+ (workspace.path / "file.txt").write_text("x", encoding="utf-8")
+
+ await workspace.dispose()
+
+ assert not workspace.path.exists()
+
+
+@pytest.mark.asyncio
+async def test_workspace_dispose_keeps_owned_path_by_default(tmp_path: Path) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ (workspace.path / "file.txt").write_text("x", encoding="utf-8")
+
+ await workspace.dispose()
+
+ assert workspace.path.exists()
+ shutil.rmtree(workspace.path)
+
+
+@pytest.mark.asyncio
+async def test_create_temp_workspace_is_cleaned_up_by_default() -> None:
+ workspace = Workspace.create()
+
+ assert workspace.path.exists()
+
+ await workspace.dispose()
+
+ assert not workspace.path.exists()
+
+
+@pytest.mark.asyncio
+async def test_dehydrate_relinks_unchanged_file_without_reupload(
+ tmp_path: Path,
+) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ attachments = FakeAttachments()
+ jobs = FakeJobs()
+ current_job = uuid.uuid4()
+ key = uuid.uuid4()
+ (workspace.path / "notes.txt").write_text("same", encoding="utf-8")
+ hydrator = WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ jobs=jobs,
+ current_job_key=str(current_job),
+ )
+ prior = {
+ "notes.txt": {
+ "attachment_key": str(key),
+ "sha256": hydrator._sha256(workspace.path / "notes.txt"),
+ "size": 4,
+ "uploaded_at": "",
+ "attachment_name": ".uipath-workspace~1notes.txt",
+ }
+ }
+
+ result = await hydrator.dehydrate(prior)
+
+ assert attachments.uploads == 0
+ assert result["notes.txt"]["attachment_key"] == str(key)
+ assert jobs.links == [(current_job, key)]
+
+
+@pytest.mark.asyncio
+async def test_attachment_names_are_single_segment_for_nested_files(
+ tmp_path: Path,
+) -> None:
+ """Attachment names must stay slash-free (a "/" breaks the blob round-trip)."""
+ workspace = Workspace.create(tmp_path / "workspace")
+ (workspace.path / "plan").mkdir(parents=True, exist_ok=True)
+ (workspace.path / "plan" / "todo.md").write_text("step 1", encoding="utf-8")
+ attachments = FakeAttachments()
+ hydrator = WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ )
+
+ registry = await hydrator.dehydrate({})
+
+ assert "plan/todo.md" in registry
+ attachment_name = registry["plan/todo.md"]["attachment_name"]
+ assert "/" not in attachment_name
+ assert all("/" not in name for name, _ in attachments.files.values())
+ assert (
+ hydrator._virtual_path_from_attachment_name(attachment_name) == "plan/todo.md"
+ )
+
+
+@pytest.mark.asyncio
+async def test_attachment_name_round_trips_special_characters(tmp_path: Path) -> None:
+ """The encoding must be reversible even for paths containing the escape char."""
+ workspace = Workspace.create(tmp_path / "workspace")
+ hydrator = WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=FakeAttachments(),
+ )
+
+ for virtual_path in ["a~~b.txt", "plan/todo.md", "we~ird/a~~b/file.txt"]:
+ name = hydrator._attachment_name_for_virtual_path(virtual_path)
+ assert "/" not in name
+ assert hydrator._virtual_path_from_attachment_name(name) == virtual_path
+
+
+@pytest.mark.asyncio
+async def test_dehydrate_drops_files_deleted_locally(tmp_path: Path) -> None:
+ """A registry entry whose file no longer exists is not carried forward."""
+ workspace = Workspace.create(tmp_path / "workspace")
+ attachments = FakeAttachments()
+ hydrator = WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ )
+ (workspace.path / "present.txt").write_text("hi", encoding="utf-8")
+ prior = {
+ "gone.txt": {
+ "attachment_key": str(uuid.uuid4()),
+ "sha256": "stale",
+ "size": 1,
+ "uploaded_at": "2026-01-01T00:00:00+00:00",
+ "attachment_name": ".uipath-workspace~1gone.txt",
+ }
+ }
+
+ result = await hydrator.dehydrate(prior)
+
+ assert "present.txt" in result
+ assert "gone.txt" not in result
+
+
+class _WriteThenRaiseRuntime:
+ def __init__(self, workspace_path: Path) -> None:
+ self.workspace_path = workspace_path
+
+ async def execute(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathExecuteOptions | None = None,
+ ) -> UiPathRuntimeResult:
+ (self.workspace_path / "partial.txt").write_text("wip", encoding="utf-8")
+ raise RuntimeError("boom")
+
+ async def stream(
+ self,
+ input: dict[str, Any] | None = None,
+ options: UiPathStreamOptions | None = None,
+ ) -> AsyncGenerator[UiPathRuntimeEvent, None]:
+ await self.execute(input, options)
+ yield UiPathRuntimeStateEvent(payload={})
+
+ async def get_schema(self) -> UiPathRuntimeSchema:
+ raise NotImplementedError
+
+ async def dispose(self) -> None:
+ pass
+
+
+@pytest.mark.asyncio
+async def test_always_policy_persists_on_failure(tmp_path: Path) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ attachments = FakeAttachments()
+ storage = MemoryStorage()
+ runtime = HydrationRuntime(
+ _WriteThenRaiseRuntime(workspace.path),
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ ),
+ registry_store=WorkspaceRegistryStore(storage, "runtime-1"),
+ policy=HydrationPolicy.ALWAYS,
+ )
+
+ with pytest.raises(RuntimeError):
+ await runtime.execute({})
+
+ assert attachments.uploads == 1
+ assert "partial.txt" in await runtime.registry_store.load()
+
+
+@pytest.mark.asyncio
+async def test_suspend_only_policy_skips_persist_on_failure(tmp_path: Path) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ attachments = FakeAttachments()
+ storage = MemoryStorage()
+ runtime = HydrationRuntime(
+ _WriteThenRaiseRuntime(workspace.path),
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ ),
+ registry_store=WorkspaceRegistryStore(storage, "runtime-1"),
+ policy=HydrationPolicy.SUSPEND_ONLY,
+ )
+
+ with pytest.raises(RuntimeError):
+ await runtime.execute({})
+
+ assert attachments.uploads == 0
+
+
+@pytest.mark.asyncio
+async def test_always_policy_persists_on_stream_failure(tmp_path: Path) -> None:
+ workspace = Workspace.create(tmp_path / "workspace")
+ attachments = FakeAttachments()
+ storage = MemoryStorage()
+ runtime = HydrationRuntime(
+ _WriteThenRaiseRuntime(workspace.path),
+ workspace=workspace,
+ hydrator=WorkspaceHydrator(
+ workspace_path=workspace.path,
+ attachments=attachments,
+ ),
+ registry_store=WorkspaceRegistryStore(storage, "runtime-1"),
+ policy=HydrationPolicy.ALWAYS,
+ )
+
+ with pytest.raises(RuntimeError):
+ async for _ in runtime.stream({}):
+ pass
+
+ assert attachments.uploads == 1
diff --git a/uv.lock b/uv.lock
index 8a9846e8..1350d745 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.11"
[options]
-exclude-newer = "2026-05-27T14:28:49.412753Z"
+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"
[options.exclude-newer-package]
@@ -99,6 +99,132 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
+[[package]]
+name = "chardet"
+version = "7.4.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/19/52/505c207f334d51e937cbaa27ff95776e16e2d120e13cbe491cd7b3a70b50/chardet-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:25a862cddc6a9ac07023e808aedd297115345fbaabc2690479481ddc0f980e09", size = 870747, upload-time = "2026-04-13T21:32:56.916Z" },
+ { url = "https://files.pythonhosted.org/packages/14/4b/d3c79495dee4831b8bebca2790e72cb90f0c5849c940570a7c7e5b70b952/chardet-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7005c88da26fd95d8abb8acbe6281d833e9a9181b03cf49b4546c4555389bd97", size = 853210, upload-time = "2026-04-13T21:32:58.309Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/99/f6a822ad1bde25a4c38dc3e770485e78e0893dfd871cd6e18ed3ea3a795e/chardet-7.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc50f28bad067393cce0af9091052c3b8df7a23115afd8ba7b2e0947f0cef1f8", size = 873625, upload-time = "2026-04-13T21:32:59.606Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/10/31932775c94a86814f76b41c4a772b52abfb0e6125324f32c6da1196c297/chardet-7.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3da294de1a681097848ab58bd3f2771a674f8039d2d87a5538b28856b815e9", size = 883436, upload-time = "2026-04-13T21:33:01.351Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/63/0f43e3acf2c436fdb32a0f904aeb03a2904d2126eed34a042a194d235926/chardet-7.4.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c45e116dd51b66226a53ade3f9f635e870de5399b90e00ce45dcc311093bf4", size = 876589, upload-time = "2026-04-13T21:33:02.636Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/a6/e9b8f8a3e99602792b01fa7d0a731737615ab56d8bfd0b52935a0ef88b85/chardet-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:ccc1f83ab4bcfb901cf39e0c4ba6bc6e726fc6264735f10e24ceb5cb47387578", size = 941866, upload-time = "2026-04-13T21:33:04.282Z" },
+ { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" },
+ { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" },
+ { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" },
+ { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" },
+ { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" },
+ { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" },
+ { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" },
+ { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" },
+ { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" },
+ { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
+ { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
+ { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
+ { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
+ { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
+ { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
+ { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
+ { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
+ { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
+ { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
+ { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
+ { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
+ { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
+ { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
+ { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+ { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+ { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+ { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+ { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+ { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+ { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+ { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+ { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
[[package]]
name = "colorama"
version = "0.4.6"
@@ -821,6 +947,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
[[package]]
name = "rich"
version = "14.2.0"
@@ -998,24 +1139,26 @@ wheels = [
[[package]]
name = "uipath-core"
-version = "0.5.17"
+version = "0.5.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-sdk" },
{ name = "pydantic" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/e3/80/a626eb3136a6765e0af06c9d5080ac0843c2a72f17b7a2170f1f45da40dd/uipath_core-0.5.17.tar.gz", hash = "sha256:13565e1eba9f059a8221494dfb3239257ddf7f265fc7057199ffe03ed066300a", size = 119023, upload-time = "2026-05-28T21:34:10.903Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/518fcb7d466a39c675194c492e8accbffdc7fd6b280d89b21ed11700a7bd/uipath_core-0.5.31.tar.gz", hash = "sha256:6abb443582d9a8ab6a504791296d49f524fb430f66d8dfaf6d6d1849958700bb", size = 130979, upload-time = "2026-07-15T06:56:03.867Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/74/cf/f4b481970621e2a9aec869302773fa2c7d346aef294a553429626369633f/uipath_core-0.5.17-py3-none-any.whl", hash = "sha256:6e088eec5130bc492ac176ab85d4924d7d4cb07ee290ed7e6a46984e9de8c12b", size = 44957, upload-time = "2026-05-28T21:34:09.534Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/37/47a4e12bc7bdcfab4bd4e8e1f2a5c083bd59fca42fd431026a3f14c642a3/uipath_core-0.5.31-py3-none-any.whl", hash = "sha256:3dff5ecb236bf46af683c34d79bb2cf458a942ec041e1cbf7b4825ae246ff00c", size = 55040, upload-time = "2026-07-15T06:56:02.446Z" },
]
[[package]]
name = "uipath-runtime"
-version = "0.11.0"
+version = "0.13.1"
source = { editable = "." }
dependencies = [
+ { name = "chardet" },
{ name = "uipath-core" },
+ { name = "vadersentiment" },
]
[package.dev-dependencies]
@@ -1034,7 +1177,11 @@ dev = [
]
[package.metadata]
-requires-dist = [{ name = "uipath-core", specifier = ">=0.5.17,<0.6.0" }]
+requires-dist = [
+ { name = "chardet", specifier = ">=5.2.0,<8.0" },
+ { name = "uipath-core", specifier = ">=0.5.31,<0.6.0" },
+ { name = "vadersentiment", specifier = ">=3.3.2,<4.0" },
+]
[package.metadata.requires-dev]
dev = [
@@ -1051,6 +1198,27 @@ dev = [
{ name = "rust-just", specifier = ">=1.39.0" },
]
+[[package]]
+name = "urllib3"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+]
+
+[[package]]
+name = "vadersentiment"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/77/8c/4a48c10a50f750ae565e341e697d74a38075a3e43ff0df6f1ab72e186902/vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9", size = 2466783, upload-time = "2020-05-22T15:06:32.81Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/fc/310e16254683c1ed35eeb97386986d6c00bc29df17ce280aed64d55537e9/vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311", size = 125950, upload-time = "2020-05-22T15:07:00.052Z" },
+]
+
[[package]]
name = "virtualenv"
version = "20.35.4"